linksee-memory 0.13.1 → 0.14.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/dist/lib/truth-engine.d.ts +6 -1
- package/dist/lib/truth-engine.js +59 -12
- package/dist/mcp/roots.js +6 -2
- package/dist/mcp/server.js +163 -14
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type Database from 'better-sqlite3';
|
|
2
|
-
export type DriftState = 'drift' | 'review' | 'held' | 'aligned';
|
|
2
|
+
export type DriftState = 'drift' | 'review' | 'held' | 'aligned' | 'unverified';
|
|
3
3
|
export type Species = 'hypothesis' | 'constraint' | 'commitment' | 'source_of_truth';
|
|
4
4
|
export interface TruthNode {
|
|
5
5
|
id: number;
|
|
@@ -39,6 +39,11 @@ export interface TruthCounts {
|
|
|
39
39
|
}
|
|
40
40
|
export interface TruthView {
|
|
41
41
|
attention: TruthNode[];
|
|
42
|
+
/** Active anchors the detector has no evidence about — neither aligned nor drifting. */
|
|
43
|
+
unverifiedByDomain: Array<{
|
|
44
|
+
domain: string;
|
|
45
|
+
nodes: TruthNode[];
|
|
46
|
+
}>;
|
|
42
47
|
alignedByDomain: Array<{
|
|
43
48
|
domain: string;
|
|
44
49
|
nodes: TruthNode[];
|
package/dist/lib/truth-engine.js
CHANGED
|
@@ -18,7 +18,7 @@ const DOMAIN_ORDER = [
|
|
|
18
18
|
'growth', 'operations', 'security', 'roadmap', 'memory', 'other',
|
|
19
19
|
];
|
|
20
20
|
const STATE_RANK = {
|
|
21
|
-
drift: 0, review: 1, held: 2, aligned: 3,
|
|
21
|
+
drift: 0, review: 1, held: 2, aligned: 3, unverified: 4,
|
|
22
22
|
};
|
|
23
23
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
24
24
|
function safeJsonParse(s, fallback) {
|
|
@@ -70,6 +70,9 @@ function summarizeEdges(verdict, edges) {
|
|
|
70
70
|
const file = ev.file_path ? String(ev.file_path).replace(/\\/g, '/').split('/').slice(-2).join('/') : null;
|
|
71
71
|
const hit = ev.hit_term ? ` hit "${ev.hit_term}"` : '';
|
|
72
72
|
const when = new Date(latest.detected_at * 1000).toISOString().slice(0, 10);
|
|
73
|
+
if (verdict === 'implements') {
|
|
74
|
+
return `Observed in reality${file ? ` — ${file}` : ''} (${when}).`;
|
|
75
|
+
}
|
|
73
76
|
const head = verdict === 'contradicts'
|
|
74
77
|
? `${edges.length} open contradiction${edges.length > 1 ? 's' : ''}`
|
|
75
78
|
: `declared but not found in reality (${edges.length} absent signal${edges.length > 1 ? 's' : ''})`;
|
|
@@ -203,6 +206,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
203
206
|
const edges = openEdges.get(r.id) ?? [];
|
|
204
207
|
const contradicts = edges.filter((e) => e.verdict === 'contradicts');
|
|
205
208
|
const absent = edges.filter((e) => e.verdict === 'absent');
|
|
209
|
+
const implemented = edges.filter((e) => e.verdict === 'implements');
|
|
206
210
|
// ── State derivation (the make-or-break logic) ──
|
|
207
211
|
let state;
|
|
208
212
|
let accounted;
|
|
@@ -251,10 +255,16 @@ export function getTruthView(db, opts = {}) {
|
|
|
251
255
|
accounted = false;
|
|
252
256
|
accountedBy = null;
|
|
253
257
|
}
|
|
254
|
-
else {
|
|
255
|
-
// 🔵
|
|
258
|
+
else if (implemented.length > 0) {
|
|
259
|
+
// 🔵 the detector saw reality match — this is the only way to be aligned without a verdict
|
|
256
260
|
state = 'aligned';
|
|
257
261
|
accounted = true;
|
|
262
|
+
accountedBy = 'observed (implements)';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// ⚫ nothing checked, nothing decided. Not a problem — but not "fine" either.
|
|
266
|
+
state = 'unverified';
|
|
267
|
+
accounted = false;
|
|
258
268
|
accountedBy = null;
|
|
259
269
|
}
|
|
260
270
|
// Say what was observed. Never claim convergence when nothing was checked — an agent
|
|
@@ -263,9 +273,9 @@ export function getTruthView(db, opts = {}) {
|
|
|
263
273
|
?? pending?.rationale
|
|
264
274
|
?? (contradicts.length > 0 ? summarizeEdges('contradicts', contradicts) : null)
|
|
265
275
|
?? (absent.length > 0 ? summarizeEdges('absent', absent) : null)
|
|
266
|
-
?? (state === 'aligned'
|
|
267
|
-
|
|
268
|
-
|
|
276
|
+
?? (state === 'aligned' && implemented.length > 0 ? summarizeEdges('implements', implemented) : null)
|
|
277
|
+
?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
|
|
278
|
+
?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
|
|
269
279
|
return {
|
|
270
280
|
id: r.id,
|
|
271
281
|
node_type: r.node_type,
|
|
@@ -288,8 +298,18 @@ export function getTruthView(db, opts = {}) {
|
|
|
288
298
|
});
|
|
289
299
|
// ── Partition: attention (loud) vs aligned (quiet) ──
|
|
290
300
|
const attention = nodes
|
|
291
|
-
.filter((n) => n.state !== 'aligned')
|
|
301
|
+
.filter((n) => n.state !== 'aligned' && n.state !== 'unverified')
|
|
292
302
|
.sort((a, b) => STATE_RANK[a.state] - STATE_RANK[b.state] || b.confidence - a.confidence);
|
|
303
|
+
const unverifiedGroups = new Map();
|
|
304
|
+
for (const n of nodes.filter((n) => n.state === 'unverified')) {
|
|
305
|
+
const d = n.domain ?? 'other';
|
|
306
|
+
if (!unverifiedGroups.has(d))
|
|
307
|
+
unverifiedGroups.set(d, []);
|
|
308
|
+
unverifiedGroups.get(d).push(n);
|
|
309
|
+
}
|
|
310
|
+
const unverifiedByDomain = [...unverifiedGroups.entries()]
|
|
311
|
+
.sort((a, b) => DOMAIN_ORDER.indexOf(a[0]) - DOMAIN_ORDER.indexOf(b[0]))
|
|
312
|
+
.map(([domain, ns]) => ({ domain, nodes: ns }));
|
|
293
313
|
const alignedGroups = new Map();
|
|
294
314
|
for (const n of nodes.filter((n) => n.state === 'aligned')) {
|
|
295
315
|
const d = n.domain ?? 'other';
|
|
@@ -311,7 +331,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
311
331
|
};
|
|
312
332
|
for (const n of nodes)
|
|
313
333
|
by_species[n.species]++;
|
|
314
|
-
const by_state = { drift: 0, review: 0, held: 0, aligned: 0 };
|
|
334
|
+
const by_state = { drift: 0, review: 0, held: 0, aligned: 0, unverified: 0 };
|
|
315
335
|
for (const n of nodes)
|
|
316
336
|
by_state[n.state]++;
|
|
317
337
|
const reopenDates = nodes
|
|
@@ -335,6 +355,7 @@ export function getTruthView(db, opts = {}) {
|
|
|
335
355
|
return {
|
|
336
356
|
attention,
|
|
337
357
|
alignedByDomain,
|
|
358
|
+
unverifiedByDomain,
|
|
338
359
|
candidates: { auto, suppressed },
|
|
339
360
|
counts: {
|
|
340
361
|
nodes: nodes.length,
|
|
@@ -366,7 +387,14 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
366
387
|
catch { /* */ }
|
|
367
388
|
const res = resolutionFor(row.id);
|
|
368
389
|
const overdue = row.review_after != null && row.review_after * 1000 < now;
|
|
369
|
-
// State derivation
|
|
390
|
+
// State derivation — MUST mirror getTruthView. It had drifted: this branch never looked at
|
|
391
|
+
// drift_edges, so check_decision could say "aligned" on the anchor drift_status flagged 🔴.
|
|
392
|
+
const openEdgesHere = db
|
|
393
|
+
.prepare(`SELECT verdict, confidence, evidence, detected_at FROM drift_edges WHERE anchor_id = ? AND status = 'open' ORDER BY detected_at DESC`)
|
|
394
|
+
.all(anchorId);
|
|
395
|
+
const contradictsHere = openEdgesHere.filter((e) => e.verdict === 'contradicts');
|
|
396
|
+
const absentHere = openEdgesHere.filter((e) => e.verdict === 'absent');
|
|
397
|
+
const implementedHere = openEdgesHere.filter((e) => e.verdict === 'implements');
|
|
370
398
|
let state, accounted, accountedBy;
|
|
371
399
|
const pendingCand = db
|
|
372
400
|
.prepare(`SELECT id, candidate_type, target_node_id, rationale, confidence, status
|
|
@@ -395,7 +423,17 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
395
423
|
accounted = true;
|
|
396
424
|
accountedBy = 'supersede (intentional evolution)';
|
|
397
425
|
}
|
|
398
|
-
else if (
|
|
426
|
+
else if (res?.action === 'dismiss') {
|
|
427
|
+
state = 'aligned';
|
|
428
|
+
accounted = true;
|
|
429
|
+
accountedBy = 'dismiss (false positive)';
|
|
430
|
+
}
|
|
431
|
+
else if (contradictsHere.length > 0) {
|
|
432
|
+
state = 'drift';
|
|
433
|
+
accounted = false;
|
|
434
|
+
accountedBy = null;
|
|
435
|
+
}
|
|
436
|
+
else if (hasPending || absentHere.length > 0) {
|
|
399
437
|
state = 'review';
|
|
400
438
|
accounted = false;
|
|
401
439
|
accountedBy = null;
|
|
@@ -405,13 +443,22 @@ export function getDecisionDetail(db, anchorId) {
|
|
|
405
443
|
accounted = false;
|
|
406
444
|
accountedBy = null;
|
|
407
445
|
}
|
|
408
|
-
else {
|
|
446
|
+
else if (implementedHere.length > 0) {
|
|
409
447
|
state = 'aligned';
|
|
410
448
|
accounted = true;
|
|
449
|
+
accountedBy = 'observed (implements)';
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
state = 'unverified';
|
|
453
|
+
accounted = false;
|
|
411
454
|
accountedBy = null;
|
|
412
455
|
}
|
|
413
456
|
const reality = cardCand?.rationale ?? (hasPending ? pendingCand[0].rationale : null)
|
|
414
|
-
?? (
|
|
457
|
+
?? (contradictsHere.length > 0 ? summarizeEdges('contradicts', contradictsHere) : null)
|
|
458
|
+
?? (absentHere.length > 0 ? summarizeEdges('absent', absentHere) : null)
|
|
459
|
+
?? (state === 'aligned' && implementedHere.length > 0 ? summarizeEdges('implements', implementedHere) : null)
|
|
460
|
+
?? (state === 'aligned' ? 'Accounted for by recorded resolution' : null)
|
|
461
|
+
?? (state === 'unverified' ? 'No signal observed (not verified against reality)' : null);
|
|
415
462
|
// Drift edges for this anchor
|
|
416
463
|
const edges = db
|
|
417
464
|
.prepare(`SELECT id AS edge_id, verdict, confidence, status, detected_at
|
package/dist/mcp/roots.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
//
|
|
6
6
|
// MCP semantics: client owns the root list, server is informed. We refresh on demand
|
|
7
7
|
// (lazily on first use) and on roots/list_changed notification.
|
|
8
|
-
import {
|
|
8
|
+
import { ListRootsResultSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
let cachedRoots = null;
|
|
10
10
|
let lastFetched = 0;
|
|
11
11
|
const STALE_MS = 60_000; // re-fetch at most once a minute
|
|
@@ -14,7 +14,11 @@ export async function fetchRoots(server) {
|
|
|
14
14
|
if (cachedRoots && now - lastFetched < STALE_MS)
|
|
15
15
|
return cachedRoots;
|
|
16
16
|
try {
|
|
17
|
-
|
|
17
|
+
// Protocol.request(request, RESULT schema). This passed the REQUEST schema, so every client's
|
|
18
|
+
// reply — `{ roots: [...] }` — failed validation against a shape expecting `{ method: 'roots/list' }`,
|
|
19
|
+
// was swallowed by the catch below, and cached as "no roots" for a minute. Roots had been empty
|
|
20
|
+
// for every client since this was written; where_am_i's root inference never once fired.
|
|
21
|
+
const res = await server.request({ method: 'roots/list', params: {} }, ListRootsResultSchema);
|
|
18
22
|
cachedRoots = Array.isArray(res?.roots) ? res.roots : [];
|
|
19
23
|
lastFetched = now;
|
|
20
24
|
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -136,15 +136,32 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
|
|
|
136
136
|
const TOOLS = [
|
|
137
137
|
{
|
|
138
138
|
name: 'remember',
|
|
139
|
-
description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved →
|
|
139
|
+
description: 'Persist knowledge across sessions and AI tools (Claude, GPT, Cursor, Codex, Gemini). The only cross-agent memory that survives session boundaries.\n\nWHEN TO CALL:\n• The moment an error or failure occurs → layer: "caveat" (auto-protected, never forgotten)\n• When a decision is made or approved → content + anchor: {} (remembered AND enforced in one call)\n• When a goal is set or updated → layer: "goal"\n• When something new is learned → layer: "learning"\n• When the user says "remember this" / "覚えておいて"\n• After completing a task or receiving user approval\n\nMODES:\n• Create (default): content is the only required field. entity defaults to the project you are in; layer defaults to "context" (or "learning" when anchor is set).\n• Create + enforce: add anchor: {} — the memory also becomes a decision the guard re-injects before Edit/Write/Bash and on session start. Give anchor.violation_signal (forbidden strings) to make contradictions detectable; anchor.affects (path globs) to scope it.\n• Update: memory_id (+ optional content, layer, importance)\n• Delete: memory_id + forget: true\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
|
|
140
140
|
inputSchema: {
|
|
141
141
|
type: 'object',
|
|
142
142
|
properties: {
|
|
143
|
-
entity_name: { type: 'string', description: '
|
|
144
|
-
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: '
|
|
143
|
+
entity_name: { type: 'string', description: 'What this memory is about. Optional — defaults to the project you are working in (from workspace roots, else the files edited recently).' },
|
|
144
|
+
entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Optional — defaults to "project".' },
|
|
145
|
+
anchor: {
|
|
146
|
+
description: 'Also declare this as an enforceable decision. Pass {} for defaults, or an object: { kind?: "decision"|"prohibition"|"constraint", violation_signal?: string[] (forbidden strings — needed for the gate to detect a contradiction), affects?: string[] (path globs that scope it), detect_terms?: string[], domain?: string, rationale?: string }. Without violation_signal the anchor is a constraint: re-injected on session start and when its scope is touched, but no contradiction can be detected.',
|
|
147
|
+
anyOf: [
|
|
148
|
+
{ type: 'boolean' },
|
|
149
|
+
{
|
|
150
|
+
type: 'object',
|
|
151
|
+
properties: {
|
|
152
|
+
kind: { type: 'string', enum: ['decision', 'prohibition', 'constraint'] },
|
|
153
|
+
violation_signal: { type: 'array', items: { type: 'string' } },
|
|
154
|
+
affects: { type: 'array', items: { type: 'string' } },
|
|
155
|
+
detect_terms: { type: 'array', items: { type: 'string' } },
|
|
156
|
+
domain: { type: 'string' },
|
|
157
|
+
rationale: { type: 'string' },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
},
|
|
145
162
|
entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
|
|
146
|
-
layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation).' },
|
|
147
|
-
content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why)' },
|
|
163
|
+
layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation). Optional — defaults to "context", or "learning" when anchor is set.' },
|
|
164
|
+
content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why). The only required field for create.' },
|
|
148
165
|
importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Set ≥0.9 to pin (protects from forgetting).' },
|
|
149
166
|
thread_id: { type: 'string', description: 'Optional thread ID to group related memories (decision chains, session groups).' },
|
|
150
167
|
force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
|
|
@@ -436,14 +453,74 @@ function handleRemember(args) {
|
|
|
436
453
|
.run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
|
|
437
454
|
db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
|
|
438
455
|
const mom = refreshMomentumForEntity(db, entityId);
|
|
439
|
-
|
|
456
|
+
const memoryId = Number(result.lastInsertRowid);
|
|
457
|
+
const out = {
|
|
440
458
|
ok: true,
|
|
441
|
-
memory_id:
|
|
459
|
+
memory_id: memoryId,
|
|
442
460
|
entity_id: entityId,
|
|
443
461
|
layer,
|
|
444
462
|
pinned: importance >= 0.9,
|
|
445
463
|
momentum: { score: mom.score, band: mom.band },
|
|
446
|
-
}
|
|
464
|
+
};
|
|
465
|
+
// "Remember" and "enforce" used to be two tools with two schemas, and an agent had to pick.
|
|
466
|
+
// Picking remember meant the decision was stored but never re-injected — the exact failure
|
|
467
|
+
// the product exists to prevent. One call now does both when the caller asks for it.
|
|
468
|
+
if (args.anchor) {
|
|
469
|
+
const spec = typeof args.anchor === 'object' && args.anchor !== null ? args.anchor : {};
|
|
470
|
+
let what = rawContent, why = spec.rationale;
|
|
471
|
+
try {
|
|
472
|
+
const c = JSON.parse(rawContent);
|
|
473
|
+
if (c && typeof c === 'object') {
|
|
474
|
+
what = String(c.what ?? c.title ?? rawContent);
|
|
475
|
+
why = why ?? (c.why ? String(c.why) : undefined);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch { /* plain text */ }
|
|
479
|
+
const signals = Array.isArray(spec.violation_signal) ? spec.violation_signal : [];
|
|
480
|
+
// A decision/prohibition needs forbidden strings for a contradiction to be detectable; without
|
|
481
|
+
// them the honest shape is a constraint (still re-injected on boot and when its scope is touched).
|
|
482
|
+
const kind = spec.kind ?? (signals.length > 0 ? 'decision' : 'constraint');
|
|
483
|
+
try {
|
|
484
|
+
const anchor = declareAnchor(db, {
|
|
485
|
+
kind,
|
|
486
|
+
statement: what.length >= 8 ? what : `${what} (decision)`,
|
|
487
|
+
rationale: why,
|
|
488
|
+
affects: Array.isArray(spec.affects) ? spec.affects : undefined,
|
|
489
|
+
detect_terms: Array.isArray(spec.detect_terms) ? spec.detect_terms : undefined,
|
|
490
|
+
violation_signal: signals.length > 0 ? signals : undefined,
|
|
491
|
+
tier: 'human',
|
|
492
|
+
source_memory_id: memoryId,
|
|
493
|
+
});
|
|
494
|
+
const nodeFields = {};
|
|
495
|
+
if (spec.domain)
|
|
496
|
+
nodeFields.domain = spec.domain;
|
|
497
|
+
if (spec.confidence !== undefined)
|
|
498
|
+
nodeFields.confidence = spec.confidence;
|
|
499
|
+
if (Object.keys(nodeFields).length > 0)
|
|
500
|
+
setNodeFields(db, anchor.id, nodeFields);
|
|
501
|
+
// Link both ways so recall can show "this memory is enforced as #N" and the anchor can
|
|
502
|
+
// point back at the moment it was decided.
|
|
503
|
+
try {
|
|
504
|
+
const c = JSON.parse(rawContent);
|
|
505
|
+
if (c && typeof c === 'object') {
|
|
506
|
+
c.anchor_id = anchor.id;
|
|
507
|
+
db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(JSON.stringify(c), memoryId);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch { /* leave content as-is */ }
|
|
511
|
+
logAnchorTouch(db, { anchorId: anchor.id, tool: 'remember', interaction: 'create' });
|
|
512
|
+
out.anchor_id = anchor.id;
|
|
513
|
+
out.anchor_kind = kind;
|
|
514
|
+
out.enforced = signals.length > 0
|
|
515
|
+
? 'contradictions are detected at the gate; re-injected on boot and in scope'
|
|
516
|
+
: 'no violation_signal given — re-injected on boot and when its scope is touched, but contradictions cannot be detected';
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
out.anchor_error = String(e?.message ?? e);
|
|
520
|
+
out.hint = 'The memory was saved. Fix the anchor spec and call declare_anchor, or remember again with a corrected anchor.';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return JSON.stringify(out);
|
|
447
524
|
}
|
|
448
525
|
// Sanitize query for FTS5 MATCH (strip chars that break the grammar, quote it).
|
|
449
526
|
// Note: with trigram tokenizer, tokens shorter than 3 chars cannot match anything.
|
|
@@ -1234,14 +1311,77 @@ async function handleRememberUnified(args) {
|
|
|
1234
1311
|
if (args.memory_id) {
|
|
1235
1312
|
return handleUpdateMemory(args);
|
|
1236
1313
|
}
|
|
1237
|
-
// Create mode (default)
|
|
1238
|
-
|
|
1314
|
+
// Create mode (default). Only content is required; everything an agent used to have to
|
|
1315
|
+
// invent is defaulted. Inventing an entity name and picking a layer were the two places a
|
|
1316
|
+
// "remember this" call stalled — the taxonomy is for the dashboard, not for the agent.
|
|
1317
|
+
if (!args.content || !String(args.content).trim()) {
|
|
1239
1318
|
return JSON.stringify({
|
|
1240
1319
|
ok: false,
|
|
1241
|
-
error: '
|
|
1320
|
+
error: 'content is required. (entity/layer are optional now.) To update, provide memory_id. To delete, set forget: true + memory_id.',
|
|
1242
1321
|
});
|
|
1243
1322
|
}
|
|
1244
|
-
|
|
1323
|
+
const a = { ...args };
|
|
1324
|
+
let entityInferred;
|
|
1325
|
+
if (!a.entity_name) {
|
|
1326
|
+
const inferred = await inferDefaultEntity();
|
|
1327
|
+
a.entity_name = inferred.name;
|
|
1328
|
+
a.entity_kind = a.entity_kind ?? inferred.kind;
|
|
1329
|
+
entityInferred = inferred.from;
|
|
1330
|
+
}
|
|
1331
|
+
if (!a.entity_kind)
|
|
1332
|
+
a.entity_kind = 'project';
|
|
1333
|
+
if (!a.layer)
|
|
1334
|
+
a.layer = a.anchor ? 'learning' : 'context';
|
|
1335
|
+
const out = handleRemember(a);
|
|
1336
|
+
if (!entityInferred)
|
|
1337
|
+
return out;
|
|
1338
|
+
try {
|
|
1339
|
+
const parsed = JSON.parse(out);
|
|
1340
|
+
if (parsed.ok)
|
|
1341
|
+
parsed.entity_inferred_from = entityInferred;
|
|
1342
|
+
return JSON.stringify(parsed);
|
|
1343
|
+
}
|
|
1344
|
+
catch {
|
|
1345
|
+
return out;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The entity a memory is about when the caller did not say: the project they are in.
|
|
1350
|
+
* Same evidence chain as where_am_i — workspace roots first, then the files edited recently.
|
|
1351
|
+
*/
|
|
1352
|
+
async function inferDefaultEntity() {
|
|
1353
|
+
try {
|
|
1354
|
+
const roots = await fetchRoots(server);
|
|
1355
|
+
if (roots.length === 1) {
|
|
1356
|
+
const base = rootPathFromUri(roots[0].uri).replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop();
|
|
1357
|
+
if (base)
|
|
1358
|
+
return { name: base, kind: 'project', from: 'workspace_root' };
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
catch { /* roots unsupported by this host */ }
|
|
1362
|
+
try {
|
|
1363
|
+
const rows = db
|
|
1364
|
+
.prepare(`SELECT file_path FROM session_file_edits WHERE occurred_at > unixepoch() - 86400 ORDER BY occurred_at DESC LIMIT 40`)
|
|
1365
|
+
.all();
|
|
1366
|
+
const paths = rows.map((r) => r.file_path.replace(/\\/g, '/').split('/').filter(Boolean));
|
|
1367
|
+
if (paths.length > 0) {
|
|
1368
|
+
// longest common directory prefix of what was touched → its basename is the project
|
|
1369
|
+
let prefix = paths[0].slice(0, -1);
|
|
1370
|
+
for (const p of paths.slice(1)) {
|
|
1371
|
+
let i = 0;
|
|
1372
|
+
while (i < prefix.length && i < p.length - 1 && prefix[i].toLowerCase() === p[i].toLowerCase())
|
|
1373
|
+
i++;
|
|
1374
|
+
prefix = prefix.slice(0, i);
|
|
1375
|
+
if (prefix.length === 0)
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
const base = prefix[prefix.length - 1];
|
|
1379
|
+
if (base && !/^(c:|users|home|[a-z])$/i.test(base))
|
|
1380
|
+
return { name: base, kind: 'project', from: 'recent_edits' };
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
catch { /* fall through */ }
|
|
1384
|
+
return { name: 'workspace', kind: 'project', from: 'fallback' };
|
|
1245
1385
|
}
|
|
1246
1386
|
async function handleRecallUnified(args) {
|
|
1247
1387
|
// File history mode (path takes priority; if query also provided, include it as context)
|
|
@@ -1294,7 +1434,8 @@ function handleDriftStatus(args) {
|
|
|
1294
1434
|
by_state.drift > 0 ? `🔴 ${by_state.drift} drifting` : null,
|
|
1295
1435
|
by_state.review > 0 ? `🟡 ${by_state.review} needs review` : null,
|
|
1296
1436
|
by_state.held > 0 ? `⚪ ${by_state.held} held` : null,
|
|
1297
|
-
`🔵 ${by_state.aligned}
|
|
1437
|
+
`🔵 ${by_state.aligned} verified`,
|
|
1438
|
+
by_state.unverified > 0 ? `⚫ ${by_state.unverified} unverified` : null,
|
|
1298
1439
|
].filter(Boolean).join(' · ');
|
|
1299
1440
|
if (args?.verbose) {
|
|
1300
1441
|
return JSON.stringify({
|
|
@@ -1315,6 +1456,13 @@ function handleDriftStatus(args) {
|
|
|
1315
1456
|
count: g.nodes.length,
|
|
1316
1457
|
nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement, reality: n.reality })),
|
|
1317
1458
|
}));
|
|
1459
|
+
// Unverified is the quiet majority on most machines. One line each, grouped by domain —
|
|
1460
|
+
// enough to see where the detector has no eyes, not enough to bury the six that matter.
|
|
1461
|
+
const unverified = view.unverifiedByDomain.map((g) => ({
|
|
1462
|
+
domain: g.domain,
|
|
1463
|
+
count: g.nodes.length,
|
|
1464
|
+
nodes: g.nodes.map((n) => ({ id: n.id, statement: n.statement })),
|
|
1465
|
+
}));
|
|
1318
1466
|
const cand = view.candidates;
|
|
1319
1467
|
return JSON.stringify({
|
|
1320
1468
|
ok: true,
|
|
@@ -1322,9 +1470,10 @@ function handleDriftStatus(args) {
|
|
|
1322
1470
|
nextReopen: view.nextReopen,
|
|
1323
1471
|
attention: view.attention,
|
|
1324
1472
|
aligned,
|
|
1473
|
+
unverified,
|
|
1325
1474
|
candidates: { auto: cand?.auto?.length ?? 0, suppressed: cand?.suppressed?.length ?? 0 },
|
|
1326
1475
|
counts: view.counts,
|
|
1327
|
-
hint: 'verbose:true for full
|
|
1476
|
+
hint: 'verbose:true for full nodes and candidate details; check_decision(anchor_id) for one node. ⚫ unverified = declared, never checked against reality: give it affects/violation_signal so the detector can see it, or leave it as a note.',
|
|
1328
1477
|
});
|
|
1329
1478
|
}
|
|
1330
1479
|
// Fix ① (2026-06-17): infer the Map project from the client's workspace roots when the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linksee-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"mcpName": "io.github.michielinksee/linksee-memory",
|
|
5
5
|
"description": "Local-first agent memory MCP — cross-agent brain with drift detection, 6-layer structured memory + token-saving file diff cache",
|
|
6
6
|
"type": "module",
|