linksee-memory 0.1.4 → 0.3.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.
@@ -4,7 +4,7 @@
4
4
  // forget / consolidate / read_smart
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
8
8
  import { openDb, runMigrations } from '../db/migrate.js';
9
9
  import { computeHeat } from '../lib/heat-index.js';
10
10
  import { decideForgetting } from '../lib/forgetting.js';
@@ -12,10 +12,24 @@ 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
14
  import { handleReadSmart as handleReadSmartImpl } from './read-smart.js';
15
- const SERVER_VERSION = '0.1.1';
15
+ import { STATIC_RESOURCES, RESOURCE_TEMPLATES, readResource } from './resources.js';
16
+ import { PROMPTS, getPrompt } from './prompts.js';
17
+ import { fetchRoots, isInsideRoots } from './roots.js';
18
+ import { sampleConsolidation } from './sampling.js';
19
+ import { confirmForget } from './elicitation.js';
20
+ const SERVER_VERSION = '0.3.0';
16
21
  const db = openDb();
17
22
  runMigrations(db);
18
- const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, { capabilities: { tools: {} } });
23
+ const server = new Server({ name: 'linksee-memory', version: SERVER_VERSION }, {
24
+ capabilities: {
25
+ tools: {},
26
+ resources: { subscribe: false, listChanged: false },
27
+ prompts: { listChanged: false },
28
+ // Sampling, Roots, Elicitation are CLIENT capabilities the server consumes.
29
+ // We don't declare them under "capabilities" — we just call them via server.request
30
+ // and gracefully degrade when the client doesn't support them.
31
+ },
32
+ });
19
33
  // ============================================================
20
34
  // Layer alias map — natural language → canonical layer
21
35
  // (agents can say layer="decisions" and we resolve to "learning")
@@ -233,14 +247,14 @@ function toFtsQuery(raw) {
233
247
  return tokens.map((t) => `"${t}"`).join(' OR ');
234
248
  }
