dsh-cortex-memory 0.1.0 → 0.2.0-rc.13

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.
Files changed (56) hide show
  1. package/LICENSE +3 -3
  2. package/README.md +85 -146
  3. package/bench/ab-engine.mjs +60 -0
  4. package/bench/ann-bench.mjs +78 -0
  5. package/bench/hnsw-bench.mjs +79 -0
  6. package/bench/metrics.js +8 -0
  7. package/bench/migration-bench.mjs +69 -0
  8. package/bench/run.js +5 -2
  9. package/bench/synthetic.js +51 -7
  10. package/bench/vector-ab.mjs +76 -0
  11. package/bin/cortex.mjs +2 -3
  12. package/client.js +286 -4
  13. package/cordis.patch.yml +1 -1
  14. package/docs/install-windows.md +115 -27
  15. package/docs/models.md +165 -30
  16. package/docs/multimodal.md +112 -0
  17. package/docs/scopes.md +142 -0
  18. package/examples/README.md +72 -0
  19. package/examples/setup-cortex-bge.ps1 +66 -0
  20. package/examples/uninstall-cortex.ps1 +91 -0
  21. package/index.js +15 -1
  22. package/package.json +5 -12
  23. package/src/adapters/markdown-import.js +20 -1
  24. package/src/adapters/markdown.js +12 -2
  25. package/src/adapters/shiori.js +1 -1
  26. package/src/api/routes.js +148 -4
  27. package/src/api/validate.js +26 -0
  28. package/src/core/attachment-reader.js +31 -0
  29. package/src/core/attachments.js +39 -0
  30. package/src/core/config.js +36 -1
  31. package/src/core/model-config.js +3 -1
  32. package/src/core/profile.js +32 -0
  33. package/src/engines/develop.js +1 -1
  34. package/src/engines/encode.js +68 -30
  35. package/src/engines/extract.js +13 -8
  36. package/src/engines/growth.js +1 -1
  37. package/src/engines/reflect.js +1 -1
  38. package/src/engines/retrieve.js +44 -13
  39. package/src/host/runtime.js +1 -1
  40. package/src/memory/query.js +1 -1
  41. package/src/memory/tokenizer.js +19 -4
  42. package/src/security/scope-grants.js +73 -0
  43. package/src/self/appraisal.js +3 -3
  44. package/src/self/emotion.js +1 -1
  45. package/src/self/identity.js +1 -1
  46. package/src/self/relationship.js +2 -2
  47. package/src/service/cortex.js +191 -3
  48. package/src/store/database.js +33 -1
  49. package/src/store/repositories/edges.js +48 -4
  50. package/src/store/repositories/traces.js +23 -12
  51. package/src/store/schema.sql +1 -0
  52. package/src/tools/definitions.js +64 -5
  53. package/src/vector/external-ann.js +37 -0
  54. package/src/vector/hnsw.js +246 -0
  55. package/src/vector/index.js +85 -2
  56. package/docs/compatibility.md +0 -39
@@ -5,10 +5,19 @@ import { DatabaseSync } from 'node:sqlite';
5
5
  import { nowIso } from '../core/time.js';
6
6
  import { normalizeConfig, validateConfig } from '../core/config.js';
7
7
  import { CortexError, ERROR_CODES } from '../core/errors.js';
8
+ import { ftsText } from '../memory/tokenizer.js';
8
9
 
9
10
  const HERE = path.dirname(fileURLToPath(import.meta.url));
10
11
  const SCHEMA_FILE = path.join(HERE, 'schema.sql');
11
12
  export const SCHEMA_VERSION = 6;
13
+ export const FTS_INDEX_VERSION = 2;
14
+
15
+ function ensureTraceColumns(db) {
16
+ const columns = db.prepare('PRAGMA table_info(traces)').all();
17
+ if (!columns.some((column) => column.name === 'metadata_json')) {
18
+ db.exec("ALTER TABLE traces ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}'");
19
+ }
20
+ }
12
21
 
