From 4061ea204127c7b3b43d91610b89e3332f773140 Mon Sep 17 00:00:00 2001 From: Merlin_r68 Date: Sun, 26 Jul 2026 20:19:16 -0400 Subject: [PATCH] fix(claude-code): send MemoryItem.timestamp so the extractor can resolve relative dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retain hook never sent `timestamp`, so the extractor had no reference time for relative expressions. Transcripts saying "yesterday" or "last week" were stored with the phrase unresolved in the fact text (literally "When: Today (relative to conversation)"), which conveys nothing once the fact is recalled weeks later. The hook already computes the instant it needs — `template_vars["timestamp"]`, recorded as `retained_at` metadata — so this passes that same value through as the MemoryItem reference time. No new config, no behaviour change when a caller omits it. Measured against a self-hosted server, same transcript and mission, anchor the only variable: "last week we soldered the tamper pull-down" became a fact dated 2026-07-19, and "yesterday" became 2026-07-25. Hindsight's own best-practices guide lists this omission as an anti-pattern: "Missing `timestamp` on retain — disables temporal retrieval strategies". --- .../claude-code/CHANGELOG.md | 14 +++++ .../claude-code/scripts/lib/client.py | 8 +++ .../claude-code/scripts/retain.py | 1 + .../claude-code/tests/test_hooks.py | 52 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/hindsight-integrations/claude-code/CHANGELOG.md b/hindsight-integrations/claude-code/CHANGELOG.md index 216ace806..fe17b96ea 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 6d6e1ec57..18ea64ab0 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 050ecddae..624f66bc3 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 34e70e249..fd54d10e0 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]