235
249
  function runFtsQuery(query, layer, limit) {
236
- let sql = `
237
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
238
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
239
- bm25(memories_fts) as bm25_score
240
- FROM memories_fts
241
- JOIN memories m ON m.id = memories_fts.rowid
242
- JOIN entities e ON e.id = m.entity_id
243
- WHERE memories_fts MATCH ?
250
+ let sql = `
251
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
252
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
253
+ bm25(memories_fts) as bm25_score
254
+ FROM memories_fts
255
+ JOIN memories m ON m.id = memories_fts.rowid
256
+ JOIN entities e ON e.id = m.entity_id
257
+ WHERE memories_fts MATCH ?
244
258
  `;
245
259
  const params = [query];
246
260
  if (layer) {
@@ -252,13 +266,13 @@ function runFtsQuery(query, layer, limit) {
252
266
  return db.prepare(sql).all(...params);
253
267
  }
254
268
  function runLikeQuery(query, entityName, layer, limit) {
255
- let sql = `
256
- SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
257
- m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
258
- 0 as bm25_score
259
- FROM memories m
260
- JOIN entities e ON e.id = m.entity_id
261
- WHERE 1=1
269
+ let sql = `
270
+ SELECT m.id, m.entity_id, e.name as entity_name, e.kind as entity_kind, e.momentum_score,
271
+ m.layer, m.content, m.importance, m.created_at, m.last_accessed_at, m.access_count,
272
+ 0 as bm25_score
273
+ FROM memories m
274
+ JOIN entities e ON e.id = m.entity_id
275
+ WHERE 1=1
262
276
  `;
263
277
  const params = [];
264
278
  if (entityName) {
@@ -479,11 +493,14 @@ function handleForget(args) {
479
493
  return JSON.stringify({ ok: false, error: `memory_id ${args.memory_id} not found` });
480
494
  }
481
495
  if (target.protected === 1 || target.importance >= 0.9) {
496
+ const isLayerProtected = target.protected === 1;
482
497
  return JSON.stringify({
483
498
  ok: false,
484
499
  preserved: true,
485
- reason: target.protected === 1 ? `${target.layer}-layer is auto-protected` : 'pinned (importance>=0.9)',
486
- hint: 'Use update_memory to lower importance below 0.9 first, then forget.',
500
+ reason: isLayerProtected ? `${target.layer}-layer is auto-protected` : 'pinned (importance>=0.9)',
501
+ hint: isLayerProtected
502
+ ? `${target.layer} memories are permanently protected (the whole point — pain lessons must not be lost). If you truly need to delete, copy its content to another layer via remember() first, then drop the DB row manually via a SQLite client.`
503
+ : 'Use update_memory to lower importance below 0.9 first, then forget.',
487
504
  });
488
505
  }
489
506
  const res = db.prepare('DELETE FROM memories WHERE id = ?').run(args.memory_id);
@@ -491,8 +508,8 @@ function handleForget(args) {
491
508
  }
492
509
  // Auto-sweep — also respect pin (importance >= 0.9) as protection
493
510
  const rows = db
494
- .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
495
- FROM memories
511
+ .prepare(`SELECT id, layer, importance, access_count, last_accessed_at, protected
512
+ FROM memories
496
513
  WHERE protected = 0 AND importance < 0.9`)
497
514
  .all();
498
515
  const now = Math.floor(Date.now() / 1000);
@@ -540,17 +557,17 @@ function handleConsolidate(args) {
540
557
  // read-only audit: count candidates using the same rules.
541
558
  const now = Math.floor(Date.now() / 1000);
542
559
  const ageCutoff = now - (args.min_age_days ?? 7) * 86400;
543
- const candidates = db.prepare(`
544
- SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
545
- FROM memories m
546
- JOIN entities e ON e.id = m.entity_id
547
- WHERE m.protected = 0
548
- AND m.importance < 0.9
549
- AND m.layer IN ('context', 'emotion', 'implementation')
550
- AND m.created_at <= ?
551
- GROUP BY m.entity_id, m.layer
552
- HAVING c >= 2
553
- ORDER BY c DESC
560
+ const candidates = db.prepare(`
561
+ SELECT m.entity_id, e.name as entity_name, m.layer, COUNT(*) as c
562
+ FROM memories m
563
+ JOIN entities e ON e.id = m.entity_id
564
+ WHERE m.protected = 0
565
+ AND m.importance < 0.9
566
+ AND m.layer IN ('context', 'emotion', 'implementation')
567
+ AND m.created_at <= ?
568
+ GROUP BY m.entity_id, m.layer
569
+ HAVING c >= 2
570
+ ORDER BY c DESC
554
571
  `).all(ageCutoff);
555
572
  const totalReplaced = candidates.reduce((s, c) => s + c.c, 0);
556
573
  return JSON.stringify({
@@ -624,19 +641,19 @@ function handleListEntities(args) {
624
641
  const minMemories = Math.max(1, Number(args?.min_memories ?? 1));
625
642
  const limit = Math.max(1, Math.min(200, Number(args?.limit ?? 30)));
626
643
  const offset = Math.max(0, Number(args?.offset ?? 0));
627
- let sql = `
628
- SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
629
- e.updated_at, e.created_at,
630
- COUNT(m.id) as memory_count,
631
- MAX(m.last_accessed_at) as last_memory_access,
632
- SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
633
- SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
634
- SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
635
- SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
636
- SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
637
- FROM entities e
638
- LEFT JOIN memories m ON m.entity_id = e.id
639
- WHERE 1=1
644
+ let sql = `
645
+ SELECT e.id, e.name, e.kind, e.canonical_key, e.momentum_score,
646
+ e.updated_at, e.created_at,
647
+ COUNT(m.id) as memory_count,
648
+ MAX(m.last_accessed_at) as last_memory_access,
649
+ SUM(CASE WHEN m.layer = 'goal' THEN 1 ELSE 0 END) as goal_count,
650
+ SUM(CASE WHEN m.layer = 'caveat' THEN 1 ELSE 0 END) as caveat_count,
651
+ SUM(CASE WHEN m.layer = 'learning' THEN 1 ELSE 0 END) as learning_count,
652
+ SUM(CASE WHEN m.layer = 'implementation' THEN 1 ELSE 0 END) as impl_count,
653
+ SUM(CASE WHEN m.importance >= 0.9 THEN 1 ELSE 0 END) as pinned_count
654
+ FROM entities e
655
+ LEFT JOIN memories m ON m.entity_id = e.id
656
+ WHERE 1=1
640
657
  `;
641
658
  const params = [];
642
659
  if (kind) {
@@ -688,29 +705,29 @@ function handleRecallFile(args) {
688
705
  return JSON.stringify({ ok: true, count: 0, note: 'No edits found for that path substring.' });
689
706
  }
690
707
  // Daily breakdown
691
- const daily = db.prepare(`
692
- SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
693
- FROM session_file_edits WHERE file_path LIKE ?
694
- GROUP BY day, operation ORDER BY day
708
+ const daily = db.prepare(`
709
+ SELECT DATE(occurred_at, 'unixepoch') as day, operation, COUNT(*) as edits
710
+ FROM session_file_edits WHERE file_path LIKE ?
711
+ GROUP BY day, operation ORDER BY day
695
712
  `).all(`%${sub}%`);
696
713
  // Distinct context_snippets (intents) — deduped, ordered by recency
697
- const intents = db.prepare(`
698
- SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
699
- FROM session_file_edits
700
- WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
701
- GROUP BY context_snippet
702
- ORDER BY last_at DESC
703
- LIMIT ?
714
+ const intents = db.prepare(`
715
+ SELECT DISTINCT context_snippet, MAX(occurred_at) as last_at, COUNT(*) as freq
716
+ FROM session_file_edits
717
+ WHERE file_path LIKE ? AND context_snippet IS NOT NULL AND LENGTH(context_snippet) > 20
718
+ GROUP BY context_snippet
719
+ ORDER BY last_at DESC
720
+ LIMIT ?
704
721
  `).all(`%${sub}%`, maxIntents);
705
722
  // Linked memories
706
- const memories = db.prepare(`
707
- SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
708
- FROM session_file_edits sfe
709
- JOIN memories m ON m.id = sfe.memory_id
710
- JOIN entities e ON e.id = m.entity_id
711
- WHERE sfe.file_path LIKE ?
712
- ORDER BY m.importance DESC
713
- LIMIT 20
723
+ const memories = db.prepare(`
724
+ SELECT DISTINCT m.id, m.layer, m.content, m.importance, e.name as entity_name
725
+ FROM session_file_edits sfe
726
+ JOIN memories m ON m.id = sfe.memory_id
727
+ JOIN entities e ON e.id = m.entity_id
728
+ WHERE sfe.file_path LIKE ?
729
+ ORDER BY m.importance DESC
730
+ LIMIT 20
714
731
  `).all(`%${sub}%`);
715
732
  // Distinct file paths matched (the substring may match multiple files)
716
733
  const paths = db.prepare(`SELECT file_path, COUNT(*) as edits FROM session_file_edits WHERE file_path LIKE ? GROUP BY file_path ORDER BY edits DESC`).all(`%${sub}%`);
@@ -743,6 +760,158 @@ function handleReadSmart(args) {
743
760
  return handleReadSmartImpl(db, { path: args.path, force: args.force });
744
761
  }
745
762
  // ============================================================
763
+ // v0.3.0 — five-blocks helpers (sampling / roots / elicitation in handlers)
764
+ // ============================================================
765
+ async function handleRecallFileWithRoots(args) {
766
+ const baseJson = handleRecallFile(args);
767
+ if (!args?.scope_to_roots)
768
+ return baseJson;
769
+ let parsed;
770
+ try {
771
+ parsed = JSON.parse(baseJson);
772
+ }
773
+ catch {
774
+ return baseJson;
775
+ }
776
+ if (!parsed?.ok || !Array.isArray(parsed.paths_matched))
777
+ return baseJson;
778
+ const roots = await fetchRoots(server);
779
+ if (roots.length === 0) {
780
+ parsed.roots_filter = { applied: false, reason: 'client provided no roots' };
781
+ return JSON.stringify(parsed);
782
+ }
783
+ const filtered = parsed.paths_matched.filter((p) => isInsideRoots(p.file_path, roots));
784
+ parsed.roots_filter = { applied: true, root_count: roots.length, before: parsed.paths_matched.length, after: filtered.length };
785
+ parsed.paths_matched = filtered;
786
+ return JSON.stringify(parsed);
787
+ }
788
+ async function handleConsolidateWithSampling(args) {
789
+ // Sampling only applies on a real run (not dry-run) and only when explicitly opted in.
790
+ if (!args?.use_llm || args?.dry_run)
791
+ return handleConsolidate(args);
792
+ // 1. Snapshot all candidate memories BEFORE consolidate runs so we can recover
793
+ // their content (the originals are deleted by consolidate).
794
+ const ageCutoff = Math.floor(Date.now() / 1000) - (typeof args?.min_age_days === 'number' ? args.min_age_days : 7) * 86400;
795
+ const snapshot = new Map();
796
+ const candidateRows = db
797
+ .prepare(`SELECT m.id, m.content, e.name as entity_name
798
+ FROM memories m JOIN entities e ON e.id = m.entity_id
799
+ WHERE m.protected = 0
800
+ AND m.layer IN ('context','emotion','implementation')
801
+ AND m.created_at <= ?`)
802
+ .all(ageCutoff);
803
+ for (const r of candidateRows)
804
+ snapshot.set(r.id, r);
805
+ // 2. Run the normal heuristic consolidate (creates learning entries, deletes source).
806
+ const baseJson = handleConsolidate(args);
807
+ let parsed;
808
+ try {
809
+ parsed = JSON.parse(baseJson);
810
+ }
811
+ catch {
812
+ return baseJson;
813
+ }
814
+ if (!parsed?.ok || !Array.isArray(parsed.learningIdsCreated)) {
815
+ parsed = parsed ?? {};
816
+ parsed.sampling = { applied: false, reason: 'consolidate returned no learning entries' };
817
+ return JSON.stringify(parsed);
818
+ }
819
+ // 3. For each new learning entry, look up its replaced_ids from the audit table,
820
+ // gather source contents from the snapshot, and request a sampled summary.
821
+ let upgraded = 0;
822
+ let declined = 0;
823
+ const declineReasons = [];
824
+ for (const learningId of parsed.learningIdsCreated) {
825
+ const audit = db.prepare('SELECT replaced_ids FROM consolidations WHERE learning_id = ?').get(learningId);
826
+ if (!audit) {
827
+ declined++;
828
+ continue;
829
+ }
830
+ let replaced;
831
+ try {
832
+ replaced = JSON.parse(audit.replaced_ids);
833
+ }
834
+ catch {
835
+ declined++;
836
+ continue;
837
+ }
838
+ if (!Array.isArray(replaced) || replaced.length < 2) {
839
+ declined++;
840
+ continue;
841
+ }
842
+ const sources = replaced
843
+ .map((id) => snapshot.get(id))
844
+ .filter((s) => Boolean(s));
845
+ if (sources.length < 2) {
846
+ declined++;
847
+ continue;
848
+ }
849
+ const entityName = sources[0]?.entity_name ?? '<entity>';
850
+ const result = await sampleConsolidation(server, sources.map((s) => s.content), entityName);
851
+ if (result.ok && result.text) {
852
+ db.prepare('UPDATE memories SET content = ? WHERE id = ?').run(result.text.trim(), learningId);
853
+ upgraded++;
854
+ }
855
+ else {
856
+ declined++;
857
+ if (result.reason && declineReasons.length < 3)
858
+ declineReasons.push(result.reason);
859
+ }
860
+ }
861
+ parsed.sampling = {
862
+ applied: true,
863
+ upgraded,
864
+ declined,
865
+ ...(declineReasons.length ? { decline_reasons: declineReasons } : {}),
866
+ };
867
+ return JSON.stringify(parsed);
868
+ }
869
+ async function handleForgetInteractive(args) {
870
+ if (!args?.interactive || !args?.memory_id)
871
+ return handleForget(args);
872
+ const id = Number(args.memory_id);
873
+ const row = db
874
+ .prepare(`SELECT m.id, m.layer, m.content, m.importance, e.name as entity FROM memories m JOIN entities e ON e.id = m.entity_id WHERE m.id = ?`)
875
+ .get(id);
876
+ if (!row)
877
+ return JSON.stringify({ ok: false, error: `memory ${id} not found` });
878
+ const ok = await confirmForget(server, {
879
+ id: row.id,
880
+ entity: row.entity,
881
+ layer: row.layer,
882
+ importance: row.importance,
883
+ preview: row.content,
884
+ });
885
+ if (!ok)
886
+ return JSON.stringify({ ok: false, declined: true, memory_id: id, reason: 'user declined elicitation' });
887
+ return handleForget({ memory_id: id });
888
+ }
889
+ // Append new optional flags to existing tools (backward-compatible).
890
+ const RECALL_FILE_TOOL = TOOLS.find((t) => t.name === 'recall_file');
891
+ if (RECALL_FILE_TOOL && RECALL_FILE_TOOL.inputSchema.properties) {
892
+ RECALL_FILE_TOOL.inputSchema.properties.scope_to_roots = {
893
+ type: 'boolean',
894
+ default: false,
895
+ description: 'If true, filter results to files inside the client-provided roots (Roots block). Skip silently when client provides no roots.',
896
+ };
897
+ }
898
+ const CONSOLIDATE_TOOL = TOOLS.find((t) => t.name === 'consolidate');
899
+ if (CONSOLIDATE_TOOL && CONSOLIDATE_TOOL.inputSchema.properties) {
900
+ CONSOLIDATE_TOOL.inputSchema.properties.use_llm = {
901
+ type: 'boolean',
902
+ default: false,
903
+ 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.',
904
+ };
905
+ }
906
+ const FORGET_TOOL = TOOLS.find((t) => t.name === 'forget');
907
+ if (FORGET_TOOL && FORGET_TOOL.inputSchema.properties) {
908
+ FORGET_TOOL.inputSchema.properties.interactive = {
909
+ type: 'boolean',
910
+ default: false,
911
+ description: 'If true, ask the user to confirm via Elicitation before deleting. Only applies when memory_id is set.',
912
+ };
913
+ }
914
+ // ============================================================
746
915
  // MCP wiring
747
916
  // ============================================================
748
917
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
@@ -764,13 +933,13 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
764
933
  text = handleListEntities(args);
765
934
  break;
766
935
  case 'forget':
767
- text = handleForget(args);
936
+ text = await handleForgetInteractive(args);
768
937
  break;
769
938
  case 'consolidate':
770
- text = handleConsolidate(args);
939
+ text = await handleConsolidateWithSampling(args);
771
940
  break;
772
941
  case 'recall_file':
773
- text = handleRecallFile(args);
942
+ text = await handleRecallFileWithRoots(args);
774
943
  break;
775
944
  case 'read_smart':
776
945
  text = handleReadSmart(args);
@@ -786,6 +955,24 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
786
955
  };
787
956
  }
788
957
  });
958
+ // ============================================================
959
+ // Resources block
960
+ // ============================================================
961
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: STATIC_RESOURCES }));
962
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({ resourceTemplates: RESOURCE_TEMPLATES }));
963
+ server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
964
+ const { uri } = req.params;
965
+ const result = readResource(db, uri);
966
+ return { contents: [result] };
967
+ });
968
+ // ============================================================
969
+ // Prompts block
970
+ // ============================================================
971
+ server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: PROMPTS }));
972
+ server.setRequestHandler(GetPromptRequestSchema, async (req) => {
973
+ const { name, arguments: promptArgs } = req.params;
974
+ return getPrompt(name, promptArgs);
975
+ });
789
976
  const transport = new StdioServerTransport();
790
977
  await server.connect(transport);
791
978
  process.stderr.write(`[linksee-memory] MCP server ready on stdio (v${SERVER_VERSION})\n`);