@caddis/cli 0.2.6 → 0.3.0
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.
- package/bundles/antigravity-plugin/agents/claude-md-curator.md +1 -1
- package/bundles/antigravity-plugin/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/claude-md/folder-agents.md.tmpl +21 -0
- package/bundles/antigravity-plugin/guard_agy.py +23 -4
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/scripts/caddis_gate.py +524 -0
- package/bundles/antigravity-plugin/scripts/caddis_inventory.py +369 -0
- package/bundles/antigravity-plugin/scripts/caddis_migrate_dir.py +270 -0
- package/bundles/antigravity-plugin/scripts/caddis_spawn.py +300 -0
- package/bundles/antigravity-plugin/scripts/caddis_statusline.py +803 -0
- package/bundles/antigravity-plugin/scripts/caddis_tidy.py +366 -0
- package/bundles/antigravity-plugin/scripts/check_doc_coverage.py +924 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-deepseek.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.ps1 +4 -0
- package/bundles/antigravity-plugin/scripts/claude-glm.sh +3 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.ps1 +64 -0
- package/bundles/antigravity-plugin/scripts/claude-oss.sh +60 -0
- package/bundles/antigravity-plugin/scripts/claudster_config.py +126 -0
- package/bundles/antigravity-plugin/scripts/claudster_doctor.py +356 -0
- package/bundles/antigravity-plugin/scripts/claudster_init.py +400 -0
- package/bundles/antigravity-plugin/scripts/claudster_migrate_rules.py +300 -0
- package/bundles/antigravity-plugin/scripts/dream_capture.py +249 -0
- package/bundles/antigravity-plugin/scripts/dream_memory.py +438 -0
- package/bundles/antigravity-plugin/scripts/enforcement_inventory.py +261 -0
- package/bundles/antigravity-plugin/scripts/hook_log.py +156 -0
- package/bundles/antigravity-plugin/scripts/oss_ask.py +98 -0
- package/bundles/antigravity-plugin/scripts/oss_model.py +166 -0
- package/bundles/antigravity-plugin/scripts/oss_review.py +718 -0
- package/bundles/antigravity-plugin/scripts/setup_project_ai.py +1429 -0
- package/bundles/antigravity-plugin/scripts/usage_review.py +1323 -0
- package/bundles/antigravity-plugin/session_end_agy.py +21 -2
- package/bundles/antigravity-plugin/skills/_registry.md +1 -0
- package/bundles/antigravity-plugin/skills/fastapi-dev/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-merge/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/ship-pr/SKILL.md +1 -1
- package/bundles/antigravity-plugin/skills/statusline/SKILL.md +105 -0
- package/bundles/antigravity-plugin/skills/use-model/SKILL.md +15 -3
- package/bundles/antigravity-plugin/warm_start_agy.py +21 -2
- package/bundles/antigravity-plugin-extras/plugin.json +1 -1
- package/bundles/antigravity-plugin-extras/skills/architecture-design/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/caching-patterns/SKILL.md +1 -1
- package/bundles/antigravity-plugin-extras/skills/webapp-development/SKILL.md +1 -1
- package/bundles/manifest.json +3 -3
- package/dist/claude-deepseek.js +544 -0
- package/dist/claude-glm.js +544 -0
- package/dist/claude-oss.js +544 -0
- package/dist/cli.js +365 -44
- package/package.json +5 -2
- package/bundles/antigravity-plugin/statusline-command-agy.sh +0 -91
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Dream Memory — caddis's short-term, self-maintaining per-repo fact store (fann Phase 5a).
|
|
2
|
+
|
|
3
|
+
Ports the dedupe / reinforce / prune / conflict logic of fann-core's
|
|
4
|
+
``src/memory/consolidator.ts`` into caddis's hooks-and-scripts world. This module is the
|
|
5
|
+
**pure consolidation engine** — the genuinely portable, fully-testable core. It owns the fact
|
|
6
|
+
schema and the four pure functions the design names (``merge`` / ``prune`` / ``conflicts`` /
|
|
7
|
+
``rank_for_surfacing``), plus thin, fail-open filesystem glue to load/save the JSONL store.
|
|
8
|
+
|
|
9
|
+
Mental model (see ``.caddis/plans/dream-memory-design.md``): Dream Memory is *short-term working
|
|
10
|
+
memory* — automatic, structured facts that get **reinforced** when they recur and **decay** when
|
|
11
|
+
they don't. It complements (never replaces) the curated layers: ``relay.md`` (session handoff),
|
|
12
|
+
``.caddis/kb/*.md`` (curated code docs), and the central ``.claude`` MEMORY.md (cross-repo
|
|
13
|
+
durable facts). A fact that proves durable is *promoted* up into a curated store and then allowed
|
|
14
|
+
to decay out here.
|
|
15
|
+
|
|
16
|
+
Design split mirrors ``check_doc_coverage.py``: a filesystem-free pure core (text/dicts in →
|
|
17
|
+
dicts out, unit-tested without a tree) and a small glue layer (``load_facts`` / ``save_facts`` /
|
|
18
|
+
the CLI) that does all I/O and **fails open & silent** — Dream Memory must never block or slow a
|
|
19
|
+
turn.
|
|
20
|
+
|
|
21
|
+
A fact (one JSON object per line in ``.caddis/memory.jsonl``)::
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
"kind": "failure-mode | rejected-approach | repo-fact | workflow-success",
|
|
25
|
+
"key": "pytest::import-error", # normalized dedup key (kind-scoped)
|
|
26
|
+
"summary": "pytest fails on a cold checkout — run `alembic upgrade` first",
|
|
27
|
+
"hitCount": 3,
|
|
28
|
+
"firstSeen": "2026-07-01T09:00:00Z",
|
|
29
|
+
"lastSeen": "2026-07-01T15:00:00Z",
|
|
30
|
+
"source": "auto | knowledge-transfer | manual",
|
|
31
|
+
"evidence": "tests/test_api.py" # optional, short
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
Fingerprint = ``kind + ":" + normalize(key)``. The file holds **consolidated** facts (not raw
|
|
35
|
+
events) — it self-compacts each cycle (``merge`` then ``prune``).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import json
|
|
42
|
+
import sys
|
|
43
|
+
from datetime import datetime, timedelta, timezone
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
# Schema constants. Tunables live here (a per-repo ``.caddis/config.toml``
|
|
48
|
+
# override is a deliberate fast-follow, matching check_doc_coverage / guard).
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
KINDS: tuple[str, ...] = ("failure-mode", "rejected-approach", "repo-fact", "workflow-success")
|
|
51
|
+
SOURCES: tuple[str, ...] = ("auto", "knowledge-transfer", "manual")
|
|
52
|
+
|
|
53
|
+
# The "don't repeat this" kinds — weighted up when surfacing (they cost the most to relearn).
|
|
54
|
+
WEIGHTED_KINDS: frozenset[str] = frozenset({"failure-mode", "rejected-approach"})
|
|
55
|
+
|
|
56
|
+
PRUNE_AGE_DAYS = 14 # single-hit facts older than this decay out
|
|
57
|
+
MAX_FACTS = 200 # hard cap on the store; oldest-by-recency overflow drops first
|
|
58
|
+
SURFACE_LIMIT = 5 # SessionStart shows at most this many (context-economy-safe)
|
|
59
|
+
|
|
60
|
+
STORE_NAME = "memory.jsonl"
|
|
61
|
+
DEFAULT_STORE = f".caddis/{STORE_NAME}" # nominal path; resolve per-repo via store_path()
|
|
62
|
+
|
|
63
|
+
_REQUIRED_FIELDS = ("kind", "key", "summary", "hitCount", "firstSeen", "lastSeen", "source")
|
|
64
|
+
_EPOCH = datetime.min.replace(tzinfo=timezone.utc)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def store_path(root) -> Path:
|
|
68
|
+
"""This repo's fact store, at ``.caddis/memory.jsonl``."""
|
|
69
|
+
try:
|
|
70
|
+
from claudster_config import artifact_read
|
|
71
|
+
return artifact_read(root, STORE_NAME)
|
|
72
|
+
except Exception:
|
|
73
|
+
return Path(root) / DEFAULT_STORE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
# Pure helpers (no filesystem) — the testable core.
|
|
78
|
+
# --------------------------------------------------------------------------- #
|
|
79
|
+
def normalize_key(value: str) -> str:
|
|
80
|
+
"""Lowercase, trim, and collapse internal whitespace — the dedup-key canonical form."""
|
|
81
|
+
return " ".join((value or "").strip().lower().split())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def fingerprint(fact: dict) -> str:
|
|
85
|
+
"""The dedup identity. Prefers an explicit ``fp`` (a hash of the FULL command, set at capture) so two
|
|
86
|
+
distinct commands that share a truncated 80-char head don't collapse into one inflated ``hitCount``;
|
|
87
|
+
falls back to ``kind:normalize(key)`` for facts written before ``fp`` existed (and for non-command facts)."""
|
|
88
|
+
fp = fact.get("fp")
|
|
89
|
+
if isinstance(fp, str) and fp:
|
|
90
|
+
return f"{fact.get('kind', '')}:{fp}"
|
|
91
|
+
basis = fact.get("key") or fact.get("summary") or ""
|
|
92
|
+
return f"{fact.get('kind', '')}:{normalize_key(basis)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_iso(value) -> datetime:
|
|
96
|
+
"""Parse an ISO-8601 timestamp to an aware datetime; unparseable/empty → the epoch floor.
|
|
97
|
+
|
|
98
|
+
Tolerant by design (the store may be hand-edited): a bad timestamp must never crash a cycle,
|
|
99
|
+
it just sorts as oldest. Accepts a trailing ``Z`` (Python <3.11 ``fromisoformat`` rejects it).
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(value, datetime):
|
|
102
|
+
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
|
103
|
+
s = (value or "").strip()
|
|
104
|
+
if not s:
|
|
105
|
+
return _EPOCH
|
|
106
|
+
if s.endswith(("Z", "z")):
|
|
107
|
+
s = s[:-1] + "+00:00"
|
|
108
|
+
try:
|
|
109
|
+
dt = datetime.fromisoformat(s)
|
|
110
|
+
except ValueError:
|
|
111
|
+
return _EPOCH
|
|
112
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def make_fact(
|
|
116
|
+
kind: str,
|
|
117
|
+
key: str,
|
|
118
|
+
summary: str,
|
|
119
|
+
observed_at: str,
|
|
120
|
+
*,
|
|
121
|
+
source: str = "auto",
|
|
122
|
+
evidence: str | None = None,
|
|
123
|
+
fp: str | None = None,
|
|
124
|
+
) -> dict:
|
|
125
|
+
"""Construct a well-formed single-hit fact (``hitCount=1``, first==last==observed).
|
|
126
|
+
|
|
127
|
+
``fp`` (optional) is the full-command dedup fingerprint; when set it overrides the key-based
|
|
128
|
+
fingerprint so distinct commands sharing a truncated display key don't merge."""
|
|
129
|
+
fact = {
|
|
130
|
+
"kind": kind,
|
|
131
|
+
"key": key,
|
|
132
|
+
"summary": summary,
|
|
133
|
+
"hitCount": 1,
|
|
134
|
+
"firstSeen": observed_at,
|
|
135
|
+
"lastSeen": observed_at,
|
|
136
|
+
"source": source,
|
|
137
|
+
}
|
|
138
|
+
if evidence:
|
|
139
|
+
fact["evidence"] = evidence
|
|
140
|
+
if fp:
|
|
141
|
+
fact["fp"] = fp
|
|
142
|
+
return fact
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_valid_fact(fact) -> bool:
|
|
146
|
+
"""True if ``fact`` has every required field, a known ``kind``, and a positive int hitCount.
|
|
147
|
+
|
|
148
|
+
Used to reject malformed/hand-broken lines when loading and to validate capture candidates.
|
|
149
|
+
"""
|
|
150
|
+
if not isinstance(fact, dict):
|
|
151
|
+
return False
|
|
152
|
+
if any(f not in fact for f in _REQUIRED_FIELDS):
|
|
153
|
+
return False
|
|
154
|
+
if fact["kind"] not in KINDS:
|
|
155
|
+
return False
|
|
156
|
+
hc = fact["hitCount"]
|
|
157
|
+
if not isinstance(hc, int) or isinstance(hc, bool) or hc < 1:
|
|
158
|
+
return False
|
|
159
|
+
return bool(str(fact["summary"]).strip())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _hit(fact: dict) -> int:
|
|
163
|
+
try:
|
|
164
|
+
n = int(fact.get("hitCount", 1))
|
|
165
|
+
except (TypeError, ValueError):
|
|
166
|
+
return 1
|
|
167
|
+
return n if n >= 1 else 1
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --------------------------------------------------------------------------- #
|
|
171
|
+
# The four pure consolidation functions the design names. Filesystem-free.
|
|
172
|
+
# --------------------------------------------------------------------------- #
|
|
173
|
+
def merge(facts: list[dict]) -> list[dict]:
|
|
174
|
+
"""Group by fingerprint into consolidated facts (insertion order of first appearance).
|
|
175
|
+
|
|
176
|
+
For each group: sum ``hitCount``; ``firstSeen`` = min, ``lastSeen`` = max; the **newest**
|
|
177
|
+
(latest ``lastSeen``) member's ``summary`` / ``source`` / ``evidence`` win. This is the
|
|
178
|
+
reinforce step — re-running it over (existing ⧺ new candidates) accretes hit counts. Each
|
|
179
|
+
returned fact is a fresh dict; inputs are never mutated.
|
|
180
|
+
"""
|
|
181
|
+
merged: dict[str, dict] = {}
|
|
182
|
+
order: list[str] = []
|
|
183
|
+
|
|
184
|
+
for f in facts:
|
|
185
|
+
fp = fingerprint(f)
|
|
186
|
+
if fp not in merged:
|
|
187
|
+
first = f.get("firstSeen") or f.get("lastSeen") or ""
|
|
188
|
+
last = f.get("lastSeen") or f.get("firstSeen") or ""
|
|
189
|
+
g = dict(f)
|
|
190
|
+
g["hitCount"] = _hit(f)
|
|
191
|
+
g["firstSeen"] = first
|
|
192
|
+
g["lastSeen"] = last
|
|
193
|
+
merged[fp] = g
|
|
194
|
+
order.append(fp)
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
g = merged[fp]
|
|
198
|
+
g["hitCount"] += _hit(f)
|
|
199
|
+
|
|
200
|
+
f_first = f.get("firstSeen") or f.get("lastSeen") or ""
|
|
201
|
+
if _parse_iso(f_first) < _parse_iso(g["firstSeen"]):
|
|
202
|
+
g["firstSeen"] = f_first
|
|
203
|
+
|
|
204
|
+
f_last = f.get("lastSeen") or f.get("firstSeen") or ""
|
|
205
|
+
# >= so a later-in-list tie counts as "newest wins" (last writer for same timestamp).
|
|
206
|
+
if _parse_iso(f_last) >= _parse_iso(g["lastSeen"]):
|
|
207
|
+
g["lastSeen"] = f_last
|
|
208
|
+
if f.get("summary"):
|
|
209
|
+
g["summary"] = f["summary"]
|
|
210
|
+
if f.get("source"):
|
|
211
|
+
g["source"] = f["source"]
|
|
212
|
+
if f.get("evidence"):
|
|
213
|
+
g["evidence"] = f["evidence"]
|
|
214
|
+
|
|
215
|
+
return [merged[fp] for fp in order]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def prune(facts: list[dict], now, *, max_age_days: int = PRUNE_AGE_DAYS, cap: int = MAX_FACTS) -> list[dict]:
|
|
219
|
+
"""Decay then cap.
|
|
220
|
+
|
|
221
|
+
1. Drop ``hitCount <= 1`` facts whose ``lastSeen`` is older than ``max_age_days`` (one-hit
|
|
222
|
+
noise fades on its own). Reinforced facts (hitCount >= 2) are never aged out.
|
|
223
|
+
2. Keep the top ``cap`` by ``(hitCount desc, lastSeen desc)`` — overflow drops the
|
|
224
|
+
least-reinforced, oldest facts first.
|
|
225
|
+
|
|
226
|
+
``now`` may be an ISO string or a datetime. Returns a new list; inputs are never mutated.
|
|
227
|
+
"""
|
|
228
|
+
now_dt = _parse_iso(now)
|
|
229
|
+
cutoff = now_dt - timedelta(days=max_age_days)
|
|
230
|
+
|
|
231
|
+
kept = [
|
|
232
|
+
f for f in facts
|
|
233
|
+
if not (_hit(f) <= 1 and _parse_iso(f.get("lastSeen")) < cutoff)
|
|
234
|
+
]
|
|
235
|
+
kept.sort(key=lambda f: (_hit(f), _parse_iso(f.get("lastSeen"))), reverse=True)
|
|
236
|
+
return kept[:cap]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def conflicts(facts: list[dict]) -> list[dict]:
|
|
240
|
+
"""Flag fingerprints carrying materially different summaries — don't auto-resolve, surface.
|
|
241
|
+
|
|
242
|
+
"Materially different" = distinct after :func:`normalize_key`. Returns one entry per
|
|
243
|
+
conflicting fingerprint: ``{"kind", "key", "summaries": [...]}`` with the distinct summaries
|
|
244
|
+
in first-seen order. Intended to run over (existing ⧺ incoming) *before* merge collapses them.
|
|
245
|
+
"""
|
|
246
|
+
by_fp: dict[str, list[dict]] = {}
|
|
247
|
+
for f in facts:
|
|
248
|
+
by_fp.setdefault(fingerprint(f), []).append(f)
|
|
249
|
+
|
|
250
|
+
out: list[dict] = []
|
|
251
|
+
for group in by_fp.values():
|
|
252
|
+
distinct: list[str] = []
|
|
253
|
+
seen_norm: set[str] = set()
|
|
254
|
+
for f in group:
|
|
255
|
+
summary = str(f.get("summary", ""))
|
|
256
|
+
norm = normalize_key(summary)
|
|
257
|
+
if norm and norm not in seen_norm:
|
|
258
|
+
seen_norm.add(norm)
|
|
259
|
+
distinct.append(summary)
|
|
260
|
+
if len(distinct) > 1:
|
|
261
|
+
out.append({
|
|
262
|
+
"kind": group[0].get("kind", ""),
|
|
263
|
+
"key": group[0].get("key", ""),
|
|
264
|
+
"summaries": distinct,
|
|
265
|
+
})
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def rank_for_surfacing(facts: list[dict], n: int = SURFACE_LIMIT, *, now=None) -> list[dict]:
|
|
270
|
+
"""Top ``n`` facts to show at SessionStart — score ≈ ``weight × hitCount × recency``.
|
|
271
|
+
|
|
272
|
+
``failure-mode`` / ``rejected-approach`` (the "don't repeat this" kinds) are weighted up. If
|
|
273
|
+
``now`` is given, recency decays continuously with age; otherwise facts are ordered by
|
|
274
|
+
``lastSeen`` directly (a now-free, deterministic proxy). Stable: ties break by ``lastSeen``
|
|
275
|
+
desc then fingerprint. Returns a new list; inputs are never mutated.
|
|
276
|
+
"""
|
|
277
|
+
now_dt = _parse_iso(now) if now is not None else None
|
|
278
|
+
|
|
279
|
+
def score(f: dict) -> float:
|
|
280
|
+
weight = 2.0 if f.get("kind") in WEIGHTED_KINDS else 1.0
|
|
281
|
+
if now_dt is None:
|
|
282
|
+
return weight * _hit(f)
|
|
283
|
+
age_days = max(0.0, (now_dt - _parse_iso(f.get("lastSeen"))).total_seconds() / 86400.0)
|
|
284
|
+
recency = 1.0 / (1.0 + age_days) # 1.0 today, halves after a day, decays smoothly
|
|
285
|
+
return weight * _hit(f) * recency
|
|
286
|
+
|
|
287
|
+
ranked = sorted(
|
|
288
|
+
facts,
|
|
289
|
+
key=lambda f: (score(f), _parse_iso(f.get("lastSeen")), fingerprint(f)),
|
|
290
|
+
reverse=True,
|
|
291
|
+
)
|
|
292
|
+
return ranked[:n]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def consolidate(facts: list[dict], now, *, max_age_days: int = PRUNE_AGE_DAYS, cap: int = MAX_FACTS) -> list[dict]:
|
|
296
|
+
"""One full cycle: ``merge`` (reinforce + dedup) then ``prune`` (decay + cap). Pure."""
|
|
297
|
+
return prune(merge(facts), now, max_age_days=max_age_days, cap=cap)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
# Filesystem glue — fail-open & silent. All I/O lives here, mirroring
|
|
302
|
+
# check_doc_coverage's pure-core / glue split. Never raises into a hook.
|
|
303
|
+
# --------------------------------------------------------------------------- #
|
|
304
|
+
def load_facts(path: Path) -> list[dict]:
|
|
305
|
+
"""Read the JSONL store, skipping blank/malformed/invalid lines. Missing file → ``[]``.
|
|
306
|
+
|
|
307
|
+
Tolerant by design: a hand-edited or partially-written store must never crash a turn.
|
|
308
|
+
"""
|
|
309
|
+
path = Path(path)
|
|
310
|
+
if not path.exists():
|
|
311
|
+
return []
|
|
312
|
+
facts: list[dict] = []
|
|
313
|
+
try:
|
|
314
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
315
|
+
except OSError:
|
|
316
|
+
return []
|
|
317
|
+
for line in text.splitlines():
|
|
318
|
+
line = line.strip()
|
|
319
|
+
if not line:
|
|
320
|
+
continue
|
|
321
|
+
try:
|
|
322
|
+
obj = json.loads(line)
|
|
323
|
+
except (json.JSONDecodeError, ValueError):
|
|
324
|
+
continue
|
|
325
|
+
if is_valid_fact(obj):
|
|
326
|
+
facts.append(obj)
|
|
327
|
+
return facts
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def save_facts(path: Path, facts: list[dict]) -> bool:
|
|
331
|
+
"""Write facts as JSONL (one compact object per line). Returns True on success, never raises.
|
|
332
|
+
|
|
333
|
+
Writes via a temp file + atomic replace so a crash mid-write can't corrupt the store.
|
|
334
|
+
"""
|
|
335
|
+
path = Path(path)
|
|
336
|
+
try:
|
|
337
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
339
|
+
lines = [json.dumps(f, ensure_ascii=False, sort_keys=True) for f in facts]
|
|
340
|
+
tmp.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
341
|
+
tmp.replace(path)
|
|
342
|
+
return True
|
|
343
|
+
except OSError:
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def load_tunables(root) -> dict:
|
|
348
|
+
"""Read ``[dream_memory]`` overrides from the repo's ``config.toml``; defaults otherwise.
|
|
349
|
+
|
|
350
|
+
Returns ``{"prune_age_days", "max_facts", "surface_limit"}``. Fail-open — a missing config, a
|
|
351
|
+
missing helper, or a bad value degrades to the baked-in default, never raising into a hook.
|
|
352
|
+
"""
|
|
353
|
+
defaults = {"prune_age_days": PRUNE_AGE_DAYS, "max_facts": MAX_FACTS, "surface_limit": SURFACE_LIMIT}
|
|
354
|
+
try:
|
|
355
|
+
from claudster_config import get_int, load_config
|
|
356
|
+
cfg = load_config(root, "dream_memory")
|
|
357
|
+
return {
|
|
358
|
+
"prune_age_days": get_int(cfg, "prune_age_days", PRUNE_AGE_DAYS),
|
|
359
|
+
"max_facts": get_int(cfg, "max_facts", MAX_FACTS),
|
|
360
|
+
"surface_limit": get_int(cfg, "surface_limit", SURFACE_LIMIT),
|
|
361
|
+
}
|
|
362
|
+
except Exception:
|
|
363
|
+
return defaults
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _repo_root() -> Path:
|
|
367
|
+
"""Git top-level if available, else cwd — location-independent (matches the caddis hooks)."""
|
|
368
|
+
try:
|
|
369
|
+
import subprocess
|
|
370
|
+
out = subprocess.run(
|
|
371
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
372
|
+
capture_output=True, text=True, timeout=5,
|
|
373
|
+
)
|
|
374
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
375
|
+
return Path(out.stdout.strip())
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
return Path.cwd()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _format_surface(facts: list[dict]) -> list[str]:
|
|
382
|
+
"""Render ranked facts as the ≤5 SessionStart lines (the surfacing hook reuses this in 5c)."""
|
|
383
|
+
lines: list[str] = []
|
|
384
|
+
for f in facts:
|
|
385
|
+
mark = "⚠ " if f.get("kind") in WEIGHTED_KINDS else " "
|
|
386
|
+
hits = _hit(f)
|
|
387
|
+
suffix = f" (×{hits})" if hits > 1 else ""
|
|
388
|
+
lines.append(f" {mark}{f.get('kind')}: {f.get('summary')}{suffix}")
|
|
389
|
+
return lines
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def main() -> None:
|
|
393
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
394
|
+
sys.stdout.reconfigure(encoding="utf-8") # Windows cp1252 can't encode ⚠/× glyphs
|
|
395
|
+
parser = argparse.ArgumentParser(
|
|
396
|
+
description="Inspect / consolidate the Dream Memory fact store (.caddis/memory.jsonl).",
|
|
397
|
+
)
|
|
398
|
+
parser.add_argument(
|
|
399
|
+
"--consolidate",
|
|
400
|
+
action="store_true",
|
|
401
|
+
help="Run a merge+prune cycle and rewrite the store (no-op if it would not change).",
|
|
402
|
+
)
|
|
403
|
+
parser.add_argument(
|
|
404
|
+
"--now",
|
|
405
|
+
default=None,
|
|
406
|
+
help="ISO timestamp to use as 'now' for decay/ranking (default: current UTC).",
|
|
407
|
+
)
|
|
408
|
+
args = parser.parse_args()
|
|
409
|
+
|
|
410
|
+
now = args.now or datetime.now(timezone.utc).isoformat()
|
|
411
|
+
root = _repo_root()
|
|
412
|
+
store = store_path(root)
|
|
413
|
+
tune = load_tunables(root)
|
|
414
|
+
facts = load_facts(store)
|
|
415
|
+
|
|
416
|
+
if not facts:
|
|
417
|
+
print("[memory] no facts yet.")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
if args.consolidate:
|
|
421
|
+
consolidated = consolidate(facts, now, max_age_days=tune["prune_age_days"], cap=tune["max_facts"])
|
|
422
|
+
save_facts(store, consolidated)
|
|
423
|
+
print(f"[memory] consolidated: {len(facts)} → {len(consolidated)} facts.")
|
|
424
|
+
facts = consolidated
|
|
425
|
+
|
|
426
|
+
top = rank_for_surfacing(facts, tune["surface_limit"], now=now)
|
|
427
|
+
print("[memory] reinforced facts for this repo (auto; fades if not seen):")
|
|
428
|
+
for line in _format_surface(top):
|
|
429
|
+
print(line)
|
|
430
|
+
issues = conflicts(facts)
|
|
431
|
+
if issues:
|
|
432
|
+
print(f"\n[memory] {len(issues)} conflicting fact(s) — review:")
|
|
433
|
+
for c in issues:
|
|
434
|
+
print(f" {c['kind']}: {c['key']} → {len(c['summaries'])} differing summaries")
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
if __name__ == "__main__":
|
|
438
|
+
main()
|