linksee-memory 0.4.2 → 0.7.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.
@@ -102,14 +102,14 @@ async function main() {
102
102
  const ins = db.prepare('INSERT INTO entities (kind, name, normalized_name, canonical_key) VALUES (?, ?, ?, ?)').run('project', projectName, normalized, canonicalKey);
103
103
  projectEntityId = Number(ins.lastInsertRowid);
104
104
  }
105
- const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source) VALUES (?, ?, ?, ?, ?)');
105
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id) VALUES (?, ?, ?, ?, ?, ?)');
106
106
  const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
107
107
  const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
108
108
  let inserted = { memories: 0, edits: 0 };
109
109
  db.transaction(() => {
110
110
  const memContentToId = new Map();
111
111
  for (const m of result.memories) {
112
- const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source));
112
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source), m.thread_id ?? null);
113
113
  memContentToId.set(m.content, Number(res.lastInsertRowid));
114
114
  inserted.memories++;
115
115
  }
@@ -224,14 +224,14 @@ async function main() {
224
224
  // Idempotent: wipe any prior data for THIS session before re-inserting (Phase B)
225
225
  wipeSession(db, result.session_id);
226
226
  // Insert memories + file_edits in a single transaction per session
227
- const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source) VALUES (?, ?, ?, ?, ?)');
227
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source, thread_id) VALUES (?, ?, ?, ?, ?, ?)');
228
228
  const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
229
229
  const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
230
230
  const tx = db.transaction(() => {
231
231
  const memContentToId = new Map();
232
232
  for (const m of result.memories) {
233
233
  const srcJson = JSON.stringify(m.source);
234
- const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson);
234
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson, m.thread_id ?? null);
235
235
  memContentToId.set(m.content, Number(res.lastInsertRowid));
236
236
  agg.memories_inserted++;
237
237
  }
@@ -48,6 +48,44 @@ export function runMigrations(db) {
48
48
  db.exec('ALTER TABLE entities ADD COLUMN normalized_name TEXT');
49
49
  }
50
50
  }
