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.
- package/README.md +22 -0
- package/dist/bin/consolidate.d.ts +12 -0
- package/dist/bin/consolidate.js +216 -0
- package/dist/bin/import-sessions.js +21 -100
- package/dist/lib/consolidate.d.ts +61 -6
- package/dist/lib/consolidate.js +199 -103
- package/dist/lib/distill-triage.js +9 -2
- package/dist/lib/forgetting.js +4 -3
- package/dist/lib/session-import.d.ts +22 -0
- package/dist/lib/session-import.js +161 -0
- package/dist/lib/test-faults.d.ts +7 -0
- package/dist/lib/test-faults.js +16 -0
- package/dist/mcp/server.js +95 -109
- package/dist/skill/SKILL.md +15 -13
- package/package.json +4 -2
package/dist/lib/test-faults.js
CHANGED
|
@@ -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
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
|
|
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 {
|
|
132
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
897
|
-
const likeRows = runLikeQuery(args.query, undefined, layer, fetchLimit, axis,
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
-
//
|
|
1207
|
-
|
|
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:
|
|
1228
|
-
|
|
1229
|
-
|
|
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.
|
|
1218
|
+
count: c.ids.length,
|
|
1233
1219
|
})),
|
|
1234
|
-
hint: 'Set dry_run=false to
|
|
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, {
|
package/dist/skill/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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",
|