linksee-memory 0.18.0 → 0.18.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.
@@ -27,4 +27,20 @@ export function rmGuarded(path, opts) {
27
27
  inject('RM');
28
28
  rmSync(path, opts);
29
29
  }
30
+ /**
31
+ * Named fault points inside a transaction (0.18.1, Codex review 1 of #23): the rollback tests
32
+ * need a failure AFTER some rows were written, not one at prepare() time.
33
+ * LINKSEE_TEST_FAULT=consolidate-after-mark | consolidate-after-edges | import-after-memories
34
+ * Unset (the normal case) this is a no-op.
35
+ */
36
+ export function faultPoint(name) {
37
+ const spec = process.env.LINKSEE_TEST_FAULT;
38
+ if (!spec)
39
+ return;
40
+ if (spec.split(',').map((s) => s.trim()).includes(name)) {
41
+ const e = new Error(`injected fault at ${name}`);
42
+ e.code = 'EINJECTED';
43
+ throw e;
44
+ }
45
+ }
30
46
  //# sourceMappingURL=test-faults.js.map
@@ -8,9 +8,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { openDb, runMigrations, DbRefusedError } from '../db/migrate.js';
10
10
  import { computeHeat } from '../lib/heat-index.js';
11
- import { decideForgetting } from '../lib/forgetting.js';
12
11
  import { refreshMomentumForEntity } from '../lib/momentum.js';
13
- import { consolidate as runConsolidate, autoConsolidateEnabled } from '../lib/consolidate.js';
12
+ import { consolidate as runConsolidate, autoConsolidateEnabled, planConsolidate, CONSOLIDATED_SQL } from '../lib/consolidate.js';
14
13
  import { isPastedExternalContent } from '../lib/session-parser.js';
15
14
  import { inferAltitude, inferType, inferState } from '../lib/session-extractor.js';
16
15
  import { normalizeEntityName } from '../lib/normalize.js';
@@ -67,6 +66,7 @@ const SUBCOMMANDS = {
67
66
  'share-draft': 'share-draft.js',
68
67
  migrate: 'migrate-legacy.js', // 0.17.2: schema 1–5 → current, without starting the server
69
68
  runtime: 'runtime.js', // 0.17.2: runtime list | prune
69
+ consolidate: 'consolidate.js', // 0.18.1: consolidate --dry-run | --apply (marks, never deletes)
70
70
  };
71
71
  const subcommand = process.argv[2];
