diff --git a/hindsight-integrations/claude-code/CHANGELOG.md b/hindsight-integrations/claude-code/CHANGELOG.md index 216ace8069..fe17b96eac 100644 --- a/hindsight-integrations/claude-code/CHANGELOG.md +++ b/hindsight-integrations/claude-code/CHANGELOG.md @@ -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 diff --git a/hindsight-integrations/claude-code/scripts/lib/client.py b/hindsight-integrations/claude-code/scripts/lib/client.py index 6d6e1ec57e..18ea64ab0e 100644 --- a/hindsight-integrations/claude-code/scripts/lib/client.py +++ b/hindsight-integrations/claude-code/scripts/lib/client.py @@ -145,6 +145,7 @@ 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. @@ -152,6 +153,11 @@ def retain( 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 = { @@ -163,6 +169,8 @@ def retain( item["context"] = context if tags: item["tags"] = tags + if timestamp: + item["timestamp"] = timestamp body = { "items": [item], "async": True, diff --git a/hindsight-integrations/claude-code/scripts/retain.py b/hindsight-integrations/claude-code/scripts/retain.py index 050ecddae2..624f66bc3a 100755 --- a/hindsight-integrations/claude-code/scripts/retain.py +++ b/hindsight-integrations/claude-code/scripts/retain.py @@ -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: diff --git a/hindsight-integrations/claude-code/tests/test_hooks.py b/hindsight-integrations/claude-code/tests/test_hooks.py index 34e70e2496..fd54d10e00 100644 --- a/hindsight-integrations/claude-code/tests/test_hooks.py +++ b/hindsight-integrations/claude-code/tests/test_hooks.py @@ -11,6 +11,7 @@ import io import json import os +import re import sys import time from unittest.mock import MagicMock, patch @@ -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]