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.
Files changed (69) hide show
  1. chip_memory/__init__.py +31 -0
  2. chip_memory/__main__.py +83 -0
  3. chip_memory/_stopwords.py +52 -0
  4. chip_memory/_utils.py +38 -0
  5. chip_memory/api.py +628 -0
  6. chip_memory/auto_write.py +451 -0
  7. chip_memory/automagic_token.py +35 -0
  8. chip_memory/bm25.py +231 -0
  9. chip_memory/collect/__init__.py +22 -0
  10. chip_memory/collect/absorb.py +631 -0
  11. chip_memory/collect/adapters/__init__.py +0 -0
  12. chip_memory/collect/adapters/claude_code.py +352 -0
  13. chip_memory/collect/adapters/git.py +353 -0
  14. chip_memory/collect/adapters/shell.py +524 -0
  15. chip_memory/collect/correlator.py +462 -0
  16. chip_memory/collect/dedup.py +212 -0
  17. chip_memory/collect/events.py +204 -0
  18. chip_memory/collect/redact.py +132 -0
  19. chip_memory/collect/review_cli.py +10 -0
  20. chip_memory/collect/signal_detector.py +594 -0
  21. chip_memory/connect_cmd.py +195 -0
  22. chip_memory/consolidation.py +353 -0
  23. chip_memory/contradiction.py +366 -0
  24. chip_memory/daemon.py +399 -0
  25. chip_memory/dashboard/static/data.json +327 -0
  26. chip_memory/dashboard/static/index.html +1141 -0
  27. chip_memory/dashboard/static/vendor/OrbitControls.js +1417 -0
  28. chip_memory/dashboard/static/vendor/three.module.js +53044 -0
  29. chip_memory/dashboard.py +480 -0
  30. chip_memory/dashboard_cmd.py +80 -0
  31. chip_memory/doctor.py +268 -0
  32. chip_memory/eval.py +330 -0
  33. chip_memory/export.py +228 -0
  34. chip_memory/export_wiki.py +467 -0
  35. chip_memory/graph/__init__.py +5 -0
  36. chip_memory/graph/aliases.py +251 -0
  37. chip_memory/graph/predicate_vocab.py +177 -0
  38. chip_memory/graph/typed_edges.py +860 -0
  39. chip_memory/health.py +191 -0
  40. chip_memory/hermes.py +363 -0
  41. chip_memory/linker.py +503 -0
  42. chip_memory/map_cmd.py +140 -0
  43. chip_memory/mcp_server.py +491 -0
  44. chip_memory/node.py +598 -0
  45. chip_memory/paths.py +109 -0
  46. chip_memory/pending.py +78 -0
  47. chip_memory/py.typed +0 -0
  48. chip_memory/query/__init__.py +2 -0
  49. chip_memory/query/triple_query.py +347 -0
  50. chip_memory/rebalance.py +67 -0
  51. chip_memory/recall.py +149 -0
  52. chip_memory/search.py +477 -0
  53. chip_memory/seed.py +86 -0
  54. chip_memory/server.py +355 -0
  55. chip_memory/synthesis/__init__.py +6 -0
  56. chip_memory/synthesis/think.py +965 -0
  57. chip_memory/telegram_watch.py +590 -0
  58. chip_memory/timeline.py +489 -0
  59. chip_memory/timeline_cmd.py +93 -0
  60. chip_memory/tui.py +678 -0
  61. chip_memory/uninstall.py +179 -0
  62. chip_memory/watch_context.py +360 -0
  63. chip_memory/why.py +32 -0
  64. chip_memory-1.1.0.dist-info/METADATA +289 -0
  65. chip_memory-1.1.0.dist-info/RECORD +69 -0
  66. chip_memory-1.1.0.dist-info/WHEEL +5 -0
  67. chip_memory-1.1.0.dist-info/entry_points.txt +2 -0
  68. chip_memory-1.1.0.dist-info/licenses/LICENSE +21 -0
  69. 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
+