linksee-memory 0.15.2 → 0.16.1
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/dist/lib/distill-triage.d.ts +29 -0
- package/dist/lib/distill-triage.js +104 -0
- package/dist/lib/guard.js +2 -1
- package/dist/mcp/server.js +27 -5
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
export declare const SETTLE_SECONDS: number;
|
|
3
|
+
export declare const STALE_DAYS = 90;
|
|
4
|
+
export declare const SHORT_CHARS = 40;
|
|
5
|
+
export type TriageVerdict = 'auto-noise' | 'auto-stale' | 'keep';
|
|
6
|
+
export declare function classifyRaw(what: string, ageDays: number, accessCount: number): {
|
|
7
|
+
verdict: TriageVerdict;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export interface TriageReport {
|
|
11
|
+
scanned: number;
|
|
12
|
+
noise: number;
|
|
13
|
+
stale: number;
|
|
14
|
+
remaining: number;
|
|
15
|
+
dryRun: boolean;
|
|
16
|
+
samples: {
|
|
17
|
+
noise: string[];
|
|
18
|
+
stale: string[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Walk every needs_distill memory outside the settle window and archive the ones a rule can
|
|
23
|
+
* settle. Idempotent — archived rows no longer match the WHERE clause.
|
|
24
|
+
*/
|
|
25
|
+
export declare function triageDistillQueue(db: Database.Database, opts?: {
|
|
26
|
+
dryRun?: boolean;
|
|
27
|
+
}): TriageReport;
|
|
28
|
+
/** Count of rows still awaiting a human/agent rewrite (outside the settle window). */
|
|
29
|
+
export declare function distillPending(db: Database.Database): number;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// distill-triage — keep the distill queue honest without an LLM.
|
|
2
|
+
//
|
|
3
|
+
// The Stop hook stores raw user utterances with needs_distill:true (no LLM runs in the hook
|
|
4
|
+
// path — anchor #70), and the agent is meant to rewrite them via recall({ dream: true }). On
|
|
5
|
+
// the author's machine that queue reached 389 and nobody drained it, because most of it was
|
|
6
|
+
// never a decision: "OK. Aからいこう", a pasted file path, "はい。そうしましょう". Asking an
|
|
7
|
+
// agent to distill those is asking it to find meaning that is not there.
|
|
8
|
+
//
|
|
9
|
+
// So: classify before asking. Two verdicts need no intelligence, only a rule —
|
|
10
|
+
// auto-noise the utterance carries no decision object (too short, a bare acknowledgement or
|
|
11
|
+
// imperative, a path/URL/attachment reference)
|
|
12
|
+
// auto-stale never recalled and older than STALE_DAYS — whatever it was, nobody came back
|
|
13
|
+
//
|
|
14
|
+
// Both are archived, never deleted: type → note, state → superseded, distill_verdict records
|
|
15
|
+
// why. needs_distill stays TRUE — anchor #70 says the flag must not be dropped, and it stays
|
|
16
|
+
// truthful: no agent ever rewrote this row. The queue, the pending count and the boot digest
|
|
17
|
+
// exclude rows that carry a verdict. Layer and protection are untouched (a caveat stays a
|
|
18
|
+
// caveat — it just stops being asked about). Reversible: delete distill_verdict from the JSON.
|
|
19
|
+
//
|
|
20
|
+
// Measured before shipping (2026-09-07, 389 raw): noise 88, stale 51, queue 250 — and the
|
|
21
|
+
// noise sample was read by a human before the rule was trusted.
|
|
22
|
+
export const SETTLE_SECONDS = 30 * 60; // a session still in motion is not triaged (matches dream)
|
|
23
|
+
export const STALE_DAYS = 90;
|
|
24
|
+
export const SHORT_CHARS = 40;
|
|
25
|
+
const ACK_ONLY = /^(ok|okay|はい|そうだね|そうですね|うん|了解|いいね|ありがとう|お願い|進めて|次いこう|やろう|それで|続けて|よし|では|じゃあ|なるほど)[\s。、.,!!]*$/i;
|
|
26
|
+
const ACK_LEAD = /^(ok|okay|はい|そうだね|そうですね|うん|了解|いいね|ありがとう|よし|では|じゃあ|なるほど)[\s。、.,!!]+/i;
|
|
27
|
+
const BARE_REF = /^(@"|@[A-Za-z]:|"?[A-Za-z]:\\|https?:\/\/)/;
|
|
28
|
+
const DECISION_OBJECT = /決めた|採用|確定|却下|やめ|禁止|方針|に(する|しよう)|でいこう|で行こう|decid|chose|going with|switch(ing)? to|settled on|approved|instead/i;
|
|
29
|
+
export function classifyRaw(what, ageDays, accessCount) {
|
|
30
|
+
const w = (what ?? '').trim();
|
|
31
|
+
if (w.length < SHORT_CHARS)
|
|
32
|
+
return { verdict: 'auto-noise', reason: `shorter than ${SHORT_CHARS} chars` };
|
|
33
|
+
if (ACK_ONLY.test(w))
|
|
34
|
+
return { verdict: 'auto-noise', reason: 'acknowledgement only' };
|
|
35
|
+
if (BARE_REF.test(w) && w.length < 120)
|
|
36
|
+
return { verdict: 'auto-noise', reason: 'bare path/URL reference' };
|
|
37
|
+
if (ACK_LEAD.test(w) && !DECISION_OBJECT.test(w) && w.length < 80)
|
|
38
|
+
return { verdict: 'auto-noise', reason: 'acknowledgement with no decision object' };
|
|
39
|
+
if (ageDays > STALE_DAYS && accessCount === 0)
|
|
40
|
+
return { verdict: 'auto-stale', reason: `never recalled in ${STALE_DAYS}+ days` };
|
|
41
|
+
return { verdict: 'keep', reason: '' };
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Walk every needs_distill memory outside the settle window and archive the ones a rule can
|
|
45
|
+
* settle. Idempotent — archived rows no longer match the WHERE clause.
|
|
46
|
+
*/
|
|
47
|
+
export function triageDistillQueue(db, opts = {}) {
|
|
48
|
+
const now = Math.floor(Date.now() / 1000);
|
|
49
|
+
const rows = db
|
|
50
|
+
.prepare(`SELECT id, content, access_count, created_at FROM memories
|
|
51
|
+
WHERE json_valid(content)
|
|
52
|
+
AND json_extract(content, '$.needs_distill') = 1
|
|
53
|
+
AND json_extract(content, '$.distill_verdict') IS NULL
|
|
54
|
+
AND created_at < ?`)
|
|
55
|
+
.all(now - SETTLE_SECONDS);
|
|
56
|
+
const report = { scanned: rows.length, noise: 0, stale: 0, remaining: 0, dryRun: !!opts.dryRun, samples: { noise: [], stale: [] } };
|
|
57
|
+
const update = db.prepare('UPDATE memories SET content = ? WHERE id = ?');
|
|
58
|
+
const apply = db.transaction(() => {
|
|
59
|
+
for (const r of rows) {
|
|
60
|
+
let c;
|
|
61
|
+
try {
|
|
62
|
+
c = JSON.parse(r.content);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
report.remaining++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const what = String(c.what ?? '');
|
|
69
|
+
const ageDays = (now - r.created_at) / 86400;
|
|
70
|
+
const { verdict, reason } = classifyRaw(what, ageDays, r.access_count ?? 0);
|
|
71
|
+
if (verdict === 'keep') {
|
|
72
|
+
report.remaining++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (verdict === 'auto-noise')
|
|
76
|
+
report.noise++;
|
|
77
|
+
else
|
|
78
|
+
report.stale++;
|
|
79
|
+
const bucket = report.samples[verdict === 'auto-noise' ? 'noise' : 'stale'];
|
|
80
|
+
if (bucket.length < 5)
|
|
81
|
+
bucket.push(what.replace(/\s+/g, ' ').slice(0, 80));
|
|
82
|
+
if (opts.dryRun)
|
|
83
|
+
continue;
|
|
84
|
+
c.distill_verdict = verdict;
|
|
85
|
+
c.distill_reason = reason;
|
|
86
|
+
c.type = 'note';
|
|
87
|
+
c.state = 'superseded';
|
|
88
|
+
update.run(JSON.stringify(c), r.id);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
apply();
|
|
92
|
+
return report;
|
|
93
|
+
}
|
|
94
|
+
/** Count of rows still awaiting a human/agent rewrite (outside the settle window). */
|
|
95
|
+
export function distillPending(db) {
|
|
96
|
+
const now = Math.floor(Date.now() / 1000);
|
|
97
|
+
const r = db
|
|
98
|
+
.prepare(`SELECT COUNT(*) AS c FROM memories
|
|
99
|
+
WHERE json_valid(content) AND json_extract(content, '$.needs_distill') = 1
|
|
100
|
+
AND json_extract(content, '$.distill_verdict') IS NULL AND created_at < ?`)
|
|
101
|
+
.get(now - SETTLE_SECONDS);
|
|
102
|
+
return r?.c ?? 0;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=distill-triage.js.map
|
package/dist/lib/guard.js
CHANGED
|
@@ -264,6 +264,7 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
264
264
|
try {
|
|
265
265
|
distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
266
266
|
WHERE layer IN ('learning', 'caveat') AND json_valid(content)
|
|
267
|
+
AND json_extract(content, '$.distill_verdict') IS NULL
|
|
267
268
|
AND (json_extract(content, '$.needs_distill') = 1
|
|
268
269
|
OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
269
270
|
OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
|
|
@@ -285,7 +286,7 @@ export function buildBootDigest(db, opts = {}) {
|
|
|
285
286
|
parts.push(`• ${f.statement ?? f.rationale}`);
|
|
286
287
|
}
|
|
287
288
|
if (distill > 0) {
|
|
288
|
-
parts.push('', `🧪 ${distill} auto-captured memories
|
|
289
|
+
parts.push('', `🧪 ${distill} auto-captured memories still need a rewrite (rules already settled the acknowledgements and stale ones). Do 3: recall({ dream: true }) → remember({ memory_id, content }) with "distilled": true.`);
|
|
289
290
|
}
|
|
290
291
|
if (anchors.length > 0)
|
|
291
292
|
parts.push('', 'Honor these unless you explicitly supersede them (resolve_drift action=supersede).');
|
package/dist/mcp/server.js
CHANGED
|
@@ -24,6 +24,7 @@ import { getTruthView, getDecisionDetail, resolveDrift } from '../lib/truth-engi
|
|
|
24
24
|
import { declareAnchor, setNodeFields } from '../lib/drift-anchors.js';
|
|
25
25
|
import { getReinjectionFriction, setGateMode } from '../lib/guard.js';
|
|
26
26
|
import { logAnchorTouch } from '../lib/anchor-touch.js';
|
|
27
|
+
import { triageDistillQueue, distillPending } from '../lib/distill-triage.js';
|
|
27
28
|
import { whereAmI, listMapProjects } from '../lib/map-view.js';
|
|
28
29
|
import { readFileSync, existsSync } from 'node:fs';
|
|
29
30
|
import { fileURLToPath } from 'node:url';
|
|
@@ -71,6 +72,16 @@ if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand))
|
|
|
71
72
|
}
|
|
72
73
|
const db = openDb();
|
|
73
74
|
runMigrations(db);
|
|
75
|
+
// Distill triage runs on EVERY start, before the first request. It is one indexed scan and a
|
|
76
|
+
// handful of JSON rewrites — cheap — and 0.16.0 had tucked it inside the auto-consolidate block
|
|
77
|
+
// below, which runs at most once a week and only after a 3-second timer. So it never ran.
|
|
78
|
+
try {
|
|
79
|
+
const t = triageDistillQueue(db);
|
|
80
|
+
if (t.noise + t.stale > 0) {
|
|
81
|
+
process.stderr.write(`[linksee-memory] distill triage: archived ${t.noise} noise + ${t.stale} stale, ${t.remaining} remain\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* never block startup */ }
|
|
74
85
|
// Auto-maintenance: consolidate stale memories on startup (non-blocking)
|
|
75
86
|
setTimeout(() => {
|
|
76
87
|
try {
|
|
@@ -195,6 +206,7 @@ const TOOLS = [
|
|
|
195
206
|
where: { description: 'Locate on the Current Truth Map. A topic string, or true to auto-locate from the files you edited recently. Returns your node, journey stage, blast radius, and the decision behind it.', anyOf: [{ type: 'string' }, { type: 'boolean' }] },
|
|
196
207
|
project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
|
|
197
208
|
dream: { type: 'boolean', default: false, description: 'Return the full triage set: North Star, orphaned proposals (each with candidate_id), distill_queue, friction. Resolve proposals with resolve_drift({ candidate_id, action }); rewrite distill items with remember({ memory_id, content }).' },
|
|
209
|
+
distill: { type: 'number', description: 'With dream: how many raw memories to return for rewriting (default 8, max 25). The queue is value-ordered; rule-verdicted items never appear.' },
|
|
198
210
|
overview: { type: 'boolean', default: false, description: 'Return the entity list instead of the session brief when no query is given.' },
|
|
199
211
|
kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
|
|
200
212
|
min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
|
|
@@ -1431,7 +1443,7 @@ async function handleSessionBrief() {
|
|
|
1431
1443
|
north_star: d.north_star ? { id: d.north_star.id, statement: String(d.north_star.statement).slice(0, 160) } : null,
|
|
1432
1444
|
proposals: d.total ?? 0,
|
|
1433
1445
|
proposals_top: (d.candidates ?? []).slice(0, 3).map((c) => ({ candidate_id: c.candidate_id ?? c.id, statement: String(c.statement ?? c.target_statement ?? '').slice(0, 120) })),
|
|
1434
|
-
distill_queue: d.distill_total ?? 0,
|
|
1446
|
+
distill_queue: d.distill_total ?? 0, // rows still needing a rewrite; rule-verdicted rows are not counted
|
|
1435
1447
|
friction: d.friction_total ?? 0,
|
|
1436
1448
|
};
|
|
1437
1449
|
}
|
|
@@ -1461,7 +1473,7 @@ async function handleRecallUnified(args) {
|
|
|
1461
1473
|
// Folded surfaces (roadmap 5): locating yourself and the triage session are ways of
|
|
1462
1474
|
// recalling, not separate tools.
|
|
1463
1475
|
if (args?.dream)
|
|
1464
|
-
return handleDream({ domain: args.domain });
|
|
1476
|
+
return handleDream({ domain: args.domain, distill: args.distill });
|
|
1465
1477
|
if (args?.where !== undefined && args?.where !== false) {
|
|
1466
1478
|
return handleWhereAmI({ query: args.where === true ? undefined : String(args.where), project: args.project, limit: args.limit });
|
|
1467
1479
|
}
|
|
@@ -1855,6 +1867,7 @@ function handleDream(args) {
|
|
|
1855
1867
|
// utterances (no LLM there); the agent rewrites them here into clean what/why via
|
|
1856
1868
|
// remember(memory_id, content). Matches needs_distill (new) AND the legacy hardcoded
|
|
1857
1869
|
// why-strings so the existing backlog is drainable without a backfill write.
|
|
1870
|
+
const distillLimit = Math.max(1, Math.min(25, Number(args?.distill ?? 8) || 8));
|
|
1858
1871
|
let distillQueue = [];
|
|
1859
1872
|
try {
|
|
1860
1873
|
const rows = db.prepare(`
|
|
@@ -1865,10 +1878,15 @@ function handleDream(args) {
|
|
|
1865
1878
|
-- settle window: the Stop hook extracts every turn, so the newest rows belong to a
|
|
1866
1879
|
-- session still in motion. Don't ask the agent to distill the conversation it is in.
|
|
1867
1880
|
AND m.created_at < unixepoch() - 1800
|
|
1881
|
+
AND json_extract(m.content, '$.distill_verdict') IS NULL
|
|
1868
1882
|
AND (json_extract(m.content, '$.needs_distill') = 1
|
|
1869
1883
|
OR json_extract(m.content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
|
|
1870
1884
|
OR json_extract(m.content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')
|
|
1871
|
-
ORDER BY m.
|
|
1885
|
+
ORDER BY (m.layer = 'caveat') DESC,
|
|
1886
|
+
(e.name <> 'Local') DESC,
|
|
1887
|
+
length(json_extract(m.content, '$.what')) DESC,
|
|
1888
|
+
m.created_at DESC
|
|
1889
|
+
LIMIT ${distillLimit}
|
|
1872
1890
|
`).all();
|
|
1873
1891
|
distillQueue = rows.map((r) => {
|
|
1874
1892
|
let c = {};
|
|
@@ -1898,7 +1916,9 @@ function handleDream(args) {
|
|
|
1898
1916
|
friction,
|
|
1899
1917
|
friction_total: friction.length,
|
|
1900
1918
|
distill_queue: distillQueue,
|
|
1901
|
-
distill_total:
|
|
1919
|
+
distill_total: distillPending(db),
|
|
1920
|
+
distill_shown: distillQueue.length,
|
|
1921
|
+
distill_hint: 'Ordered by value (caveats first, named entities, longer text). Pass distill: N (max 25) for a longer drain. Rules already set a distill_verdict on acknowledgements, bare paths and 90-day-old never-recalled items — you only see what needs judgement.',
|
|
1902
1922
|
message: friction.length > 0
|
|
1903
1923
|
? 'No North Star declared yet — but the re-injection layer surfaced friction below: anchors being violated despite being re-surfaced. Declare a North Star (declare_anchor node_type:"north_star"), and act on the friction items.'
|
|
1904
1924
|
: 'No North Star declared yet. Declare one with declare_anchor(node_type: "north_star") before dreaming.',
|
|
@@ -1987,7 +2007,9 @@ function handleDream(args) {
|
|
|
1987
2007
|
friction,
|
|
1988
2008
|
friction_total: friction.length,
|
|
1989
2009
|
distill_queue: distillQueue,
|
|
1990
|
-
distill_total:
|
|
2010
|
+
distill_total: distillPending(db),
|
|
2011
|
+
distill_shown: distillQueue.length,
|
|
2012
|
+
distill_hint: 'Ordered by value (caveats first, named entities, longer text). Pass distill: N (max 25) for a longer drain. Rules already set a distill_verdict on acknowledgements, bare paths and 90-day-old never-recalled items — you only see what needs judgement.',
|
|
1991
2013
|
guide: guideParts.join(' '),
|
|
1992
2014
|
});
|
|
1993
2015
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
5
|
"description": "Hand a project over — to your next session, to Cursor/Codex/Gemini, to your successor — with the reasons attached. Local-first memory MCP for coding agents: decisions re-injected before the agent acts, drift detection across every project you run. One SQLite file, nothing leaves your machine.",
|
|
6
6
|
"type": "module",
|