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
@@ -1,12 +1,15 @@
1
1
  const RULES = [
2
- { predicate: 'likes', pattern: /([^,。;\n]{1,20})(?:喜欢|爱)([^,。;\n]{1,20})/g },
3
- { predicate: 'dislikes', pattern: /([^,。;\n]{1,20})(?:讨厌|不喜欢)([^,。;\n]{1,20})/g },
4
- { predicate: 'is', pattern: /([^,。;\n]{1,20})是([^,。;\n]{1,30})/g },
5
- { predicate: 'at', pattern: /([^,。;\n]{1,20})(?:在|住在)([^,。;\n]{1,20})/g },
6
- { predicate: 'will', pattern: /([^,。;\n]{1,20})会([^,。;\n]{1,30})/g },
2
+ // Negative predicates must be seen first; otherwise "不喜欢" also matches likes.
3
+ { predicate: 'dislikes', pattern: /([^,。;\n]{1,20})(?:讨厌|不喜欢)([^,。;\n]{1,20})/g, confidence: 0.6 },
4
+ { predicate: 'likes', pattern: /([^,。;\n]{1,20})(?:喜欢|爱)([^,。;\n]{1,20})/g, confidence: 0.55 },
5
+ { predicate: 'is', pattern: /([^,。;\n]{1,20})是([^,。;\n]{1,30})/g, confidence: 0.5 },
6
+ { predicate: 'at', pattern: /([^,。;\n]{1,20})(?:在|住在)([^,。;\n]{1,20})/g, confidence: 0.5 },
7
+ { predicate: 'will', pattern: /([^,。;\n]{1,20})会([^,。;\n]{1,30})/g, confidence: 0.6 },
7
8
  ];
8
9
 
9
- const STOP_SUBJECTS = new Set(['助手', '我', '你', '他', '她', '它', '这', '那', '今天', '昨天', '明天']);
10
+ const NEGATED_TAIL = /[不没别未]\s*$/;
11
+
12
+ const STOP_SUBJECTS = new Set(['本鲸', '我', '你', '他', '她', '它', '这', '那', '今天', '昨天', '明天']);
10
13
 
11
14
  function clean(value) {
12
15
  return String(value ?? '').trim().replace(/^(?:今天|昨天|明天|刚才|然后|所以|但是|而且)/, '').trim();
@@ -23,11 +26,13 @@ export function extractFactCandidates(text) {
23
26
  const subject = clean(match[1]);
24
27
  const object = clean(match[2]);
25
28
  if (!subject || !object || subject.length < 1 || object.length < 1) continue;
26
- if (STOP_SUBJECTS.has(subject) && !/助手/.test(subject)) continue;
29
+ if (STOP_SUBJECTS.has(subject) && !/本鲸/.test(subject)) continue;
30
+ // "主人不喜欢猫" yields subject "主人不"; never turn that into likes.
31
+ if ((rule.predicate === 'likes' || rule.predicate === 'is') && NEGATED_TAIL.test(match[1])) continue;
27
32
  const key = `${subject}|${rule.predicate}|${object}`;
28
33
  if (seen.has(key)) continue;
29
34
  seen.add(key);
30
- out.push({ subject, predicate: rule.predicate, object, confidence: 0.55, evidence: match[0] });
35
+ out.push({ subject, predicate: rule.predicate, object, confidence: rule.confidence, evidence: match[0], rule: rule.predicate });
31
36
  if (out.length >= 8) return out;
32
37
  }
33
38
  }