72
72
  if (subcommand && Object.prototype.hasOwnProperty.call(SUBCOMMANDS, subcommand)) {
@@ -101,35 +101,46 @@ try {
101
101
  catch { /* never block startup */ }
102
102
  // Auto-maintenance: consolidate stale memories on startup (non-blocking).
103
103
  //
104
- // OFF by default since 0.17.1 (L-1-106 / audit F1): consolidate deletes the original rows after
105
- // writing a summary that keeps only the top 3 × 200 characters, ignores thread_id, and cascades
106
- // away their edges — reasons and history lost with no action from the user. It now runs only
107
- // when asked: LINKSEE_AUTO_CONSOLIDATE=1 (or `--auto-consolidate` on the server command line).
108
- // There is no `consolidate` tool (merged away in 0.7.0). Rows already removed by earlier versions
109
- // are not restored.
110
- if (autoConsolidateEnabled(process.env, process.argv.slice(2)))
111
- setTimeout(() => {
104
+ // OFF by default (0.17.1 intended this; 0.18.1 makes it hold and takes the delete out):
105
+ // up to 0.18.0 consolidate deleted the original rows after writing a summary that kept only the
106
+ // top 3 × 200 characters, then a forget-sweep deleted every other cold row — reasons and history
107
+ // lost with no action from the user (1,360 rows on 2026-10-29, 559 on 2026-11-05 on the
108
+ // maintainer's DB). Since 0.18.1 nothing is deleted: originals are marked `consolidated_into`
109
+ // and hidden from the default recall. It still runs only when asked: LINKSEE_AUTO_CONSOLIDATE=1
110
+ // (or `--auto-consolidate` on the server command line); by hand: `npx linksee-memory consolidate
111
+ // --dry-run | --apply`. When off, one stderr line says how many rows a run would fold, so a
112
+ // growing database is noticed without anything being touched. There is no `consolidate` MCP tool
113
+ // (merged away in 0.7.0). Rows already removed by earlier versions are not restored.
114
+ const autoConsolidateOn = autoConsolidateEnabled(process.env, process.argv.slice(2));
115
+ setTimeout(() => {
116
+ try {
117
+ if (!autoConsolidateOn) {
118
+ // Read-only: count what a run would fold. Never throws out of here, never writes.
119
+ const plan = planConsolidate(db, { min_age_days: 7 });
120
+ process.stderr.write(`[linksee-memory] auto-consolidate is off (set LINKSEE_AUTO_CONSOLIDATE=1 to enable); ${plan.wouldFold} cold memories would be folded` +
121
+ ` (run: npx linksee-memory consolidate --dry-run)\n`);
122
+ return;
123
+ }
124
+ let shouldRun = true;
112
125
  try {
113
- let shouldRun = true;
114
- try {
115
- // Check if consolidations table exists before querying
116
- const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='consolidations'").get();
117
- if (tableExists) {
118
- const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
119
- if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
120
- shouldRun = false;
121
- }
122
- }
123
- catch {
124
- shouldRun = false; /* genuinely unexpected — skip to be safe */
125
- }
126
- if (shouldRun) {
127
- runConsolidate(db, { scope: 'all', min_age_days: 7 });
128
- process.stderr.write('[linksee-memory] auto-consolidate complete\n');
126
+ // Check if consolidations table exists before querying
127
+ const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='consolidations'").get();
128
+ if (tableExists) {
129
+ const lastRow = db.prepare('SELECT MAX(created_at) as ts FROM consolidations').get();
130
+ if (lastRow?.ts && (Date.now() / 1000 - lastRow.ts) / 86400 < 7)
131
+ shouldRun = false;
129
132
  }
130
133
  }
131
- catch { /* non-fatal */ }
132
- }, 3000);
134
+ catch {
135
+ shouldRun = false; /* genuinely unexpected — skip to be safe */
136
+ }
137
+ if (shouldRun) {
138
+ const r = runConsolidate(db, { scope: 'all', min_age_days: 7 });
139
+ process.stderr.write(`[linksee-memory] auto-consolidate complete: ${r.clustersCompressed} summaries, ${r.memoriesFolded} rows marked consolidated_into (0 deleted)\n`);
140
+ }
141
+ }
142
+ catch { /* non-fatal */ }
143
+ }, 3000);
133
144
  const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, {
134
145
  capabilities: {
135
146
  tools: {},
@@ -232,6 +243,7 @@ const TOOLS = [
232
243
  max_intents: { type: 'number', description: 'For file mode: max user-intent snippets. Default 10.', default: 10 },
233
244
  scope_to_roots: { type: 'boolean', default: false, description: 'For file mode: filter to client-provided roots.' },
234
245
  include_superseded: { type: 'boolean', default: false, description: 'Also return memories that a newer memory superseded (hidden by default; each carries superseded_by).' },
246
+ include_consolidated: { type: 'boolean', default: false, description: 'Also return memories that consolidate folded into a summary (hidden by default, still in the DB; each carries consolidated_into = the summary id).' },
235
247
  explain: { type: 'boolean', default: false, description: 'Include ranking internals (composite, heat, band, momentum, match_reasons, score_breakdown). Off by default so more memories fit the token budget.' },
236
248
  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' }] },
237
249
  project: { type: 'string', description: 'With where: the Map project slug, when several maps are imported.' },
@@ -744,6 +756,7 @@ function appendAxisFilters(sql, params, filters) {
744
756
  // NULL-safe: plain-text rows and JSON without a state have mem_state NULL, and
745
757
  // `NOT (NULL = 'superseded' OR …)` is NULL — which would drop them (Codex review 2 of #21).
746
758
  const SUPERSEDED_SQL = `(COALESCE(m.mem_state, '') = 'superseded' OR EXISTS (SELECT 1 FROM memory_edges me WHERE me.to_memory_id = m.id AND me.relation = 'supersedes'))`;
759
+ const SHOW_ALL = { sup: 'include', con: 'include' };
747
760
  function appendSupersededFilter(sql, mode) {
748
761
  if (mode === 'exclude')
749
762
  return sql + ` AND NOT ${SUPERSEDED_SQL}`;
@@ -751,7 +764,17 @@ function appendSupersededFilter(sql, mode) {
751
764
  return sql + ` AND ${SUPERSEDED_SQL}`;
752
765
  return sql;
753
766
  }
754
- function runFtsQuery(query, layer, limit, axis, sup = 'include') {
767
+ function appendConsolidatedFilter(sql, mode) {
768
+ if (mode === 'exclude')
769
+ return sql + ` AND NOT ${CONSOLIDATED_SQL}`;
770
+ if (mode === 'only')
771
+ return sql + ` AND ${CONSOLIDATED_SQL}`;
772
+ return sql;
773
+ }
774
+ function appendHiddenFilters(sql, h) {
775
+ return appendConsolidatedFilter(appendSupersededFilter(sql, h.sup), h.con);
776
+ }
777
+ function runFtsQuery(query, layer, limit, axis, hidden = SHOW_ALL) {
755
778
  let sql = `
756
779
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
757
780
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
@@ -769,12 +792,12 @@ function runFtsQuery(query, layer, limit, axis, sup = 'include') {
769
792
  }
770
793
  if (axis)
771
794
  sql = appendAxisFilters(sql, params, axis);
772
- sql = appendSupersededFilter(sql, sup);
795
+ sql = appendHiddenFilters(sql, hidden);
773
796
  sql += ' ORDER BY bm25_score ASC LIMIT ?';
774
797
  params.push(limit);
775
798
  return db.prepare(sql).all(...params);
776
799
  }
777
- function runLikeQuery(query, entityName, layer, limit, axis, sup = 'include') {
800
+ function runLikeQuery(query, entityName, layer, limit, axis, hidden = SHOW_ALL) {
778
801
  let sql = `
779
802
  SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
780
803
  m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
@@ -795,7 +818,7 @@ function runLikeQuery(query, entityName, layer, limit, axis, sup = 'include') {
795
818
  }
796
819
  if (axis)
797
820
  sql = appendAxisFilters(sql, params, axis);
798
- sql = appendSupersededFilter(sql, sup);
821
+ sql = appendHiddenFilters(sql, hidden);
799
822
  if (query && !entityName) {
800
823
  sql += ' AND (e.name LIKE ? OR m.content LIKE ?)';
801
824
  params.push(`%${query}%`, `%${query}%`);
@@ -849,6 +872,13 @@ function contentSignature(raw, entityId) {
849
872
  return ''; // too short to dedup safely
850
873
  return `${entityId}::${norm}`;
851
874
  }
875
+ // The summary id a folded row points at (0.18.1), or undefined for an unfolded / plain-text row.
876
+ function consolidatedIntoOf(parsedContent) {
877
+ if (!parsedContent || typeof parsedContent !== 'object' || Array.isArray(parsedContent))
878
+ return undefined;
879
+ const v = parsedContent.consolidated_into;
880
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
881
+ }
852
882
  function handleRecall(args) {
853
883
  // max_tokens is enforced for real at assembly time (greedy fill by measured
854
884
  // serialized size), not via a crude fixed per-memory estimate. hardLimit caps COUNT.
@@ -875,26 +905,34 @@ function handleRecall(args) {
875
905
  // Superseded rows are left out in SQL by default; asking for them (include_superseded, or
876
906
  // mem_state:'superseded') keeps them. The hidden ones are counted with the same filters.
877
907
  const showSuperseded = args?.include_superseded === true || axis.mem_state === 'superseded';
908
+ // Consolidated rows (0.18.1: marked `consolidated_into`, kept in the table) are hidden the
909
+ // same way; include_consolidated shows them. The two counts do not overlap: a row that is
910
+ // both superseded and consolidated is counted once, under superseded.
911
+ const showConsolidated = args?.include_consolidated === true;
878
912
  const sup = showSuperseded ? 'include' : 'exclude';
913
+ const con = showConsolidated ? 'include' : 'exclude';
914
+ const hidden = { sup, con };
879
915
  const HIDDEN_COUNT_CAP = 1000;
880
- let supersededHidden = 0;
881
- if (!showSuperseded) {
916
+ const countHidden = (h) => {
882
917
  const hiddenIds = new Set();
883
918
  if (canUseFts) {
884
- for (const r of runFtsQuery(ftsQuery, layer, HIDDEN_COUNT_CAP, axis, 'only'))
919
+ for (const r of runFtsQuery(ftsQuery, layer, HIDDEN_COUNT_CAP, axis, h))
885
920
  hiddenIds.add(r.id);
886
- for (const r of runLikeQuery(args.query, undefined, layer, HIDDEN_COUNT_CAP, axis, 'only'))
921
+ for (const r of runLikeQuery(args.query, undefined, layer, HIDDEN_COUNT_CAP, axis, h))
887
922
  hiddenIds.add(r.id);
888
923
  }
889
924
  else {
890
- for (const r of runLikeQuery(args.query, args.entity_name, layer, HIDDEN_COUNT_CAP, axis, 'only'))
925
+ for (const r of runLikeQuery(args.query, args.entity_name, layer, HIDDEN_COUNT_CAP, axis, h))
891
926
  hiddenIds.add(r.id);
892
927
  }
893
- supersededHidden = hiddenIds.size;
894
- }
928
+ return hiddenIds.size;
929
+ };
930
+ const supersededHidden = showSuperseded ? 0 : countHidden({ sup: 'only', con: 'include' });
931
+ const consolidatedHidden = showConsolidated ? 0 : countHidden({ sup, con: 'only' });
932
+ const capped = (n) => (n >= HIDDEN_COUNT_CAP ? `${HIDDEN_COUNT_CAP}+` : n);
895
933
  if (canUseFts) {
896
- const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis, sup);
897
- const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis, sup);
934
+ const ftsRows = runFtsQuery(ftsQuery, layer, fetchLimit, axis, hidden);
935
+ const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis, hidden);
898
936
  const seen = new Map();
899
937
  for (const r of ftsRows)
900
938
  seen.set(r.id, { ...r, _via: 'fts' });
@@ -915,7 +953,7 @@ function handleRecall(args) {
915
953
  searchMethod = 'like';
916
954
  }
917
955
  else {
918
- rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis, sup).map((r) => ({ ...r, _via: 'like' }));
956
+ rows = runLikeQuery(args.query, args.entity_name, layer, fetchLimit, axis, hidden).map((r) => ({ ...r, _via: 'like' }));
919
957
  searchMethod = 'like';
920
958
  }
921
959
  const useFts = searchMethod !== 'like';
@@ -1087,6 +1125,7 @@ function handleRecall(args) {
1087
1125
  ...(byFromSource(r._source) ? { by: byFromSource(r._source) } : {}),
1088
1126
  ...(supersedesOf.has(r.id) ? { supersedes: supersedesOf.get(r.id).map((x) => `#${x}`).join(' ') } : {}),
1089
1127
  ...(supersededBy.has(r.id) ? { superseded_by: supersededBy.get(r.id) } : {}),
1128
+ ...(consolidatedIntoOf(parsedContent) !== undefined ? { consolidated_into: consolidatedIntoOf(parsedContent) } : {}),
1090
1129
  // Ranking internals only on request — they roughly double the per-memory cost and an
1091
1130
  // agent acting on the memory needs what/why/state, not the scorer's arithmetic.
1092
1131
  ...(args?.explain ? {
@@ -1131,7 +1170,8 @@ function handleRecall(args) {
1131
1170
  type: axis.mem_type ?? null,
1132
1171
  state: axis.mem_state ?? null,
1133
1172
  },
1134
- ...(supersededHidden > 0 ? { superseded_hidden: supersededHidden >= HIDDEN_COUNT_CAP ? `${HIDDEN_COUNT_CAP}+` : supersededHidden, superseded_hint: 'include_superseded: true to show them' } : {}),
1173
+ ...(supersededHidden > 0 ? { superseded_hidden: capped(supersededHidden), superseded_hint: 'include_superseded: true to show them' } : {}),
1174
+ ...(consolidatedHidden > 0 ? { consolidated_hidden: capped(consolidatedHidden), consolidated_hint: 'include_consolidated: true to show them (rows folded into a summary; still in the DB)' } : {}),
1135
1175
  memories: memoriesOut,
1136
1176
  });
1137
1177
  }
@@ -1156,82 +1196,28 @@ function handleForget(args) {
1156
1196
  const res = db.prepare('DELETE FROM memories WHERE id = ?').run(args.memory_id);
1157
1197
  return JSON.stringify({ ok: true, deleted: res.changes, memory_id: args.memory_id });
1158
1198
  }
1159
- // Auto-sweep — also respect pin (importance >= 0.9) as protection
1160
- const rows = db
1161
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected, altitude
1162
- FROM memories
1163
- WHERE protected = 0 AND importance < 0.9`)
1164
- .all();
1165
- const now = Math.floor(Date.now() / 1000);
1166
- const actions = [];
1167
- for (const r of rows) {
1168
- const daysSince = (now - r.last_accessed_at) / 86400;
1169
- const heat = computeHeat({
1170
- accessesLast30d: daysSince < 30 ? r.access_count : 0,
1171
- accessesLast90d: daysSince < 90 ? r.access_count : 0,
1172
- daysSinceLastAccess: daysSince,
1173
- totalAccesses: r.access_count,
1174
- baseImportance: r.importance,
1175
- });
1176
- const action = decideForgetting({
1177
- daysSinceLastAccess: daysSince,
1178
- importance: r.importance,
1179
- heatScore: heat.score,
1180
- protected: r.protected === 1,
1181
- layer: r.layer,
1182
- altitude: r.altitude ?? undefined,
1183
- });
1184
- if (action !== 'keep')
1185
- actions.push({ id: r.id, action });
1186
- }
1187
- const toDropIds = actions.filter((a) => a.action === 'drop').map((a) => a.id);
1188
- if (!args.dry_run) {
1189
- const del = db.prepare('DELETE FROM memories WHERE id = ?');
1190
- const tx = db.transaction((ids) => { for (const id of ids)
1191
- del.run(id); });
1192
- tx(toDropIds);
1193
- }
1194
- return JSON.stringify({
1195
- ok: true,
1196
- dry_run: !!args.dry_run,
1197
- scanned: rows.length,
1198
- to_drop: toDropIds.length,
1199
- to_compress: actions.filter((a) => a.action === 'compress').length,
1200
- sample_ids_to_drop: toDropIds.slice(0, 10),
1201
- });
1199
+ // 0.18.1: the id-less auto-sweep that used to follow here (DELETE every row whose forgetting
1200
+ // score crossed a threshold) is removed. No caller reached it; it is gone so that the only
1201
+ // memory DELETE in the server is the explicit one above.
1202
+ return JSON.stringify({ ok: false, error: 'memory_id required: forget deletes one memory by id; there is no sweep' });
1202
1203
  }
1203
1204
  function handleConsolidate(args) {
1204
1205
  const dryRun = !!args?.dry_run;
1205
1206
  if (dryRun) {
1206
- // Preview: simulate by running against a transaction we roll back
1207
- // We don't have a clean "preview" path in lib/consolidate, so do a best-effort
1208
- // read-only audit: count candidates using the same rules.
1209
- const now = Math.floor(Date.now() / 1000);
1210
- const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
1211
- const candidates = db.prepare(`
1212
- SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
1213
- FROM memories m
1214
- JOIN entities e ON e.id = m.entity_id
1215
- WHERE m.protected = 0
1216
- AND m.importance < 0.9
1217
- AND m.layer IN ('context', 'emotion', 'implementation')
1218
- AND m.created_at <= ?
1219
- GROUP BY m.entity_id, m.layer
1220
- HAVING c >= 2
1221
- ORDER BY c DESC
1222
- `).all(ageCutoff);
1223
- const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
1207
+ // Read-only preview, the same plan `--apply` would act on (0.18.1).
1208
+ const plan = planConsolidate(db, { min_age_days: typeof args?.min_age_days === 'number' ? args.min_age_days : undefined });
1224
1209
  return JSON.stringify({
1225
1210
  ok: true,
1226
1211
  dry_run: true,
1227
- clusters: candidates.length,
1228
- memories_replaced_if_run: totalReplaced,
1229
- preview: candidates.slice(0, 20).map((c) => ({
1212
+ clusters: plan.clusters.length,
1213
+ memories_folded_if_run: plan.wouldFold,
1214
+ deleted_if_run: 0,
1215
+ preview: plan.clusters.slice(0, 20).map((c) => ({
1230
1216
  entity: c.entity_name,
1231
1217
  layer: c.layer,
1232
- count: c.c,
1218
+ count: c.ids.length,
1233
1219
  })),
1234
- hint: 'Set dry_run=false to actually consolidate.',
1220
+ hint: 'Set dry_run=false to consolidate (originals are kept and marked consolidated_into).',
1235
1221
  });
1236
1222
  }
1237
1223
  const result = runConsolidate(db, {
@@ -30,9 +30,11 @@ description: |
30
30
 
31
31
  Claude Code forgets everything when a session ends. The solution the user taught yesterday, the failure you hit today, the decision made three days ago — all of it is normally lost. **linksee-memory is the "memory that doesn't disappear" device.**
32
32
 
33
- Writes are handled automatically by the Stop hook (already running). But **reads require the agent to actively pull**. This skill instills that "go look first" habit in the agent.
33
+ Writes happen automatically only when `linksee-memory setup` installed the Stop hook. The Claude Code plugin alone has no hooks: nothing is saved unless the agent calls `remember`. Either way, **reads require the agent to actively pull**. This skill instills that "go look first" habit in the agent.
34
34
 
35
- *JP: Claude Code は session が終わると全部忘れる。linksee-memory は「消えない記憶」を作る装置。書き込みは Stop hook が自動でやってくれるが、読み出しはエージェントが能動的にやる必要がある。*
35
+ *JP: Claude Code は session が終わると全部忘れる。linksee-memory は「消えない記憶」を作る装置。書き込みの自動保存は `setup` で Stop hook を入れた場合だけ。plugin 単独では hook が無いので、`remember` を呼ばない限り何も保存されない。読み出しはどちらでもエージェントが能動的にやる必要がある。*
36
+
37
+ **Tool names depend on how linksee-memory was installed.** The examples below use the short tool name (`recall`, `remember`, …). Registered with `npx -y linksee-memory setup` or `claude mcp add`, the full name is `mcp__linksee__<tool>`; installed as the Claude Code plugin, it is `mcp__plugin_linksee-memory_linksee__<tool>` ([why](https://code.claude.com/docs/en/mcp#plugin-provided-mcp-servers)). Use whichever is present in your tool list.
36
38
 
37
39
  ---
38
40
 
@@ -185,7 +187,7 @@ Before starting any new task, inject past context.
185
187
  **At the very beginning of a conversation**, use `list_entities` first to understand what you know:
186
188
 
187
189
  ```
188
- mcp__linksee__list_entities({ min_memories: 5, limit: 10 })
190
+ list_entities({ min_memories: 5, limit: 10 })
189
191
  ```
190
192
 
191
193
  The returned "high-momentum entities" are the projects likely to be discussed. Each entity's `layer_breakdown` reveals patterns ("this project has many caveats", "goal is unfinished", etc.).
@@ -193,7 +195,7 @@ The returned "high-momentum entities" are the projects likely to be discussed. E
193
195
  Then, once a specific task starts, recall:
194
196
 
195
197
  ```
196
- mcp__linksee__recall({
198
+ recall({
197
199
  query: "<keywords of current task — project name + technology>",
198
200
  max_tokens: 2000
199
201
  })
@@ -244,7 +246,7 @@ The recall engine uses FTS5 full-text search + heat_score ranking. Your query de
244
246
  Before touching a specific file, check its edit history:
245
247
 
246
248
  ```
247
- mcp__linksee__recall_file({
249
+ recall_file({
248
250
  path_substring: "<file path or substring match>",
249
251
  max_intents: 5
250
252
  })
@@ -259,7 +261,7 @@ Returns: the file's entire edit history + **the user message that drove each edi
259
261
  When you need to read a file, **use `read_smart` instead of the standard `Read` tool**:
260
262
 
261
263
  ```
262
- mcp__linksee__read_smart({
264
+ read_smart({
263
265
  path: "<absolute path>"
264
266
  })
265
267
  ```
@@ -290,7 +292,7 @@ update_memory({
290
292
  The moment an error, failure, or "doesn't work" happens, record immediately:
291
293
 
292
294
  ```
293
- mcp__linksee__remember({
295
+ remember({
294
296
  entity_name: "<project name or service name>",
295
297
  entity_kind: "project",
296
298
  layer: "caveat",
@@ -330,7 +332,7 @@ mcp__linksee__remember({
330
332
  When you understand something new, change approaches, or solve a problem:
331
333
 
332
334
  ```
333
- mcp__linksee__remember({
335
+ remember({
334
336
  entity_name: "<entity>",
335
337
  entity_kind: "project | concept | ...",
336
338
  layer: "learning",
@@ -372,7 +374,7 @@ Recording `prior_belief` leaves a **belief-update history**. Later, this becomes
372
374
  2. ❌ Solve an error on the spot without recording — future you (or another agent) will hit the same failure
373
375
  3. ❌ Use `Read` everywhere instead of `read_smart` (wastes tokens)
374
376
  4. ❌ Write caveats in a flippant tone — preserve them seriously
375
- 5. ❌ Try to call a `consolidate` tool — there is none; consolidation is a server opt-in that deletes originals (see below)
377
+ 5. ❌ Try to call a `consolidate` tool — there is none; consolidation is a CLI subcommand / server opt-in (see below)
376
378
  6. ❌ Recall the same entity twice in one session without new context (wastes tokens)
377
379
  7. ❌ Write `recall({ query: "what happened last time" })` — use specific keywords
378
380
 
@@ -380,9 +382,9 @@ Recording `prior_belief` leaves a **belief-update history**. Later, this becomes
380
382
 
381
383
  ## 🔁 Consolidation — off unless the user turns it on
382
384
 
383
- There is no `consolidate` tool. Consolidation clusters cold, low-importance memories older than 7 days into one `learning`-layer entry and **deletes the originals**, so since 0.17.1 it never runs on its own. The user can opt in by starting the server with `LINKSEE_AUTO_CONSOLIDATE=1` (then it runs at start-up, at most weekly).
385
+ There is no `consolidate` tool. Consolidation clusters cold, low-importance memories older than 7 days into one `learning`-layer entry. Since 0.18.1 it **keeps the originals**: each gets `consolidated_into: <summary id>` and `recall` hides it by default (`consolidated_hidden` says how many; `include_consolidated: true` shows them). Up to 0.18.0 it deleted them — rows lost that way come back only from a backup. It never runs on its own: the user runs `npx -y linksee-memory consolidate --dry-run` (shows) / `--apply` (folds), or starts the server with `LINKSEE_AUTO_CONSOLIDATE=1` (at start-up, at most weekly).
384
386
 
385
- Do not suggest turning it on as routine housekeeping. If the user asks about a large DB, explain the trade-off (originals are deleted) and suggest backing up `~/.linksee-memory/` first.
387
+ Do not suggest turning it on as routine housekeeping. If the user asks about a large DB, say what it does (summaries + hidden originals, nothing deleted) and point at `--dry-run` first.
386
388
 
387
389
  **`caveat` memories and active `goal` memories are never consolidated away.**
388
390
 
@@ -714,7 +716,7 @@ Now you have both bodies of knowledge before starting
714
716
 
715
717
  | Moment | recall precision | Why |
716
718
  |---|---|---|
717
- | Day 1 | low (little data) | Stop hook is still collecting |
719
+ | Day 1 | low (little data) | the Stop hook (if `setup` installed it) or your own `remember` calls are still collecting |
718
720
  | Week 1 | medium | thousands of memories, FTS5 kicks in |
719
721
  | Month 1 | high | heat_score stabilizes, important memories surface |
720
722
  | Month 3+ | strongest | learnings crystallized (distilled) |
@@ -724,6 +726,6 @@ Now you have both bodies of knowledge before starting
724
726
  ---
725
727
 
726
728
  *This skill runs on top of linksee-memory MCP v0.4.0+.*
727
- *Auto-write via Stop hook, explicit read via recall.*
729
+ *Auto-write via the Stop hook when `setup` installed it (the plugin has no hooks), explicit read via recall.*
728
730
  *Listed in MCP Official Registry, PulseMCP, mcpservers.org, Glama.*
729
731
  *MIT License — Synapse Arrows PTE. LTD.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.18.0",
3
+ "version": "0.18.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",
@@ -16,7 +16,8 @@
16
16
  "linksee-memory-guard": "dist/bin/guard-hook.js",
17
17
  "linksee-memory-map": "dist/bin/map-import.js",
18
18
  "linksee-memory-export": "dist/bin/export-report.js",
19
- "linksee-memory-share-draft": "dist/bin/share-draft.js"
19
+ "linksee-memory-share-draft": "dist/bin/share-draft.js",
20
+ "linksee-memory-consolidate": "dist/bin/consolidate.js"
20
21
  },
21
22
  "main": "./dist/mcp/server.js",
22
23
  "files": [
@@ -30,6 +31,7 @@
30
31
  "scripts": {
31
32
  "build": "tsc && node -e \"require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql'); require('fs').mkdirSync('dist/skill',{recursive:true}); require('fs').copyFileSync('src/skill/SKILL.md','dist/skill/SKILL.md')\"",
32
33
  "start": "node dist/mcp/server.js",
34
+ "test": "node test/share-draft.mjs && node test/live-check.mjs && node test/supersede-source.mjs && node test/session-import.mjs && node test/import-cli-append-only.mjs && node test/distill-triage.mjs && node test/consolidate-no-delete.mjs && node test/auto-consolidate.mjs",
33
35
  "dev": "tsx src/mcp/server.ts",
34
36
  "migrate": "node dist/db/migrate.js",
35
37
  "migrate:dev": "tsx src/db/migrate.ts",