chip-memory 1.1.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.
- chip_memory/__init__.py +31 -0
- chip_memory/__main__.py +83 -0
- chip_memory/_stopwords.py +52 -0
- chip_memory/_utils.py +38 -0
- chip_memory/api.py +628 -0
- chip_memory/auto_write.py +451 -0
- chip_memory/automagic_token.py +35 -0
- chip_memory/bm25.py +231 -0
- chip_memory/collect/__init__.py +22 -0
- chip_memory/collect/absorb.py +631 -0
- chip_memory/collect/adapters/__init__.py +0 -0
- chip_memory/collect/adapters/claude_code.py +352 -0
- chip_memory/collect/adapters/git.py +353 -0
- chip_memory/collect/adapters/shell.py +524 -0
- chip_memory/collect/correlator.py +462 -0
- chip_memory/collect/dedup.py +212 -0
- chip_memory/collect/events.py +204 -0
- chip_memory/collect/redact.py +132 -0
- chip_memory/collect/review_cli.py +10 -0
- chip_memory/collect/signal_detector.py +594 -0
- chip_memory/connect_cmd.py +195 -0
- chip_memory/consolidation.py +353 -0
- chip_memory/contradiction.py +366 -0
- chip_memory/daemon.py +399 -0
- chip_memory/dashboard/static/data.json +327 -0
- chip_memory/dashboard/static/index.html +1141 -0
- chip_memory/dashboard/static/vendor/OrbitControls.js +1417 -0
- chip_memory/dashboard/static/vendor/three.module.js +53044 -0
- chip_memory/dashboard.py +480 -0
- chip_memory/dashboard_cmd.py +80 -0
- chip_memory/doctor.py +268 -0
- chip_memory/eval.py +330 -0
- chip_memory/export.py +228 -0
- chip_memory/export_wiki.py +467 -0
- chip_memory/graph/__init__.py +5 -0
- chip_memory/graph/aliases.py +251 -0
- chip_memory/graph/predicate_vocab.py +177 -0
- chip_memory/graph/typed_edges.py +860 -0
- chip_memory/health.py +191 -0
- chip_memory/hermes.py +363 -0
- chip_memory/linker.py +503 -0
- chip_memory/map_cmd.py +140 -0
- chip_memory/mcp_server.py +491 -0
- chip_memory/node.py +598 -0
- chip_memory/paths.py +109 -0
- chip_memory/pending.py +78 -0
- chip_memory/py.typed +0 -0
- chip_memory/query/__init__.py +2 -0
- chip_memory/query/triple_query.py +347 -0
- chip_memory/rebalance.py +67 -0
- chip_memory/recall.py +149 -0
- chip_memory/search.py +477 -0
- chip_memory/seed.py +86 -0
- chip_memory/server.py +355 -0
- chip_memory/synthesis/__init__.py +6 -0
- chip_memory/synthesis/think.py +965 -0
- chip_memory/telegram_watch.py +590 -0
- chip_memory/timeline.py +489 -0
- chip_memory/timeline_cmd.py +93 -0
- chip_memory/tui.py +678 -0
- chip_memory/uninstall.py +179 -0
- chip_memory/watch_context.py +360 -0
- chip_memory/why.py +32 -0
- chip_memory-1.1.0.dist-info/METADATA +289 -0
- chip_memory-1.1.0.dist-info/RECORD +69 -0
- chip_memory-1.1.0.dist-info/WHEEL +5 -0
- chip_memory-1.1.0.dist-info/entry_points.txt +2 -0
- chip_memory-1.1.0.dist-info/licenses/LICENSE +21 -0
- chip_memory-1.1.0.dist-info/top_level.txt +1 -0
chip_memory/api.py
ADDED
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
"""
|
|
2
|
+
chip_memory.api — the one-line API for any agent.
|
|
3
|
+
|
|
4
|
+
The point: any agent (yours, mine, a third-party) should be able to
|
|
5
|
+
use chip-memory with three lines of code:
|
|
6
|
+
|
|
7
|
+
from chip_memory import api
|
|
8
|
+
api.remember("the user prefers terse responses")
|
|
9
|
+
ctx = api.ask("what do I know about polish ecommerce")
|
|
10
|
+
api.bump(ctx[0]['id']) # mark a result as useful
|
|
11
|
+
|
|
12
|
+
That's it. No path wrangling, no search import, no heat math, no
|
|
13
|
+
embedding model awareness. The whole brain is hidden behind three verbs.
|
|
14
|
+
|
|
15
|
+
Why this is a separate module from search/auto_write/etc:
|
|
16
|
+
- search.py is the CLI/search primitive (returns raw scored nodes)
|
|
17
|
+
- api.py is the **agent contract** — return values are pre-formatted
|
|
18
|
+
to be dropped into a system prompt, errors are caught and returned
|
|
19
|
+
as `{"error": "..."}` dicts, and the three verbs are stable
|
|
20
|
+
(their signatures won't change between versions, even if internals do).
|
|
21
|
+
|
|
22
|
+
Optional env: CHIP_HOME (default ~/chip-memory/brain/), CHIP_AGENT
|
|
23
|
+
(default 'default') — used to namespace feedback per-agent.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from . import _utils, paths, node as node_mod
|
|
34
|
+
from . import search as search_mod
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("chip-memory")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
40
|
+
# ask — query the brain, get system-prompt-friendly context back
|
|
41
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
def ask(query: str, top_k: int = 5, domain: str | None = None,
|
|
44
|
+
as_brief: bool = True,
|
|
45
|
+
use_vector: bool = True, use_bm25: bool = True,
|
|
46
|
+
try_typed_edges: bool = True) -> list[dict[str, Any]]:
|
|
47
|
+
"""Ask the brain about a topic. Returns a list of search result dicts.
|
|
48
|
+
|
|
49
|
+
NOTE: The return shape of ask() may vary — when try_typed_edges matches,
|
|
50
|
+
it returns a single-item list with a ``typed_edge`` marker dict instead
|
|
51
|
+
of standard search results. For a stable return shape use ``answer()``
|
|
52
|
+
instead, which always returns ``{"kind": ..., "results": [...]}``.
|
|
53
|
+
|
|
54
|
+
Each result: {"id", "content", "domain", "type", "score", "heat",
|
|
55
|
+
"importance", "cosine_similarity", "bm25_score",
|
|
56
|
+
"hybrid_score"}
|
|
57
|
+
|
|
58
|
+
If try_typed_edges=True (default), first attempts a structured typed-edge
|
|
59
|
+
query ("who works at acme"). If it matches a known pattern and has results,
|
|
60
|
+
returns those instead of BM25/vector search. If no edge pattern matches,
|
|
61
|
+
falls back to standard hybrid search.
|
|
62
|
+
|
|
63
|
+
If as_brief=True (default), the results are also sorted/filtered
|
|
64
|
+
to be useful in a system prompt — skip cold, sort by score, cap
|
|
65
|
+
at top_k. If as_brief=False, you get the raw ranked list.
|
|
66
|
+
|
|
67
|
+
Returns [] if the brain is empty or no results pass the threshold.
|
|
68
|
+
Never raises — failures return [] (logged via the brain's normal
|
|
69
|
+
log path; you can also catch exceptions by passing top_k=0).
|
|
70
|
+
"""
|
|
71
|
+
if not query or not query.strip():
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
# Try typed-edge query first (structured relations)
|
|
75
|
+
if try_typed_edges:
|
|
76
|
+
try:
|
|
77
|
+
from .query import triple_query as tq
|
|
78
|
+
edge_result = tq.try_query(query)
|
|
79
|
+
if edge_result:
|
|
80
|
+
# Return as a single result with the structured answer
|
|
81
|
+
return [{
|
|
82
|
+
"id": "typed_edge_result",
|
|
83
|
+
"content": edge_result,
|
|
84
|
+
"domain": "structured",
|
|
85
|
+
"type": "typed_edge_answer",
|
|
86
|
+
"score": 1.0,
|
|
87
|
+
"heat": 1.0,
|
|
88
|
+
"importance": 1.0,
|
|
89
|
+
"cosine_similarity": 0.0,
|
|
90
|
+
"bm25_score": 0.0,
|
|
91
|
+
"hybrid_score": 0.0,
|
|
92
|
+
"typed_edge": True,
|
|
93
|
+
}]
|
|
94
|
+
except Exception:
|
|
95
|
+
log.warning("typed-edge query failed, falling through to standard search", exc_info=True)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
results = search_mod.search(
|
|
99
|
+
query, top_k=top_k, domain=domain,
|
|
100
|
+
skip_cold=True, min_score=0.02,
|
|
101
|
+
use_vector=use_vector, use_bm25=use_bm25,
|
|
102
|
+
)
|
|
103
|
+
except Exception:
|
|
104
|
+
log.exception("ask() search failed")
|
|
105
|
+
return []
|
|
106
|
+
if not results:
|
|
107
|
+
return []
|
|
108
|
+
# Bump access_count on returned nodes (this is the "hot" feedback).
|
|
109
|
+
# Locked so concurrent api.ask() calls don't race on access_count writes.
|
|
110
|
+
with node_mod.brain_lock():
|
|
111
|
+
now = node_mod.now_iso()
|
|
112
|
+
for r in results:
|
|
113
|
+
p = paths.nodes_dir() / f"{r['id']}.json"
|
|
114
|
+
if not p.exists():
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
n = json.loads(p.read_text(encoding="utf-8"))
|
|
118
|
+
except (json.JSONDecodeError, OSError):
|
|
119
|
+
continue
|
|
120
|
+
n["access_count"] = n.get("access_count", 0) + 1
|
|
121
|
+
n["last_accessed"] = now
|
|
122
|
+
node_mod.atomic_write_json(p, n)
|
|
123
|
+
# Co-access boost: every time the agent asks and gets back a cluster,
|
|
124
|
+
# the map learns "these go together." Best-effort — failure doesn't
|
|
125
|
+
# affect the return value.
|
|
126
|
+
if len(results) >= 2:
|
|
127
|
+
try:
|
|
128
|
+
from . import linker
|
|
129
|
+
from . import timeline
|
|
130
|
+
boosted = linker.boost_co_access([r["id"] for r in results])
|
|
131
|
+
if boosted > 0:
|
|
132
|
+
# Log the co-access for the timeline. We log a single
|
|
133
|
+
# "boosted cluster" event rather than N(N-1)/2 edge
|
|
134
|
+
# events — the timeline would be unreadable otherwise.
|
|
135
|
+
timeline.log_event(
|
|
136
|
+
timeline.EV_LINK_BOOSTED,
|
|
137
|
+
cluster=[r["id"] for r in results],
|
|
138
|
+
boosts_applied=boosted,
|
|
139
|
+
query=query[:80],
|
|
140
|
+
)
|
|
141
|
+
except Exception:
|
|
142
|
+
log.warning("co-access boost failed in api.ask()", exc_info=True)
|
|
143
|
+
return results
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def ask_text(query: str, top_k: int = 5) -> str:
|
|
147
|
+
"""ask() but as a pre-formatted string for direct system-prompt use.
|
|
148
|
+
|
|
149
|
+
Returns "" if no results. Example output:
|
|
150
|
+
|
|
151
|
+
[betting/rule] Kelly criterion with safety: stake = (edge / odds) * 0.25
|
|
152
|
+
[dropshipping/event] VEVERLO hit 9k EUR/month after 6-7 months
|
|
153
|
+
|
|
154
|
+
Grouped by domain, with a 220-char preview per node.
|
|
155
|
+
"""
|
|
156
|
+
results = ask(query, top_k=top_k, as_brief=True)
|
|
157
|
+
if not results:
|
|
158
|
+
return ""
|
|
159
|
+
by_domain: dict[str, list[dict[str, Any]]] = {}
|
|
160
|
+
for r in results:
|
|
161
|
+
by_domain.setdefault(r["domain"], []).append(r)
|
|
162
|
+
lines = [f"# Brain context for: {query!r} ({len(results)} nodes)"]
|
|
163
|
+
for dom, items in by_domain.items():
|
|
164
|
+
lines.append(f"\n## {dom} ({len(items)})")
|
|
165
|
+
for r in items:
|
|
166
|
+
preview = _utils.truncate_preview(r["content"].replace("\n", " ").strip(), 220)
|
|
167
|
+
lines.append(f"- [{r['type']}] {preview}")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
172
|
+
# answer — stable-shape query result
|
|
173
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def answer(query: str, top_k: int = 5, domain: str | None = None,
|
|
177
|
+
use_vector: bool = True, use_bm25: bool = True,
|
|
178
|
+
try_typed_edges: bool = True) -> dict[str, Any]:
|
|
179
|
+
"""Answer a query with a stable return shape.
|
|
180
|
+
|
|
181
|
+
Unlike ``ask()``, which may return different shapes (a typed-edge result
|
|
182
|
+
dict, a list of search results, or an empty list), ``answer()`` always
|
|
183
|
+
returns a dict with a predictable ``kind`` key so callers never need to
|
|
184
|
+
branch on the presence of a ``typed_edge`` marker.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
A dict with keys:
|
|
188
|
+
- kind: ``"typed_edge"`` | ``"search"`` | ``"empty"``
|
|
189
|
+
- results: list of result dicts (contents vary by kind)
|
|
190
|
+
|
|
191
|
+
Example::
|
|
192
|
+
|
|
193
|
+
>>> api.answer("who works at acme")
|
|
194
|
+
{"kind": "typed_edge", "results": [{"content": "...", "type": "typed_edge_answer"}]}
|
|
195
|
+
|
|
196
|
+
>>> api.answer("python tips")
|
|
197
|
+
{"kind": "search", "results": [{"id": "...", "content": "...", ...}]}
|
|
198
|
+
|
|
199
|
+
>>> api.answer("zzznothing")
|
|
200
|
+
{"kind": "empty", "results": []}
|
|
201
|
+
"""
|
|
202
|
+
if not query or not query.strip():
|
|
203
|
+
return {"kind": "empty", "results": []}
|
|
204
|
+
|
|
205
|
+
# Try typed-edge query first (structured relations)
|
|
206
|
+
if try_typed_edges:
|
|
207
|
+
try:
|
|
208
|
+
from .query import triple_query as tq
|
|
209
|
+
edge_result = tq.try_query(query)
|
|
210
|
+
if edge_result:
|
|
211
|
+
return {
|
|
212
|
+
"kind": "typed_edge",
|
|
213
|
+
"results": [{
|
|
214
|
+
"content": edge_result,
|
|
215
|
+
"type": "typed_edge_answer",
|
|
216
|
+
}],
|
|
217
|
+
}
|
|
218
|
+
except Exception:
|
|
219
|
+
log.warning(
|
|
220
|
+
"typed-edge query failed in answer(), falling through",
|
|
221
|
+
exc_info=True,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
results = search_mod.search(
|
|
226
|
+
query, top_k=top_k, domain=domain,
|
|
227
|
+
skip_cold=True, min_score=0.02,
|
|
228
|
+
use_vector=use_vector, use_bm25=use_bm25,
|
|
229
|
+
)
|
|
230
|
+
except Exception:
|
|
231
|
+
log.exception("answer() search failed")
|
|
232
|
+
return {"kind": "empty", "results": []}
|
|
233
|
+
|
|
234
|
+
if not results:
|
|
235
|
+
return {"kind": "empty", "results": []}
|
|
236
|
+
|
|
237
|
+
# Bump access_count on returned nodes (same as ask()).
|
|
238
|
+
with node_mod.brain_lock():
|
|
239
|
+
now = node_mod.now_iso()
|
|
240
|
+
for r in results:
|
|
241
|
+
p = paths.nodes_dir() / f"{r['id']}.json"
|
|
242
|
+
if not p.exists():
|
|
243
|
+
continue
|
|
244
|
+
try:
|
|
245
|
+
n = json.loads(p.read_text(encoding="utf-8"))
|
|
246
|
+
except (json.JSONDecodeError, OSError):
|
|
247
|
+
continue
|
|
248
|
+
n["access_count"] = n.get("access_count", 0) + 1
|
|
249
|
+
n["last_accessed"] = now
|
|
250
|
+
node_mod.atomic_write_json(p, n)
|
|
251
|
+
|
|
252
|
+
# Co-access boost (same as ask()).
|
|
253
|
+
if len(results) >= 2:
|
|
254
|
+
try:
|
|
255
|
+
from . import linker
|
|
256
|
+
from . import timeline
|
|
257
|
+
boosted = linker.boost_co_access([r["id"] for r in results])
|
|
258
|
+
if boosted > 0:
|
|
259
|
+
timeline.log_event(
|
|
260
|
+
timeline.EV_LINK_BOOSTED,
|
|
261
|
+
cluster=[r["id"] for r in results],
|
|
262
|
+
boosts_applied=boosted,
|
|
263
|
+
query=query[:80],
|
|
264
|
+
)
|
|
265
|
+
except Exception:
|
|
266
|
+
log.warning("co-access boost failed in api.answer()", exc_info=True)
|
|
267
|
+
|
|
268
|
+
return {"kind": "search", "results": results}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
272
|
+
# remember — write a single node (or several)
|
|
273
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def remember(content: str, type: str = "fact", domain: str = "general",
|
|
276
|
+
tags: list[str] | None = None, importance: float = 0.5,
|
|
277
|
+
source: str | None = None,
|
|
278
|
+
ref: str | None = None,
|
|
279
|
+
ttl: str | None = None,
|
|
280
|
+
skip_redact: bool = False,
|
|
281
|
+
provenance: dict | None = None,
|
|
282
|
+
trust_confidence: float | None = None) -> dict[str, Any] | None:
|
|
283
|
+
"""Write a single memory node. Returns the new node dict, or None on failure.
|
|
284
|
+
|
|
285
|
+
SECURITY: content is redacted by default before storage (Rail 5).
|
|
286
|
+
Pass skip_redact=True to bypass (only for internal use where content
|
|
287
|
+
was already redacted upstream — e.g., the absorb pipeline).
|
|
288
|
+
|
|
289
|
+
Idempotent: writing the same content twice returns the existing
|
|
290
|
+
node the second time (same id, same fields). This means agents
|
|
291
|
+
can re-call remember() with the same content safely — no dedup
|
|
292
|
+
bookkeeping needed on the caller side.
|
|
293
|
+
|
|
294
|
+
`source` defaults to $CHIP_AGENT (so different agents writing to
|
|
295
|
+
the same brain can be told apart in audit logs).
|
|
296
|
+
|
|
297
|
+
`ttl` is a duration string like "30d", "12h", "2w", "90m" — the
|
|
298
|
+
node auto-expires after that. Use for things that age: prices,
|
|
299
|
+
project status, contact info. None = never expires.
|
|
300
|
+
"""
|
|
301
|
+
if not content or len(content.strip()) < 10:
|
|
302
|
+
return None
|
|
303
|
+
# Rail 5: redact secrets unless explicitly skipped (absorb pipeline
|
|
304
|
+
# already redacted upstream). This protects ALL write paths —
|
|
305
|
+
# MCP, API, CLI, HTTP server — with a single enforcement point.
|
|
306
|
+
if not skip_redact:
|
|
307
|
+
from .collect import redact as _redact
|
|
308
|
+
content = _redact.redact(content)
|
|
309
|
+
v = node_mod.validate({
|
|
310
|
+
"content": content, "type": type, "domain": domain,
|
|
311
|
+
"tags": tags or [], "importance": importance,
|
|
312
|
+
})
|
|
313
|
+
if v is None:
|
|
314
|
+
return None
|
|
315
|
+
source_str = source or os.environ.get("CHIP_AGENT", "api")
|
|
316
|
+
v["source"] = source_str
|
|
317
|
+
if provenance is not None:
|
|
318
|
+
v["provenance"] = provenance
|
|
319
|
+
else:
|
|
320
|
+
v["provenance"] = {"source": source_str, "ref": ref}
|
|
321
|
+
if trust_confidence is not None:
|
|
322
|
+
v["trust_confidence"] = trust_confidence
|
|
323
|
+
else:
|
|
324
|
+
v["trust_confidence"] = node_mod.compute_trust_confidence(source_str)
|
|
325
|
+
if ttl:
|
|
326
|
+
exp = node_mod.parse_ttl(ttl)
|
|
327
|
+
if exp:
|
|
328
|
+
v["expires_at"] = exp
|
|
329
|
+
created, nid = node_mod.write(v)
|
|
330
|
+
# Re-read so we return the full record (with id, timestamps, defaults).
|
|
331
|
+
# Note: the timeline hook for "node added" lives in node.write itself,
|
|
332
|
+
# not here — that way every entry point (api.remember, seed, daemon
|
|
333
|
+
# cycle) gets logged without us having to hook each one.
|
|
334
|
+
n = node_mod.read(nid)
|
|
335
|
+
return n
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def remember_many(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
339
|
+
"""Bulk version of remember(). Each item is a kwargs-dict.
|
|
340
|
+
|
|
341
|
+
Returns only the successfully written nodes. Invalid items are
|
|
342
|
+
silently skipped (same policy as auto_write).
|
|
343
|
+
"""
|
|
344
|
+
out = []
|
|
345
|
+
for item in items:
|
|
346
|
+
content = item.get("content", "")
|
|
347
|
+
if not content:
|
|
348
|
+
continue
|
|
349
|
+
n = remember(
|
|
350
|
+
content=content,
|
|
351
|
+
type=item.get("type", "fact"),
|
|
352
|
+
domain=item.get("domain", "general"),
|
|
353
|
+
tags=item.get("tags", []),
|
|
354
|
+
importance=item.get("importance", 0.5),
|
|
355
|
+
source=item.get("source"),
|
|
356
|
+
provenance=item.get("provenance"),
|
|
357
|
+
trust_confidence=item.get("trust_confidence"),
|
|
358
|
+
)
|
|
359
|
+
if n is not None:
|
|
360
|
+
out.append(n)
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
365
|
+
# bump — feedback on a node (useful / not useful)
|
|
366
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def bump(node_id: str, useful: bool = True, amount: float = 0.05) -> dict[str, Any] | None:
|
|
369
|
+
"""Provide feedback on a node. Adjusts importance by ±`amount`.
|
|
370
|
+
|
|
371
|
+
Same semantics as POST /feedback on the server. Returns the updated
|
|
372
|
+
node, or None if not found.
|
|
373
|
+
|
|
374
|
+
Why: this is the *only* way to teach the brain what matters. If
|
|
375
|
+
a node gets bumped a lot, its heat stays high via access_count.
|
|
376
|
+
If it gets negative-bumped, its importance sinks and the daemon
|
|
377
|
+
will eventually archive it. Use this in your agent loop:
|
|
378
|
+
|
|
379
|
+
results = api.ask(...)
|
|
380
|
+
for r in results:
|
|
381
|
+
if user_liked_this(r):
|
|
382
|
+
api.bump(r['id'], useful=True)
|
|
383
|
+
"""
|
|
384
|
+
n = node_mod.read(node_id)
|
|
385
|
+
if n is None:
|
|
386
|
+
return None
|
|
387
|
+
# Lock the read-modify-write so concurrent api.bump / daemon heat
|
|
388
|
+
# updates don't clobber each other's changes.
|
|
389
|
+
with node_mod.brain_lock():
|
|
390
|
+
# Re-read inside the lock to get the latest state
|
|
391
|
+
n = node_mod.read(node_id)
|
|
392
|
+
if n is None:
|
|
393
|
+
return None
|
|
394
|
+
if useful:
|
|
395
|
+
n["importance"] = min(1.0, n.get("importance", 0.5) + amount)
|
|
396
|
+
else:
|
|
397
|
+
n["importance"] = max(0.0, n.get("importance", 0.5) - (amount * 2))
|
|
398
|
+
n["last_accessed"] = node_mod.now_iso()
|
|
399
|
+
n["access_count"] = n.get("access_count", 0) + 1
|
|
400
|
+
node_mod.write_raw(paths.nodes_dir() / f"{node_id}.json", n)
|
|
401
|
+
return n
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
405
|
+
# forget / archive — explicit cleanup
|
|
406
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def forget(node_id: str) -> bool:
|
|
409
|
+
"""Archive a node (never delete). Returns True if it was archived.
|
|
410
|
+
|
|
411
|
+
Archived nodes are skipped by search and by health, but kept on
|
|
412
|
+
disk for audit/recovery. The `chip-memory search --include-archived`
|
|
413
|
+
flag (not yet implemented) would show them.
|
|
414
|
+
"""
|
|
415
|
+
n = node_mod.read(node_id)
|
|
416
|
+
if n is None:
|
|
417
|
+
return False
|
|
418
|
+
# Lock so concurrent writes don't clobber each other.
|
|
419
|
+
with node_mod.brain_lock():
|
|
420
|
+
n = node_mod.read(node_id)
|
|
421
|
+
if n is None:
|
|
422
|
+
return False
|
|
423
|
+
n["archived"] = True
|
|
424
|
+
n["archived_at"] = node_mod.now_iso()
|
|
425
|
+
node_mod.write_raw(paths.nodes_dir() / f"{node_id}.json", n)
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
430
|
+
# stats — one-line brain health
|
|
431
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
def stats() -> dict[str, Any]:
|
|
434
|
+
"""Quick brain stats for agent introspection. Returns a dict.
|
|
435
|
+
|
|
436
|
+
Keys: nodes, domains, hot, avg_heat, health_score, top_domain,
|
|
437
|
+
cold_nodes (not accessed in 60+ days), orphan_nodes (zero map
|
|
438
|
+
connections), hubs (top-5 most-connected nodes, with degree).
|
|
439
|
+
"""
|
|
440
|
+
nodes = node_mod.load_all()
|
|
441
|
+
if not nodes:
|
|
442
|
+
return {"nodes": 0, "domains": 0, "hot": 0, "avg_heat": 0.0,
|
|
443
|
+
"health_score": 0, "top_domain": None, "cold_nodes": 0,
|
|
444
|
+
"orphan_nodes": 0, "hubs": []}
|
|
445
|
+
from collections import Counter
|
|
446
|
+
domains = Counter(n.get("domain", "unknown") for n in nodes)
|
|
447
|
+
hot = sum(1 for n in nodes if n.get("heat", 0) >= 0.4)
|
|
448
|
+
avg_heat = sum(n.get("heat", 0) for n in nodes) / len(nodes)
|
|
449
|
+
# Lazy import: health not always needed (empty-brain fast path skips it)
|
|
450
|
+
from . import health as health_mod
|
|
451
|
+
h = health_mod.compute_health(nodes)
|
|
452
|
+
cold = len(health_mod.find_stale(nodes))
|
|
453
|
+
# Map stats (the connection network)
|
|
454
|
+
from . import linker
|
|
455
|
+
orphan = linker.orphan_count(nodes)
|
|
456
|
+
hubs = linker.hub_nodes(nodes, top_n=5)
|
|
457
|
+
return {
|
|
458
|
+
"nodes": len(nodes),
|
|
459
|
+
"domains": len(domains),
|
|
460
|
+
"hot": hot,
|
|
461
|
+
"avg_heat": round(avg_heat, 3),
|
|
462
|
+
"health_score": h["score"],
|
|
463
|
+
"top_domain": domains.most_common(1)[0][0] if domains else None,
|
|
464
|
+
"cold_nodes": cold,
|
|
465
|
+
"orphan_nodes": orphan,
|
|
466
|
+
"hubs": hubs,
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
471
|
+
# why — provenance/usage report for a single node
|
|
472
|
+
# ─────────────────────────────────────────────────────────────────────
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def why(node_id: str) -> str | None:
|
|
476
|
+
"""Return a formatted provenance/usage report for a single node.
|
|
477
|
+
|
|
478
|
+
Displays:
|
|
479
|
+
- Node id, content preview, type, domain
|
|
480
|
+
- Importance, heat
|
|
481
|
+
- Provenance: source, trust_confidence, created_at
|
|
482
|
+
- Usage: access_count, last_accessed
|
|
483
|
+
- Links: connection count and top link strengths
|
|
484
|
+
- Heat history
|
|
485
|
+
|
|
486
|
+
Returns None if node not found.
|
|
487
|
+
"""
|
|
488
|
+
n = node_mod.read(node_id)
|
|
489
|
+
if n is None:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
lines: list[str] = []
|
|
493
|
+
lines.append(f"Node: {n['id']}")
|
|
494
|
+
# Full content (the node's stored content)
|
|
495
|
+
content = n.get("content", "")
|
|
496
|
+
lines.append(f"Content: {content}")
|
|
497
|
+
lines.append(f"Type: {n.get('type', '?')} | Domain: {n.get('domain', '?')}")
|
|
498
|
+
imp = n.get("importance", 0.5)
|
|
499
|
+
heat = n.get("heat", 0.0)
|
|
500
|
+
lines.append(f"Importance: {imp:.2f} | Heat: {heat:.2f}")
|
|
501
|
+
lines.append("")
|
|
502
|
+
|
|
503
|
+
# Provenance section
|
|
504
|
+
prov = n.get("provenance", {})
|
|
505
|
+
src = prov.get("source", n.get("source", "?"))
|
|
506
|
+
tc = n.get("trust_confidence", 0.5)
|
|
507
|
+
lines.append("Provenance:")
|
|
508
|
+
lines.append(f" Source: {src}")
|
|
509
|
+
lines.append(f" Trust confidence: {tc:.2f}")
|
|
510
|
+
ref = prov.get("ref")
|
|
511
|
+
if ref:
|
|
512
|
+
lines.append(f" Ref: {ref}")
|
|
513
|
+
created = n.get("created_at", "?")
|
|
514
|
+
lines.append(f" Learned: {created}")
|
|
515
|
+
lines.append("")
|
|
516
|
+
|
|
517
|
+
# Usage section
|
|
518
|
+
ac = n.get("access_count", 0)
|
|
519
|
+
la = n.get("last_accessed", "?")
|
|
520
|
+
lines.append("Usage:")
|
|
521
|
+
lines.append(f" Access count: {ac}")
|
|
522
|
+
lines.append(f" Last accessed: {la}")
|
|
523
|
+
lines.append("")
|
|
524
|
+
|
|
525
|
+
# Links section
|
|
526
|
+
conns = n.get("connections") or []
|
|
527
|
+
strengths = n.get("link_strengths") or []
|
|
528
|
+
lines.append("Links:")
|
|
529
|
+
if conns:
|
|
530
|
+
lines.append(f" Connected to: {len(conns)} other nodes")
|
|
531
|
+
if strengths and len(strengths) == len(conns):
|
|
532
|
+
paired = sorted(zip(conns, strengths), key=lambda x: -x[1])[:5]
|
|
533
|
+
strength_strs = [f"{cid[:12]} ({s:.2f})" for cid, s in paired]
|
|
534
|
+
lines.append(f" Link strengths: {', '.join(strength_strs)}")
|
|
535
|
+
else:
|
|
536
|
+
# Show connections when link_strengths isn't available
|
|
537
|
+
lines.append(f" Link strengths: {', '.join(c[:12] + ' (1.00)' for c in conns[:5])}")
|
|
538
|
+
else:
|
|
539
|
+
lines.append(" Connected to: 0 other nodes")
|
|
540
|
+
lines.append("")
|
|
541
|
+
|
|
542
|
+
# Heat history
|
|
543
|
+
lines.append("Heat history:")
|
|
544
|
+
heat_created = n.get("heat", 0.4)
|
|
545
|
+
lines.append(f" Created at heat {heat_created:.2f}")
|
|
546
|
+
# No per-node heat bump tracking exists yet; note it.
|
|
547
|
+
lines.append(" (No heat bumps recorded)")
|
|
548
|
+
lines.append("")
|
|
549
|
+
|
|
550
|
+
return "\n".join(lines)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def think(query: str, brain_dir: str | None = None,
|
|
554
|
+
as_of: str | None = None) -> str:
|
|
555
|
+
"""Answer a natural language query with cited evidence + knowledge gaps.
|
|
556
|
+
|
|
557
|
+
No LLM calls — pure heuristic answer engine. Uses hybrid search +
|
|
558
|
+
typed-edge expansion + linker connections, then groups results by
|
|
559
|
+
entity and reports gaps for unanswerable sub-asks.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
query: Natural language question (e.g. "who works at acme and who founded it").
|
|
563
|
+
brain_dir: Optional brain directory override. Defaults to CHIP_BRAIN_DIR.
|
|
564
|
+
as_of: Temporal filter for typed-edge queries. None = current truth,
|
|
565
|
+
ISO string = valid at that time. Auto-extracted from query
|
|
566
|
+
if a temporal qualifier is found.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
Formatted string with headings, cited evidence, and a "Knowledge gaps"
|
|
570
|
+
section listing sub-asks with no data.
|
|
571
|
+
"""
|
|
572
|
+
try:
|
|
573
|
+
from .synthesis.think import think as _think
|
|
574
|
+
return _think(query, brain_dir=brain_dir, as_of=as_of)
|
|
575
|
+
except Exception:
|
|
576
|
+
log.exception("think() failed")
|
|
577
|
+
return f"## Query: {query}\n\nError: think mode encountered an internal error.\n"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def main():
|
|
581
|
+
"""CLI wrapper for the agent contract. Used by `chip-memory remember`.
|
|
582
|
+
|
|
583
|
+
Usage:
|
|
584
|
+
chip-memory remember "the user prefers terse responses" [--domain general] [--importance 0.8]
|
|
585
|
+
"""
|
|
586
|
+
import argparse
|
|
587
|
+
ap = argparse.ArgumentParser(description="Remember a fact in the chip-memory brain")
|
|
588
|
+
ap.add_argument("content", help="The fact to remember")
|
|
589
|
+
ap.add_argument("--domain", default="general", help="Domain (default: general)")
|
|
590
|
+
ap.add_argument("--type", dest="node_type", default="fact", help="Node type (default: fact)")
|
|
591
|
+
ap.add_argument("--importance", type=float, default=0.5, help="Importance 0.0-1.0 (default: 0.5)")
|
|
592
|
+
ap.add_argument("--tags", default="", help="Comma-separated tags")
|
|
593
|
+
ap.add_argument("--ttl", default=None, help="Time-to-live, e.g. '30d', '12h', '2w' (default: never)")
|
|
594
|
+
ap.add_argument("--json", action="store_true", help="Output as JSON")
|
|
595
|
+
ap.add_argument("--provenance", default=None,
|
|
596
|
+
help='Provenance as JSON, e.g. \'{"source":"git_fix","ref":"abc123"}\'')
|
|
597
|
+
ap.add_argument("--trust-confidence", type=float, default=None,
|
|
598
|
+
help="Trust confidence 0.0-1.0 (default: computed from source)")
|
|
599
|
+
args = ap.parse_args()
|
|
600
|
+
|
|
601
|
+
tags = [t.strip() for t in args.tags.split(",") if t.strip()]
|
|
602
|
+
provenance = args.provenance
|
|
603
|
+
if provenance:
|
|
604
|
+
import json as _json
|
|
605
|
+
provenance = _json.loads(provenance)
|
|
606
|
+
result = remember(
|
|
607
|
+
args.content,
|
|
608
|
+
type=args.node_type,
|
|
609
|
+
domain=args.domain,
|
|
610
|
+
importance=args.importance,
|
|
611
|
+
tags=tags,
|
|
612
|
+
ttl=args.ttl,
|
|
613
|
+
provenance=provenance,
|
|
614
|
+
trust_confidence=args.trust_confidence,
|
|
615
|
+
)
|
|
616
|
+
if result is None:
|
|
617
|
+
print("ERROR: validation failed (content too short/long, bad domain/type, or bad TTL)", file=sys.stderr)
|
|
618
|
+
sys.exit(1)
|
|
619
|
+
if args.json:
|
|
620
|
+
import json as _json
|
|
621
|
+
print(_json.dumps(result, indent=2), flush=True)
|
|
622
|
+
else:
|
|
623
|
+
print(f"remembered: {result['id']} domain={result['domain']} importance={result['importance']}", flush=True)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
if __name__ == "__main__":
|
|
627
|
+
main()
|
|
628
|
+
|