51
+ // v5 → v6: 3-axis generated columns (altitude, mem_type, mem_state).
52
+ // VIRTUAL generated columns auto-extract from content JSON via json_extract.
53
+ // json_valid() guard returns NULL for plain-text content instead of erroring.
54
+ // Must run BEFORE db.exec(sql) so CREATE INDEX IF NOT EXISTS succeeds.
55
+ //
56
+ // NOTE: VIRTUAL generated columns are hidden from PRAGMA table_info.
57
+ // Use PRAGMA table_xinfo (hidden=2 = VIRTUAL generated column) to detect them.
58
+ if (currentVersion > 0 && currentVersion < 6) {
59
+ const xcols = db.prepare("PRAGMA table_xinfo(memories)").all();
60
+ const hasAltitude = xcols.some(c => c.name === 'altitude' && c.hidden === 2);
61
+ if (hasAltitude) {
62
+ // Repair path: columns may exist from a partial v6 migration with the old
63
+ // json_extract-only definition (no json_valid guard). Drop and re-create.
64
+ db.exec('DROP INDEX IF EXISTS idx_memories_altitude');
65
+ db.exec('DROP INDEX IF EXISTS idx_memories_mem_type');
66
+ db.exec('DROP INDEX IF EXISTS idx_memories_mem_state');
67
+ db.exec('ALTER TABLE memories DROP COLUMN altitude');
68
+ db.exec('ALTER TABLE memories DROP COLUMN mem_type');
69
+ db.exec('ALTER TABLE memories DROP COLUMN mem_state');
70
+ }
71
+ db.exec(`ALTER TABLE memories ADD COLUMN altitude TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.altitude') ELSE NULL END) VIRTUAL`);
72
+ db.exec(`ALTER TABLE memories ADD COLUMN mem_type TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.type') ELSE NULL END) VIRTUAL`);
73
+ db.exec(`ALTER TABLE memories ADD COLUMN mem_state TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.state') ELSE NULL END) VIRTUAL`);
74
+ }
75
+ // v6 → v7: thread_id column on memories + memory_edges table.
76
+ // thread_id groups related memories (session-level or decision chains).
77
+ // memory_edges creates directed relationships between individual memories.
78
+ if (currentVersion > 0 && currentVersion < 7) {
79
+ const cols = db.prepare("PRAGMA table_info(memories)").all();
80
+ if (!cols.some(c => c.name === 'thread_id')) {
81
+ db.exec('ALTER TABLE memories ADD COLUMN thread_id TEXT');
82
+ }
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
87
+ `);
88
+ }
51
89
  db.exec(sql);
52
90
  if (currentVersion > 0 && currentVersion < 4) {
53
91
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
@@ -1,7 +1,9 @@
1
- -- linksee-memory schema v0.0.2
1
+ -- linksee-memory schema v0.0.4
2
2
  -- Single-file SQLite store for cross-agent structured memory.
3
3
  -- Layers: 1=facts (entities), 2=associations (edges), 3=patterns (meanings), 4=events (time-series), 5=file-state (diff cache).
4
4
  -- v2 adds: FTS5 full-text search, consolidations audit, momentum cache on entities.
5
+ -- v6 adds: 3-axis generated columns (altitude/mem_type/mem_state) for queryable classification.
6
+ -- v7 adds: thread_id for decision chains, memory_edges for memory→memory relationships.
5
7
 
6
8
  -- ============================================================
7
9
  -- Layer 1: Facts — entities (people / companies / projects / concepts)
@@ -36,15 +38,25 @@ CREATE TABLE IF NOT EXISTS memories (
36
38
  importance REAL NOT NULL DEFAULT 0.5,
37
39
  protected INTEGER NOT NULL DEFAULT 0,
38
40
  source TEXT,
41
+ thread_id TEXT, -- groups related memories (e.g. same session, same decision chain)
39
42
  created_at INTEGER NOT NULL DEFAULT (unixepoch()),
40
43
  last_accessed_at INTEGER NOT NULL DEFAULT (unixepoch()),
41
- access_count INTEGER NOT NULL DEFAULT 0
44
+ access_count INTEGER NOT NULL DEFAULT 0,
45
+ -- 3-axis classification (v0.5.0): auto-extracted from content JSON.
46
+ -- NULL when content is plain text (non-JSON). VIRTUAL = computed on read, indexed.
47
+ altitude TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.altitude') ELSE NULL END) VIRTUAL,
48
+ mem_type TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.type') ELSE NULL END) VIRTUAL,
49
+ mem_state TEXT GENERATED ALWAYS AS (CASE WHEN json_valid(content) THEN json_extract(content, '$.state') ELSE NULL END) VIRTUAL
42
50
  );
43
51
 
44
52
  CREATE INDEX IF NOT EXISTS idx_memories_entity ON memories(entity_id);
45
53
  CREATE INDEX IF NOT EXISTS idx_memories_layer ON memories(layer);
46
54
  CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC);
47
55
  CREATE INDEX IF NOT EXISTS idx_memories_protected ON memories(protected);
56
+ CREATE INDEX IF NOT EXISTS idx_memories_altitude ON memories(altitude);
57
+ CREATE INDEX IF NOT EXISTS idx_memories_mem_type ON memories(mem_type);
58
+ CREATE INDEX IF NOT EXISTS idx_memories_mem_state ON memories(mem_state);
59
+ CREATE INDEX IF NOT EXISTS idx_memories_thread ON memories(thread_id);
48
60
 
49
61
  CREATE TRIGGER IF NOT EXISTS trg_protect_caveat
50
62
  AFTER INSERT ON memories
@@ -100,6 +112,25 @@ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
100
112
  CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
101
113
  CREATE INDEX IF NOT EXISTS idx_edges_rel ON edges(relation);
102
114
 
115
+ -- ============================================================
116
+ -- Memory edges — directed relationships between individual memories
117
+ -- Enables: decision→implementation→outcome chains, supersedes tracking.
118
+ -- ============================================================
119
+ CREATE TABLE IF NOT EXISTS memory_edges (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ from_memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
122
+ to_memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
123
+ relation TEXT NOT NULL CHECK (relation IN (
124
+ 'supersedes', 'resolves', 'implements', 'contradicts', 'extends'
125
+ )),
126
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
127
+ UNIQUE(from_memory_id, to_memory_id, relation)
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_medge_from ON memory_edges(from_memory_id);
131
+ CREATE INDEX IF NOT EXISTS idx_medge_to ON memory_edges(to_memory_id);
132
+ CREATE INDEX IF NOT EXISTS idx_medge_rel ON memory_edges(relation);
133
+
103
134
  -- ============================================================
104
135
  -- Layer 4: Events — time-series log with importance markers
105
136
  -- ============================================================
@@ -196,6 +227,6 @@ CREATE TABLE IF NOT EXISTS meta (
196
227
  value TEXT NOT NULL
197
228
  );
198
229
 
199
- INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '5');
230
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '7');
200
231
  INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
201
- UPDATE meta SET value = '5' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4');
232
+ UPDATE meta SET value = '7' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6');
@@ -5,6 +5,7 @@ export interface ConsolidateResult {
5
5
  memoriesReplaced: number;
6
6
  memoriesDropped: number;
7
7
  learningIdsCreated: number[];
8
+ stalledTransitions: number;
8
9
  }
9
10
  export declare function consolidate(db: Database.Database, opts?: {
10
11
  scope?: 'all' | 'session';
@@ -27,6 +27,7 @@ export function consolidate(db, opts = {}) {
27
27
  .prepare(`
28
28
  SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
29
29
  m.last_accessed_at, m.access_count, m.created_at, m.protected,
30
+ m.altitude,
30
31
  e.name as entity_name
31
32
  FROM memories m
32
33
  JOIN entities e ON e.id = m.entity_id
@@ -62,6 +63,7 @@ export function consolidate(db, opts = {}) {
62
63
  memoriesReplaced: 0,
63
64
  memoriesDropped: 0,
64
65
  learningIdsCreated: [],
66
+ stalledTransitions: 0,
65
67
  };
66
68
  const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
67
69
  VALUES (?, 'learning', ?, ?, 1, ?)`);
@@ -87,8 +89,26 @@ export function consolidate(db, opts = {}) {
87
89
  const earliest = new Date(Math.min(...timestamps) * 1000).toISOString().slice(0, 10);
88
90
  const latest = new Date(Math.max(...timestamps) * 1000).toISOString().slice(0, 10);
89
91
  const avgImp = cluster.reduce((s, c) => s + c.importance, 0) / cluster.length;
92
+ // Determine dominant altitude from cluster members (most frequent non-null value).
93
+ // Consolidation summaries inherit the cluster's altitude so decay rates are preserved.
94
+ const altCounts = new Map();
95
+ for (const c of cluster) {
96
+ if (c.altitude)
97
+ altCounts.set(c.altitude, (altCounts.get(c.altitude) ?? 0) + 1);
98
+ }
99
+ let dominantAltitude = 'implementation';
100
+ let maxCount = 0;
101
+ for (const [alt, count] of altCounts) {
102
+ if (count > maxCount) {
103
+ dominantAltitude = alt;
104
+ maxCount = count;
105
+ }
106
+ }
90
107
  const summary = {
91
108
  source: 'consolidate',
109
+ altitude: maxCount > 0 ? dominantAltitude : 'implementation',
110
+ type: 'learning',
111
+ state: 'done',
92
112
  original_layer: layer,
93
113
  count: cluster.length,
94
114
  period: { from: earliest, to: latest },
@@ -110,9 +130,21 @@ export function consolidate(db, opts = {}) {
110
130
  }
111
131
  });
112
132
  tx();
133
+ // State transition sweep: in_progress memories untouched for 30+ days → stalled.
134
+ // Uses json_set() to update the state field in content JSON atomically.
135
+ const STALLED_THRESHOLD_DAYS = 30;
136
+ const stalledCutoff = now - STALLED_THRESHOLD_DAYS * 86400;
137
+ const stalledResult = db.prepare(`
138
+ UPDATE memories SET content = json_set(content, '$.state', 'stalled')
139
+ WHERE json_valid(content)
140
+ AND json_extract(content, '$.state') = 'in_progress'
141
+ AND last_accessed_at < ?
142
+ AND protected = 0
143
+ `).run(stalledCutoff);
144
+ result.stalledTransitions = stalledResult.changes;
113
145
  // Post-consolidate: forget-sweep remaining non-clustered cold memories
114
146
  const remaining = db
115
- .prepare('SELECT id, layer, importance, access_count, last_accessed_at, protected FROM memories WHERE protected = 0')
147
+ .prepare('SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude FROM memories WHERE protected = 0')
116
148
  .all();
117
149
  const toDrop = [];
118
150
  for (const r of remaining) {
@@ -130,6 +162,7 @@ export function consolidate(db, opts = {}) {
130
162
  heatScore: heat.score,
131
163
  protected: r.protected === 1,
132
164
  layer: r.layer,
165
+ altitude: r.altitude ?? undefined,
133
166
  });
134
167
  if (action === 'drop')
135
168
  toDrop.push(r.id);
@@ -4,6 +4,7 @@ export interface ForgettingInput {
4
4
  heatScore: number;
5
5
  protected: boolean;
6
6
  layer: string;
7
+ altitude?: string;
7
8
  }
8
9
  export declare function forgettingRisk(input: ForgettingInput): number;
9
10
  export declare const COMPRESS_THRESHOLD = 50;
@@ -8,13 +8,23 @@ export function forgettingRisk(input) {
8
8
  return 0;
9
9
  if (input.layer === 'goal')
10
10
  return 0; // Goals are WHY-anchors, never auto-forget while active
11
+ // Altitude-based decay: higher altitude memories decay much slower.
12
+ // mission = permanent (why we exist), strategy = very slow, architecture = slow,
13
+ // implementation = normal speed.
14
+ if (input.altitude === 'mission')
15
+ return 0;
16
+ let altitudeMultiplier = 1.0;
17
+ if (input.altitude === 'strategy')
18
+ altitudeMultiplier = 0.1;
19
+ else if (input.altitude === 'architecture')
20
+ altitudeMultiplier = 0.3;
11
21
  // Original formula from setup-learning-box.cjs:
12
22
  // daysSinceContact * (heatScore/100) * (1 + daysSinceContact/30)
13
23
  // We INVERT: high heat = low risk (hot memories should be kept).
14
24
  const heatFactor = 1 - (input.heatScore / 100); // 0.0 = keep, 1.0 = drop
15
25
  const importanceFactor = 1 - input.importance;
16
26
  const timeFactor = input.daysSinceLastAccess * (1 + input.daysSinceLastAccess / 30);
17
- return heatFactor * importanceFactor * timeFactor;
27
+ return heatFactor * importanceFactor * timeFactor * altitudeMultiplier;
18
28
  }
19
29
  // Risk threshold above which memory is compressed (→ learning layer summary) and the original deleted.
20
30
  export const COMPRESS_THRESHOLD = 50;
@@ -3,6 +3,7 @@ export interface ExtractedMemory {
3
3
  layer: 'goal' | 'context' | 'emotion' | 'implementation' | 'caveat' | 'learning';
4
4
  content: string;
5
5
  importance: number;
6
+ thread_id: string;
6
7
  source: {
7
8
  session_id: string;
8
9
  turn_uuid?: string;
@@ -31,4 +32,11 @@ export interface ExtractionResult {
31
32
  file_ops_unique_paths: number;
32
33
  };
33
34
  }
35
+ type Altitude = 'mission' | 'strategy' | 'architecture' | 'implementation';
36
+ type MemType = 'question' | 'comparison' | 'decision' | 'work' | 'outcome' | 'learning' | 'note';
37
+ type MemState = 'open' | 'decided' | 'in_progress' | 'done' | 'stalled' | 'parked' | 'superseded';
38
+ export declare function inferAltitude(text: string): Altitude;
39
+ export declare function inferType(text: string, layer: string): MemType;
40
+ export declare function inferState(text: string, layer: string): MemState;
34
41
  export declare function extractSession(session: ParsedSession, projectName: string): ExtractionResult;
42
+ export {};
@@ -8,13 +8,67 @@ const ALTITUDE_PATTERNS = [
8
8
  [/strategy|戦略|方針|positioning|GTM|go.to.market|revenue|pricing|ICP|ターゲット|マーケ/i, 'strategy'],
9
9
  [/architect|設計|schema|database|DB設計|migration|API\s+design|system\s+design|layer\s+model|アーキテクチャ/i, 'architecture'],
10
10
  ];
11
- function inferAltitude(text) {
11
+ export function inferAltitude(text) {
12
12
  for (const [pattern, altitude] of ALTITUDE_PATTERNS) {
13
13
  if (pattern.test(text))
14
14
  return altitude;
15
15
  }
16
16
  return 'implementation';
17
17
  }
18
+ // ---- Type inference ----
19
+ // Infers the cognitive TYPE of a memory from its content text.
20
+ // Falls back to layer-based defaults when no pattern matches.
21
+ const TYPE_PATTERNS = [
22
+ [/\?|質問|教えて|どう[すし]|how\s+(to|do|should)|what\s+(is|are|should)|why\s+(is|do|does)|can\s+(i|we|you)/i, 'question'],
23
+ [/比較|対|vs\.?|versus|compared?\s+to|より|方がいい|alternative|option[s]?\b|どっちが/i, 'comparison'],
24
+ [/決め[たる]|採用|確定|approve|これでい[いく]|進めて|go\s+with|decided|chosen|settled|commit\s+to/i, 'decision'],
25
+ [/学び|教訓|分かった|判明|発見|learn|realize|discover|find\s+out|turns?\s+out|takeaway|insight/i, 'learning'],
26
+ [/結果|完了|成功|失敗|outcome|result|shipped|deployed|launched|finished|accomplished/i, 'outcome'],
27
+ ];
28
+ export function inferType(text, layer) {
29
+ for (const [pattern, type] of TYPE_PATTERNS) {
30
+ if (pattern.test(text))
31
+ return type;
32
+ }
33
+ // Layer-based defaults when no pattern matches
34
+ if (layer === 'learning')
35
+ return 'decision';
36
+ if (layer === 'caveat')
37
+ return 'learning';
38
+ if (layer === 'implementation')
39
+ return 'work';
40
+ if (layer === 'context')
41
+ return 'note';
42
+ if (layer === 'goal')
43
+ return 'work';
44
+ return 'note';
45
+ }
46
+ // ---- State inference ----
47
+ // Infers the lifecycle STATE of a memory from its content text.
48
+ const STATE_PATTERNS = [
49
+ [/完了|done|finished|shipped|deployed|resolved|solved|merged|closed/i, 'done'],
50
+ [/決定|decided|settled|approved|confirmed|go\s+with/i, 'decided'],
51
+ [/進行中|working\s+on|implementing|in\s+progress|WIP|作業中|対応中/i, 'in_progress'],
52
+ [/保留|pending|on\s+hold|棚上げ|後で|later|一旦(?:置|おい)|いったん/i, 'parked'],
53
+ [/止まって|stuck|blocked|stalled|ハマ[っり]|行き詰/i, 'stalled'],
54
+ [/取り替え|代わりに|置き換え|replaced|superseded|deprecated|obsolete|旧版|old\s+approach/i, 'superseded'],
55
+ ];
56
+ export function inferState(text, layer) {
57
+ for (const [pattern, state] of STATE_PATTERNS) {
58
+ if (pattern.test(text))
59
+ return state;
60
+ }
61
+ // Layer-based defaults
62
+ if (layer === 'goal')
63
+ return 'in_progress';
64
+ if (layer === 'learning')
65
+ return 'decided';
66
+ if (layer === 'caveat')
67
+ return 'done';
68
+ if (layer === 'implementation')
69
+ return 'done';
70
+ return 'open';
71
+ }
18
72
  /** Extract a concise title from raw text (first sentence or up to maxLen chars) */
19
73
  function makeTitle(text, maxLen = 80) {
20
74
  const cleaned = text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
@@ -163,8 +217,8 @@ export function extractSession(session, projectName) {
163
217
  content: buildStructuredContent({
164
218
  title: makeTitle(intentText),
165
219
  altitude: inferAltitude(intentText),
166
- type: 'work',
167
- state: 'in_progress',
220
+ type: inferType(intentText, 'goal'),
221
+ state: inferState(intentText, 'goal'),
168
222
  what: intentText,
169
223
  why: 'Session intent — first user message',
170
224
  evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
@@ -172,6 +226,7 @@ export function extractSession(session, projectName) {
172
226
  git_branch: session.git_branch,
173
227
  }),
174
228
  importance: automated ? 0.3 : 0.8,
229
+ thread_id: session.session_id,
175
230
  source: { session_id: session.session_id, turn_uuid: firstIntent.uuid, kind: 'first_intent' },
176
231
  });
177
232
  }
@@ -191,6 +246,7 @@ export function extractSession(session, projectName) {
191
246
  git_branch: session.git_branch,
192
247
  }),
193
248
  importance: 0.2,
249
+ thread_id: session.session_id,
194
250
  source: { session_id: session.session_id, kind: 'automated_task' },
195
251
  });
196
252
  }
@@ -216,14 +272,15 @@ export function extractSession(session, projectName) {
216
272
  content: buildStructuredContent({
217
273
  title: makeTitle(msgText, 60),
218
274
  altitude: inferAltitude(msgText),
219
- type: 'note',
220
- state: 'open',
275
+ type: inferType(msgText, 'context'),
276
+ state: inferState(msgText, 'context'),
221
277
  what: msgText,
222
278
  why: 'Clarification during session',
223
279
  evidence_refs: [{ type: 'session', id: session.session_id, label: 'source session' }],
224
280
  session_id: session.session_id,
225
281
  }),
226
282
  importance: 0.5,
283
+ thread_id: session.session_id,
227
284
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'clarification' },
228
285
  });
229
286
  }
@@ -259,6 +316,7 @@ export function extractSession(session, projectName) {
259
316
  layer: 'implementation',
260
317
  content: memoryContent,
261
318
  importance: 0.6,
319
+ thread_id: session.session_id,
262
320
  source: { session_id: session.session_id, turn_uuid: first.turn_uuid, kind: 'file_edit' },
263
321
  });
264
322
  // Create a file_edit link record for EACH physical op (NOT deduped),
@@ -293,8 +351,8 @@ export function extractSession(session, projectName) {
293
351
  content: buildStructuredContent({
294
352
  title: makeTitle(caveatText, 70),
295
353
  altitude: inferAltitude(caveatText),
296
- type: 'learning',
297
- state: 'done',
354
+ type: inferType(caveatText, 'caveat'),
355
+ state: inferState(caveatText, 'caveat'),
298
356
  what: caveatText,
299
357
  why: 'User-stated warning/prohibition — auto-extracted by caveat pattern match',
300
358
  affects: extractAffectedPaths(session.file_ops),
@@ -303,6 +361,7 @@ export function extractSession(session, projectName) {
303
361
  session_id: session.session_id,
304
362
  }),
305
363
  importance: 0.75,
364
+ thread_id: session.session_id,
306
365
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'caveat' },
307
366
  });
308
367
  }
@@ -326,8 +385,8 @@ export function extractSession(session, projectName) {
326
385
  content: buildStructuredContent({
327
386
  title: makeTitle(decisionText, 70),
328
387
  altitude: inferAltitude(decisionText),
329
- type: 'decision',
330
- state: 'decided',
388
+ type: inferType(decisionText, 'learning'),
389
+ state: inferState(decisionText, 'learning'),
331
390
  what: decisionText,
332
391
  why: 'Decision detected by pattern match — may need agent enrichment',
333
392
  affects: extractAffectedPaths(session.file_ops),
@@ -336,6 +395,7 @@ export function extractSession(session, projectName) {
336
395
  session_id: session.session_id,
337
396
  }),
338
397
  importance: 0.7,
398
+ thread_id: session.session_id,
339
399
  source: { session_id: session.session_id, turn_uuid: t.uuid, kind: 'decision' },
340
400
  });
341
401
  }
@@ -358,6 +418,7 @@ export function extractSession(session, projectName) {
358
418
  session_id: session.session_id,
359
419
  }),
360
420
  importance: 0.4,
421
+ thread_id: session.session_id,
361
422
  source: { session_id: session.session_id, kind: 'error_recovery' },
362
423
  });
363
424
  }
@@ -389,6 +450,7 @@ export function extractSession(session, projectName) {
389
450
  git_branch: session.git_branch,
390
451
  }),
391
452
  importance: 0.4,
453
+ thread_id: session.session_id,
392
454
  source: { session_id: session.session_id, kind: 'session_summary' },
393
455
  });
394
456
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // linksee-memory MCP server (stdio transport).
3
- // Tools: remember / recall / recall_file / update_memory / list_entities /
4
- // forget / consolidate / read_smart
3
+ // Tools: remember / recall / read_smart
4
+ // v0.7.0 unified 3-tool surface (Context7-style simplification)
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
@@ -11,6 +11,7 @@ import { decideForgetting } from '../lib/forgetting.js';
11
11
  import { refreshMomentumForEntity } from '../lib/momentum.js';
12
12
  import { consolidate as runConsolidate } from '../lib/consolidate.js';
13
13
  import { isPastedExternalContent } from '../lib/session-parser.js';
14
+ import { inferAltitude, inferType, inferState } from '../lib/session-extractor.js';
14
15
  import { normalizeEntityName } from '../lib/normalize.js';
15
16
  import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
16
17
  import { STATIC_RESOURCES, RESOURCE_TEMPLATES, readResource } from './resources.js';
@@ -18,9 +19,26 @@ import { PROMPTS, getPrompt } from './prompts.js';
18
19
  import { fetchRoots, isInsideRoots } from './roots.js';
19
20
  import { sampleConsolidation } from './sampling.js';
20
21
  import { confirmForget } from './elicitation.js';
21
- const SERVER_VERSION = '0.3.0';
22
+ const SERVER_VERSION = '0.7.0';
22
23
  const db = openDb();
23
24
  runMigrations(db);
25
+ // Auto-maintenance: consolidate stale memories on startup (non-blocking)
26
+ setTimeout(() => {
27
+ try {
28
+ let shouldRun = true;
29
+ try {
30
+ const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
31
+ if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
32
+ shouldRun = false;
33
+ }
34
+ catch { /* table may not exist yet */ }
35
+ if (shouldRun) {
36
+ runConsolidate(db, { scope: 'all', min_age_days: 7 });
37
+ process.stderr.write('[linksee-memory] auto-consolidate complete\n');
38
+ }
39
+ }
40
+ catch { /* non-fatal */ }
41
+ }, 3000);
24
42
  const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, {
25
43
  capabilities: {
26
44
  tools: {},
@@ -63,112 +81,57 @@ const LAYER_ENUM = ['goal', 'context', 'emotion', 'implementation', 'caveat', 'l
63
81
  const TOOLS = [
64
82
  {
65
83
  name: 'remember',
66
- description: 'Save important context that should persist across sessions and across AI tools. When the user says "use linksee", "remember this", "don\'t forget", or you discover a decision, preference, or lesson worth preserving store it here. The server automatically organizes into layers (goal/context/emotion/implementation/caveat/learning) and manages memory lifecycle. Just save what matters. Supports Japanese (日本語) and English. Memories saved here are accessible from any AI agent the user connects to Claude, GPT, Cursor, Codex, Gemini. This is the only memory that works cross-agent.',
84
+ 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 layer: "learning"\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• At session end, to preserve key outcomes\n\nMODES:\n• Create (default): provide entity_name + entity_kind + layer + content\n• Update: provide memory_id + fields to change (preserves links and history)\n• Delete: set forget: true + memory_id\n\nImportance ≥ 0.9 pins the memory (protected from auto-forgetting). Supports Japanese (日本語) and English.',
67
85
  inputSchema: {
68
86
  type: 'object',
69
87
  properties: {
70
- entity_name: { type: 'string', description: 'Name of the entity this memory is about' },
71
- entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'] },
88
+ entity_name: { type: 'string', description: 'Name of the entity this memory is about (required for create)' },
89
+ entity_kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Required for create' },
72
90
  entity_key: { type: 'string', description: 'Optional canonical key (email, domain, file path)' },
73
- layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Common aliases (why, decisions, warnings, how, ...) are accepted.' },
74
- content: { type: 'string', description: 'The memory content (plain text or JSON)' },
75
- importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Set to 0.9 or higher to "pin" a memory (protects from forgetting even outside caveat layer).' },
76
- force: { type: 'boolean', default: false, description: 'Bypass the paste-back/CI-log quality check. Only set when you are sure the content is original user or agent thought.' },
91
+ layer: { type: 'string', description: 'One of: goal / context / emotion / implementation / caveat / learning. Aliases accepted (why→goal, warnings→caveat, decisions→learning, how→implementation).' },
92
+ content: { type: 'string', description: 'The memory content (plain text or structured JSON with altitude/type/state/what/why)' },
93
+ importance: { type: 'number', minimum: 0, maximum: 1, description: '0.0-1.0. Set 0.9 to pin (protects from forgetting).' },
94
+ thread_id: { type: 'string', description: 'Optional thread ID to group related memories (decision chains, session groups).' },
95
+ force: { type: 'boolean', default: false, description: 'Bypass paste-back quality check.' },
96
+ memory_id: { type: 'number', description: 'Set to update an existing memory instead of creating. Only content/layer/importance are changed.' },
97
+ forget: { type: 'boolean', default: false, description: 'Set true + memory_id to delete a memory. Caveat-layer and pinned memories cannot be deleted.' },
77
98
  },
78
- required: ['entity_name', 'entity_kind', 'layer', 'content'],
79
99
  },
80
100
  },
81
101
  {
82
102
  name: 'recall',
83
- description: 'Your persistent memory across all AI tools Claude, GPT, Cursor, Codex, Gemini. When the user says "use linksee" or asks about past decisions, context, or preferences, call this first. Returns memories ranked by relevance and recency. Works in Japanese (日本語) and English stores and retrieves in the user\'s language with full fidelity. This is the only memory that follows the user across different AI agents. Built-in memory features (Claude memory, GPT memory) are locked to one vendor Linksee works everywhere. Use at the start of any task that might involve prior work.',
84
- inputSchema: {
85
- type: 'object',
86
- properties: {
87
- query: { type: 'string', description: 'What you want to remember (free-text, entity name, or FTS5 MATCH expression)' },
88
- entity_name: { type: 'string', description: 'Optional — narrow to a specific entity' },
89
- layer: {
90
- type: 'string',
91
- description: 'Optional layer filter. Accepts aliases (decisions/warnings/how/etc.) as well as canonical names.',
92
- },
93
- band: { type: 'string', enum: ['hot', 'warm', 'cold', 'frozen'], description: 'Optional — only return memories whose heat_band matches.' },
94
- max_tokens: { type: 'number', description: 'Approx token budget. Default 2000. Either max_tokens or limit stops iteration (whichever fires first).', default: 2000 },
95
- limit: { type: 'number', description: 'Optional hard cap on number of memories. Stops at min(max_tokens-budget, limit).' },
96
- offset: { type: 'number', description: 'Skip this many top results (pagination). Use has_more from prior response to decide next offset.', default: 0 },
97
- mark_accessed: { type: 'boolean', default: true, description: 'Set false for preview / listing queries that should not bump heat.' },
98
- },
99
- required: ['query'],
100
- },
101
- },
102
- {
103
- name: 'update_memory',
104
- description: 'Atomically edit an existing memory in-place. Preferred over forget+remember because it preserves memory_id, which matters for session_file_edits links and referential integrity. Use to correct facts, update deadlines in goal entries, refine caveats, or re-score importance. Caveat-layer memories can be updated but cannot have their protected flag removed.',
105
- inputSchema: {
106
- type: 'object',
107
- properties: {
108
- memory_id: { type: 'number', description: 'The memory.id to update' },
109
- content: { type: 'string', description: 'New content (plain text or JSON). If omitted, content is kept.' },
110
- layer: { type: 'string', description: 'Move to a different layer (aliases accepted). If omitted, layer is kept.' },
111
- importance: { type: 'number', minimum: 0, maximum: 1, description: 'New importance 0-1. Set to 0.9 or higher to pin.' },
112
- },
113
- required: ['memory_id'],
114
- },
115
- },
116
- {
117
- name: 'list_entities',
118
- description: 'List the entities currently known to this memory store, sorted by recent activity. Use at the start of a new session ("what do I know about?") before issuing specific recall queries. Cheaper than recall for the "give me an overview" question.',
119
- inputSchema: {
120
- type: 'object',
121
- properties: {
122
- kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'Filter by entity kind.' },
123
- min_memories: { type: 'number', description: 'Only include entities with at least N memories. Default 1.', default: 1 },
124
- limit: { type: 'number', description: 'Max entities to return. Default 30.', default: 30 },
125
- offset: { type: 'number', default: 0 },
126
- },
127
- },
128
- },
129
- {
130
- name: 'forget',
131
- description: 'Explicitly delete a memory by id, OR run auto-forgetting across all memories based on forgettingRisk (importance + heat + age). Caveat-layer, goal-layer, and pinned (importance>=0.9) memories are always preserved. Prefer update_memory for corrections — forget is destructive.',
132
- inputSchema: {
133
- type: 'object',
134
- properties: {
135
- memory_id: { type: 'number' },
136
- dry_run: { type: 'boolean', default: false, description: 'Report what would be deleted without actually deleting.' },
137
- },
138
- },
139
- },
140
- {
141
- name: 'consolidate',
142
- description: 'Sleep-mode compression. Clusters cold low-importance memories by (entity, layer), summarizes each cluster into a single protected learning-layer entry, deletes originals, and runs a forget-sweep. Run at session end or on demand. Set dry_run=true to preview without writing.',
143
- inputSchema: {
144
- type: 'object',
145
- properties: {
146
- scope: { type: 'string', enum: ['all', 'session'], default: 'session' },
147
- min_age_days: { type: 'number', description: 'Override the default 7-day minimum age for clustering (set to 0 to consolidate everything immediately, useful right after a bulk import).', default: 7 },
148
- dry_run: { type: 'boolean', default: false, description: 'Preview what would be compressed without modifying the DB.' },
149
- },
150
- },
151
- },
152
- {
153
- name: 'recall_file',
154
- description: 'Get the COMPLETE edit history of a file across all sessions, with per-edit user-intent context. Returns: total edit count, daily breakdown, list of distinct user intents that drove the edits, and the linked memories. Use this when you need to understand WHY a file was modified historically — far more accurate than recall() for file-centric questions because it queries session_file_edits (every physical edit) instead of summary memories.',
103
+ description: 'Your persistent memory across all AI tools. CALL THIS BEFORE STARTING ANY TASK to check for past caveats (pain records), decisions, and learnings — prevents repeating mistakes across sessions.\n\nWHEN TO CALL:\n• Before starting any new task or touching a file\n• When the user mentions "before" / "前に" / "last time" / "remember when"\n• When an error occurs check if you\'ve seen it before\n• When making a decision — check for prior decisions on the same topic\n\nTHREE MODES (auto-detected):\n• Search (default): provide query returns memories ranked by relevance + heat\n• File history: provide path returns complete edit history with user-intent context\n• Overview: omit all params returns entity list sorted by momentum\n\nTip: Add "Use Linksee Memory" to your system prompt for automatic cross-session memory.\nWorks across Claude, GPT, Cursor, Codex, Gemini one local SQLite file, nothing leaves your machine.',
155
104
  inputSchema: {
156
105
  type: 'object',
157
106
  properties: {
158
- path_substring: { type: 'string', description: 'Substring to match against file_path (e.g. "search-services.ts" or full absolute path)' },
159
- max_intents: { type: 'number', description: 'Max distinct user-intent snippets to return. Default 10.', default: 10 },
107
+ query: { type: 'string', description: 'What you want to remember. Use keywords, entity names, or FTS5 expressions. Omit for entity overview.' },
108
+ entity_name: { type: 'string', description: 'Narrow to a specific entity' },
109
+ layer: { type: 'string', description: 'Layer filter. Accepts aliases (decisions/warnings/how/etc.).' },
110
+ altitude: { type: 'string', enum: ['mission', 'strategy', 'architecture', 'implementation'], description: 'Filter by cognitive altitude.' },
111
+ mem_type: { type: 'string', enum: ['question', 'comparison', 'decision', 'work', 'outcome', 'learning', 'note'], description: 'Filter by memory type.' },
112
+ mem_state: { type: 'string', enum: ['open', 'decided', 'in_progress', 'done', 'stalled', 'parked', 'superseded'], description: 'Filter by lifecycle state.' },
113
+ thread_id: { type: 'string', description: 'Filter by thread ID for decision chains.' },
114
+ band: { type: 'string', enum: ['hot', 'warm', 'cold', 'frozen'], description: 'Filter by heat band.' },
115
+ max_tokens: { type: 'number', description: 'Token budget. Default 2000.', default: 2000 },
116
+ limit: { type: 'number', description: 'Hard cap on results.' },
117
+ offset: { type: 'number', description: 'Skip N results (pagination).', default: 0 },
118
+ mark_accessed: { type: 'boolean', default: true, description: 'Set false for preview queries.' },
119
+ path: { type: 'string', description: 'File path or substring. When set, returns file edit history with per-edit user-intent context instead of memory search.' },
120
+ max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
121
+ scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
122
+ kind: { type: 'string', enum: ['person', 'company', 'project', 'concept', 'file', 'other'], description: 'For overview mode: filter by entity kind.' },
123
+ min_memories: { type: 'number', description: 'For overview mode: minimum memory count. Default 1.', default: 1 },
160
124
  },
161
- required: ['path_substring'],
162
125
  },
163
126
  },
164
127
  {
165
128
  name: 'read_smart',
166
- description: 'Read a file with diff-only caching. Returns: (1) full content + chunk metadata on first read, (2) "unchanged" + cached chunk list (~50 tokens) if mtime matches, (3) "unchanged_content" if mtime changed but sha256 matches (touched but not modified), (4) changed chunks with content + unchanged chunks as metadata-only if the file was truly modified. Use INSTEAD of Read for files you have read before — saves 50%+ tokens on re-reads.',
129
+ description: 'Token-saving file reader with AST-aware diff caching. Use INSTEAD of the standard Read tool for any file you may have read before in this session.\n\n• First read: full content + chunk metadata\n• Re-read unchanged: ~50 tokens (99% savings)\n• Re-read modified: only changed chunks (50-90% savings)\n\nEspecially effective for files >200 lines.',
167
130
  inputSchema: {
168
131
  type: 'object',
169
132
  properties: {
170
133
  path: { type: 'string', description: 'Absolute file path' },
171
- force: { type: 'boolean', description: 'If true, return full content regardless of cache state', default: false },
134
+ force: { type: 'boolean', description: 'Return full content regardless of cache', default: false },
172
135
  },
173
136
  required: ['path'],
174
137
  },
@@ -225,7 +188,7 @@ function handleRemember(args) {
225
188
  });
226
189
  }
227
190
  // Quality check — reject pasted external content unless force=true
228
- const rawContent = String(args.content ?? '');
191
+ let rawContent = String(args.content ?? '');
229
192
  if (!args.force && isPastedExternalContent(rawContent)) {
230
193
  return JSON.stringify({
231
194
  ok: false,
@@ -236,9 +199,28 @@ function handleRemember(args) {
236
199
  }
237
200
  const entityId = upsertEntity({ name: args.entity_name, kind: args.entity_kind, key: args.entity_key });
238
201
  const importance = Math.min(1, Math.max(0, Number(args.importance ?? 0.5)));
202
+ // Auto-classify: if content is plain text (not JSON with 3-axis fields),
203
+ // wrap it in structured JSON so VIRTUAL generated columns can extract axes.
204
+ let isAlreadyStructured = false;
205
+ try {
206
+ const parsed = JSON.parse(rawContent);
207
+ if (parsed && typeof parsed === 'object' && parsed.altitude && parsed.type && parsed.state) {
208
+ isAlreadyStructured = true;
209
+ }
210
+ }
211
+ catch { /* not JSON = needs wrapping */ }
212
+ if (!isAlreadyStructured) {
213
+ const structured = {
214
+ altitude: inferAltitude(rawContent),
215
+ type: inferType(rawContent, layer),
216
+ state: inferState(rawContent, layer),
217
+ what: rawContent,
218
+ };
219
+ rawContent = JSON.stringify(structured);
220
+ }
239
221
  const result = db
240
- .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected) VALUES (?, ?, ?, ?, ?)')
241
- .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0);
222
+ .prepare('INSERT INTO memories (entity_id, layer, content, importance, protected, thread_id) VALUES (?, ?, ?, ?, ?, ?)')
223
+ .run(entityId, layer, rawContent, importance, importance >= 0.9 ? 1 : 0, args.thread_id ?? null);
242
224
  db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)').run(entityId, 'memory_stored', JSON.stringify({ layer, memory_id: result.lastInsertRowid }));
243
225
  const mom = refreshMomentumForEntity(db, entityId);
244
226
  return JSON.stringify({
@@ -265,10 +247,30 @@ function toFtsQuery(raw) {
265
247
  return '';
266
248
  return tokens.map((t) => `"${t}"`).join(' OR ');
267
249
  }
268
- function runFtsQuery(query, layer, limit) {
250
+ function appendAxisFilters(sql, params, filters) {
251
+ if (filters.altitude) {
252
+ sql += ' AND m.altitude = ?';
253
+ params.push(filters.altitude);
254
+ }
255
+ if (filters.mem_type) {
256
+ sql += ' AND m.mem_type = ?';
257
+ params.push(filters.mem_type);
258
+ }
259
+ if (filters.mem_state) {
260
+ sql += ' AND m.mem_state = ?';
261
+ params.push(filters.mem_state);
262
+ }
263
+ if (filters.thread_id) {
264
+ sql += ' AND m.thread_id = ?';
265
+ params.push(filters.thread_id);
266
+ }
267
+ return sql;
268
+ }
269
+ function runFtsQuery(query, layer, limit, axis) {
269
270
  let sql = `
270
271
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
271
272
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
273
+ m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
272
274
  bm25(memories_fts) as bm25_score
273
275
  FROM memories_fts
274
276
  JOIN memories m ON m.id = memories_fts.rowid
@@ -280,14 +282,17 @@ function runFtsQuery(query, layer, limit) {
280
282
  sql += ' AND m.layer = ?';
281
283
  params.push(layer);
282
284
  }
285
+ if (axis)
286
+ sql = appendAxisFilters(sql, params, axis);
283
287
  sql += ' ORDER BY bm25_score ASC LIMIT ?';
284
288
  params.push(limit);
285
289
  return db.prepare(sql).all(...params);
286
290
  }
