linksee-memory 0.7.2 → 0.8.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/README.md +111 -7
- package/dist/bin/declare-anchor.d.ts +2 -0
- package/dist/bin/declare-anchor.js +146 -0
- package/dist/bin/detect-drift.d.ts +2 -0
- package/dist/bin/detect-drift.js +91 -0
- package/dist/db/migrate.js +52 -28
- package/dist/db/schema.sql +348 -232
- package/dist/lib/drift-anchors.d.ts +78 -0
- package/dist/lib/drift-anchors.js +224 -0
- package/dist/lib/drift-detection.d.ts +62 -0
- package/dist/lib/drift-detection.js +416 -0
- package/dist/lib/drift-view.d.ts +62 -0
- package/dist/lib/drift-view.js +120 -0
- package/dist/lib/truth-engine.d.ts +84 -0
- package/dist/lib/truth-engine.js +417 -0
- package/dist/mcp/server.js +168 -3
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# linksee-memory
|
|
2
2
|
|
|
3
|
-
> **Your agent forgets everything when a session ends.
|
|
3
|
+
> **Your agent forgets everything when a session ends. Worse — it silently drifts from what you decided last week.**
|
|
4
4
|
>
|
|
5
|
-
> Local-first cross-LLM memory MCP — one SQLite file that **Claude Code, Cursor, Windsurf, OpenAI Codex, and Gemini CLI** all read from. Not just "what happened" but **WHY** it happened: 6-layer structured memory with precision recall that
|
|
5
|
+
> Local-first cross-LLM memory MCP — one SQLite file that **Claude Code, Cursor, Windsurf, OpenAI Codex, and Gemini CLI** all read from. Not just "what happened" but **WHY** it happened: 6-layer structured memory with precision recall, **plus drift detection that catches when reality diverges from your decisions**.
|
|
6
|
+
>
|
|
7
|
+
> Memory is the entry point. Drift detection is the real value.
|
|
6
8
|
>
|
|
7
9
|
> `npx linksee-memory-setup` — one command, done.
|
|
8
10
|
|
|
@@ -87,21 +89,69 @@ Every memory is tagged with **exactly one layer**. `caveat`-layer entries are pr
|
|
|
87
89
|
|
|
88
90
|
Most "agent memory" services (Mem0, Letta, Zep) save a flat list of facts. Then the agent looks at "edited file X 30 times" and has no idea why. **linksee-memory keeps the WHY.**
|
|
89
91
|
|
|
90
|
-
It is a Model Context Protocol (MCP) server with **
|
|
92
|
+
It is a Model Context Protocol (MCP) server with **7 tools** that gives any AI agent structured memory + drift detection:
|
|
91
93
|
|
|
92
94
|
| | Mem0 / Letta / Zep | Claude Code auto-memory | linksee-memory |
|
|
93
95
|
|---|---|---|---|
|
|
94
96
|
| Cross-agent | △ (cloud) | ❌ Claude only | ✅ single SQLite file |
|
|
95
97
|
| 6-layer WHY structure | ❌ flat | ❌ flat markdown | ✅ goal / context / emotion / impl / caveat / learning |
|
|
98
|
+
| **Drift detection** | ❌ | ❌ | ✅ intent ↔ reality divergence tracking |
|
|
96
99
|
| File diff cache | ❌ | ❌ | ✅ AST-aware, 50-99% token savings on re-reads |
|
|
97
100
|
| Active forgetting | △ | ❌ | ✅ Ebbinghaus curve, caveat layer protected |
|
|
98
101
|
| Local-first / private | ❌ | ✅ | ✅ |
|
|
99
102
|
|
|
100
|
-
##
|
|
103
|
+
## Four pillars
|
|
101
104
|
|
|
102
105
|
1. **Token savings** via `read_smart` — sha256 + AST/heading/indent chunking. Re-reads return only diffs. **Measured 86% saved on a typical TS file edit, 99% saved on unchanged re-reads.**
|
|
103
106
|
2. **Cross-agent portability** — single SQLite file at `~/.linksee-memory/memory.db`. Same brain for Claude Code, Cursor, Windsurf, OpenAI Codex, Gemini CLI.
|
|
104
107
|
3. **WHY-first structured memory** — six explicit layers (`goal` / `context` / `emotion` / `implementation` / `caveat` / `learning`). Solves "flat fact memory is useless without goals".
|
|
108
|
+
4. **Drift detection** — declare decisions as anchors, then the engine automatically detects when committed reality diverges from stated intent. Think "Datadog for product decisions" — unaccounted divergences surface as drift, intentional evolution (recorded as supersede/fix) stays quiet.
|
|
109
|
+
|
|
110
|
+
## 🔍 Drift Detection — "Intent Datadog"
|
|
111
|
+
|
|
112
|
+
Most teams make decisions, then forget them. The agent from last week decided "we'll use FTS5 instead of vector search" — but this week a new session installs `pgvector` without knowing why that was rejected. **That's drift.** Not a bug. Not malice. Just forgotten context.
|
|
113
|
+
|
|
114
|
+
Linksee Memory's drift detection catches this:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
You: What's drifting right now?
|
|
118
|
+
Agent: [calls drift_status]
|
|
119
|
+
|
|
120
|
+
28 anchors: ⚪ 1 held · 🔵 27 aligned
|
|
121
|
+
|
|
122
|
+
Needs attention:
|
|
123
|
+
⚪ HELD — "Focus on 4 areas: Recipe layer, agent-native API,
|
|
124
|
+
Japanese market, Agent Insights"
|
|
125
|
+
↻ Reopens 2026-07-04
|
|
126
|
+
|
|
127
|
+
Everything else is aligned — no unaccounted divergence.
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### How it works
|
|
131
|
+
|
|
132
|
+
1. **Declare** decisions as anchors: `declare_anchor({ kind: "decision", statement: "We use FTS5, not vector search", violation_signal: ["pgvector", "embedding"] })`
|
|
133
|
+
2. **The engine detects** when committed code reality diverges from these anchors
|
|
134
|
+
3. **State derivation** classifies each anchor:
|
|
135
|
+
- 🔴 **Drift** — reality diverges with no recorded resolution
|
|
136
|
+
- 🟡 **Review** — a soft signal awaits your decision
|
|
137
|
+
- ⚪ **Held** — you acknowledged the gap, parked it with a review date
|
|
138
|
+
- 🔵 **Aligned** — reality matches intent, or a recorded resolution explains the change
|
|
139
|
+
4. **Resolve** with `fix`, `supersede`, `acknowledge`, or `dismiss`
|
|
140
|
+
|
|
141
|
+
The **make-or-break rule**: a divergence accounted for by a recorded resolution (supersede/fix/acknowledge) is NOT drift. Only unaccounted gaps are flagged. This means intentional evolution stays quiet while silent abandonment gets caught.
|
|
142
|
+
|
|
143
|
+
### 4-species taxonomy
|
|
144
|
+
|
|
145
|
+
Anchors are classified into four species with different display formats:
|
|
146
|
+
|
|
147
|
+
| Species | Icon | Display Format | Example |
|
|
148
|
+
|---|---|---|---|
|
|
149
|
+
| Hypothesis | 🧪 | Decision Card (journal format) | "We'll launch English-first on HN" |
|
|
150
|
+
| Constraint | 🔒 | Rule (pass/fail checklist) | "All writes go through remember()" |
|
|
151
|
+
| Commitment | 🔁 | Heartbeat (alive/dead) | "Ship a new version every week" |
|
|
152
|
+
| Source of Truth | 📍 | Reference (stable anchor) | "MCP server runs on stdio, single SQLite" |
|
|
153
|
+
|
|
154
|
+
---
|
|
105
155
|
|
|
106
156
|
## Quick Start — One Command
|
|
107
157
|
|
|
@@ -248,7 +298,17 @@ All editors share the same `~/.linksee-memory/memory.db`. A decision made in Cla
|
|
|
248
298
|
|
|
249
299
|
Default: `~/.linksee-memory/memory.db`. Override with `LINKSEE_MEMORY_DIR` env var.
|
|
250
300
|
|
|
251
|
-
## What's new in v0.
|
|
301
|
+
## What's new in v0.8
|
|
302
|
+
|
|
303
|
+
| Feature | Detail |
|
|
304
|
+
|---|---|
|
|
305
|
+
| **4 drift detection tools** | `drift_status`, `check_decision`, `declare_anchor`, `resolve_drift` — agents can now query and act on intent ↔ reality divergence. The biggest gap in agent memory (decisions are forgotten across sessions) is now closed. |
|
|
306
|
+
| **Truth engine** | State derivation logic (drift/review/held/aligned) now lives in the MCP engine, not just the dashboard. Any MCP client can query drift status. |
|
|
307
|
+
| **4-species taxonomy** | Anchors classified as hypothesis/constraint/commitment/source_of_truth with species-appropriate display formats. |
|
|
308
|
+
| **Resolution priority** | When multiple resolutions exist for an anchor, the most recent one wins (prevents stale acknowledge from shadowing a newer fix). |
|
|
309
|
+
|
|
310
|
+
<details>
|
|
311
|
+
<summary>What's new in v0.7</summary>
|
|
252
312
|
|
|
253
313
|
| Feature | Detail |
|
|
254
314
|
|---|---|
|
|
@@ -258,6 +318,8 @@ Default: `~/.linksee-memory/memory.db`. Override with `LINKSEE_MEMORY_DIR` env v
|
|
|
258
318
|
| **"Use Linksee Memory" trigger** | Add "Use Linksee Memory" to any prompt to force memory recall — same adoption pattern as Context7. |
|
|
259
319
|
| **Claude Code Plugin** | `claude plugin add -- linksee-memory` — ships MCP server + auto-invocation skill in one install. |
|
|
260
320
|
|
|
321
|
+
</details>
|
|
322
|
+
|
|
261
323
|
<details>
|
|
262
324
|
<summary>What's new in v0.4</summary>
|
|
263
325
|
|
|
@@ -270,7 +332,9 @@ Default: `~/.linksee-memory/memory.db`. Override with `LINKSEE_MEMORY_DIR` env v
|
|
|
270
332
|
|
|
271
333
|
</details>
|
|
272
334
|
|
|
273
|
-
##
|
|
335
|
+
## 7 Tools (v0.8)
|
|
336
|
+
|
|
337
|
+
### Memory tools
|
|
274
338
|
|
|
275
339
|
| Tool | What it does |
|
|
276
340
|
|---|---|
|
|
@@ -278,7 +342,16 @@ Default: `~/.linksee-memory/memory.db`. Override with `LINKSEE_MEMORY_DIR` env v
|
|
|
278
342
|
| `recall` | **Search / file history / overview.** Modes: search (`query`), file history (`path`), entity overview (no params). FTS5 + heat × momentum ranking with `match_reasons`. |
|
|
279
343
|
| `read_smart` | **Token-saving file reader** with AST diff caching. First read = full content. Re-read unchanged = ~50 tokens. Re-read modified = changed chunks only. |
|
|
280
344
|
|
|
281
|
-
|
|
345
|
+
### Drift tools (v0.8.0)
|
|
346
|
+
|
|
347
|
+
| Tool | What it does |
|
|
348
|
+
|---|---|
|
|
349
|
+
| `drift_status` | **"What's drifting right now?"** Returns the truth map with 4-species classification (hypothesis/constraint/commitment/source_of_truth) and per-node state (🔴 drift / 🟡 review / ⚪ held / 🔵 aligned). |
|
|
350
|
+
| `check_decision` | **Deep-dive into a specific decision.** Returns the full context: what was decided, why, what reality says, pending candidates, and drift edges. |
|
|
351
|
+
| `declare_anchor` | **Record a decision as a truth-map anchor.** The drift detector checks these against committed reality. Supports v9 fields (domain, confidence, lifecycle, review_after). |
|
|
352
|
+
| `resolve_drift` | **Close the loop.** Record a resolution: `fix` (reality now matches), `supersede` (intent evolved), `acknowledge` (parking with review date), or `dismiss` (false positive). |
|
|
353
|
+
|
|
354
|
+
Previous versions exposed 3 tools — v0.8.0 added 4 drift tools that let agents query and act on product-level intent ↔ reality divergence. The memory tools are unchanged.
|
|
282
355
|
|
|
283
356
|
### CLI utilities
|
|
284
357
|
|
|
@@ -343,6 +416,10 @@ The conversation↔file linkage is the key. Every file edit captured by the Stop
|
|
|
343
416
|
- ✅ Structured memory v2 (3-axis classification: altitude × type × state)
|
|
344
417
|
- ✅ Cross-LLM: Claude Code, Cursor, Windsurf, OpenAI Codex, Gemini CLI
|
|
345
418
|
- ✅ Landing page ([linksee-site.vercel.app](https://linksee-site.vercel.app))
|
|
419
|
+
- ✅ Drift detection engine + 4 MCP drift tools — v0.8.0
|
|
420
|
+
- ✅ 4-species truth map (hypothesis/constraint/commitment/source_of_truth) — v0.8.0
|
|
421
|
+
- ✅ Dashboard with Decision Register visualization
|
|
422
|
+
- 🔮 Obsidian plugin (read truth map in your vault)
|
|
346
423
|
- 🔮 Vector search via `sqlite-vec` (already in deps, embedding backend pending)
|
|
347
424
|
- 🔮 Cross-device cloud sync (Pro tier)
|
|
348
425
|
|
|
@@ -477,6 +554,16 @@ If you want to force a manual consolidation, restart the MCP server — auto-con
|
|
|
477
554
|
|
|
478
555
|
## FAQ
|
|
479
556
|
|
|
557
|
+
<details>
|
|
558
|
+
<summary><strong>What is drift detection and why do I need it?</strong></summary>
|
|
559
|
+
|
|
560
|
+
Drift = when your code reality silently diverges from what you decided. Example: Last week you decided "FTS5, not vector search" but this week a new agent session installs pgvector without knowing the history.
|
|
561
|
+
|
|
562
|
+
Linksee Memory tracks this by letting you declare decisions as "anchors" and then automatically checking committed code against them. The make-or-break rule: **intentional evolution (recorded as fix/supersede) stays quiet, while unaccounted gaps get flagged.** It's like Datadog but for product decisions instead of server metrics.
|
|
563
|
+
|
|
564
|
+
You don't need to use drift detection to benefit from linksee-memory — the 3 memory tools (remember/recall/read_smart) work independently. Drift tools are an additional layer for teams and solo devs managing multiple projects.
|
|
565
|
+
</details>
|
|
566
|
+
|
|
480
567
|
<details>
|
|
481
568
|
<summary><strong>How is this different from Mem0 / Letta / Zep?</strong></summary>
|
|
482
569
|
|
|
@@ -556,6 +643,23 @@ After install, in a new Claude session ask: *"Can you remember that I prefer Typ
|
|
|
556
643
|
|
|
557
644
|
## Changelog
|
|
558
645
|
|
|
646
|
+
### v0.8.0 — Drift Detection MCP Tools (2026-06-08)
|
|
647
|
+
|
|
648
|
+
**3 tools → 7 tools.** The biggest update since launch — agents can now detect, query, and resolve intent ↔ reality drift.
|
|
649
|
+
|
|
650
|
+
**New tools:**
|
|
651
|
+
- **`drift_status`** — returns the truth map with 4-species classification and per-node drift state
|
|
652
|
+
- **`check_decision`** — deep-dive into a single anchor: state, edges, pending candidates
|
|
653
|
+
- **`declare_anchor`** — record a decision/constraint/prohibition as a truth-map node (with v9 ProjectCoreNode fields)
|
|
654
|
+
- **`resolve_drift`** — close the feedback loop: fix / supersede / acknowledge / dismiss
|
|
655
|
+
|
|
656
|
+
**New engine module:**
|
|
657
|
+
- **`truth-engine.ts`** — state derivation logic migrated from the dashboard into the MCP engine. Any MCP client can now query drift status without a dashboard.
|
|
658
|
+
- **Resolution priority fix**: when multiple resolutions reference the same anchor, the most recent one wins (by `resolved_at` timestamp). Prevents a stale acknowledge from shadowing a newer fix.
|
|
659
|
+
- **4-species classification**: nodes classified by `decision_mode` into hypothesis / constraint / commitment / source_of_truth with display format guidance.
|
|
660
|
+
|
|
661
|
+
No breaking changes to existing memory tools. All 3 memory tools (remember, recall, read_smart) are unchanged.
|
|
662
|
+
|
|
559
663
|
### v0.7.2 — Recall ergonomics + auto-edge detection + classifier precision (2026-05-30)
|
|
560
664
|
|
|
561
665
|
Quality pass on v0.7.0 / v0.7.1 — sharper day-to-day agent UX and cleaner data for the dashboard:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-declare — declare / list / retire drift anchors (v8).
|
|
3
|
+
//
|
|
4
|
+
// The explicit write path for drift observability. Anchors declared here are clean
|
|
5
|
+
// by construction (declare-don't-mine): a human typed them. The bulk seeding script
|
|
6
|
+
// (curate the existing candidate pool) reuses curateAnchorFromMemory() from the lib.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// linksee-memory-declare list [--status active|retired] [--kind prohibition|decision|constraint]
|
|
10
|
+
// linksee-memory-declare add --kind <k> --statement "<text>" [--rationale "<text>"]
|
|
11
|
+
// [--affects "glob1,glob2"] [--terms "t1,t2"] [--violation "v1,v2"] [--tier human|explicit]
|
|
12
|
+
// linksee-memory-declare retire --id <n>
|
|
13
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
14
|
+
import { declareAnchor, listAnchors, retireAnchor, setNodeFields, getCurrentTruth, getAlertPolicy, setAlertPolicy, } from '../lib/drift-anchors.js';
|
|
15
|
+
function parseFlags(argv) {
|
|
16
|
+
const out = {};
|
|
17
|
+
for (let i = 0; i < argv.length; i++) {
|
|
18
|
+
const a = argv[i];
|
|
19
|
+
if (a.startsWith('--')) {
|
|
20
|
+
const key = a.slice(2);
|
|
21
|
+
const next = argv[i + 1];
|
|
22
|
+
if (next === undefined || next.startsWith('--')) {
|
|
23
|
+
out[key] = 'true';
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
out[key] = next;
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function splitList(v) {
|
|
34
|
+
if (!v)
|
|
35
|
+
return [];
|
|
36
|
+
return v
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
function print(obj) {
|
|
42
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
43
|
+
}
|
|
44
|
+
function main() {
|
|
45
|
+
const [, , sub, ...rest] = process.argv;
|
|
46
|
+
const flags = parseFlags(rest);
|
|
47
|
+
const db = openDb();
|
|
48
|
+
runMigrations(db); // ensure drift tables exist when run standalone
|
|
49
|
+
try {
|
|
50
|
+
switch (sub) {
|
|
51
|
+
case 'add': {
|
|
52
|
+
const anchor = declareAnchor(db, {
|
|
53
|
+
kind: flags.kind,
|
|
54
|
+
statement: flags.statement ?? '',
|
|
55
|
+
rationale: flags.rationale,
|
|
56
|
+
affects: splitList(flags.affects),
|
|
57
|
+
detect_terms: splitList(flags.terms),
|
|
58
|
+
violation_signal: splitList(flags.violation),
|
|
59
|
+
tier: flags.tier || undefined,
|
|
60
|
+
});
|
|
61
|
+
// v9 ProjectCoreNode fields (minimal input — only what's given):
|
|
62
|
+
// --node-type --domain --mode --confidence --cadence --stale-days --applies --not-applies
|
|
63
|
+
const nf = {};
|
|
64
|
+
if (flags['node-type'])
|
|
65
|
+
nf.node_type = flags['node-type'];
|
|
66
|
+
if (flags.domain)
|
|
67
|
+
nf.domain = flags.domain;
|
|
68
|
+
if (flags.mode)
|
|
69
|
+
nf.decision_mode = flags.mode;
|
|
70
|
+
if (flags.confidence)
|
|
71
|
+
nf.confidence = Number(flags.confidence);
|
|
72
|
+
if (flags.cadence || flags['stale-days']) {
|
|
73
|
+
const cp = { enabled: true };
|
|
74
|
+
if (flags.cadence)
|
|
75
|
+
cp.cadence_days = Number(flags.cadence);
|
|
76
|
+
if (flags['stale-days'])
|
|
77
|
+
cp.stale_threshold_days = Number(flags['stale-days']);
|
|
78
|
+
nf.card_policy = cp;
|
|
79
|
+
}
|
|
80
|
+
if (flags.applies || flags['not-applies']) {
|
|
81
|
+
const vs = {};
|
|
82
|
+
if (flags.applies)
|
|
83
|
+
vs.applies_to = splitList(flags.applies);
|
|
84
|
+
if (flags['not-applies'])
|
|
85
|
+
vs.does_not_apply_to = splitList(flags['not-applies']);
|
|
86
|
+
nf.validity_scope = vs;
|
|
87
|
+
}
|
|
88
|
+
if (Object.keys(nf).length)
|
|
89
|
+
setNodeFields(db, anchor.id, nf);
|
|
90
|
+
print({ ok: true, declared: anchor, node_fields: Object.keys(nf).length ? nf : null });
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case 'retire': {
|
|
94
|
+
const id = Number(flags.id);
|
|
95
|
+
if (!Number.isFinite(id))
|
|
96
|
+
throw new Error('--id <n> required');
|
|
97
|
+
const ok = retireAnchor(db, id);
|
|
98
|
+
print({ ok, retired: ok ? id : null });
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'list':
|
|
102
|
+
case undefined: {
|
|
103
|
+
const anchors = listAnchors(db, {
|
|
104
|
+
status: flags.status,
|
|
105
|
+
kind: flags.kind,
|
|
106
|
+
});
|
|
107
|
+
print({ ok: true, count: anchors.length, anchors });
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'truth': {
|
|
111
|
+
// ⑧ read_smart-style scoped read: active Current-Truth slice only.
|
|
112
|
+
const nodes = getCurrentTruth(db, { domain: flags.domain, decision_mode: flags.mode });
|
|
113
|
+
print({ ok: true, scope: { domain: flags.domain ?? 'all', decision_mode: flags.mode ?? 'all' }, count: nodes.length, current_truth: nodes });
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'policy': {
|
|
117
|
+
const p = {};
|
|
118
|
+
if (flags['max-cards-per-day'])
|
|
119
|
+
p.max_cards_per_day = Number(flags['max-cards-per-day']);
|
|
120
|
+
if (flags['max-soft-per-week'])
|
|
121
|
+
p.max_soft_cards_per_week = Number(flags['max-soft-per-week']);
|
|
122
|
+
if (flags['min-soft-confidence'])
|
|
123
|
+
p.min_confidence_for_soft_card = Number(flags['min-soft-confidence']);
|
|
124
|
+
if (flags['two-sided'])
|
|
125
|
+
p.require_two_sided_evidence = flags['two-sided'] !== 'false';
|
|
126
|
+
const policy = Object.keys(p).length ? setAlertPolicy(db, p) : getAlertPolicy(db);
|
|
127
|
+
print({ ok: true, alert_policy: policy });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
default:
|
|
131
|
+
throw new Error(`unknown subcommand "${sub}" — use: add | list | retire | truth | policy`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
print({ ok: false, error: err?.message ?? String(err) });
|
|
136
|
+
db.close();
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
db.close();
|
|
140
|
+
}
|
|
141
|
+
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
142
|
+
process.argv[1]?.endsWith('declare-anchor.ts') ||
|
|
143
|
+
process.argv[1]?.endsWith('declare-anchor.js')) {
|
|
144
|
+
main();
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=declare-anchor.js.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// linksee-memory-detect — run the drift detector (照合: declared intent vs. actual reality).
|
|
3
|
+
//
|
|
4
|
+
// The manual-trigger entry point for drift detection (the cadence question). Mirrors
|
|
5
|
+
// declare-anchor.ts; purely additive — touches only drift_edges (the feature's own table) and
|
|
6
|
+
// ONLY when --persist is passed. SAFE BY DEFAULT: a bare run is a dry-run (reads only, no
|
|
7
|
+
// writes), so you can preview drift before committing it to the view. Pass --persist to write
|
|
8
|
+
// the edges that /drift renders. No embedding layer: matching is lexical/glob/trigram-FTS only.
|
|
9
|
+
//
|
|
10
|
+
// Usage:
|
|
11
|
+
// linksee-memory-detect # dry-run — preview drift, writes NOTHING (default)
|
|
12
|
+
// linksee-memory-detect --persist # write contradicts/absent edges into drift_edges
|
|
13
|
+
// linksee-memory-detect --stale-days 30 # override absence staleness gate (default 14)
|
|
14
|
+
// linksee-memory-detect --threshold 0.5 # override emit threshold (default 0.5)
|
|
15
|
+
import { openDb, runMigrations } from '../db/migrate.js';
|
|
16
|
+
import { detectDrift, detectFileViolations } from '../lib/drift-detection.js';
|
|
17
|
+
function parseFlags(argv) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i++) {
|
|
20
|
+
const a = argv[i];
|
|
21
|
+
if (a.startsWith('--')) {
|
|
22
|
+
const key = a.slice(2);
|
|
23
|
+
const next = argv[i + 1];
|
|
24
|
+
if (next === undefined || next.startsWith('--')) {
|
|
25
|
+
out[key] = 'true';
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
out[key] = next;
|
|
29
|
+
i++;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function print(obj) {
|
|
36
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
37
|
+
}
|
|
38
|
+
function main() {
|
|
39
|
+
const [, , ...rest] = process.argv;
|
|
40
|
+
const flags = parseFlags(rest);
|
|
41
|
+
const persist = flags.persist === 'true';
|
|
42
|
+
const staleDays = flags['stale-days'] !== undefined ? Number(flags['stale-days']) : undefined;
|
|
43
|
+
const emitThreshold = flags.threshold !== undefined ? Number(flags.threshold) : undefined;
|
|
44
|
+
if (staleDays !== undefined && !Number.isFinite(staleDays))
|
|
45
|
+
throw new Error('--stale-days <n> must be a number');
|
|
46
|
+
if (emitThreshold !== undefined && !Number.isFinite(emitThreshold))
|
|
47
|
+
throw new Error('--threshold <n> must be a number');
|
|
48
|
+
const db = openDb();
|
|
49
|
+
runMigrations(db); // ensure drift tables exist when run standalone
|
|
50
|
+
try {
|
|
51
|
+
const res = detectDrift(db, { dryRun: !persist, staleDays, emitThreshold });
|
|
52
|
+
const fres = detectFileViolations(db, { dryRun: !persist, emitThreshold });
|
|
53
|
+
print({
|
|
54
|
+
ok: true,
|
|
55
|
+
mode: persist ? 'PERSISTED' : 'DRY RUN (no writes — pass --persist to write)',
|
|
56
|
+
persisted: res.persisted,
|
|
57
|
+
anchorsScanned: res.anchorsScanned,
|
|
58
|
+
editsScanned: res.editsScanned,
|
|
59
|
+
// v1 — edit-snippet scan (what a captured edit's snippet contained)
|
|
60
|
+
editSnippetScan: {
|
|
61
|
+
contradicts: res.contradicts,
|
|
62
|
+
absent: res.absent,
|
|
63
|
+
edgesEmitted: res.edgesEmitted,
|
|
64
|
+
byAnchorHits: res.byAnchor.filter((b) => b.contradicts > 0 || b.absent > 0),
|
|
65
|
+
samples: res.samples.slice(0, 6),
|
|
66
|
+
},
|
|
67
|
+
// v2 — current-file scan (live file:line against violation_signal)
|
|
68
|
+
currentFileScan: {
|
|
69
|
+
filesScanned: fres.filesScanned,
|
|
70
|
+
anchorsCapped: fres.anchorsCapped,
|
|
71
|
+
contradicts: fres.contradicts,
|
|
72
|
+
edgesEmitted: fres.edgesEmitted,
|
|
73
|
+
byAnchorHits: fres.byAnchor,
|
|
74
|
+
samples: fres.samples.slice(0, 14),
|
|
75
|
+
},
|
|
76
|
+
totalEdgesEmitted: res.edgesEmitted + fres.edgesEmitted,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
print({ ok: false, error: err?.message ?? String(err) });
|
|
81
|
+
db.close();
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
if (import.meta.url === `file://${process.argv[1]}` ||
|
|
87
|
+
process.argv[1]?.endsWith('detect-drift.ts') ||
|
|
88
|
+
process.argv[1]?.endsWith('detect-drift.js')) {
|
|
89
|
+
main();
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=detect-drift.js.map
|
package/dist/db/migrate.js
CHANGED
|
@@ -33,11 +33,11 @@ export function runMigrations(db) {
|
|
|
33
33
|
// v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support.
|
|
34
34
|
// Only runs when upgrading an existing DB from schema v1-3.
|
|
35
35
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
36
|
-
db.exec(`
|
|
37
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
38
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
39
|
-
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
40
|
-
DROP TABLE IF EXISTS memories_fts;
|
|
36
|
+
db.exec(`
|
|
37
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ai;
|
|
38
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_ad;
|
|
39
|
+
DROP TRIGGER IF EXISTS trg_memories_fts_au;
|
|
40
|
+
DROP TABLE IF EXISTS memories_fts;
|
|
41
41
|
`);
|
|
42
42
|
}
|
|
43
43
|
// v4 → v5: add normalized_name column BEFORE schema.sql runs,
|
|
@@ -81,11 +81,35 @@ export function runMigrations(db) {
|
|
|
81
81
|
db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
|
|
82
82
|
}
|
|
83
83
|
// Backfill thread_id from content JSON session_id for existing memories
|
|
84
|
-
db.exec(`
|
|
85
|
-
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
86
|
-
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
84
|
+
db.exec(`
|
|
85
|
+
UPDATE memories SET thread_id = json_extract(content, '$.session_id')
|
|
86
|
+
WHERE thread_id IS NULL AND json_valid(content) AND json_extract(content, '$.session_id') IS NOT NULL
|
|
87
87
|
`);
|
|
88
88
|
}
|
|
89
|
+
// v8 → v9: ProjectCoreNode — extend drift_anchors into the Current Truth Map node.
|
|
90
|
+
// ADDITIVE columns only (ADD COLUMN with safe defaults) — NO CHECK rebuild, so the existing
|
|
91
|
+
// detector/dashboard (which read the original columns + status active/retired) are unaffected.
|
|
92
|
+
// `lifecycle` carries the rich state; `status` stays the coarse scan-gate. New tables
|
|
93
|
+
// (reality_events, memory_write_candidates) are created by db.exec(sql) below (CREATE IF NOT EXISTS).
|
|
94
|
+
if (currentVersion > 0 && currentVersion < 9) {
|
|
95
|
+
const have = new Set(db.prepare('PRAGMA table_info(drift_anchors)').all().map((c) => c.name));
|
|
96
|
+
const addCol = (name, ddl) => {
|
|
97
|
+
if (!have.has(name))
|
|
98
|
+
db.exec(`ALTER TABLE drift_anchors ADD COLUMN ${ddl}`);
|
|
99
|
+
};
|
|
100
|
+
addCol('node_type', 'node_type TEXT');
|
|
101
|
+
addCol('domain', 'domain TEXT');
|
|
102
|
+
addCol('decision_mode', 'decision_mode TEXT');
|
|
103
|
+
addCol('confidence', 'confidence REAL NOT NULL DEFAULT 0.8');
|
|
104
|
+
addCol('lifecycle', "lifecycle TEXT NOT NULL DEFAULT 'active'");
|
|
105
|
+
addCol('validity_scope', "validity_scope TEXT NOT NULL DEFAULT '{}'");
|
|
106
|
+
addCol('card_policy', "card_policy TEXT NOT NULL DEFAULT '{}'");
|
|
107
|
+
addCol('reality_manifestations', "reality_manifestations TEXT NOT NULL DEFAULT '[]'");
|
|
108
|
+
addCol('evidence_refs', "evidence_refs TEXT NOT NULL DEFAULT '[]'");
|
|
109
|
+
addCol('review_after', 'review_after INTEGER');
|
|
110
|
+
addCol('last_confirmed_at', 'last_confirmed_at INTEGER');
|
|
111
|
+
addCol('owner', 'owner TEXT');
|
|
112
|
+
}
|
|
89
113
|
db.exec(sql);
|
|
90
114
|
if (currentVersion > 0 && currentVersion < 4) {
|
|
91
115
|
db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
|
|
@@ -124,12 +148,12 @@ function migrateV5EntityNormalization(db) {
|
|
|
124
148
|
}
|
|
125
149
|
})();
|
|
126
150
|
// 4. Auto-merge duplicate entities (same kind + normalized_name)
|
|
127
|
-
const dupes = db.prepare(`
|
|
128
|
-
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
129
|
-
FROM entities
|
|
130
|
-
WHERE normalized_name IS NOT NULL
|
|
131
|
-
GROUP BY kind, normalized_name
|
|
132
|
-
HAVING COUNT(*) > 1
|
|
151
|
+
const dupes = db.prepare(`
|
|
152
|
+
SELECT kind, normalized_name, GROUP_CONCAT(id) as ids
|
|
153
|
+
FROM entities
|
|
154
|
+
WHERE normalized_name IS NOT NULL
|
|
155
|
+
GROUP BY kind, normalized_name
|
|
156
|
+
HAVING COUNT(*) > 1
|
|
133
157
|
`).all();
|
|
134
158
|
if (dupes.length > 0) {
|
|
135
159
|
console.log(`[linksee-memory] v5 migration: merging ${dupes.length} duplicate entity clusters`);
|
|
@@ -150,12 +174,12 @@ function mergeEntityCluster(db, ids) {
|
|
|
150
174
|
if (ids.length < 2)
|
|
151
175
|
return;
|
|
152
176
|
// Score each entity: prefer most memories, then has canonical_key, then lowest id
|
|
153
|
-
const rows = db.prepare(`
|
|
154
|
-
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
155
|
-
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
156
|
-
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
157
|
-
GROUP BY e.id
|
|
158
|
-
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
177
|
+
const rows = db.prepare(`
|
|
178
|
+
SELECT e.id, e.name, e.canonical_key, COUNT(m.id) as mem_count
|
|
179
|
+
FROM entities e LEFT JOIN memories m ON m.entity_id = e.id
|
|
180
|
+
WHERE e.id IN (${ids.map(() => '?').join(',')})
|
|
181
|
+
GROUP BY e.id
|
|
182
|
+
ORDER BY mem_count DESC, (e.canonical_key IS NOT NULL) DESC, e.id ASC
|
|
159
183
|
`).all(...ids);
|
|
160
184
|
const keep = rows[0];
|
|
161
185
|
const mergeIds = rows.slice(1).map(r => r.id);
|
|
@@ -176,16 +200,16 @@ function mergeEntityCluster(db, ids) {
|
|
|
176
200
|
db.prepare('UPDATE events SET entity_id = ? WHERE entity_id = ?').run(keep.id, mid);
|
|
177
201
|
// Reassign edges (both directions)
|
|
178
202
|
// Handle UNIQUE constraint: delete duplicates first
|
|
179
|
-
db.prepare(`
|
|
180
|
-
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
181
|
-
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
182
|
-
)
|
|
203
|
+
db.prepare(`
|
|
204
|
+
DELETE FROM edges WHERE from_id = ? AND EXISTS (
|
|
205
|
+
SELECT 1 FROM edges e2 WHERE e2.from_id = ? AND e2.to_id = edges.to_id AND e2.relation = edges.relation
|
|
206
|
+
)
|
|
183
207
|
`).run(mid, keep.id);
|
|
184
208
|
db.prepare('UPDATE edges SET from_id = ? WHERE from_id = ?').run(keep.id, mid);
|
|
185
|
-
db.prepare(`
|
|
186
|
-
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
187
|
-
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
188
|
-
)
|
|
209
|
+
db.prepare(`
|
|
210
|
+
DELETE FROM edges WHERE to_id = ? AND EXISTS (
|
|
211
|
+
SELECT 1 FROM edges e2 WHERE e2.to_id = ? AND e2.from_id = edges.from_id AND e2.relation = edges.relation
|
|
212
|
+
)
|
|
189
213
|
`).run(mid, keep.id);
|
|
190
214
|
db.prepare('UPDATE edges SET to_id = ? WHERE to_id = ?').run(keep.id, mid);
|
|
191
215
|
// Reassign consolidations
|