Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions hindsight-integrations/claude-code/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@
`HINDSIGHT_USER_ID` is unset) are now dropped from retain requests. Previously
such tags were sent as-is. Tags without `:` are unaffected.

### Fixed

- Retain requests now send the `MemoryItem.timestamp` field, using the same
wall-clock instant already recorded as `retained_at` metadata. Previously the
hook never sent one, so the extractor had no reference time to resolve
relative expressions: a transcript saying "yesterday" or "last week" was
stored with that phrase unresolved in the fact text (e.g.
`"When: Today (relative to conversation)"`), which conveys nothing once the
fact is recalled weeks later. Measured against a self-hosted server, adding
the anchor turned "last week we soldered the tamper pull-down" into a fact
dated `2026-07-19`, and "yesterday" into `2026-07-25`. Hindsight's own
best-practices guide lists omitting `timestamp` as an anti-pattern
("Missing `timestamp` on retain — disables temporal retrieval strategies").

## [0.1.0] - 2025-03-23

### Added
Expand Down
8 changes: 8 additions & 0 deletions hindsight-integrations/claude-code/scripts/lib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,19 @@ def retain(
context: Optional[str] = None,
metadata: Optional[dict] = None,
tags: Optional[list] = None,
timestamp: Optional[str] = None,
timeout: int = 15,
) -> dict:
"""Retain content into a bank's memory.

Posts with async=true so the server processes in the background.
The context field helps Hindsight cluster memories by provenance
(e.g. "claude-code" vs manual retains).

timestamp (ISO 8601) is the MemoryItem reference time the extractor
uses to resolve relative expressions in the transcript. Omitting it
leaves phrases like "yesterday" unresolved in the stored fact text,
which is meaningless once the fact is recalled weeks later.
"""
path = f"/v1/default/banks/{urllib.parse.quote(bank_id, safe='')}/memories"
item = {
Expand All @@ -163,6 +169,8 @@ def retain(
item["context"] = context
if tags:
item["tags"] = tags
if timestamp:
item["timestamp"] = timestamp
body = {
"items": [item],
"async": True,
Expand Down
1 change: 1 addition & 0 deletions hindsight-integrations/claude-code/scripts/retain.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def _resolve_template(value: str) -> str:
context=config.get("retainContext", "claude-code"),
metadata=metadata,
tags=tags,
timestamp=template_vars["timestamp"],
timeout=15,
)
if retention_progress is not None:
Expand Down
52 changes: 52 additions & 0 deletions hindsight-integrations/claude-code/tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import io
import json
import os
import re
import sys
import time
from unittest.mock import MagicMock, patch
Expand Down Expand Up @@ -875,3 +876,54 @@ def capture(req, timeout=None):
mod.main()

assert "called" not in captured

# Intention: the Hindsight API's MemoryItem accepts a `timestamp` — the
# reference time the extractor uses to resolve relative expressions. The
# hook previously never sent one, so a transcript saying "yesterday" was
# stored with that phrase unresolved (and `occurred_start` left null),
# which is meaningless once the fact is recalled weeks later. The retain
# hook must stamp every request with the session's wall-clock time.

def test_retain_sends_timestamp_for_relative_date_resolution(self, monkeypatch, tmp_path):
# Input: an ordinary retain with a transcript present.
# Expected: the posted item carries an ISO-8601 `timestamp`, and it
# matches the `retained_at` metadata so both describe the same instant.
messages = [{"role": "user", "content": "yesterday we shipped v0.17.2"}, {"role": "assistant", "content": "ok"}]
transcript = make_transcript_file(tmp_path, messages)
captured = {}

def capture(req, timeout=None):
if "/memories" in req.full_url and "/recall" not in req.full_url:
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"status": "accepted"})

hook_input = make_hook_input(transcript_path=transcript)
_run_hook("retain", hook_input, monkeypatch, tmp_path, urlopen_side_effect=capture)

assert "body" in captured, "retain API was not called"
item = captured["body"]["items"][0]
assert "timestamp" in item, "retain must send a reference timestamp"
assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", item["timestamp"]), item["timestamp"]
assert item["timestamp"] == item["metadata"]["retained_at"]

def test_client_omits_timestamp_field_when_not_supplied(self, monkeypatch, tmp_path):
# Input: HindsightClient.retain called without a timestamp.
# Expected: no `timestamp` key at all — the server then falls back to
# ingestion time rather than receiving an explicit null.
scripts_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "scripts"))
sys.path.insert(0, scripts_dir)
try:
from lib.client import HindsightClient
finally:
sys.path.remove(scripts_dir)

captured = {}

def capture(req, timeout=None):
captured["body"] = json.loads(req.data.decode())
return FakeHTTPResponse({"status": "accepted"})

with patch("urllib.request.urlopen", side_effect=capture):
HindsightClient("http://localhost:9077", None).retain(bank_id="b", content="hello")

assert "timestamp" not in captured["body"]["items"][0]