13
22
  function ensureFactsColumns(db) {
14
23
  const columns = db.prepare('PRAGMA table_info(facts)').all();
@@ -25,6 +34,27 @@ function ensureParent(file) {
25
34
  fs.mkdirSync(path.dirname(file), { recursive: true });
26
35
  }
27
36
 
37
+ function rebuildFtsIndex(db) {
38
+ db.exec('DELETE FROM traces_fts_index');
39
+ const insert = db.prepare('INSERT INTO traces_fts_index(trace_id, text) VALUES (?, ?)');
40
+ const rows = db.prepare('SELECT id, title, content, summary FROM traces').all();
41
+ for (const row of rows) {
42
+ const text = ftsText(`${row.title ?? ''} ${row.content ?? ''} ${row.summary ?? ''}`, { soft: true });
43
+ if (text) insert.run(row.id, text);
44
+ }
45
+ }
46
+
47
+ function ensureFtsIndex(db) {
48
+ const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('fts_index_version');
49
+ if (Number(row?.value ?? 0) === FTS_INDEX_VERSION) return false;
50
+ rebuildFtsIndex(db);
51
+ db.prepare(`
52
+ INSERT INTO meta(key, value, updated_at) VALUES (?, ?, ?)
53
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
54
+ `).run('fts_index_version', String(FTS_INDEX_VERSION), nowIso());
55
+ return true;
56
+ }
57
+
28
58
  function readSchema() {
29
59
  return fs.readFileSync(SCHEMA_FILE, 'utf8');
30
60
  }
@@ -44,6 +74,7 @@ export function migrate(db, { version = SCHEMA_VERSION } = {}) {
44
74
  db.exec('BEGIN IMMEDIATE');
45
75
  try {
46
76
  db.exec(readSchema());
77
+ ensureTraceColumns(db);
47
78
  ensureFactsColumns(db);
48
79
  const row = db.prepare('SELECT value FROM meta WHERE key = ?').get('schema_version');
49
80
  if (!row) {
@@ -53,8 +84,9 @@ export function migrate(db, { version = SCHEMA_VERSION } = {}) {
53
84
  } else if (Number(row.value) < version) {
54
85
  db.prepare('UPDATE meta SET value = ?, updated_at = ? WHERE key = ?').run(String(version), nowIso(), 'schema_version');
55
86
  }
87
+ const ftsRebuilt = ensureFtsIndex(db);
56
88
  db.exec('COMMIT');
57
- return { version, applied: true };
89
+ return { version, applied: true, ftsRebuilt };
58
90
  } catch (error) {
59
91
  try { db.exec('ROLLBACK'); } catch { /* ignore rollback failure */ }
60
92
  throw error;
@@ -31,6 +31,9 @@ export function createEdgeRepository(db) {
31
31
  ORDER BY valid_from DESC LIMIT 1
32
32
  `).get(scopeId, fromId, toId, rel, validFrom);
33
33
  if (existing) {
34
+ const current = rowToEdge(existing);
35
+ // A candidate must never downgrade an already-active edge.
36
+ if (metadata?.status === 'candidate' && current.metadata?.status !== 'candidate') return current;
34
37
  db.prepare('UPDATE edges SET weight = ?, metadata_json = ?, updated_at = ? WHERE id = ?')
35
38
  .run(Number(weight), stableStringify(metadata), nowIso(), existing.id);
36
39
  return get(existing.id);
@@ -68,13 +71,54 @@ export function createEdgeRepository(db) {
68
71
  return rows.map(rowToEdge).filter((edge) => {
69
72
  if (seen.has(edge.id)) return false;
70
73
  seen.add(edge.id);
71
- return true;
74
+ return edge.metadata?.status !== 'candidate' && edge.metadata?.status !== 'rejected';
72
75
  });
73
76
  }
74
77
 
75
- function list({ scopeId = 'global', limit = 200 } = {}) {
78
+ function list({ scopeId = 'global', limit = 200, includeCandidates = false } = {}) {
76
79
  const count = Math.max(1, Math.min(10000, Number(limit) || 200));
77
- return db.prepare('SELECT * FROM edges WHERE scope_id = ? ORDER BY updated_at DESC LIMIT ?').all(scopeId, count).map(rowToEdge);
80
+ return db.prepare('SELECT * FROM edges WHERE scope_id = ? ORDER BY updated_at DESC LIMIT ?').all(scopeId, count)
81
+ .map(rowToEdge)
82
+ .filter((edge) => includeCandidates || (edge.metadata?.status !== 'candidate' && edge.metadata?.status !== 'rejected'));
83
+ }
84
+
85
+ function addCandidate({ scopeId = 'global', fromId, toId, rel = 'related', weight = 0.5, confidence = 0.5, sourceEventId = null, sourceTraceId = null, evidence = null, metadata = {} } = {}) {
86
+ return upsert({
87
+ scopeId,
88
+ fromId,
89
+ toId,
90
+ rel,
91
+ weight,
92
+ metadata: {
93
+ ...metadata,
94
+ status: 'candidate',
95
+ confidence,
96
+ sourceEventId,
97
+ sourceTraceId,
98
+ evidence,
99
+ proposedAt: nowIso(),
100
+ },
101
+ });
102
+ }
103
+
104
+ function listCandidates({ scopeId = 'global', limit = 200 } = {}) {
105
+ return list({ scopeId, limit, includeCandidates: true }).filter((edge) => edge.metadata?.status === 'candidate');
106
+ }
107
+
108
+ function approveCandidate(id, { at = nowIso() } = {}) {
109
+ const edge = get(id);
110
+ if (!edge || edge.metadata?.status !== 'candidate') return null;
111
+ db.prepare('UPDATE edges SET metadata_json = ?, updated_at = ? WHERE id = ?')
112
+ .run(stableStringify({ ...edge.metadata, status: 'active', approvedAt: at }), at, id);
113
+ return get(id);
114
+ }
115
+
116
+ function rejectCandidate(id, { at = nowIso() } = {}) {
117
+ const edge = get(id);
118
+ if (!edge || edge.metadata?.status !== 'candidate') return null;
119
+ db.prepare('UPDATE edges SET valid_to = ?, metadata_json = ?, updated_at = ? WHERE id = ?')
120
+ .run(at, stableStringify({ ...edge.metadata, status: 'rejected', rejectedAt: at }), at, id);
121
+ return get(id);
78
122
  }
79
123
 
80
124
  function invalidate(id, { at = nowIso() } = {}) {
@@ -82,5 +126,5 @@ export function createEdgeRepository(db) {
82
126
  return get(id);
83
127
  }
84
128
 
85
- return { get, upsert, neighbors, list, invalidate };
129
+ return { get, upsert, neighbors, list, addCandidate, listCandidates, approveCandidate, rejectCandidate, invalidate };
86
130
  }
@@ -1,11 +1,12 @@
1
1
  import { hashText, traceId } from '../../core/ids.js';
2
- import { asJsonArray, stableStringify } from '../../core/json.js';
2
+ import { asJsonArray, asJsonObject, stableStringify } from '../../core/json.js';
3
3
  import { nowIso } from '../../core/time.js';
4
4
  import { ftsText, buildMatchQuery } from '../../memory/tokenizer.js';
5
5
  import { CortexError, ERROR_CODES } from '../../core/errors.js';
6
+ import { FTS_INDEX_VERSION } from '../database.js';
6
7
 
7
8
  const COLUMNS = [
8
- 'title', 'content', 'summary', 'keywords_json', 'tags_json', 'event_id',
9
+ 'title', 'content', 'summary', 'keywords_json', 'tags_json', 'metadata_json', 'event_id',
9
10
  'session_id', 'turn_id', 'actor', 'source', 'valid_at', 'recorded_at',
10
11
  'last_access_at', 'access_count', 'importance', 'confidence', 'salience',
11
12
  'valence', 'arousal', 'sensitivity', 'state', 'compression', 'stability',
@@ -20,6 +21,7 @@ const FIELD_MAP = Object.freeze({
20
21
  kind: 'kind',
21
22
  keywords: 'keywords_json',
22
23
  tags: 'tags_json',
24
+ metadata: 'metadata_json',
23
25
  eventId: 'event_id',
24
26
  sessionId: 'session_id',
25
27
  turnId: 'turn_id',
@@ -57,6 +59,7 @@ function rowToTrace(row) {
57
59
  summary: row.summary,
58
60
  keywords: asJsonArray(row.keywords_json),
59
61
  tags: asJsonArray(row.tags_json),
62
+ metadata: asJsonObject(row.metadata_json),
60
63
  eventId: row.event_id,
61
64
  sessionId: row.session_id,
62
65
  turnId: row.turn_id,
@@ -87,9 +90,11 @@ function rowToTrace(row) {
87
90
  }
88
91
 
89
92
  export function createTraceRepository(db) {
90
- function syncFts(trace) {
91
- db.prepare('DELETE FROM traces_fts_index WHERE trace_id = ?').run(trace.id);
92
- const text = ftsText(`${trace.title ?? ''} ${trace.content ?? ''} ${trace.summary ?? ''}`);
93
+ function syncFts(trace, { replace = true } = {}) {
94
+ // trace_id is UNINDEXED in FTS5. Deleting by it on a brand-new insert scans
95
+ // the whole index and makes bulk writes quadratic; only updates need delete.
96
+ if (replace) db.prepare('DELETE FROM traces_fts_index WHERE trace_id = ?').run(trace.id);
97
+ const text = ftsText(`${trace.title ?? ''} ${trace.content ?? ''} ${trace.summary ?? ''}`, { soft: true });
93
98
  if (text) {
94
99
  db.prepare('INSERT INTO traces_fts_index(trace_id, text) VALUES (?, ?)').run(trace.id, text);
95
100
  }
@@ -110,6 +115,7 @@ export function createTraceRepository(db) {
110
115
  summary: input.summary ?? null,
111
116
  keywords: asJsonArray(input.keywords),
112
117
  tags: asJsonArray(input.tags),
118
+ metadata: asJsonObject(input.metadata),
113
119
  eventId: input.eventId,
114
120
  sessionId: input.sessionId ?? null,
115
121
  turnId: input.turnId ?? null,
@@ -140,15 +146,15 @@ export function createTraceRepository(db) {
140
146
  db.prepare(`
141
147
  INSERT INTO traces(
142
148
  id, scope_id, kind, title, content, summary, keywords_json, tags_json,
143
- event_id, session_id, turn_id, actor, source, valid_at, recorded_at,
149
+ metadata_json, event_id, session_id, turn_id, actor, source, valid_at, recorded_at,
144
150
  last_access_at, access_count, importance, confidence, salience, valence,
145
151
  arousal, sensitivity, state, compression, stability, difficulty,
146
152
  retrievability, review_count, lapse_count, last_review_at, hash,
147
153
  created_at, updated_at
148
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
154
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
149
155
  `).run(
150
156
  trace.id, trace.scopeId, trace.kind, trace.title, trace.content, trace.summary,
151
- stableStringify(trace.keywords), stableStringify(trace.tags), trace.eventId,
157
+ stableStringify(trace.keywords), stableStringify(trace.tags), stableStringify(trace.metadata), trace.eventId,
152
158
  trace.sessionId, trace.turnId, trace.actor, trace.source, trace.validAt,
153
159
  trace.recordedAt, trace.lastAccessAt, trace.accessCount, trace.importance,
154
160
  trace.confidence, trace.salience, trace.valence, trace.arousal,
@@ -156,7 +162,7 @@ export function createTraceRepository(db) {
156
162
  trace.difficulty, trace.retrievability, trace.reviewCount, trace.lapseCount,
157
163
  trace.lastReviewAt, trace.hash, trace.createdAt, trace.updatedAt,
158
164
  );
159
- syncFts(trace);
165
+ syncFts(trace, { replace: false });
160
166
  return trace;
161
167
  }
162
168
 
@@ -198,6 +204,7 @@ export function createTraceRepository(db) {
198
204
  if (!(key in patch)) continue;
199
205
  let value = patch[key];
200
206
  if (key === 'keywords' || key === 'tags') value = stableStringify(asJsonArray(value));
207
+ if (key === 'metadata') value = stableStringify(asJsonObject(value));
201
208
  sets.push(`${column} = ?`);
202
209
  values.push(value);
203
210
  }
@@ -222,8 +229,8 @@ export function createTraceRepository(db) {
222
229
  return result.changes > 0;
223
230
  }
224
231
 
225
- function searchFts(query, { scopeId = 'global', limit = 30, includeArchived = false } = {}) {
226
- const match = buildMatchQuery(query);
232
+ function searchFts(query, { scopeId = 'global', limit = 30, includeArchived = false, soft = false } = {}) {
233
+ const match = buildMatchQuery(query, { soft });
227
234
  if (!match) return [];
228
235
  const count = Math.max(1, Math.min(200, Number(limit) || 30));
229
236
  const stateClause = includeArchived ? "t.state IN ('active','archived')" : "t.state = 'active'";
@@ -253,7 +260,11 @@ export function createTraceRepository(db) {
253
260
  function reindexFts() {
254
261
  db.exec('DELETE FROM traces_fts_index');
255
262
  const rows = db.prepare('SELECT id, title, content, summary FROM traces').all();
256
- for (const row of rows) syncFts({ id: row.id, title: row.title, content: row.content, summary: row.summary });
263
+ for (const row of rows) syncFts({ id: row.id, title: row.title, content: row.content, summary: row.summary }, { replace: false });
264
+ db.prepare(`
265
+ INSERT INTO meta(key, value, updated_at) VALUES (?, ?, ?)
266
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
267
+ `).run('fts_index_version', String(FTS_INDEX_VERSION), nowIso());
257
268
  return rows.length;
258
269
  }
259
270
 
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS traces (
40
40
  summary TEXT,
41
41
  keywords_json TEXT NOT NULL DEFAULT '[]',
42
42
  tags_json TEXT NOT NULL DEFAULT '[]',
43
+ metadata_json TEXT NOT NULL DEFAULT '{}',
43
44
  event_id TEXT NOT NULL REFERENCES events(id),
44
45
  session_id TEXT,
45
46
  turn_id TEXT,
@@ -118,15 +118,23 @@ export function createToolDefinitions(service) {
118
118
  isConcurrencySafe: () => false,
119
119
  async execute(args = {}) {
120
120
  if (args.action === 'add') {
121
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
121
122
  const item = service.working.add({ text: args.text, kind: args.kind, priority: args.priority, pinned: args.pinned === true, ttlMs: args.ttlMs });
122
123
  return { text: `已加入工作记忆 ${item.id}:${item.text}` };
123
124
  }
124
125
  if (args.action === 'pin') {
126
+ if (args.id && !service.scopeWritableById('working', args.id)) return { text: '未执行:该工作项属于只读共享 scope。' };
125
127
  const item = service.working.update(args.id, { pinned: args.pinned !== false });
126
128
  return { text: item ? `已${item.pinned ? '置顶' : '取消置顶'} ${item.id}` : `没有 ${args.id}` };
127
129
  }
128
- if (args.action === 'remove') return { text: service.working.remove(args.id) ? `已删除 ${args.id}` : `没有 ${args.id}` };
129
- if (args.action === 'purge') return { text: `已清理 ${service.working.purgeExpired()} 条过期工作记忆` };
130
+ if (args.action === 'remove') {
131
+ if (args.id && !service.scopeWritableById('working', args.id)) return { text: '未执行:该工作项属于只读共享 scope。' };
132
+ return { text: service.working.remove(args.id) ? `已删除 ${args.id}` : `没有 ${args.id}` };
133
+ }
134
+ if (args.action === 'purge') {
135
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
136
+ return { text: `已清理 ${service.working.purgeExpired()} 条过期工作记忆` };
137
+ }
130
138
  const items = service.working.list();
131
139
  return { text: items.length ? items.map((item) => `- ${item.pinned ? '[pin] ' : ''}${item.id} (${item.priority}) ${item.text}`).join('\n') : '(工作记忆为空)' };
132
140
  },
@@ -153,14 +161,17 @@ export function createToolDefinitions(service) {
153
161
  isConcurrencySafe: () => false,
154
162
  async execute(args = {}) {
155
163
  if (args.action === 'add') {
164
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
156
165
  const goal = service.goals.add({ title: args.title, description: args.description, priority: args.priority });
157
166
  return { text: `目标已建 ${goal.id}:${goal.title}` };
158
167
  }
159
168
  if (args.action === 'complete') {
169
+ if (args.id && !service.scopeWritableById('goal', args.id)) return { text: '未执行:该目标属于只读共享 scope。' };
160
170
  const done = service.goals.complete(args.id);
161
171
  return { text: done ? `目标已完成:${done.title}` : `没有这个目标:${args.id}` };
162
172
  }
163
173
  if (args.action === 'update') {
174
+ if (args.id && !service.scopeWritableById('goal', args.id)) return { text: '未执行:该目标属于只读共享 scope。' };
164
175
  const goal = service.goals.update(args.id, { title: args.title, description: args.description, priority: args.priority, progress: args.progress, status: args.status });
165
176
  return { text: goal ? `已更新 ${goal.id}:${goal.status} ${Math.round(goal.progress * 100)}%` : `没有 ${args.id}` };
166
177
  }
@@ -190,6 +201,7 @@ export function createToolDefinitions(service) {
190
201
  return { text: result.revived ? `已复活 ${args.id}(下一次检索可命中)` : `没有这条记忆:${args.id}` };
191
202
  }
192
203
  if (args.action === 'compress') {
204
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
193
205
  const result = service.compression.compress({ limit: args.limit });
194
206
  return { text: `压缩归档 ${result.archived} 条。${result.items.map((item) => `\n- ${item.id} coverage ${item.coverage.toFixed(2)}`).join('')}` };
195
207
  }
@@ -219,6 +231,7 @@ export function createToolDefinitions(service) {
219
231
  isConcurrencySafe: () => false,
220
232
  async execute(args = {}) {
221
233
  if (args.action === 'diary-write') {
234
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
222
235
  const item = service.life.diaryWrite({ text: args.text, mood: args.mood });
223
236
  return { text: `日记已写:${item.id}\n${item.text}` };
224
237
  }
@@ -230,6 +243,7 @@ export function createToolDefinitions(service) {
230
243
  return { text: service.life.diaryForget(args.id) ? `已删日记 ${args.id}` : `没有这条日记:${args.id}` };
231
244
  }
232
245
  if (args.action === 'dream') {
246
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
233
247
  const dream = await service.life.dreamGenerate();
234
248
  return { text: `梦 ${dream.id}(来源 ${dream.sourceCount} 条,${dream.model}):\n${dream.text}` };
235
249
  }
@@ -237,6 +251,7 @@ export function createToolDefinitions(service) {
237
251
  const list = service.life.sleepRuns({ limit: args.limit });
238
252
  return { text: list.length ? list.map((item) => `- ${item.id} · ${item.at}:${JSON.stringify(item.report)}`).join('\n') : '(还没有睡眠记录)' };
239
253
  }
254
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
240
255
  const report = await service.life.sleep({ windowDays: args.windowDays ?? 1 });
241
256
  return { text: `睡眠整理完成:清理工作记忆 ${report.purgedWorking} 条 · 反思 ${report.reflected} · 阶段 ${report.stage ?? '—'} · 情感对象 ${report.affectTargets}` };
242
257
  },
@@ -265,6 +280,7 @@ export function createToolDefinitions(service) {
265
280
  return { text: result.emotions.length ? result.emotions.map((item) => `${item.name}:${item.score}`).join(' / ') : '没有命中情绪词。' };
266
281
  }
267
282
  if (args.action === 'record') {
283
+ if (!service.scopeWritable(service.settings().scope)) return { text: '未执行:当前 scope 只读共享。' };
268
284
  const result = service.emotion.record({ text: args.text, target: args.target, traceId: args.traceId });
269
285
  return { text: result.event ? `已记情绪事件 ${result.event.id}:${result.emotions.map((item) => item.name).join('、')} → ${result.event.target}(${result.target.attitude})` : '没有命中情绪词。' };
270
286
  }
@@ -352,6 +368,7 @@ export function createToolDefinitions(service) {
352
368
  if (args.action === 'sync') {
353
369
  const dir = args.dir;
354
370
  if (!dir) return { text: '同步 Markdown 需要 dir。' };
371
+ if (args.dryRun !== true && !service.scopeWritable(args.scope ?? service.settings().scope)) return { text: '未执行:该 scope 只读共享。' };
355
372
  const result = service.syncMarkdown(dir, { dryRun: args.dryRun === true, scopeId: args.scope });
356
373
  return { text: `Markdown 同步${result.dryRun ? '(dry-run)' : ''}:更新 ${result.updated},新建 ${result.created},文件 ${result.files}` };
357
374
  }
@@ -497,6 +514,7 @@ export function createToolDefinitions(service) {
497
514
  if (args.action === 'add') {
498
515
  if (!args.title) return { text: '准则需要 title。' };
499
516
  if (!args.body) return { text: '准则需要 body。' };
517
+ if (!service.scopeWritable(args.scope ?? service.settings().scope)) return { text: '未执行:该 scope 只读共享。' };
500
518
  const rule = service.playbook.add({ title: args.title, body: args.body, tags: args.tags, validTo: args.validTo ?? null, scopeId: args.scope });
501
519
  return { text: `已加准则 ${rule.id}:${rule.title}${rule.validTo ? `(到期 ${rule.validTo})` : ''}` };
502
520
  }
@@ -506,20 +524,24 @@ export function createToolDefinitions(service) {
506
524
  }
507
525
  if (args.action === 'vote') {
508
526
  if (!args.id) return { text: '投票需要 id。' };
527
+ if (!service.scopeWritableById('playbook', args.id)) return { text: '未执行:该准则属于只读共享 scope。' };
509
528
  const rule = service.playbook.vote({ id: args.id, signal: args.signal ?? 'helpful' });
510
529
  return { text: rule ? `已记 ${rule.id}:helpful=${rule.helpful} harmful=${rule.harmful}` : `没有准则 ${args.id}` };
511
530
  }
512
531
  if (args.action === 'retire') {
513
532
  if (!args.id) return { text: '停用需要 id。' };
533
+ if (!service.scopeWritableById('playbook', args.id)) return { text: '未执行:该准则属于只读共享 scope。' };
514
534
  const rule = service.playbook.retire(args.id);
515
535
  return { text: rule ? `已停用 ${rule.id}` : `没有准则 ${args.id}` };
516
536
  }
517
537
  if (args.action === 'suggest') {
538
+ if (!service.scopeWritable(args.scope ?? service.settings().scope)) return { text: '未执行:该 scope 只读共享。' };
518
539
  const result = service.playbook.suggest({ scopeId: args.scope, limit: args.limit ?? 5 });
519
540
  return { text: result.count ? result.candidates.map((rule) => `- [candidate] ${rule.id} · ${rule.title}:${rule.body}`).join('\n') : '(没有新的准则候选)' };
520
541
  }
521
542
  if (args.action === 'approve') {
522
543
  if (!args.id) return { text: '确认需要 id。' };
544
+ if (!service.scopeWritableById('playbook', args.id)) return { text: '未执行:该准则属于只读共享 scope。' };
523
545
  const rule = service.playbook.approve(args.id);
524
546
  return { text: rule ? `已确认 ${rule.id},下一轮生效` : `没有准则 ${args.id}` };
525
547
  }
@@ -567,6 +589,7 @@ export function createToolDefinitions(service) {
567
589
  output: TEXT_OUTPUT,
568
590
  isConcurrencySafe: () => true,
569
591
  async execute(args = {}) {
592
+ if (!args.id || !String(args.id).trim()) return { text: `没有这条记忆:${args.id}` };
570
593
  const trace = service.get(args.id);
571
594
  if (!trace) return { text: `没有这条记忆:${args.id}` };
572
595
  return {
@@ -614,6 +637,23 @@ export function createToolDefinitions(service) {
614
637
  required: ['subject', 'predicate'],
615
638
  },
616
639
  },
640
+ attachments: {
641
+ type: 'array',
642
+ description: 'Reference-only attachment metadata; no bytes are fetched or stored.',
643
+ items: {
644
+ type: 'object',
645
+ additionalProperties: false,
646
+ properties: {
647
+ kind: { type: 'string', enum: ['image', 'audio', 'video', 'file'] },
648
+ uri: { type: 'string' },
649
+ name: { type: 'string' },
650
+ mime: { type: 'string' },
651
+ bytes: { type: 'number' },
652
+ sha256: { type: 'string' },
653
+ },
654
+ required: ['kind', 'uri'],
655
+ },
656
+ },
617
657
  },
618
658
  },
619
659
  output: TEXT_OUTPUT,
@@ -629,10 +669,25 @@ export function createToolDefinitions(service) {
629
669
  operationId: args.operationId,
630
670
  scopeId: args.scope,
631
671
  facts: args.facts,
672
+ attachments: args.attachments,
632
673
  });
633
- if (result.skipped) return { text: `未写入:${result.reason}${result.trace ? `(已有 ${result.trace.id})` : ''}` };
674
+ if (result.skipped) {
675
+ const hint = {
676
+ 'low-compression': '内容信息密度太低。若确实是用户要求记住的,请传 explicit: true,或提高 importance 后再试。',
677
+ 'too-short': '内容太短。请写成一条完整、可检索的记忆。',
678
+ 'stop-phrase': '这是停用短语。若确实要记,请换一条有具体内容的表达。',
679
+ duplicate: '已有相同内容。可以继续使用已有 trace,不必重复写。',
680
+ idempotent: '同一个 operationId 已处理过。可以继续使用已有 trace。',
681
+ 'scope-read-only': '这个 scope 是只读共享的;请换到可写 scope,或让宿主解除只读。',
682
+ }[result.reason];
683
+ const factsNote = result.facts?.length ? `\n已应用 ${result.facts.length} 条事实。` : '';
684
+ const edgeNote = result.edgeCandidates?.length ? `\n候选边:${result.edgeCandidates.length} 条(待审核)。` : '';
685
+ return {
686
+ text: `未写入:${result.reason}${result.trace ? `(已有 ${result.trace.id})` : ''}${factsNote}${edgeNote}${hint ? `\n提示:${hint}` : ''}`,
687
+ };
688
+ }
634
689
  return {
635
- text: `已写入 ${result.trace.id}(${result.trace.kind})\n事件:${result.event.id}\n压缩值:${result.compression.toFixed(3)}${result.facts?.length ? `\n事实:${result.facts.length} 条` : ''}`,
690
+ text: `已写入 ${result.trace.id}(${result.trace.kind})\n事件:${result.event.id}\n压缩值:${result.compression.toFixed(3)}${result.facts?.length ? `\n事实:${result.facts.length} 条` : ''}${result.edgeCandidates?.length ? `\n候选边:${result.edgeCandidates.length} 条(待审核)` : ''}${result.trace.metadata?.attachments?.length ? `\n附件:${result.trace.metadata.attachments.length} 个(仅元数据)` : ''}`,
636
691
  };
637
692
  },
638
693
  presentCall: () => ({ card: 'generic', title: '写入记忆', kind: 'other' }),
@@ -676,8 +731,12 @@ export function createToolDefinitions(service) {
676
731
  output: TEXT_OUTPUT,
677
732
  isConcurrencySafe: () => false,
678
733
  async execute(args = {}) {
734
+ if (!args.id || !String(args.id).trim()) throw new Error('traceId is required');
735
+ if (!service.scopeWritableById('trace', args.id)) return { text: '未执行:该记忆属于只读共享 scope。' };
679
736
  const result = service.forget({ traceId: args.id, mode: args.mode });
680
- if (!result.removed) return { text: `没有这条记忆:${args.id}` };
737
+ if (!result.removed) {
738
+ return { text: result.reason === 'scope-read-only' ? '未执行:该记忆属于只读共享 scope。' : `没有这条记忆:${args.id}` };
739
+ }
681
740
  return { text: args.mode === 'delete' ? `已删除 ${args.id},并级联清理派生数据。` : `已归档 ${args.id},可用关键词唤醒。` };
682
741
  },
683
742
  presentCall: () => ({ card: 'generic', title: '遗忘/删除记忆', kind: 'other' }),
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Future external ANN client. It is intentionally not wired into the search
3
+ * path yet: a local index service with its own lifecycle must exist first.
4
+ */
5
+ export function createExternalAnnClient({ endpoint, timeoutMs = 800, headers = {}, fetchImpl = fetch } = {}) {
6
+ const url = String(endpoint ?? '').trim();
7
+ if (!/^https?:\/\//i.test(url)) throw new Error('external ANN endpoint must be http(s)');
8
+ const timeout = Math.max(100, Math.min(10000, Number(timeoutMs) || 800));
9
+
10
+ async function search(vector, k = 10, { signal } = {}) {
11
+ if (!Array.isArray(vector) || !vector.length) throw new Error('external ANN query vector is required');
12
+ const controller = new AbortController();
13
+ const timer = setTimeout(() => controller.abort(), timeout);
14
+ const onAbort = () => controller.abort();
15
+ signal?.addEventListener?.('abort', onAbort, { once: true });
16
+ try {
17
+ const response = await fetchImpl(url, {
18
+ method: 'POST',
19
+ headers: { 'content-type': 'application/json', ...headers },
20
+ body: JSON.stringify({ vector, k: Math.max(1, Math.min(200, Number(k) || 10)) }),
21
+ signal: controller.signal,
22
+ });
23
+ if (!response.ok) throw new Error(`external ANN returned ${response.status}`);
24
+ const body = await response.json();
25
+ if (!Array.isArray(body?.items)) throw new Error('external ANN response needs items[]');
26
+ return body.items
27
+ .filter((item) => typeof item?.id === 'string' && Number.isFinite(item?.score))
28
+ .map((item) => ({ id: item.id, score: item.score }))
29
+ .slice(0, Math.max(1, Math.min(200, Number(k) || 10)));
30
+ } finally {
31
+ clearTimeout(timer);
32
+ signal?.removeEventListener?.('abort', onAbort);
33
+ }
34
+ }
35
+
36
+ return { endpoint: url, search };
37
+ }