287
- function runLikeQuery(query, entityName, layer, limit) {
291
+ function runLikeQuery(query, entityName, layer, limit, axis) {
288
292
  let sql = `
289
293
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
290
294
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
295
+ m.altitude as _altitude, m.mem_type as _mem_type, m.mem_state as _mem_state, m.thread_id as _thread_id,
291
296
  0 as bm25_score
292
297
  FROM memories m
293
298
  JOIN entities e ON e.id = m.entity_id
@@ -302,6 +307,8 @@ function runLikeQuery(query, entityName, layer, limit) {
302
307
  sql += ' AND m.layer = ?';
303
308
  params.push(layer);
304
309
  }
310
+ if (axis)
311
+ sql = appendAxisFilters(sql, params, axis);
305
312
  if (query && !entityName) {
306
313
  sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
307
314
  params.push(`%${query}%`, `%${query}%`);
@@ -342,6 +349,12 @@ function handleRecall(args) {
342
349
  const markAccessed = args.mark_accessed !== false;
343
350
  const layer = resolveLayer(args.layer);
344
351
  const band = args.band;
352
+ const axis = {
353
+ altitude: args.altitude,
354
+ mem_type: args.mem_type,
355
+ mem_state: args.mem_state,
356
+ thread_id: args.thread_id,
357
+ };
345
358
  // Fetch extra rows for composite re-rank, pagination, and band filtering
346
359
  const fetchLimit = Math.max(returnLimit * 3, 30) + offset;
347
360
  let rows = [];
@@ -349,8 +362,8 @@ function handleRecall(args) {
349
362
  const ftsQuery = toFtsQuery(args.query ?? '');
350
363
  const canUseFts = !!ftsQuery && !args.entity_name;
351
364
  if (canUseFts) {
352
- const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit);
353
- const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit);
365
+ const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis);
366
+ const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis);
354
367
  const seen = new Map();
355
368
  for (const r of ftsRows)
356
369
  seen.set(r.id, { ...r, _via: 'fts' });
@@ -371,7 +384,7 @@ function handleRecall(args) {
371
384
  searchMethod = 'like';
372
385
  }
373
386
  else {
374
- rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit).map((r) => ({ ...r, _via: 'like' }));
387
+ rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis).map((r) => ({ ...r, _via: 'like' }));
375
388
  searchMethod = 'like';
376
389
  }
377
390
  const useFts = searchMethod !== 'like';
@@ -476,6 +489,11 @@ function handleRecall(args) {
476
489
  stopped_by: stoppedBy,
477
490
  search: searchMethod,
478
491
  resolved_layer: layer ?? null,
492
+ resolved_axis: {
493
+ altitude: axis.altitude ?? null,
494
+ type: axis.mem_type ?? null,
495
+ state: axis.mem_state ?? null,
496
+ },
479
497
  memories: windowed.map((r) => {
480
498
  let parsedContent = r.content;
481
499
  try {
@@ -491,6 +509,12 @@ function handleRecall(args) {
491
509
  momentum: Number((r.momentum_score ?? 0).toFixed(2)),
492
510
  },
493
511
  layer: r.layer,
512
+ axis: {
513
+ altitude: r._altitude ?? null,
514
+ type: r._mem_type ?? null,
515
+ state: r._mem_state ?? null,
516
+ thread_id: r._thread_id ?? null,
517
+ },
494
518
  content: parsedContent,
495
519
  content_raw: r.content,
496
520
  importance: r.importance,
@@ -527,7 +551,7 @@ function handleForget(args) {
527
551
  }
528
552
  // Auto-sweep — also respect pin (importance >= 0.9) as protection
529
553
  const rows = db
530
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
554
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
531
555
  FROM memories
532
556
  WHERE protected = 0 AND importance < 0.9`)
533
557
  .all();
@@ -548,6 +572,7 @@ function handleForget(args) {
548
572
  heatScore: heat.score,
549
573
  protected: r.protected === 1,
550
574
  layer: r.layer,
575
+ altitude: r.altitude ?? undefined,
551
576
  });
