-
Notifications
You must be signed in to change notification settings - Fork 7.6k
feat(convo): preserve authored timestamp from transcripts #1890
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c45a16
37f2fb1
57e4d9d
9a05061
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| # Authored date (`authored_at`) | ||
|
|
||
| Conversation transcripts carry a per-line ISO-8601 `timestamp` (both Claude Code and | ||
| Codex JSONL). The miner records the most recent one per file as the drawer's | ||
| **`authored_at`** — when the content was actually written. | ||
|
|
||
| This is distinct from the ingest date: | ||
|
|
||
| | Field | Meaning | | ||
| |-------|---------| | ||
| | `filed_at` / result `created_at` | When the drawer was **mined** (written to the palace). A bulk re-mine collapses these to a single instant. | | ||
| | `authored_at` | When the underlying content was **written**, recovered from the transcript timestamps. Survives re-mining. | | ||
|
|
||
| `authored_at` is surfaced in search results (and shown in the CLI `search` output), and is | ||
| used as a deterministic tie-break in hybrid ranking: candidates with identical scores order | ||
| with the more recently authored drawer first. Drawers without per-line timestamps (e.g. | ||
| markdown) fall back to `filed_at`. | ||
|
|
||
| ## Backfilling existing memory | ||
|
|
||
| New mines populate `authored_at` automatically. Drawers mined before this feature only have | ||
| `filed_at`. Re-mining does **not** fix them — the scanner skips files already mined at the | ||
| current `NORMALIZE_VERSION`. Two options: | ||
|
|
||
| 1. **In-place backfill (recommended — no re-embedding).** `scripts/backfill_authored_at.py` | ||
| reads each convos drawer's source transcript and updates only the `authored_at` metadata. | ||
| Idempotent and safe to re-run; embeddings are untouched. | ||
|
|
||
| ```bash | ||
| python scripts/backfill_authored_at.py \ | ||
| --palace ~/.mempalace/palace \ | ||
| --sessions ~/.claude --sessions ~/.codex # dry run | ||
| python scripts/backfill_authored_at.py \ | ||
| --palace ~/.mempalace/palace \ | ||
| --sessions ~/.claude --sessions ~/.codex --apply # write | ||
| ``` | ||
|
|
||
| For the Docker MCP image, mount the volume and session dirs read-only — see the header of | ||
| `scripts/backfill_authored_at.py` for the exact `docker run` invocation. | ||
|
|
||
| > Back up first: `tar czf palace-backup.tgz -C <palace-dir> .` (or snapshot the | ||
| > `mempalace-data` volume). | ||
|
|
||
| 2. **Drop and recreate.** Delete the affected drawers and re-mine the transcripts; the fresh | ||
| mine stamps `authored_at`. Simpler, but re-embeds everything. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -221,7 +221,18 @@ def _hybrid_rank( | |||||||||||||||||||||||
| r["bm25_score"] = round(raw, 3) | ||||||||||||||||||||||||
| scored.append((vector_weight * vec_sim + bm25_weight * norm, r)) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| scored.sort(key=lambda pair: pair[0], reverse=True) | ||||||||||||||||||||||||
| # Break exact score ties toward the more recently authored drawer so equal-score | ||||||||||||||||||||||||
| # candidates rank chronologically instead of in arbitrary backend order. ISO-8601 | ||||||||||||||||||||||||
| # ``authored_at`` strings sort chronologically; missing dates sort oldest. | ||||||||||||||||||||||||
| # authored_at lives at the top level on the search_memories path and nested under | ||||||||||||||||||||||||
| # "metadata" on the candidate-union path; check both so the tie-break works for each. | ||||||||||||||||||||||||
| scored.sort( | ||||||||||||||||||||||||
| key=lambda pair: ( | ||||||||||||||||||||||||
| pair[0], | ||||||||||||||||||||||||
| pair[1].get("authored_at") or pair[1].get("metadata", {}).get("authored_at") or "", | ||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||
| reverse=True, | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
Comment on lines
+229
to
+235
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tie-breaker logic assumes that As a result, We should update the key function to support both structures by checking the top-level
Suggested change
|
||||||||||||||||||||||||
| results[:] = [r for _, r in scored] | ||||||||||||||||||||||||
| return results | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -681,6 +692,7 @@ def _metadata_filter_sql(row_id_expr: str) -> tuple[str, list[str]]: | |||||||||||||||||||||||
| "source_file": Path(full_source).name if full_source else "?", | ||||||||||||||||||||||||
| "source_path": full_source, | ||||||||||||||||||||||||
| "created_at": meta.get("filed_at", "unknown"), | ||||||||||||||||||||||||
| "authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")), | ||||||||||||||||||||||||
| # No vector distance available in BM25-only mode. | ||||||||||||||||||||||||
| "similarity": None, | ||||||||||||||||||||||||
| "distance": None, | ||||||||||||||||||||||||
|
|
@@ -783,6 +795,7 @@ def _merge_bm25_union_candidates( | |||||||||||||||||||||||
| "source_file": Path(full_source).name if full_source else "?", | ||||||||||||||||||||||||
| "source_path": full_source, | ||||||||||||||||||||||||
| "created_at": meta.get("filed_at", "unknown"), | ||||||||||||||||||||||||
| "authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")), | ||||||||||||||||||||||||
| "similarity": None, | ||||||||||||||||||||||||
| "distance": None, | ||||||||||||||||||||||||
| "effective_distance": None, | ||||||||||||||||||||||||
|
|
@@ -1198,6 +1211,7 @@ def search_memories( | |||||||||||||||||||||||
| "source_file": Path(source).name if source else "?", | ||||||||||||||||||||||||
| "source_path": source, | ||||||||||||||||||||||||
| "created_at": meta.get("filed_at", "unknown"), | ||||||||||||||||||||||||
| "authored_at": meta.get("authored_at", meta.get("filed_at", "unknown")), | ||||||||||||||||||||||||
| "similarity": round(_distance_to_similarity(effective_dist, metric), 3), | ||||||||||||||||||||||||
| "distance": round(dist, 4), | ||||||||||||||||||||||||
| "effective_distance": round(effective_dist, 4), | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| #!/usr/bin/env python3 | ||
| """Backfill ``authored_at`` onto existing conversation drawers. | ||
|
|
||
| New mines stamp ``authored_at`` automatically (see ``convo_miner._extract_authored_at``), | ||
| but drawers mined before that change only have ``filed_at`` (ingest time). Re-mining does | ||
| NOT fix them: the scanner skips files already mined at the current ``NORMALIZE_VERSION``. | ||
|
|
||
| This migration updates the affected drawers IN PLACE — metadata only, embeddings are left | ||
| untouched, so there is no re-embedding cost. It is idempotent (drawers already correct are | ||
| skipped) and safe to re-run. It only touches ``ingest_mode == "convos"`` drawers; markdown | ||
| drawers have no per-line timestamps and keep their ``filed_at`` fallback. | ||
|
|
||
| Drawers whose source transcript is no longer on disk are left as-is (they keep falling back | ||
| to ``filed_at``), so point ``--sessions`` at the directories that still hold your ``.jsonl`` | ||
| transcripts (e.g. ``~/.claude`` and ``~/.codex``). | ||
|
|
||
| Usage (dry-run prints what would change; pass --apply to write): | ||
|
|
||
| python scripts/backfill_authored_at.py \ | ||
| --palace ~/.mempalace/palace \ | ||
| --sessions ~/.claude --sessions ~/.codex [--apply] | ||
|
|
||
| In Docker (the MCP image), mount the volume and your session dirs read-only: | ||
|
|
||
| docker run --rm \ | ||
| -v mempalace-data:/data \ | ||
| -v ~/.claude:/sessions/claude:ro -v ~/.codex:/sessions/codex:ro \ | ||
| -v "$PWD/scripts/backfill_authored_at.py:/tmp/backfill.py:ro" \ | ||
| --entrypoint /app/.venv/bin/python mempalace:local \ | ||
| /tmp/backfill.py --palace /data/.mempalace/palace \ | ||
| --sessions /sessions/claude --sessions /sessions/codex --apply | ||
| """ | ||
|
|
||
| import argparse | ||
| import glob | ||
| import os | ||
|
|
||
| import chromadb | ||
|
|
||
| from mempalace.convo_miner import _extract_authored_at | ||
|
|
||
| COLLECTION = "mempalace_drawers" | ||
| PAGE = 2000 | ||
| BATCH = 1000 | ||
|
|
||
|
|
||
| def _index_sessions(session_dirs): | ||
| """Map ``basename.jsonl -> realpath`` for every transcript under the given dirs.""" | ||
| index = {} | ||
| for root in session_dirs: | ||
| for f in glob.glob(os.path.join(os.path.expanduser(root), "**", "*.jsonl"), recursive=True): | ||
| index.setdefault(os.path.basename(f), f) | ||
| return index | ||
|
|
||
|
|
||
| def backfill_authored_at(collection, session_dirs, apply=False): | ||
| """Stamp ``authored_at`` on convos drawers from their source transcript timestamps. | ||
|
|
||
| Returns a stats dict: ``scanned``, ``updated``, ``resolved_files``, ``unresolved_files``. | ||
| """ | ||
| index = _index_sessions(session_dirs) | ||
| cache = {} | ||
| unresolved = set() | ||
| pending_ids, pending_metas = [], [] | ||
| scanned = updated = 0 | ||
|
|
||
| def flush(): | ||
| nonlocal pending_ids, pending_metas, updated | ||
| if pending_ids and apply: | ||
| collection.update(ids=pending_ids, metadatas=pending_metas) | ||
| updated += len(pending_ids) | ||
| pending_ids, pending_metas = [], [] | ||
|
|
||
| offset = 0 | ||
| while True: | ||
| res = collection.get( | ||
| where={"ingest_mode": "convos"}, include=["metadatas"], limit=PAGE, offset=offset | ||
| ) | ||
| ids = res["ids"] | ||
| if not ids: | ||
| break | ||
| for drawer_id, meta in zip(ids, res["metadatas"]): | ||
| scanned += 1 | ||
| basename = os.path.basename(meta.get("source_file") or "") | ||
| if basename in cache: | ||
| authored = cache[basename] | ||
| else: | ||
| path = index.get(basename) | ||
| authored = _extract_authored_at(path) if path else None | ||
| cache[basename] = authored | ||
| if path is None and basename: | ||
| unresolved.add(basename) | ||
| if authored and meta.get("authored_at") != authored: | ||
| new_meta = dict(meta) | ||
| new_meta["authored_at"] = authored | ||
| pending_ids.append(drawer_id) | ||
| pending_metas.append(new_meta) | ||
| if len(pending_ids) >= BATCH: | ||
| flush() | ||
| offset += len(ids) | ||
| flush() | ||
| return { | ||
| "scanned": scanned, | ||
| "updated": updated, | ||
| "resolved_files": sum(1 for v in cache.values() if v), | ||
| "unresolved_files": len(unresolved), | ||
| } | ||
|
|
||
|
|
||
| def main(): | ||
| parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) | ||
| parser.add_argument("--palace", required=True, help="Path to the ChromaDB palace dir") | ||
| parser.add_argument( | ||
| "--sessions", | ||
| action="append", | ||
| default=[], | ||
| required=True, | ||
| help="Directory holding .jsonl transcripts (repeatable)", | ||
| ) | ||
| parser.add_argument( | ||
| "--apply", | ||
| action="store_true", | ||
| help="Write changes (default is a dry run that only reports counts)", | ||
| ) | ||
| args = parser.parse_args() | ||
|
|
||
| client = chromadb.PersistentClient(path=os.path.expanduser(args.palace)) | ||
| collection = client.get_collection(COLLECTION) | ||
| stats = backfill_authored_at(collection, args.sessions, apply=args.apply) | ||
| mode = "APPLIED" if args.apply else "DRY-RUN (use --apply to write)" | ||
| print( | ||
| f"{mode}: scanned={stats['scanned']} updated={stats['updated']} " | ||
| f"resolved_files={stats['resolved_files']} unresolved_files={stats['unresolved_files']}" | ||
| ) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a JSONL line contains a non-string
timestamp(such as an integer, boolean, or dictionary), or if different lines have mixed types, the comparisonts > latestcan raise aTypeError(e.g.,TypeError: '>' not supported between instances of 'str' and 'int'). Since this comparison is outside the innertry-exceptblock, it will propagate and crash the entire mining process.We should validate that
tsis a string (since ISO-8601 timestamps are strings) and perform the comparison inside thetryblock to safely handle any unexpected types.