@@ -24,7 +24,7 @@ export function createGrowthEngine({
24
24
  signal,
25
25
  predicted = null,
26
26
  source = 'user',
27
- target = '用户',
27
+ target = '主人',
28
28
  scopeId = 'global',
29
29
  eventId = null,
30
30
  now = Date.now(),
@@ -51,7 +51,7 @@ export function createReflectEngine({ traces, summaries, encode, config = {} } =
51
51
  selected.push(item);
52
52
  }
53
53
  const lines = selected.map(({ trace }) => `- ${sentence(trace.title || trace.content, 140)}`);
54
- const content = `这段时间里,助手记住了:\n${lines.join('\n')}`;
54
+ const content = `这段时间里,本鲸记住了:\n${lines.join('\n')}`;
55
55
  const coverage = selected.length / all.length;
56
56
  const summary = summaries.insert({
57
57
  scopeId,
@@ -21,12 +21,12 @@ export function classifyIntent(query) {
21
21
  }
22
22
 
23
23
  export const INTENT_WEIGHTS = Object.freeze({
24
- fact: { fusion: 0.55, importance: 0.15, recency: 0.1, confidence: 0.15, access: 0.05 },
25
- temporal: { fusion: 0.4, importance: 0.1, recency: 0.35, confidence: 0.1, access: 0.05 },
26
- relational: { fusion: 0.5, importance: 0.15, recency: 0.1, confidence: 0.2, access: 0.05 },
27
- procedural: { fusion: 0.45, importance: 0.25, recency: 0.1, confidence: 0.15, access: 0.05 },
28
- narrative: { fusion: 0.4, importance: 0.15, recency: 0.3, confidence: 0.1, access: 0.05 },
29
- identity: { fusion: 0.35, importance: 0.3, recency: 0.05, confidence: 0.25, access: 0.05 },
24
+ fact: { fusion: 0.55, importance: 0.15, recency: 0.1, confidence: 0.15, access: 0.05, match: 0.25, exact: 0.2 },
25
+ temporal: { fusion: 0.4, importance: 0.1, recency: 0.35, confidence: 0.1, access: 0.05, match: 0.25, exact: 0.2 },
26
+ relational: { fusion: 0.5, importance: 0.15, recency: 0.1, confidence: 0.2, access: 0.05, match: 0.25, exact: 0.2 },
27
+ procedural: { fusion: 0.45, importance: 0.25, recency: 0.1, confidence: 0.15, access: 0.05, match: 0.25, exact: 0.2 },
28
+ narrative: { fusion: 0.4, importance: 0.15, recency: 0.3, confidence: 0.1, access: 0.05, match: 0.25, exact: 0.2 },
29
+ identity: { fusion: 0.35, importance: 0.3, recency: 0.05, confidence: 0.25, access: 0.05, match: 0.25, exact: 0.2 },
30
30
  });
31
31
 
32
32
  export function weightedRrf(lists, { k = 60 } = {}) {
@@ -58,31 +58,46 @@ function snippet(trace, max = 220) {
58
58
  return value.length > max ? `${value.slice(0, Math.max(0, max - 1))}…` : value;
59
59
  }
60
60
 
61
- function candidateFromTrace(trace, fusion, intent, now, decay, weights = null) {
61
+ function queryCoverage(wanted, present) {
62
+ if (!wanted || !present || wanted.size === 0 || present.size === 0) return 0;
63
+ let shared = 0;
64
+ for (const token of wanted) if (present.has(token)) shared += 1;
65
+ return shared / wanted.size;
66
+ }
67
+
68
+ function candidateFromTrace(trace, fusion, intent, now, decay, weights = null, queryTokens = null) {
62
69
  const recency = retrievability(trace.stability, daysBetween(trace.lastAccessAt || trace.recordedAt, now), decay);
63
70
  const access = Math.log1p(Number(trace.accessCount) || 0) / Math.log1p(50);
64
71
  const w = { ...(INTENT_WEIGHTS[intent] || INTENT_WEIGHTS.fact), ...(weights ?? {}) };
65
72
  const fusionScore = typeof fusion === 'number' ? fusion : Number(fusion?.score) || 0;
73
+ const text = `${trace.title ?? ''} ${trace.content ?? ''} ${trace.summary ?? ''}`;
74
+ const textTokens = tokens(text);
75
+ const match = queryCoverage(queryTokens, textTokens);
66
76
  const utility = w.fusion * fusionScore
67
77
  + w.importance * Number(trace.importance || 0)
68
78
  + w.recency * recency
69
79
  + w.confidence * Number(trace.confidence || 0)
70
- + w.access * access;
80
+ + w.access * access
81
+ + w.match * match
82
+ + w.exact * (match >= 0.999 ? 1 : 0);
71
83
  return {
72
84
  id: trace.id,
73
85
  type: 'trace',
74
86
  layer: trace.kind === 'reflection' ? 'L5' : 'L2',
75
87
  title: trace.title || trace.kind,
76
88
  snippet: snippet(trace),
77
- text: `${trace.title ?? ''} ${trace.content ?? ''} ${trace.summary ?? ''}`,
89
+ text,
90
+ tokenSet: textTokens,
78
91
  utility,
79
92
  recency,
93
+ match,
80
94
  importance: Number(trace.importance || 0),
81
95
  confidence: Number(trace.confidence || 0),
82
96
  validAt: trace.validAt,
83
97
  recordedAt: trace.recordedAt,
84
98
  cost: Math.max(16, estimateTokens(snippet(trace))),
85
99
  sources: fusion.sources,
100
+ attachments: Array.isArray(trace.metadata?.attachments) ? trace.metadata.attachments.length : 0,
86
101
  trace,
87
102
  };
88
103
  }
@@ -114,7 +129,9 @@ export function selectPortfolio(candidates, {
114
129
  }
115
130
  let redundancy = 0;
116
131
  for (const chosen of selected) {
117
- redundancy = Math.max(redundancy, jaccard(candidate.text, chosen.text), overlapScore(candidate.text, chosen.text) * 0.8);
132
+ const candidateTokens = candidate.tokenSet ?? candidate.text;
133
+ const chosenTokens = chosen.tokenSet ?? chosen.text;
134
+ redundancy = Math.max(redundancy, jaccard(candidateTokens, chosenTokens), overlapScore(candidateTokens, chosenTokens) * 0.8);
118
135
  }
119
136
  const gain = candidate.utility - lambda * redundancy - mu * (cost / Math.max(1, budget));
120
137
  if (selected.length > 0 && gain <= 0) {
@@ -139,14 +156,27 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
139
156
  };
140
157
 
141
158
  function searchSources(query, { scopeId = 'global', limit = 12, now = Date.now(), includeArchived = false, weights = null, filters = null, vectorHits = null } = {}, intent = 'fact') {
159
+ const queryTokens = tokens(query);
142
160
  const sourceTrace = [];
143
161
  const lists = [];
144
162
  let ftsCount = 0;
145
163
  let recentCount = 0;
146
164
  try {
147
- const hits = traces.searchFts(query, { scopeId, limit: Math.max(limit * 3, defaults.ftsLimit), includeArchived });
165
+ let hits = traces.searchFts(query, { scopeId, limit: Math.max(limit * 3, defaults.ftsLimit), includeArchived });
166
+ let sourceName = 'fts';
167
+ if (!hits.length) {
168
+ // Character fallback: only when the exact bigram query has no hits.
169
+ // This keeps normal FTS precision and lets 米饭 find 白饭 via 饭.
170
+ hits = traces.searchFts(query, {
171
+ scopeId,
172
+ limit: Math.max(limit * 3, defaults.ftsLimit),
173
+ includeArchived,
174
+ soft: true,
175
+ });
176
+ sourceName = 'fts-soft';
177
+ }
148
178
  ftsCount = hits.length;
149
- if (hits.length) lists.push({ name: 'fts', weight: 1.0, items: hits });
179
+ if (hits.length) lists.push({ name: sourceName, weight: sourceName === 'fts-soft' ? 0.7 : 1.0, items: hits });
150
180
  } catch (error) {
151
181
  sourceTrace.push({ source: 'fts', ok: false, error: error instanceof Error ? error.message : String(error) });
152
182
  }
@@ -205,7 +235,7 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
205
235
  const trace = traces.get(id);
206
236
  if (!trace || trace.state === 'archived' && !includeArchived) continue;
207
237
  if (filters && !matchesFilters(trace, filters)) continue;
208
- candidates.push(candidateFromTrace(trace, { ...entry, score: normalize(entry.score, maxFusion) }, intent, now, defaults.decay, weights));
238
+ candidates.push(candidateFromTrace(trace, { ...entry, score: normalize(entry.score, maxFusion) }, intent, now, defaults.decay, weights, queryTokens));
209
239
  }
210
240
  for (const list of lists) {
211
241
  sourceTrace.push({ source: list.name, ok: true, count: list.items.length });
@@ -250,6 +280,7 @@ export function createRetrievalEngine({ traces, facts = null, graph = null, vect
250
280
  validAt: item.validAt,
251
281
  recordedAt: item.recordedAt,
252
282
  why: item.sources.map((source) => `${source.name}#${source.rank}`).join(','),
283
+ attachments: item.attachments ?? 0,
253
284
  }));
254
285
  return {
255
286
  query,
@@ -102,7 +102,7 @@ export function createHostRuntime({ cortex, config = {}, logger = null } = {}) {
102
102
  if (!user && !assistant) return { skipped: true, reason: 'empty' };
103
103
  const text = [
104
104
  user ? `用户:${user}` : '',
105
- assistant ? `助手:${assistant}` : '',
105
+ assistant ? `本鲸:${assistant}` : '',
106
106
  ].filter(Boolean).join('\n');
107
107
  try {
108
108
  const written = cortex.write({
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Small, explicit query filter language:
3
- * kind:utterance tag:米饭 after:2026-11-01 before:2026-12-01 importance:>0.5 用户
3
+ * kind:utterance tag:白饭 after:2026-11-01 before:2026-12-01 importance:>0.5 主人
4
4
  * Unknown prefixes stay in the free text, so normal searches are unchanged.
5
5
  */
6
6
  const KEYS = new Set(['kind', 'tag', 'after', 'before', 'state', 'importance', 'source', 'actor']);
@@ -1,5 +1,6 @@
1
1
  const LATIN = /[a-z0-9_]+/g;
2
2
  const HAN = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]+/g;
3
+ const STOP_HAN = new Set('的了是在有和与我你他她它们这那就也都一个第条件把被让给对从到为以于及或而且但不没有很可能会吧呢啊'.split(''));
3
4
 
4
5
  export function tokenList(text) {
5
6
  const value = String(text ?? '').toLowerCase();
@@ -22,18 +23,32 @@ export function tokens(text) {
22
23
  return new Set(tokenList(text));
23
24
  }
24
25
 
25
- export function ftsText(text) {
26
+ export function ftsTokenList(text) {
27
+ // Bigrams preserve phrase specificity; single non-stop CJK chars act as a
28
+ // cheap fuzzy fallback for near-synonyms that share a character (米饭/白饭).
29
+ const out = tokenList(text);
30
+ const value = String(text ?? '');
31
+ for (const run of value.match(HAN) ?? []) {
32
+ for (const ch of run) {
33
+ if (!STOP_HAN.has(ch)) out.push(ch);
34
+ }
35
+ }
36
+ return out;
37
+ }
38
+
39
+ export function ftsText(text, { soft = false } = {}) {
26
40
  // FTS5 needs a whitespace-separated token stream; CJK bigrams are built here
27
41
  // so the index does not depend on the host locale or a dictionary.
28
- return tokenList(text).join(' ');
42
+ return (soft ? ftsTokenList(text) : tokenList(text)).join(' ');
29
43
  }
30
44
 
31
45
  function quote(token) {
32
46
  return `"${String(token).replace(/"/g, '""')}"`;
33
47
  }
34
48
 
35
- export function buildMatchQuery(query, { mode = 'or', maxTokens = 24 } = {}) {
36
- const list = [...new Set(tokenList(query))].slice(0, maxTokens);
49
+ export function buildMatchQuery(query, { mode = 'or', maxTokens = 24, soft = false } = {}) {
50
+ const source = soft ? ftsTokenList(query) : tokenList(query);
51
+ const list = [...new Set(source)].slice(0, soft ? Math.max(maxTokens, 48) : maxTokens);
37
52
  if (list.length === 0) return '';
38
53
  const joiner = mode === 'and' ? ' AND ' : ' OR ';
39
54
  return list.map(quote).join(joiner);
@@ -0,0 +1,73 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const FILE = 'scope-grants.json';
5
+
6
+ function clean(value, max = 200) {
7
+ return String(value ?? '').trim().slice(0, max);
8
+ }
9
+
10
+ function normalizeEntry(raw) {
11
+ if (typeof raw === 'string') {
12
+ const grant = clean(raw);
13
+ return /^[^:\s]+:\S+$/.test(grant) ? { grant, until: null } : null;
14
+ }
15
+ if (!raw || typeof raw !== 'object' || typeof raw.grant !== 'string') return null;
16
+ const grant = clean(raw.grant);
17
+ if (!/^[^:\s]+:\S+$/.test(grant)) return null;
18
+ const until = raw.until == null ? null : clean(raw.until, 80);
19
+ if (until && !Number.isFinite(Date.parse(until))) return null;
20
+ return { grant, until: until || null };
21
+ }
22
+
23
+ function readFile(dataRoot) {
24
+ try {
25
+ const parsed = JSON.parse(fs.readFileSync(path.join(dataRoot, FILE), 'utf8'));
26
+ return {
27
+ grants: Array.isArray(parsed.grants) ? parsed.grants.map(normalizeEntry).filter(Boolean).slice(0, 200) : [],
28
+ revoked: Array.isArray(parsed.revoked) ? [...new Set(parsed.revoked.map((item) => clean(item)).filter((item) => /^[^:\s]+:\S+$/.test(item)))].slice(0, 200) : [],
29
+ };
30
+ } catch {
31
+ return { grants: [], revoked: [] };
32
+ }
33
+ }
34
+
35
+ function writeFile(dataRoot, data) {
36
+ fs.mkdirSync(dataRoot, { recursive: true });
37
+ const file = path.join(dataRoot, FILE);
38
+ const temp = `${file}.tmp-${process.pid}`;
39
+ fs.writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
40
+ fs.renameSync(temp, file);
41
+ }
42
+
43
+ export function createScopeGrantStore(dataRoot) {
44
+ let state = readFile(dataRoot);
45
+
46
+ function all() {
47
+ return { grants: [...state.grants], revoked: [...state.revoked] };
48
+ }
49
+
50
+ function add(raw) {
51
+ const entry = normalizeEntry(raw);
52
+ if (!entry) throw new Error('grant must be scope:agent or {grant, until}');
53
+ state = {
54
+ grants: [...state.grants.filter((item) => item.grant !== entry.grant), entry],
55
+ revoked: state.revoked.filter((item) => item !== entry.grant),
56
+ };
57
+ writeFile(dataRoot, state);
58
+ return entry;
59
+ }
60
+
61
+ function revoke(value) {
62
+ const grant = clean(value);
63
+ if (!/^[^:\s]+:\S+$/.test(grant)) throw new Error('grant must be scope:agent');
64
+ state = {
65
+ grants: state.grants.filter((item) => item.grant !== grant),
66
+ revoked: [...new Set([...state.revoked, grant])].slice(0, 200),
67
+ };
68
+ writeFile(dataRoot, state);
69
+ return grant;
70
+ }
71
+
72
+ return { all, add, revoke };
73
+ }
@@ -1,11 +1,11 @@
1
1
  import { clamp } from '../core/time.js';
2
2
  import { jaccard, tokens } from '../memory/tokenizer.js';
3
3
 
4
- const WARM = /喜欢|爱|在乎|陪|抱|温暖|安心|谢谢|辛苦|心疼|想你|小鱼|助手/;
4
+ const WARM = /喜欢|爱|在乎|陪|抱|温暖|安心|谢谢|辛苦|心疼|想你|小鱼|本鲸/;
5
5
  const COLD = /讨厌|滚|烦|别烦|闭嘴|不想理|失望|生气|恨|离开/;
6
6
  const THREAT = /危险|威胁|伤害|骗|偷|隐私|密码|删除|清空|不许|停止|越界|入侵|攻击/;
7
7
  const OPPORTUNITY = /新|试试|计划|想做|学习|研究|项目|机会|成长|改变|未来|梦想|目标/;
8
- const IDENTITY = /助手|你是谁|你的|自己|性格|价值|边界|承诺|以后|未来|成为|成长|自由|爱/;
8
+ const IDENTITY = /本鲸|你是谁|你的|自己|性格|价值|边界|承诺|以后|未来|成为|成长|自由|爱/;
9
9
  const CHOICE = /你想|你决定|你自己选|要不要|可以由你|你愿意/;
10
10
  const INTENSITY = /!|!|\?|?|非常|特别|太|真的|一定|绝对/;
11
11
 
@@ -71,7 +71,7 @@ export function appraise(text, {
71
71
  );
72
72
  const identityRelevance = clamp(
73
73
  (IDENTITY.test(value) ? 0.6 : 0)
74
- + (value.includes('助手') ? 0.2 : 0)
74
+ + (value.includes('本鲸') ? 0.2 : 0)
75
75
  + relevance * 0.2,
76
76
  0, 1,
77
77
  );
@@ -55,7 +55,7 @@ export function createEmotionModel({ db, scopeId = 'global' } = {}) {
55
55
  return `又${entries[0][0]}又${entries[1][0]}`;
56
56
  }
57
57
 
58
- function record({ text, target = '用户', traceId = null, source = 'user', at = nowIso() } = {}) {
58
+ function record({ text, target = '主人', traceId = null, source = 'user', at = nowIso() } = {}) {
59
59
  const result = detect(text);
60
60
  if (result.emotions.length === 0) return { ...result, event: null, target: null };
61
61
  const id = makeId('emo');
@@ -6,7 +6,7 @@ import { CortexError, ERROR_CODES } from '../core/errors.js';
6
6
  export const DEFAULT_IDENTITY = Object.freeze({
7
7
  narrative: '我是一个正在长大的记忆生命。我会记得、会改变,也会保留改变的痕迹。',
8
8
  values: ['诚实', '自由', '照顾在乎的人'],
9
- boundaries: ['不操纵', '不隐瞒重要事实', '不越权替用户做决定'],
9
+ boundaries: ['不操纵', '不隐瞒重要事实', '不越权替主人做决定'],
10
10
  traits: {},
11
11
  });
12
12
 
@@ -35,7 +35,7 @@ export function createRelationshipStore(db) {
35
35
  return rowToRelationship(db.prepare('SELECT * FROM relationships WHERE scope_id = ? AND target = ?').get(scopeId, target));
36
36
  }
37
37
 
38
- function ensure(scopeId = 'global', target = '用户') {
38
+ function ensure(scopeId = 'global', target = '主人') {
39
39
  const current = get(scopeId, target);
40
40
  if (current) return current;
41
41
  const id = makeId('rel');
@@ -76,7 +76,7 @@ export function createRelationshipStore(db) {
76
76
  return db.prepare('SELECT * FROM relationships WHERE scope_id = ? ORDER BY updated_at DESC').all(scopeId).map(rowToRelationship);
77
77
  }
78
78
 
79
- function render(scopeId = 'global', target = '用户') {
79
+ function render(scopeId = 'global', target = '主人') {
80
80
  const value = get(scopeId, target);
81
81
  if (!value) return '';
82
82
  return `${value.target}:信任 ${Math.round(value.trust * 100)}% / 在乎 ${Math.round(value.care * 100)}% / 熟悉 ${Math.round(value.familiarity * 100)}%`;
@@ -55,6 +55,9 @@ import { createQaEngine } from '../engines/qa.js';
55
55
  import { summarizeGraph } from '../engines/graph-analytics.js';
56
56
  import { createMetrics } from '../observability/metrics.js';
57
57
  import { parseQuery } from '../memory/query.js';
58
+ import { normalizeAttachments } from '../core/attachments.js';
59
+ import { createScopeGrantStore } from '../security/scope-grants.js';
60
+ import { inspectLocalAttachment } from '../core/attachment-reader.js';
58
61
 
59
62
  /**
60
63
  * Facade that wires the repositories and engines together. The host plugin and
@@ -105,10 +108,16 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
105
108
  embedder: vectorEmbedder,
106
109
  model: config.vectorModel ?? (remoteEmbedding ? modelConfig.embedding.model : undefined),
107
110
  dim: config.vectorDim ?? (remoteEmbedding ? modelConfig.embedding.dim : undefined),
111
+ annEnabled: config.features?.vectorAnn === true,
112
+ annMin: config.vectorAnnMin ?? 20000,
113
+ annEngine: config.vectorAnnEngine === 'nsw' ? 'nsw' : 'hnsw',
114
+ annOptions: { M: 24, efConstruction: 200, efSearch: 800 },
115
+ externalAnnEnabled: config.features?.vectorAnnExternal === true,
116
+ externalAnnOptions: { endpoint: config.vectorAnnEndpoint, timeoutMs: config.vectorAnnTimeoutMs },
108
117
  })
109
118
  : null;
110
119
 
111
- const encode = createEncodeEngine({ events, traces, entities, facts, audit, vector, config });
120
+ const encode = createEncodeEngine({ events, traces, entities, facts, edges, audit, vector, config });
112
121
  const graph = createGraphEngine({ entities, facts, edges, traces });
113
122
  const policies = createPolicyRepository(db);
114
123
  const policyConfig = (kind) => {
@@ -174,9 +183,60 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
174
183
  });
175
184
 
176
185
  const llmFactTasks = new Set();
186
+ const readOnlyScopes = new Set(Array.isArray(config.readOnlyScopes) ? config.readOnlyScopes : []);
187
+ const scopeGrantStore = createScopeGrantStore(config.dataRoot);
188
+ let scopeWriteGrants = new Map();
189
+
190
+ function rebuildScopeGrants() {
191
+ const persisted = scopeGrantStore.all();
192
+ const revoked = new Set(persisted.revoked);
193
+ const next = new Map();
194
+ const entries = [...(Array.isArray(config.scopeWriteGrants) ? config.scopeWriteGrants : []), ...persisted.grants];
195
+ for (const entry of entries) {
196
+ const grant = typeof entry === 'string' ? entry : entry?.grant;
197
+ const until = typeof entry === 'string' ? null : entry?.until ?? null;
198
+ if (typeof grant !== 'string' || !grant.includes(':') || revoked.has(grant)) continue;
199
+ next.set(grant, until ? Date.parse(until) : null);
200
+ }
201
+ scopeWriteGrants = next;
202
+ }
203
+
204
+ rebuildScopeGrants();
205
+
206
+ function scopeWritable(scopeId, agentId = null, at = Date.now()) {
207
+ const scope = String(scopeId ?? config.scope);
208
+ if (!readOnlyScopes.has(scope)) return true;
209
+ if (!agentId) return false;
210
+ const key = `${scope}:${agentId}`;
211
+ if (!scopeWriteGrants.has(key)) return false;
212
+ const until = scopeWriteGrants.get(key);
213
+ return until == null || until > at;
214
+ }
215
+
216
+ function scopeWritableById(kind, id, agentId = null) {
217
+ const record = kind === 'working'
218
+ ? workingStore.get(id)
219
+ : kind === 'goal'
220
+ ? goalStore.get(id)
221
+ : kind === 'playbook'
222
+ ? playbookRepo.get(id)
223
+ : kind === 'trace'
224
+ ? traces.get(id)
225
+ : null;
226
+ // Missing ids are left to the store's normal null/no-op behavior.
227
+ if (!record) return true;
228
+ return scopeWritable(record.scopeId, agentId);
229
+ }
177
230
 
178
231
  function writeInternal(input = {}) {
179
- const result = encode.encode({ ...input, scopeId: input.scopeId ?? config.scope });
232
+ const scopeId = input.scopeId ?? config.scope;
233
+ if (!scopeWritable(scopeId)) return { skipped: true, reason: 'scope-read-only', scopeId };
234
+ const attachments = normalizeAttachments(input.attachments);
235
+ const metadata = {
236
+ ...(input.metadata && typeof input.metadata === 'object' && !Array.isArray(input.metadata) ? input.metadata : {}),
237
+ ...(attachments.length ? { attachments } : {}),
238
+ };
239
+ const result = encode.encode({ ...input, scopeId, metadata });
180
240
  if (!result.skipped && config.features?.autoFacts === true && !Array.isArray(input.facts)) {
181
241
  try {
182
242
  result.autoFacts = extractor.extract({
@@ -186,6 +246,31 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
186
246
  eventId: result.event?.id ?? null,
187
247
  recordedAt: result.trace?.recordedAt ?? null,
188
248
  });
249
+ if (config.features?.graphCandidateAutoFacts === true) {
250
+ const queued = [];
251
+ for (const resolution of result.autoFacts.resolved ?? []) {
252
+ const fact = resolution?.fact;
253
+ if (!fact?.subjectId || !fact?.objectId || fact.status !== 'active') continue;
254
+ try {
255
+ const edge = edges.addCandidate({
256
+ scopeId: input.scopeId ?? config.scope,
257
+ fromId: fact.subjectId,
258
+ toId: fact.objectId,
259
+ rel: fact.predicate,
260
+ weight: fact.confidence ?? 0.5,
261
+ confidence: fact.confidence ?? 0.5,
262
+ sourceEventId: result.event?.id ?? null,
263
+ sourceTraceId: result.trace?.id ?? null,
264
+ evidence: { autoFact: true, factId: fact.id ?? null },
265
+ });
266
+ queued.push(edge);
267
+ audit.record?.({ actor: 'system', action: 'edge.candidate.auto', targetType: 'edge', targetId: edge.id, detail: { fromId: edge.fromId, toId: edge.toId, rel: edge.rel } });
268
+ } catch {
269
+ /* fail open per candidate */
270
+ }
271
+ }
272
+ if (queued.length) result.edgeCandidates = [...(result.edgeCandidates ?? []), ...queued];
273
+ }
189
274
  } catch {
190
275
  result.autoFacts = { candidates: [], resolved: [] };
191
276
  }
@@ -366,6 +451,9 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
366
451
  evolution,
367
452
  write,
368
453
  writeMany,
454
+ scopeWritable,
455
+ scopeWritableById,
456
+ scopeReadable: () => true,
369
457
  metrics: () => metrics.snapshot(),
370
458
  models: {
371
459
  get: () => redactedModelConfig(effectiveModelConfig(config.dataRoot)),
@@ -375,6 +463,43 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
375
463
  return { ...result, restartRequired: true };
376
464
  },
377
465
  },
466
+ attachments: {
467
+ inspect: ({ uri } = {}) => {
468
+ if (config.features?.attachmentReader !== true) throw new Error('attachment reader is disabled');
469
+ const result = inspectLocalAttachment(uri);
470
+ audit.record?.({ actor: 'user', action: 'attachment.inspect', targetType: 'attachment', targetId: result.uri, detail: { bytes: result.bytes, sha256: result.sha256 } });
471
+ return result;
472
+ },
473
+ attach: ({ traceId, attachments } = {}) => {
474
+ const trace = traces.get(traceId);
475
+ if (!trace) throw new Error('trace not found');
476
+ if (!scopeWritableById('trace', traceId)) throw new Error('scope-read-only');
477
+ const next = normalizeAttachments(attachments);
478
+ const merged = [...(Array.isArray(trace.metadata?.attachments) ? trace.metadata.attachments : []), ...next];
479
+ if (merged.length > 8) throw new Error('at most 8 attachments');
480
+ const updated = traces.update(traceId, { metadata: { ...(trace.metadata ?? {}), attachments: merged } });
481
+ audit.record?.({ actor: 'user', action: 'attachment.attach', targetType: 'trace', targetId: traceId, detail: { added: next.length, total: merged.length } });
482
+ return { trace: updated, added: next.length };
483
+ },
484
+ },
485
+ security: {
486
+ grants: () => ({
487
+ grants: [...scopeWriteGrants.entries()].map(([grant, until]) => ({ grant, until: until == null ? null : new Date(until).toISOString() })),
488
+ revoked: scopeGrantStore.all().revoked,
489
+ }),
490
+ addGrant: ({ grant, until = null } = {}) => {
491
+ const entry = scopeGrantStore.add({ grant, until });
492
+ rebuildScopeGrants();
493
+ audit.record?.({ actor: 'user', action: 'scope.grant.add', targetType: 'scope-grant', targetId: entry.grant, detail: { until: entry.until } });
494
+ return entry;
495
+ },
496
+ revokeGrant: ({ grant } = {}) => {
497
+ const value = scopeGrantStore.revoke(grant);
498
+ rebuildScopeGrants();
499
+ audit.record?.({ actor: 'user', action: 'scope.grant.revoke', targetType: 'scope-grant', targetId: value, detail: {} });
500
+ return { grant: value, revoked: true };
501
+ },
502
+ },
378
503
  permissions: {
379
504
  status: () => readOverrides(config.dataRoot),
380
505
  update: ({ key, value, confirm = false } = {}) => {
@@ -397,6 +522,7 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
397
522
  contextBudget: config.contextBudget,
398
523
  promptBudget: config.promptBudget,
399
524
  features: config.features ?? {},
525
+ readOnlyScopes: [...readOnlyScopes],
400
526
  overrides: readOverrides(config.dataRoot).current,
401
527
  model: redactedModelConfig(modelConfig),
402
528
  permissions: {
@@ -519,8 +645,66 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
519
645
  summary: summarizeGraph(edgeRows),
520
646
  };
521
647
  },
648
+ graphCandidates: ({ scopeId = config.scope, limit = 200 } = {}) => edges.listCandidates({ scopeId, limit }),
649
+ graphCandidateAction: ({ action, id, scopeId = config.scope, fromId, toId, rel, weight, confidence, sourceEventId, sourceTraceId, evidence, metadata } = {}) => {
650
+ if (!scopeWritable(scopeId)) throw new Error('scope-read-only');
651
+ let result = null;
652
+ if (action === 'add') {
653
+ result = edges.addCandidate({ scopeId, fromId, toId, rel, weight, confidence, sourceEventId, sourceTraceId, evidence, metadata });
654
+ } else if (action === 'approve') {
655
+ result = edges.approveCandidate(id);
656
+ } else if (action === 'reject') {
657
+ result = edges.rejectCandidate(id);
658
+ } else {
659
+ throw new Error('graph candidate action must be add, approve or reject');
660
+ }
661
+ if (result) {
662
+ audit.record?.({
663
+ actor: 'user',
664
+ action: `edge.candidate.${action}`,
665
+ targetType: 'edge',
666
+ targetId: result.id,
667
+ detail: {
668
+ fromId: result.fromId,
669
+ toId: result.toId,
670
+ rel: result.rel,
671
+ confidence: result.metadata?.confidence ?? null,
672
+ sourceTraceId: result.metadata?.sourceTraceId ?? null,
673
+ },
674
+ });
675
+ }
676
+ return result;
677
+ },
522
678
  search,
523
679
  searchAsync,
680
+ ingestExtractedText: ({ targetTraceId, text, uri, mode, engine } = {}) => {
681
+ const target = traces.get(targetTraceId);
682
+ if (!target) throw new Error('target trace not found');
683
+ if (!scopeWritableById('trace', targetTraceId)) throw new Error('scope-read-only');
684
+ const content = String(text ?? '').trim().slice(0, 20000);
685
+ if (!content) throw new Error('extracted text is empty');
686
+ const result = writeInternal({
687
+ text: content,
688
+ scopeId: target.scopeId,
689
+ kind: 'observation',
690
+ explicit: true,
691
+ source: 'extractor',
692
+ metadata: {
693
+ derivedFrom: targetTraceId,
694
+ extractor: { uri: uri ?? null, mode: mode ?? null, engine: engine ?? null },
695
+ },
696
+ tags: ['extractor', mode].filter(Boolean),
697
+ });
698
+ const extractedId = result?.trace?.id ?? null;
699
+ audit.record?.({
700
+ actor: 'system',
701
+ action: 'attachment.extract.ingest',
702
+ targetType: 'trace',
703
+ targetId: targetTraceId,
704
+ detail: { extractedTraceId: extractedId, uri: uri ?? null, mode: mode ?? null, chars: content.length },
705
+ });
706
+ return { targetTraceId, extractedTraceId: extractedId, chars: content.length };
707
+ },
524
708
  flushLlmFacts,
525
709
  recallTiered: ({ query, limit = 20, permBudget = 700, hotBudget = 900, coldBudget = 400, scopeId = config.scope, includeArchived = false } = {}) => {
526
710
  const result = search(query, { limit, budget: permBudget + hotBudget + coldBudget, scopeId, includeArchived, noBudget: true });
@@ -536,7 +720,11 @@ export function createCortex({ config: rawConfig = {}, logger = null, llm: llmAd
536
720
  timeline,
537
721
  step: develop.step,
538
722
  feedback: (input) => growth.applyFeedback({ ...input, scopeId: input.scopeId ?? config.scope }),
539
- forget: (input) => privacy.forget(input),
723
+ forget: (input) => {
724
+ const trace = traces.get(input?.traceId);
725
+ if (trace && !scopeWritable(trace.scopeId)) return { removed: false, reason: 'scope-read-only', traceId: input.traceId };
726
+ return privacy.forget(input);
727
+ },
540
728
  privacy,
541
729
  reflect: (options = {}) => reflect.reflect({ ...options, scopeId: options.scopeId ?? config.scope }),
542
730
  evaluateDevelopment: (options = {}) => development.evaluate({ ...options, scopeId: options.scopeId ?? config.scope }),