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 +29 -0
- cogito_mem/__main__.py +3 -0
- cogito_mem/api.py +226 -0
- cogito_mem/capture/__init__.py +128 -0
- cogito_mem/capture/claude_code.py +253 -0
- cogito_mem/capture/codex.py +130 -0
- cogito_mem/capture/exports.py +143 -0
- cogito_mem/cli.py +464 -0
- cogito_mem/client.py +198 -0
- cogito_mem/doctor.py +102 -0
- cogito_mem/embedding.py +205 -0
- cogito_mem/errors.py +44 -0
- cogito_mem/evals/__init__.py +1 -0
- cogito_mem/evals/common.py +88 -0
- cogito_mem/evals/locomo.py +300 -0
- cogito_mem/evals/locomo_qa.py +694 -0
- cogito_mem/evals/longmemeval.py +335 -0
- cogito_mem/evals/longmemeval_recall.py +361 -0
- cogito_mem/evals/speed.py +314 -0
- cogito_mem/ids.py +51 -0
- cogito_mem/install.py +447 -0
- cogito_mem/keyword.py +77 -0
- cogito_mem/mcp_server.py +238 -0
- cogito_mem/memory.py +401 -0
- cogito_mem/names.py +174 -0
- cogito_mem/recall.py +292 -0
- cogito_mem/runtime.py +510 -0
- cogito_mem/server.py +278 -0
- cogito_mem/split.py +108 -0
- cogito_mem/store.py +561 -0
- cogito_mem/terms.py +39 -0
- cogito_mem/timeutil.py +64 -0
- cogito_mem/tokenizer.py +76 -0
- cogito_mem-0.2.0.dist-info/METADATA +90 -0
- cogito_mem-0.2.0.dist-info/RECORD +38 -0
- cogito_mem-0.2.0.dist-info/WHEEL +4 -0
- cogito_mem-0.2.0.dist-info/entry_points.txt +2 -0
- cogito_mem-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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]
|