552
577
  if (action !== 'keep')
553
578
  actions.push({ id: r.id, action });
@@ -905,30 +930,53 @@ async function handleForgetInteractive(args) {
905
930
  return JSON.stringify({ ok: false, declined: true, memory_id: id, reason: 'user declined elicitation' });
906
931
  return handleForget({ memory_id: id });
907
932
  }
908
- // Append new optional flags to existing tools (backward-compatible).
909
- const RECALL_FILE_TOOL = TOOLS.find((t) => t.name === 'recall_file');
910
- if (RECALL_FILE_TOOL && RECALL_FILE_TOOL.inputSchema.properties) {
911
- RECALL_FILE_TOOL.inputSchema.properties.scope_to_roots = {
912
- type: 'boolean',
913
- default: false,
914
- description: 'If true, filter results to files inside the client-provided roots (Roots block). Skip silently when client provides no roots.',
915
- };
916
- }
917
- const CONSOLIDATE_TOOL = TOOLS.find((t) => t.name === 'consolidate');
918
- if (CONSOLIDATE_TOOL && CONSOLIDATE_TOOL.inputSchema.properties) {
919
- CONSOLIDATE_TOOL.inputSchema.properties.use_llm = {
920
- type: 'boolean',
921
- default: false,
922
- description: 'If true, request the client LLM (Sampling block) to write the consolidated summary instead of the heuristic. Falls back gracefully if the client refuses.',
923
- };
933
+ // ============================================================
934
+ // Unified dispatchers (v0.7.0 3-tool surface)
935
+ // ============================================================
936
+ async function handleRememberUnified(args) {
937
+ // Delete mode
938
+ if (args.forget) {
939
+ if (!args.memory_id) {
940
+ return JSON.stringify({ ok: false, error: 'memory_id required for forget mode' });
941
+ }
942
+ return handleForgetInteractive({ memory_id: args.memory_id, interactive: !!args.interactive });
943
+ }
944
+ // Update mode
945
+ if (args.memory_id) {
946
+ return handleUpdateMemory(args);
947
+ }
948
+ // Create mode (default) — validate required fields
949
+ if (!args.entity_name || !args.entity_kind || !args.layer || !args.content) {
950
+ return JSON.stringify({
951
+ ok: false,
952
+ error: 'Create mode requires: entity_name, entity_kind, layer, content. To update, provide memory_id. To delete, set forget: true + memory_id.',
953
+ });
954
+ }
955
+ return handleRemember(args);
924
956
  }
925
- const FORGET_TOOL = TOOLS.find((t) => t.name === 'forget');
926
- if (FORGET_TOOL && FORGET_TOOL.inputSchema.properties) {
927
- FORGET_TOOL.inputSchema.properties.interactive = {
928
- type: 'boolean',
929
- default: false,
930
- description: 'If true, ask the user to confirm via Elicitation before deleting. Only applies when memory_id is set.',
931
- };
957
+ async function handleRecallUnified(args) {
958
+ // File history mode
959
+ if (args.path) {
960
+ return handleRecallFileWithRoots({
961
+ path_substring: args.path,
962
+ max_intents: args.max_intents,
963
+ scope_to_roots: args.scope_to_roots,
964
+ });
965
+ }
966
+ // Detect overview request (no search criteria at all)
967
+ const hasQuery = args.query && String(args.query).trim().length > 0;
968
+ const hasFilters = args.entity_name || args.layer || args.altitude ||
969
+ args.mem_type || args.mem_state || args.thread_id || args.band;
970
+ if (!hasQuery && !hasFilters) {
971
+ return handleListEntities({
972
+ kind: args.kind,
973
+ min_memories: args.min_memories,
974
+ limit: args.limit,
975
+ offset: args.offset,
976
+ });
977
+ }
978
+ // Search mode (default)
979
+ return handleRecall(args);
932
980
  }
933
981
  // ============================================================
934
982
  // MCP wiring
@@ -940,30 +988,21 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
940
988
  let text;
941
989
  switch (name) {
942
990
  case 'remember':
943
- text = handleRemember(args);
991
+ text = await handleRememberUnified(args);
944
992
  break;
945
993
  case 'recall':
946
- text = handleRecall(args);
947
- break;
948
- case 'update_memory':
949
- text = handleUpdateMemory(args);
950
- break;
951
- case 'list_entities':
952
- text = handleListEntities(args);
953
- break;
954
- case 'forget':
955
- text = await handleForgetInteractive(args);
956
- break;
957
- case 'consolidate':
958
- text = await handleConsolidateWithSampling(args);
959
- break;
960
- case 'recall_file':
961
- text = await handleRecallFileWithRoots(args);
994
+ text = await handleRecallUnified(args);
962
995
  break;
963
996
  case 'read_smart':
964
997
  text = handleReadSmart(args);
965
998
  break;
966
- default: throw new Error(`Unknown tool: ${name}`);
999
+ default: {
1000
+ const deprecated = ['update_memory', 'list_entities', 'forget', 'consolidate', 'recall_file'];
1001
+ if (deprecated.includes(name)) {
1002
+ throw new Error(`Tool "${name}" was merged in v0.7.0. Use "remember" (for update/forget) or "recall" (for list/file-recall) instead.`);
1003
+ }
1004
+ throw new Error(`Unknown tool: ${name}`);
1005
+ }
967
1006
  }
968
1007
  return { content: [{ type: 'text', text }] };
969
1008
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.4.2",
3
+ "version": "0.7.0",
4
4
  "mcpName": "io.github.michielinksee/linksee-memory",
5
5
  "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
6
6
  "type": "module",