cogito-mem 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cogito_mem/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """cogito-mem: a fast, simple, local memory for agents.
2
+
3
+ from cogito_mem import Memory
4
+ mem = Memory() # ~/.cogito-mem, EmbeddingGemma 2 via llama.cpp
5
+ mem.handle({"op": "append", "items": [{"author": "user", "text": "..."}]})
6
+ mem.handle({"op": "query", "text": "..."})
7
+
8
+ ``Client`` sends the same dictionaries to the local service instead.
9
+ """
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = ["Memory", "Config", "Client", "ApiError", "__version__"]
14
+
15
+
16
+ def __getattr__(name): # keep `import cogito_mem` light for hooks and the MCP bridge
17
+ if name in ("Memory", "Config"):
18
+ from . import memory
19
+
20
+ return getattr(memory, name)
21
+ if name == "Client":
22
+ from .client import Client
23
+
24
+ return Client
25
+ if name == "ApiError":
26
+ from .errors import ApiError
27
+
28
+ return ApiError
29
+ raise AttributeError(name)
cogito_mem/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cogito_mem/api.py ADDED
@@ -0,0 +1,226 @@
1
+ """Request validation for the two operations (requirements §3).
2
+
3
+ Requests have static keys: an unknown key is an error, an optional key may be left
4
+ out or set to null, and the defaults below fill it in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+
12
+ from . import timeutil
13
+ from .errors import invalid
14
+
15
+ NAMESPACE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
16
+ KINDS = ("message", "piece", "digest", "statement")
17
+
18
+ DEFAULT_NAMESPACE = "default"
19
+ DEFAULT_THREAD = "default"
20
+ DEFAULT_TOP_K = 10
21
+ MAX_TOP_K = 100
22
+ DEFAULT_MAX_TOKENS = 1000
23
+ MAX_MAX_TOKENS = 100_000
24
+ MAX_ITEMS = 1000
25
+ MAX_TEXT_CHARS = 4_000_000
26
+
27
+ _APPEND_KEYS = {"op", "namespace", "import", "items"}
28
+ _ITEM_KEYS = {"thread_id", "author", "valid_at", "title", "text", "client_ref", "retracts"}
29
+ _QUERY_KEYS = {"op", "namespace", "text", "context", "filter", "as_of", "top_k", "max_tokens"}
30
+ _FILTER_KEYS = {"thread_id", "author", "kind", "valid_after", "valid_before", "include_retracted"}
31
+
32
+
33
+ @dataclass
34
+ class ItemIn:
35
+ author: str
36
+ text: str
37
+ thread_id: str = DEFAULT_THREAD
38
+ valid_at: int | None = None
39
+ title: str | None = None
40
+ client_ref: str | None = None
41
+ retracts: list[str] = field(default_factory=list)
42
+
43
+
44
+ @dataclass
45
+ class AppendIn:
46
+ namespace: str
47
+ import_: bool
48
+ items: list[ItemIn]
49
+
50
+
51
+ @dataclass
52
+ class FilterIn:
53
+ thread_id: str | None = None
54
+ author: str | None = None
55
+ kind: list[str] | None = None
56
+ valid_after: int | None = None
57
+ valid_before: int | None = None
58
+ include_retracted: bool = False
59
+
60
+
61
+ @dataclass
62
+ class QueryIn:
63
+ namespace: str
64
+ text: str
65
+ context: str | None
66
+ filter: FilterIn
67
+ as_of: int | None
68
+ top_k: int
69
+ max_tokens: int
70
+
71
+
72
+ def _check_keys(obj: dict, allowed: set[str], where: str) -> None:
73
+ unknown = sorted(set(obj) - allowed)
74
+ if unknown:
75
+ raise invalid(f"unknown key{'s' if len(unknown) > 1 else ''} in {where}: {', '.join(unknown)}")
76
+
77
+
78
+ def _opt_str(obj: dict, key: str, where: str, *, non_empty: bool = True) -> str | None:
79
+ value = obj.get(key)
80
+ if value is None:
81
+ return None
82
+ if not isinstance(value, str):
83
+ raise invalid(f"{where}{key} must be a string")
84
+ if non_empty and not value.strip():
85
+ raise invalid(f"{where}{key} must not be empty")
86
+ return value
87
+
88
+
89
+ def _opt_time(obj: dict, key: str, where: str) -> int | None:
90
+ value = obj.get(key)
91
+ if value is None:
92
+ return None
93
+ if not isinstance(value, str):
94
+ raise invalid(f"{where}{key} must be an RFC 3339 string")
95
+ try:
96
+ return timeutil.parse(value)
97
+ except ValueError as exc:
98
+ raise invalid(f"{where}{key}: {exc}") from None
99
+
100
+
101
+ def _opt_int(obj: dict, key: str, default: int, lo: int, hi: int) -> int:
102
+ value = obj.get(key)
103
+ if value is None:
104
+ return default
105
+ if isinstance(value, bool) or not isinstance(value, int):
106
+ raise invalid(f"{key} must be an integer")
107
+ if not lo <= value <= hi:
108
+ raise invalid(f"{key} must be between {lo} and {hi}, got {value}")
109
+ return value
110
+
111
+
112
+ def _namespace(body: dict) -> str:
113
+ ns = body.get("namespace")
114
+ if ns is None:
115
+ return DEFAULT_NAMESPACE
116
+ if not isinstance(ns, str) or not NAMESPACE_RE.match(ns):
117
+ raise invalid(
118
+ "namespace must be 1-64 characters of letters, digits, '.', '_' or '-', "
119
+ "starting with a letter or digit"
120
+ )
121
+ return ns
122
+
123
+
124
+ def parse_append(body: dict) -> AppendIn:
125
+ _check_keys(body, _APPEND_KEYS, "the append request")
126
+ namespace = _namespace(body)
127
+ import_ = body.get("import")
128
+ if import_ is None:
129
+ import_ = False
130
+ if not isinstance(import_, bool):
131
+ raise invalid("import must be true or false")
132
+ raw_items = body.get("items")
133
+ if not isinstance(raw_items, list) or not raw_items:
134
+ raise invalid("items must be a non-empty list")
135
+ if len(raw_items) > MAX_ITEMS:
136
+ raise invalid(f"at most {MAX_ITEMS} items per request, got {len(raw_items)}")
137
+ items = []
138
+ last_valid = None
139
+ for i, raw in enumerate(raw_items):
140
+ where = f"items[{i}]."
141
+ if not isinstance(raw, dict):
142
+ raise invalid(f"items[{i}] must be an object")
143
+ _check_keys(raw, _ITEM_KEYS, f"items[{i}]")
144
+ author = _opt_str(raw, "author", where)
145
+ if author is None:
146
+ raise invalid(f"{where}author is required")
147
+ text = raw.get("text")
148
+ if not isinstance(text, str) or not text.strip():
149
+ raise invalid(f"{where}text is required and must be a non-empty string")
150
+ if len(text) > MAX_TEXT_CHARS:
151
+ raise invalid(f"{where}text is longer than {MAX_TEXT_CHARS} characters")
152
+ thread_id = _opt_str(raw, "thread_id", where) or DEFAULT_THREAD
153
+ valid_at = _opt_time(raw, "valid_at", where)
154
+ title = _opt_str(raw, "title", where, non_empty=False)
155
+ client_ref = _opt_str(raw, "client_ref", where)
156
+ retracts = raw.get("retracts")
157
+ if retracts is None:
158
+ retracts = []
159
+ if not isinstance(retracts, list) or not all(isinstance(r, str) and r for r in retracts):
160
+ raise invalid(f"{where}retracts must be a list of item ids")
161
+ if import_:
162
+ if valid_at is None:
163
+ raise invalid(f"{where}valid_at is required when import is true")
164
+ if last_valid is not None and valid_at < last_valid:
165
+ raise invalid(f"{where}valid_at must not decrease when import is true")
166
+ last_valid = valid_at
167
+ items.append(
168
+ ItemIn(
169
+ author=author,
170
+ text=text,
171
+ thread_id=thread_id,
172
+ valid_at=valid_at,
173
+ title=title or None,
174
+ client_ref=client_ref,
175
+ retracts=list(dict.fromkeys(retracts)),
176
+ )
177
+ )
178
+ return AppendIn(namespace=namespace, import_=import_, items=items)
179
+
180
+
181
+ def parse_query(body: dict) -> QueryIn:
182
+ _check_keys(body, _QUERY_KEYS, "the query request")
183
+ namespace = _namespace(body)
184
+ text = body.get("text")
185
+ if not isinstance(text, str) or not text.strip():
186
+ raise invalid("text is required and must be a non-empty string")
187
+ if len(text) > MAX_TEXT_CHARS:
188
+ raise invalid(f"text is longer than {MAX_TEXT_CHARS} characters")
189
+ context = _opt_str(body, "context", "", non_empty=False) or None
190
+ raw_filter = body.get("filter")
191
+ if raw_filter is None:
192
+ raw_filter = {}
193
+ if not isinstance(raw_filter, dict):
194
+ raise invalid("filter must be an object")
195
+ _check_keys(raw_filter, _FILTER_KEYS, "filter")
196
+ kind = raw_filter.get("kind")
197
+ if kind is not None:
198
+ if isinstance(kind, str):
199
+ raise invalid("filter.kind must be a list of kinds, e.g. [\"message\"]")
200
+ if not isinstance(kind, list) or not all(isinstance(k, str) for k in kind):
201
+ raise invalid("filter.kind must be a list of kinds")
202
+ bad = sorted(set(kind) - set(KINDS))
203
+ if bad:
204
+ raise invalid(f"unknown kind {bad[0]!r}; kinds are {', '.join(KINDS)}")
205
+ include_retracted = raw_filter.get("include_retracted")
206
+ if include_retracted is None:
207
+ include_retracted = False
208
+ if not isinstance(include_retracted, bool):
209
+ raise invalid("filter.include_retracted must be true or false")
210
+ flt = FilterIn(
211
+ thread_id=_opt_str(raw_filter, "thread_id", "filter."),
212
+ author=_opt_str(raw_filter, "author", "filter."),
213
+ kind=kind,
214
+ valid_after=_opt_time(raw_filter, "valid_after", "filter."),
215
+ valid_before=_opt_time(raw_filter, "valid_before", "filter."),
216
+ include_retracted=include_retracted,
217
+ )
218
+ return QueryIn(
219
+ namespace=namespace,
220
+ text=text,
221
+ context=context,
222
+ filter=flt,
223
+ as_of=_opt_time(body, "as_of", ""),
224
+ top_k=_opt_int(body, "top_k", DEFAULT_TOP_K, 1, MAX_TOP_K),
225
+ max_tokens=_opt_int(body, "max_tokens", DEFAULT_MAX_TOKENS, 1, MAX_MAX_TOKENS),
226
+ )
@@ -0,0 +1,128 @@
1
+ """Capture chats into the memory as you go (requirements §3.5).
2
+
3
+ Each source turns its chat files into append items with a stable ``client_ref``,
4
+ so sending a message twice changes nothing. Live capture appends normally (the
5
+ memory learns of a message when it is captured); backfilling old files uses
6
+ ``import`` so ``as_of`` sees them as if the memory had been running then.
7
+
8
+ Obvious secrets (API keys, tokens, private keys) are replaced with ``[redacted]``
9
+ before anything is stored, because the memory never forgets. Text inside
10
+ ``<private>…</private>`` is left out. ``COGITO_MEM_CAPTURE=off`` pauses capture.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ from pathlib import Path
19
+ from typing import Iterable, Protocol
20
+
21
+ BATCH = 200
22
+
23
+ _SECRETS = [
24
+ re.compile(r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----", re.S),
25
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_\-]{20,}"),
26
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}"),
27
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}"),
28
+ re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}"),
29
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
30
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9\-]{10,}"),
31
+ re.compile(r"\bAIza[0-9A-Za-z_\-]{35}"),
32
+ re.compile(r"\bhf_[A-Za-z0-9]{30,}"),
33
+ re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"),
34
+ re.compile(r"(?i)(?<=\bbearer )[A-Za-z0-9._~+/=\-]{16,}"),
35
+ ]
36
+ # ``password=…``, also inside a longer name (``OPENROUTER_API_KEY=…``), and the password in a URL.
37
+ _ASSIGNED = re.compile(
38
+ r"(?i)(?<![A-Za-z0-9])((?:api[_-]?key|secret(?:[_-]?key)?|access[_-]?token|auth[_-]?token|password|passwd"
39
+ r"|client[_-]?secret)[\"']?\s*[:=]\s*[\"']?)([^\s\"']{8,})"
40
+ )
41
+ _URL_PASSWORD = re.compile(r"(://[^/\s:@]+:)[^/\s@]+(?=@)")
42
+ _PRIVATE = re.compile(r"<private>.*?(?:</private>|\Z)", re.S | re.I)
43
+
44
+ REDACTED = "[redacted]"
45
+
46
+
47
+ def redact(text: str) -> str:
48
+ for pattern in _SECRETS:
49
+ text = pattern.sub(REDACTED, text)
50
+ text = _URL_PASSWORD.sub(lambda m: m.group(1) + REDACTED, text)
51
+ return _ASSIGNED.sub(lambda m: m.group(1) + REDACTED, text)
52
+
53
+
54
+ def without_private(text: str) -> str:
55
+ """The text without what is marked ``<private>…</private>`` (an unclosed tag hides the rest)."""
56
+ return _PRIVATE.sub("", text)
57
+
58
+
59
+ def enabled(setting: str = "COGITO_MEM_CAPTURE") -> bool:
60
+ """A capture switch: on unless the variable says ``off`` (or 0, false, no)."""
61
+ return os.environ.get(setting, "on").strip().lower() not in ("0", "off", "false", "no")
62
+
63
+
64
+ class Sink(Protocol):
65
+ def handle(self, body: dict) -> dict: ...
66
+
67
+
68
+ def send(sink: Sink, items: list[dict], namespace: str = "default", import_: bool = False) -> dict:
69
+ """Append items in batches. Returns counts of added and duplicate items."""
70
+ counts = {"added": 0, "duplicate": 0}
71
+ if import_:
72
+ items = sorted(items, key=lambda it: it["valid_at"])
73
+ for i in range(0, len(items), BATCH):
74
+ body = {"op": "append", "namespace": namespace, "import": import_, "items": items[i : i + BATCH]}
75
+ for result in sink.handle(body)["items"]:
76
+ counts[result["status"]] += 1
77
+ return counts
78
+
79
+
80
+ class Cursor:
81
+ """How far into a chat file capture has read: a byte offset and the line number there."""
82
+
83
+ def __init__(self, path: Path):
84
+ self.path = path
85
+ try:
86
+ state = json.loads(path.read_text())
87
+ self.offset, self.line = int(state["offset"]), int(state["line"])
88
+ except (OSError, ValueError, KeyError, TypeError):
89
+ self.offset, self.line = 0, 0
90
+
91
+ def save(self, offset: int, line: int) -> None:
92
+ self.path.parent.mkdir(parents=True, exist_ok=True)
93
+ tmp = self.path.with_suffix(".tmp")
94
+ tmp.write_text(json.dumps({"offset": offset, "line": line}))
95
+ tmp.replace(self.path)
96
+ self.offset, self.line = offset, line
97
+
98
+
99
+ def read_new_lines(path: Path, offset: int = 0, line: int = 0) -> tuple[list[tuple[int, str]], int, int]:
100
+ """Complete lines after byte ``offset`` as (line number, text), plus the new offset and line.
101
+
102
+ Line numbers count from the start of the file, so they are stable ids for a line.
103
+ """
104
+ size = path.stat().st_size
105
+ if offset > size: # the file was replaced
106
+ offset, line = 0, 0
107
+ with open(path, "rb") as fh:
108
+ fh.seek(offset)
109
+ data = fh.read()
110
+ end = data.rfind(b"\n") + 1
111
+ if end == 0:
112
+ return [], offset, line
113
+ raw_lines = data[:end].split(b"\n")[:-1]
114
+ out = [(line + i, raw.decode("utf-8", "replace")) for i, raw in enumerate(raw_lines)]
115
+ return out, offset + end, line + len(raw_lines)
116
+
117
+
118
+ def jsonl(lines: Iterable[tuple[int, str]]) -> Iterable[tuple[int, dict]]:
119
+ for n, line in lines:
120
+ line = line.strip()
121
+ if not line:
122
+ continue
123
+ try:
124
+ obj = json.loads(line)
125
+ except ValueError:
126
+ continue
127
+ if isinstance(obj, dict):
128
+ yield n, obj
@@ -0,0 +1,253 @@
1
+ """Claude Code: read the session transcript (JSONL) after each turn.
2
+
3
+ The ``Stop`` and ``SessionEnd`` hooks run ``cogito-mem capture claude-code``, which
4
+ reads the hook's JSON from stdin and hands the work to a background process, so
5
+ the chat never waits for the memory.
6
+
7
+ Kept: the user's typed messages and the assistant's text replies, without what is
8
+ marked ``<private>…</private>``, and what the session did: one ``actions`` item for
9
+ each run of tool calls between two prompts, with the commands it ran (and which
10
+ failed or did not run) and the files it read, edited or wrote, such as
11
+ "Ran `pytest -q` (failed), `pytest -q`. Edited src/x.py. Read README.md."
12
+ (``COGITO_MEM_CAPTURE_ACTIONS=off`` leaves these out). Left out: tool output and
13
+ file contents, thinking, sub-agent (sidechain) turns, slash-command output,
14
+ injected system reminders and the summary Claude Code writes when it compacts a
15
+ session.
16
+
17
+ ``client_ref`` is the transcript line's ``uuid`` (for an ``actions`` item, that of its
18
+ first tool call, so reading a turn again, whole or from its middle, adds no second
19
+ copy); ``thread_id`` is the session id; ``title`` is the project, the line's ``cwd``
20
+ (a Claude Code worktree, ``<repo>/.claude/worktrees/<name>``, counts as its
21
+ repository), so recall can prefer the current project.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import re
28
+ from pathlib import Path
29
+ from typing import Iterable
30
+
31
+ from . import Cursor, Sink, enabled, jsonl, read_new_lines, redact, send, without_private
32
+
33
+ ACTIONS = "actions" # the author of what a turn did (store.SIDE_AUTHOR)
34
+ ACTION_CHARS = 800 # about one piece (``split.PIECE_TOKENS``), so a busy turn cannot fill a reply
35
+ COMMAND_CHARS = 80
36
+
37
+ _SKIP_PREFIXES = (
38
+ "<command-name>",
39
+ "<command-message>",
40
+ "<command-args>",
41
+ "<local-command-stdout>",
42
+ "<local-command-stderr>",
43
+ "<bash-input>",
44
+ "<bash-stdout>",
45
+ "<bash-stderr>",
46
+ "Caveat: The messages below were generated",
47
+ "[Request interrupted by user",
48
+ "<user-prompt-submit-hook>",
49
+ "This session is being continued from a previous conversation", # a compaction summary
50
+ "<task-notification>", # a background task finishing, reported to Claude, not typed
51
+ )
52
+ _REMINDER = re.compile(r"<system-reminder>.*?</system-reminder>", re.S)
53
+ _WORKTREE = re.compile(r"[\\/]\.claude[\\/]worktrees[\\/][^\\/]+.*$", re.S)
54
+ _VERBS = {"Read": "Read", "NotebookRead": "Read", "Edit": "Edited", "MultiEdit": "Edited", "NotebookEdit": "Edited",
55
+ "Write": "Wrote"} # fmt: skip
56
+ _NOT_RUN = ("<tool_use_error>", "The user doesn't want to", "[Request interrupted")
57
+ _EXIT_CODE = re.compile(r"Exit code (\d+)")
58
+
59
+
60
+ def projects_dir() -> Path:
61
+ base = os.environ.get("CLAUDE_CONFIG_DIR")
62
+ return (Path(base).expanduser() if base else Path.home() / ".claude") / "projects"
63
+
64
+
65
+ def project_of(cwd: object) -> str | None:
66
+ """The project a working directory belongs to: the directory, or a worktree's repository."""
67
+ if not isinstance(cwd, str) or not cwd.strip():
68
+ return None
69
+ cwd = cwd.rstrip("/\\") or cwd
70
+ return _WORKTREE.sub("", cwd) or cwd
71
+
72
+
73
+ def project_folder(project: str) -> str:
74
+ """The folder name Claude Code gives a project under ``~/.claude/projects``."""
75
+ return re.sub(r"[^A-Za-z0-9]", "-", project)
76
+
77
+
78
+ def _text_of(content: object) -> str:
79
+ if isinstance(content, str):
80
+ return content
81
+ if isinstance(content, list):
82
+ parts = [b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text"]
83
+ return "\n\n".join(p for p in parts if p)
84
+ return ""
85
+
86
+
87
+ def items_from_lines(
88
+ lines: Iterable[tuple[int, dict]], session_id: str | None = None, actions: bool = False
89
+ ) -> list[dict]:
90
+ """The messages as items, in order; with ``actions``, also one item per turn for its tool calls."""
91
+ items = []
92
+ turns: list[tuple[dict, dict]] = [] # (actions item, its tool calls by id)
93
+ calls: dict | None = None # the tool calls of the turn under way
94
+ for _, rec in lines:
95
+ kind = rec.get("type")
96
+ if kind not in ("user", "assistant") or any(rec.get(k) for k in ("isSidechain", "isMeta", "isCompactSummary")):
97
+ continue
98
+ message = rec.get("message")
99
+ if not isinstance(message, dict):
100
+ continue
101
+ text = without_private(_REMINDER.sub("", _text_of(message.get("content")))).strip()
102
+ if text and not text.startswith(_SKIP_PREFIXES) and rec.get("uuid"):
103
+ author = "user" if kind == "user" else "assistant"
104
+ items.append(_item(rec, author, redact(text), f"claude-code:{rec['uuid']}", session_id))
105
+ if not actions:
106
+ continue
107
+ content = message.get("content")
108
+ blocks = [b for b in content if isinstance(b, dict)] if isinstance(content, list) else []
109
+ if kind == "user" and not any(b.get("type") == "tool_result" for b in blocks):
110
+ calls = None # a prompt: the turn's actions end here
111
+ for block in blocks:
112
+ if block.get("type") == "tool_use" and isinstance(block.get("input"), dict):
113
+ if calls is None:
114
+ if not rec.get("uuid"):
115
+ continue
116
+ calls = {}
117
+ turns.append((_item(rec, ACTIONS, "", f"claude-code:{rec['uuid']}:actions", session_id), calls))
118
+ items.append(turns[-1][0])
119
+ call = {"name": block.get("name"), "input": block["input"], "cwd": rec.get("cwd")}
120
+ calls[block.get("id") or len(calls)] = call
121
+ elif block.get("type") == "tool_result" and calls is not None and block.get("tool_use_id") in calls:
122
+ calls[block["tool_use_id"]]["outcome"] = _outcome(block)
123
+ for item, turn in turns:
124
+ item["text"] = _summary(list(turn.values()))
125
+ return [it for it in items if it["text"]]
126
+
127
+
128
+ def _item(rec: dict, author: str, text: str, client_ref: str, session_id: str | None) -> dict:
129
+ item = {
130
+ "author": author,
131
+ "thread_id": rec.get("sessionId") or session_id or "default",
132
+ "text": text,
133
+ "client_ref": client_ref,
134
+ }
135
+ project = project_of(rec.get("cwd"))
136
+ if project:
137
+ item["title"] = project
138
+ if isinstance(rec.get("timestamp"), str):
139
+ item["valid_at"] = rec["timestamp"]
140
+ return item
141
+
142
+
143
+ def _outcome(result: dict) -> str | None:
144
+ """``failed``, ``not run`` (refused or interrupted), or None for a call that went through."""
145
+ text = _text_of(result.get("content")).lstrip()
146
+ if text.startswith(_NOT_RUN):
147
+ return "not run"
148
+ code = _EXIT_CODE.match(text)
149
+ if result.get("is_error") or (code and code.group(1) != "0"):
150
+ return "failed"
151
+ return None
152
+
153
+
154
+ def _summary(calls: list[dict]) -> str:
155
+ """One sentence per kind of action: the commands in order, then the files by what was done to them."""
156
+ commands: list[str] = []
157
+ files: dict[str, list[str]] = {}
158
+ for call in calls:
159
+ args, outcome = call["input"], call.get("outcome")
160
+ cwd = call["cwd"] if isinstance(call["cwd"], str) else ""
161
+ if call["name"] == "Bash" and isinstance(args.get("command"), str) and args["command"].strip():
162
+ entry = f"`{_command(args['command'], cwd)}`" + (f" ({outcome})" if outcome else "")
163
+ if entry not in commands:
164
+ commands.append(entry)
165
+ continue
166
+ path = args.get("file_path") or args.get("notebook_path")
167
+ if not isinstance(path, str) or not path.strip() or outcome: # a failed edit changed nothing
168
+ continue
169
+ paths = files.setdefault(_VERBS.get(call["name"]) or f"Used {call['name']} on", [])
170
+ if _relative(path, cwd) not in paths:
171
+ paths.append(_relative(path, cwd))
172
+ changed = {p for verb, paths in files.items() if verb != "Read" for p in paths}
173
+ if "Read" in files: # files are read before they are edited: that says nothing more
174
+ files["Read"] = [p for p in files["Read"] if p not in changed]
175
+ kinds = [("Ran", commands), *sorted(files.items(), key=lambda kv: kv[0] == "Read")]
176
+ parts = [(verb, entries) for verb, entries in kinds if entries]
177
+ # Each kind gets an equal share of the room, and what a short one leaves goes to the others.
178
+ texts = [""] * len(parts)
179
+ room = ACTION_CHARS
180
+ by_length = sorted(range(len(parts)), key=lambda i: sum(len(e) + 2 for e in parts[i][1]))
181
+ for left, i in zip(range(len(parts), 0, -1), by_length):
182
+ texts[i] = _listing(*parts[i], room // left)
183
+ room -= len(texts[i]) + 1
184
+ text = redact(" ".join(texts))
185
+ return text if len(text) <= ACTION_CHARS else text[: ACTION_CHARS - 2] + " …"
186
+
187
+
188
+ def _listing(verb: str, entries: list[str], room: int) -> str:
189
+ """``Verb a, b, c.`` with as many entries as fit in ``room`` characters, and how many more there were."""
190
+ text = verb
191
+ for n, entry in enumerate(entries):
192
+ if n and len(text) + len(entry) + (3 if n == len(entries) - 1 else 18) > room:
193
+ return f"{text}, and {len(entries) - n} more."
194
+ text += (", " if n else " ") + entry
195
+ return text + "."
196
+
197
+
198
+ def _command(command: str, cwd: str) -> str:
199
+ """The command's first line, cut to ``COMMAND_CHARS``, with paths under ``cwd`` relative to it."""
200
+ command = redact(command.strip()) # before it is cut, so no part of a secret is left
201
+ base = re.escape(cwd.rstrip("/\\"))
202
+ if base:
203
+ command = re.sub(rf"^cd +([\"']?){base}[/\\]?\1 *(?:&&|;) *", "", command)
204
+ command = re.sub(rf"(?<![^\s\"'=:(]){base}[/\\]", "", command)
205
+ first, *rest = command.splitlines() or [""]
206
+ if rest or len(first) > COMMAND_CHARS:
207
+ first = first[:COMMAND_CHARS].rstrip() + " …"
208
+ return first
209
+
210
+
211
+ def _relative(path: str, cwd: str) -> str:
212
+ base = cwd.rstrip("/\\")
213
+ if base and path.startswith(base) and path[len(base) : len(base) + 1] in ("/", "\\"):
214
+ return path[len(base) + 1 :]
215
+ return path
216
+
217
+
218
+ def capture_transcript(
219
+ sink: Sink,
220
+ transcript: Path,
221
+ state_dir: Path,
222
+ session_id: str | None = None,
223
+ namespace: str = "default",
224
+ import_: bool = False,
225
+ ) -> dict:
226
+ """Append the new messages of one transcript. Returns counts of added and duplicate items."""
227
+ session = session_id or transcript.stem
228
+ cursor = Cursor(state_dir / "claude-code" / f"{_safe(session)}.json")
229
+ lines, offset, line = read_new_lines(transcript, cursor.offset, cursor.line)
230
+ items = items_from_lines(jsonl(lines), session, actions=enabled("COGITO_MEM_CAPTURE_ACTIONS"))
231
+ if import_:
232
+ items = [it for it in items if "valid_at" in it]
233
+ counts = send(sink, items, namespace, import_) if items else {"added": 0, "duplicate": 0}
234
+ cursor.save(offset, line)
235
+ return counts
236
+
237
+
238
+ def capture_all(sink: Sink, state_dir: Path, namespace: str = "default", root: Path | None = None) -> dict:
239
+ """Backfill every transcript under ~/.claude/projects (as history, with ``import``)."""
240
+ total = {"added": 0, "duplicate": 0, "files": 0}
241
+ base = root or projects_dir()
242
+ if not base.is_dir():
243
+ return total
244
+ for path in sorted(base.glob("*/*.jsonl")):
245
+ counts = capture_transcript(sink, path, state_dir, path.stem, namespace, import_=True)
246
+ total["added"] += counts["added"]
247
+ total["duplicate"] += counts["duplicate"]
248
+ total["files"] += 1
249
+ return total
250
+
251
+
252
+ def _safe(name: str) -> str:
253
+ return re.sub(r"[^A-Za-z0-9._-]", "_", name)[:128]