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.
@@ -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 are still raw utterances — call dream() and rewrite the distill_queue via remember(memory_id, content) with "distilled": true.`);
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).');
@@ -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.created_at DESC LIMIT 8
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: distillQueue.length,
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: distillQueue.length,
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.15.2",
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",