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
@@ -15,11 +15,30 @@ function mulberry32(seed) {
15
15
  };
16
16
  }
17
17
 
18
- const PLACES = ['米饭', '插件', '记忆引擎', '鲸鱼', '用户', '伙伴', '直播', '语音', '项目', '代码', '测试', 'D:\\legacy-data', 'QQ', 'B站', '声音', '梦', '日记'];
18
+ const PLACES = ['白饭', '插件', '记忆引擎', '鲸鱼', '主人', '月神', '直播', '语音', '项目', '代码', '测试', 'D:\\shiori-data', 'QQ', 'B站', '声音', '梦', '日记'];
19
19
  const VERBS = ['修好了', '想起来了', '决定继续', '记录一下', '有点担心', '很开心', '测试了', '重新整理了', '发现了', '计划明天'];
20
20
  const TOPICS = ['图检索', '向量检索', '遗忘曲线', '工作卡', '火花卡', '身份版本', '关系模型', '反思', '巩固', '守护', '备份', '权限'];
21
+ const PARAPHRASES = new Map([
22
+ ['白饭', '米饭'],
23
+ ['插件', '扩展程序'],
24
+ ['记忆引擎', '记忆系统'],
25
+ ['鲸鱼', '鲸'],
26
+ ['直播', '实况'],
27
+ ['测试', '验证'],
28
+ ['图检索', '图谱查询'],
29
+ ['向量检索', '向量查询'],
30
+ ['遗忘曲线', '遗忘衰减'],
31
+ ['工作卡', '任务卡'],
32
+ ['火花卡', '灵感卡'],
33
+ ['身份版本', '人设版本'],
34
+ ['关系模型', '关系图谱'],
35
+ ['巩固', '强化'],
36
+ ['守护', '保护'],
37
+ ['备份', '存档'],
38
+ ['权限', '许可'],
39
+ ]);
21
40
 
22
- export function generateSynthetic({ count = 1000, queries = 50, seed = 42 } = {}) {
41
+ export function generateSynthetic({ count = 1000, queries = 50, seed = 42, task = 'needle' } = {}) {
23
42
  const random = mulberry32(seed);
24
43
  const pick = (list) => list[Math.floor(random() * list.length)];
25
44
  const traces = [];
@@ -46,10 +65,35 @@ export function generateSynthetic({ count = 1000, queries = 50, seed = 42 } = {}
46
65
  }
47
66
  }
48
67
  const queryList = [];
49
- for (let i = 0; i < queries; i += 1) {
50
- const token = pick([...idByToken.keys()]);
51
- const relevant = (idByToken.get(token) ?? []).slice(0, 8);
52
- queryList.push({ query: token, type: 'keyword', relevant });
68
+ if (task === 'needle') {
69
+ for (let i = 0; i < queries; i += 1) {
70
+ // A unique needle: the query names one specific row by its ordinal.
71
+ // This measures index/ranking correctness, not semantic paraphrase recall.
72
+ const targetIndex = Math.floor(random() * count);
73
+ const target = traces[targetIndex];
74
+ queryList.push({
75
+ query: `${target.title} 第 ${targetIndex} 条合成记忆`,
76
+ type: 'needle',
77
+ relevant: [target.id],
78
+ targetId: target.id,
79
+ });
80
+ }
81
+ } else if (task === 'theme' || task === 'paraphrase') {
82
+ const tokens = [...idByToken.keys()].filter((token) =>
83
+ (idByToken.get(token)?.length ?? 0) >= 2 && (task !== 'paraphrase' || PARAPHRASES.has(token)));
84
+ for (let i = 0; i < queries && tokens.length; i += 1) {
85
+ const token = pick(tokens);
86
+ const relevant = idByToken.get(token).slice();
87
+ const query = task === 'paraphrase' ? (PARAPHRASES.get(token) ?? token) : token;
88
+ queryList.push({
89
+ query,
90
+ type: task,
91
+ relevant,
92
+ targetToken: token,
93
+ });
94
+ }
95
+ } else {
96
+ throw new Error(`unknown synthetic task: ${task}`);
53
97
  }
54
- return { traces, queries: queryList, seed, generatedAt: new Date().toISOString() };
98
+ return { traces, queries: queryList, seed, task, generatedAt: new Date().toISOString() };
55
99
  }
@@ -0,0 +1,76 @@
1
+ import { mkdtempSync, rmSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { createCortex } from '../src/service/cortex.js';
5
+ import { createOpenAIEmbedder } from '../src/vector/providers.js';
6
+ import { generateSynthetic } from './synthetic.js';
7
+ import { evaluateQueries, percentile } from './metrics.js';
8
+
9
+ const count = Number(process.argv[2] ?? 300);
10
+ const queryCount = Number(process.argv[3] ?? 50);
11
+ const useVector = process.argv[4] !== 'fts';
12
+ const dir = mkdtempSync(path.join(os.tmpdir(), `cortex-vector-ab-${useVector ? 'bge' : 'fts'}-`));
13
+ const embedder = createOpenAIEmbedder({
14
+ baseUrl: 'http://127.0.0.1:11434/v1',
15
+ model: 'bge-m3',
16
+ dim: 1024,
17
+ timeoutMs: 30000,
18
+ });
19
+ const cortex = createCortex({
20
+ config: {
21
+ dataRoot: dir,
22
+ dbFile: path.join(dir, 'cortex.sqlite'),
23
+ contextBudget: 2200,
24
+ vectorEnabled: useVector,
25
+ features: useVector ? { vector: true } : {},
26
+ ...(useVector ? { embedder } : {}),
27
+ },
28
+ });
29
+ try {
30
+ const dataset = generateSynthetic({ count, queries: queryCount, seed: 42, task: 'paraphrase' });
31
+ const written = cortex.writeMany(dataset.traces.map((trace) => ({
32
+ text: trace.text,
33
+ title: trace.title,
34
+ tags: trace.tags,
35
+ importance: trace.importance,
36
+ explicit: true,
37
+ operationId: `vector-ab:${trace.id}`,
38
+ now: Date.parse(trace.recordedAt),
39
+ })), { chunkSize: 250 });
40
+ const idMap = new Map();
41
+ for (let i = 0; i < written.length; i += 1) {
42
+ if (written[i].trace) idMap.set(dataset.traces[i].id, written[i].trace.id);
43
+ }
44
+ if (useVector) {
45
+ await new Promise((resolve) => setTimeout(resolve, 1500));
46
+ const rebuilt = await cortex.vector.rebuildAsync({ limit: count });
47
+ console.error(`vector rebuild: ${JSON.stringify(rebuilt)}`);
48
+ }
49
+ const results = [];
50
+ const latencies = [];
51
+ for (const query of dataset.queries) {
52
+ const start = performance.now();
53
+ const result = useVector
54
+ ? await cortex.searchAsync(query.query, { limit: 10, budget: 2200 })
55
+ : cortex.search(query.query, { limit: 10, budget: 2200 });
56
+ latencies.push(performance.now() - start);
57
+ results.push({
58
+ items: result.items,
59
+ relevant: query.relevant.map((id) => idMap.get(id)).filter(Boolean),
60
+ });
61
+ }
62
+ const metrics = evaluateQueries(results, { k: 10 });
63
+ console.log(JSON.stringify({
64
+ task: 'paraphrase',
65
+ vector: useVector,
66
+ count,
67
+ queries: dataset.queries.length,
68
+ p50Ms: Math.round((percentile(latencies, 50) ?? 0) * 100) / 100,
69
+ p95Ms: Math.round((percentile(latencies, 95) ?? 0) * 100) / 100,
70
+ vectorStatus: cortex.vector?.status?.() ?? null,
71
+ ...metrics,
72
+ }, null, 2));
73
+ } finally {
74
+ cortex.close();
75
+ rmSync(dir, { recursive: true, force: true });
76
+ }
package/bin/cortex.mjs CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createCortex } from '../src/service/cortex.js';
@@ -34,7 +34,7 @@ Commands:
34
34
  self
35
35
  health
36
36
  guard
37
- import-shiori --root <legacy-data>
37
+ import-shiori --root <shiori-data>
38
38
  export-md --dir <target>
39
39
  import-md --dir <vault> [--dry-run]
40
40
  bench [--count 10000] [--queries 100]
@@ -94,4 +94,3 @@ function main() {
94
94
  }
95
95
 
96
96
  main();
97
-
package/client.js CHANGED
@@ -12,7 +12,7 @@ window.__ModuleLoader__.load({
12
12
  zh: {
13
13
  navLabel: 'Cortex 记忆',
14
14
  title: 'Cortex 记忆',
15
- subtitle: '助手的分层记忆 · 自我状态 · 守护 · 进化',
15
+ subtitle: '本鲸的分层记忆 · 自我状态 · 守护 · 进化',
16
16
  refresh: '刷新',
17
17
  loading: '读取中…',
18
18
  retry: '重试',
@@ -96,6 +96,24 @@ window.__ModuleLoader__.load({
96
96
  detailContent: '正文',
97
97
  detailSummary: '摘要',
98
98
  detailTags: '标签',
99
+ detailAttachments: '附件(仅元数据)',
100
+ attachmentAddTitle: '关联本地附件',
101
+ attachmentAddHint: '填 file:// 路径;“检查”走本地读取器(默认关),“关联”只写引用元数据。',
102
+ attachmentCheck: '检查',
103
+ attachmentSave: '关联',
104
+ attachmentSaved: '已关联',
105
+ extractTitle: '从本地提取到记忆',
106
+ extractHint: 'OCR/ASR 结果会写成一条派生记忆,不改原 trace。',
107
+ extractTarget: '目标记忆',
108
+ extractLayout: '版面',
109
+ layoutAuto: '自动',
110
+ layoutSingle: '单栏',
111
+ layoutTwoColumn: '双栏',
112
+ layoutHandwriting: '手写',
113
+ extractRun: '提取并写入',
114
+ extractSaved: '已写入派生记忆',
115
+ extractSuggested: '建议:当前这条',
116
+ extractChanged: '已改选目标',
99
117
  detailKeywords: '关键词',
100
118
  detailFeedback: '这条记忆有用吗',
101
119
  feedbackUsed: '用到了',
@@ -156,6 +174,12 @@ window.__ModuleLoader__.load({
156
174
  graphRank: 'PageRank',
157
175
  graphScaleNote: '节点越大,PageRank 越高;轨道只是布局参考,不是边。',
158
176
  graphTopCount: '展示',
177
+ graphCandidatesTitle: '候选边(待审核)',
178
+ graphCandidatesHint: '候选边不会进入图谱,批准后才生效。',
179
+ graphCandidatesEmpty: '没有待审核的候选边。',
180
+ graphApprove: '批准',
181
+ graphReject: '拒绝',
182
+ graphCandidateConfidence: '置信',
159
183
  settingsTitle: '运行设置',
160
184
  settingsHint: '设置与指标均为只读快照。',
161
185
  runtime: '运行时',
@@ -168,6 +192,16 @@ window.__ModuleLoader__.load({
168
192
  permissionConfirm: '修改权限会写入本机 cortex 数据目录,重启后生效。继续吗?',
169
193
  permissionSaved: '已保存;重启 DSH 后生效。',
170
194
  permissionRestart: '重启后生效',
195
+ scopeGrants: '共享写授权',
196
+ scopeGrantsHint: '只对带 x-cortex-agent 的 API 生效;撤销立即生效并持久化。工具路径不带 agent。',
197
+ scopeGrantName: '授权(scope:agent)',
198
+ scopeGrantUntil: '到期(ISO,可空)',
199
+ scopeGrantAdd: '添加授权',
200
+ scopeGrantRevoke: '撤销',
201
+ scopeGrantEmpty: '没有共享写授权。',
202
+ scopeGrantForever: '永久',
203
+ scopeGrantRevoked: '已撤销',
204
+ scopeGrantSaved: '已保存',
171
205
  vectorEnable: '启用向量',
172
206
  vectorDisabled: '向量默认关闭。启用后使用内置 hash-256(不需要下载模型),重启后生效。',
173
207
  modelTitle: '嵌入 / 重排模型',
@@ -195,6 +229,7 @@ window.__ModuleLoader__.load({
195
229
  vector: '向量',
196
230
  scope: 'scope',
197
231
  dataRoot: 'dataRoot',
232
+ readOnlyScopes: '只读 scope',
198
233
  dbFile: 'dbFile',
199
234
  contextBudget: 'contextBudget',
200
235
  promptBudget: 'promptBudget',
@@ -324,6 +359,24 @@ window.__ModuleLoader__.load({
324
359
  detailContent: 'Content',
325
360
  detailSummary: 'Summary',
326
361
  detailTags: 'Tags',
362
+ detailAttachments: 'Attachments (metadata only)',
363
+ attachmentAddTitle: 'Attach a local file',
364
+ attachmentAddHint: 'Enter a file:// path. Check uses the local reader (off by default); Attach only stores reference metadata.',
365
+ attachmentCheck: 'Check',
366
+ attachmentSave: 'Attach',
367
+ attachmentSaved: 'Attached',
368
+ extractTitle: 'Extract into memory',
369
+ extractHint: 'OCR/ASR output is stored as a derived trace; the original trace is unchanged.',
370
+ extractTarget: 'Target trace',
371
+ extractLayout: 'Layout',
372
+ layoutAuto: 'Auto',
373
+ layoutSingle: 'Single column',
374
+ layoutTwoColumn: 'Two columns',
375
+ layoutHandwriting: 'Handwriting',
376
+ extractRun: 'Extract and write',
377
+ extractSaved: 'Derived trace written',
378
+ extractSuggested: 'Suggested: current trace',
379
+ extractChanged: 'Target changed',
327
380
  detailKeywords: 'Keywords',
328
381
  detailFeedback: 'Was this memory useful?',
329
382
  feedbackUsed: 'Used',
@@ -384,6 +437,12 @@ window.__ModuleLoader__.load({
384
437
  graphRank: 'PageRank',
385
438
  graphScaleNote: 'Bigger nodes have higher PageRank; the orbits are layout guides, not edges.',
386
439
  graphTopCount: 'Showing',
440
+ graphCandidatesTitle: 'Candidate edges (pending)',
441
+ graphCandidatesHint: 'Candidate edges never enter the graph until approved.',
442
+ graphCandidatesEmpty: 'No pending candidate edges.',
443
+ graphApprove: 'Approve',
444
+ graphReject: 'Reject',
445
+ graphCandidateConfidence: 'Confidence',
387
446
  settingsTitle: 'Runtime settings',
388
447
  settingsHint: 'Settings and metrics are read-only snapshots.',
389
448
  runtime: 'Runtime',
@@ -396,6 +455,16 @@ window.__ModuleLoader__.load({
396
455
  permissionConfirm: 'Permission changes write a local override and take effect after restarting DSH. Continue?',
397
456
  permissionSaved: 'Saved; restart DSH to apply.',
398
457
  permissionRestart: 'restart required',
458
+ scopeGrants: 'Shared write grants',
459
+ scopeGrantsHint: 'Only applies to API calls with x-cortex-agent; revoke is immediate and persisted. Tool calls do not send an agent header.',
460
+ scopeGrantName: 'Grant (scope:agent)',
461
+ scopeGrantUntil: 'Until (ISO, optional)',
462
+ scopeGrantAdd: 'Add grant',
463
+ scopeGrantRevoke: 'Revoke',
464
+ scopeGrantEmpty: 'No shared write grants.',
465
+ scopeGrantForever: 'forever',
466
+ scopeGrantRevoked: 'Revoked',
467
+ scopeGrantSaved: 'Saved',
399
468
  vectorEnable: 'Enable vector',
400
469
  vectorDisabled: 'Vector is off by default. Enabling uses the local hash-256 embedder (no model download); restart required.',
401
470
  modelTitle: 'Embedding / rerank models',
@@ -423,6 +492,7 @@ window.__ModuleLoader__.load({
423
492
  vector: 'Vector',
424
493
  scope: 'scope',
425
494
  dataRoot: 'dataRoot',
495
+ readOnlyScopes: 'Read-only scopes',
426
496
  dbFile: 'dbFile',
427
497
  contextBudget: 'contextBudget',
428
498
  promptBudget: 'promptBudget',
@@ -879,7 +949,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
879
949
  h(Field, { key: 'tag', label: t('filterTag'), hint: t('filterTagsHint') }, h('input', {
880
950
  className: 'cortex-input',
881
951
  value: filters.tag,
882
- placeholder: '米饭, 用户',
952
+ placeholder: '白饭, 主人',
883
953
  'aria-label': t('filterTag'),
884
954
  onChange: (event) => change('tag', event.target.value),
885
955
  onKeyDown: enter,
@@ -941,6 +1011,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
941
1011
  h('div', { key: 'head', className: 'cortex-result-head' }, [
942
1012
  h(Badge, { key: 'layer' }, item.layer ?? item.type ?? 'trace'),
943
1013
  h('b', { key: 'title', className: 'cortex-result-title' }, item.title ?? item.id),
1014
+ item.attachments ? h('span', { key: 'attachments', className: 'cortex-meta' }, `📎 ${item.attachments}`) : null,
944
1015
  h('span', { key: 'score', className: 'cortex-result-score' }, `score ${formatNumber(item.score, 3)}`),
945
1016
  ]),
946
1017
  h('div', { key: 'meta', className: 'cortex-meta' }, `${item.id} · ${formatTime(item.recordedAt)}${item.why ? ` · ${item.why}` : ''}`),
@@ -977,6 +1048,117 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
977
1048
  ].map(([key, label, value]) => [key, label, displayValue(value)]);
978
1049
  }
979
1050
 
1051
+ function AttachmentComposer({ trace, t, onSaved }) {
1052
+ const [uri, setUri] = useState('');
1053
+ const [note, setNote] = useState('');
1054
+ const [busy, setBusy] = useState(false);
1055
+ const inspect = async () => {
1056
+ if (!uri.trim()) return;
1057
+ setBusy(true); setNote('');
1058
+ try {
1059
+ const result = await api('/attachments/inspect', { method: 'POST', body: JSON.stringify({ uri: uri.trim() }) });
1060
+ setNote(`${result.name} · ${result.bytes} B · ${result.sha256.slice(0, 16)}…`);
1061
+ } catch (issue) {
1062
+ setNote(String(issue.message ?? issue));
1063
+ } finally { setBusy(false); }
1064
+ };
1065
+ const save = async () => {
1066
+ if (!uri.trim()) return;
1067
+ setBusy(true); setNote('');
1068
+ try {
1069
+ await api('/trace/attachments', { method: 'POST', body: JSON.stringify({ id: trace.id, attachments: [{ kind: 'file', uri: uri.trim() }] }) });
1070
+ setUri('');
1071
+ setNote(t('attachmentSaved'));
1072
+ onSaved?.();
1073
+ } catch (issue) {
1074
+ setNote(String(issue.message ?? issue));
1075
+ } finally { setBusy(false); }
1076
+ };
1077
+ return h('section', { className: 'cortex-detail-section' }, [
1078
+ h('h4', {}, t('attachmentAddTitle')),
1079
+ h('div', { className: 'cortex-note' }, t('attachmentAddHint')),
1080
+ h('div', { className: 'cortex-line' }, [
1081
+ h('input', { className: 'cortex-input', value: uri, placeholder: 'file:///C:/path/rice.png', onChange: (event) => setUri(event.target.value) }),
1082
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: inspect }, t('attachmentCheck')),
1083
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: save }, t('attachmentSave')),
1084
+ ]),
1085
+ note ? h('div', { className: 'cortex-meta' }, note) : null,
1086
+ ]);
1087
+ }
1088
+
1089
+ function ExtractionComposer({ trace, t, onSaved }) {
1090
+ const [uri, setUri] = useState('');
1091
+ const [mode, setMode] = useState('ocr');
1092
+ const [language, setLanguage] = useState('');
1093
+ const [layout, setLayout] = useState('auto');
1094
+ const [targetId, setTargetId] = useState(trace.id);
1095
+ const [candidates, setCandidates] = useState([{ id: trace.id, title: trace.title || trace.snippet || trace.id }]);
1096
+ const [busy, setBusy] = useState(false);
1097
+ const [note, setNote] = useState('');
1098
+ useEffect(() => {
1099
+ let alive = true;
1100
+ api(`/timeline?id=${encodeURIComponent(trace.id)}&before=5&after=5`)
1101
+ .then((value) => {
1102
+ if (!alive) return;
1103
+ const list = [];
1104
+ const seen = new Set();
1105
+ const push = (item) => {
1106
+ if (!item?.id || seen.has(item.id)) return;
1107
+ seen.add(item.id);
1108
+ list.push({ id: item.id, title: item.title || item.snippet || item.id });
1109
+ };
1110
+ push(value?.anchor);
1111
+ for (const item of value?.items ?? []) push(item);
1112
+ if (!list.length) push({ id: trace.id, title: trace.title || trace.id });
1113
+ setCandidates(list.slice(0, 8));
1114
+ })
1115
+ .catch(() => {});
1116
+ return () => { alive = false; };
1117
+ }, [trace.id]);
1118
+ const run = async () => {
1119
+ if (!uri.trim()) return;
1120
+ setBusy(true); setNote('');
1121
+ try {
1122
+ const result = await api('/attachments/extract', {
1123
+ method: 'POST',
1124
+ body: JSON.stringify({ uri: uri.trim(), mode, language: language.trim() || undefined, layout, traceId: targetId }),
1125
+ });
1126
+ setNote(`${t('extractSaved')} ${result.extractedTraceId ?? ''}`.trim());
1127
+ setUri('');
1128
+ onSaved?.();
1129
+ } catch (issue) {
1130
+ setNote(String(issue.message ?? issue));
1131
+ } finally { setBusy(false); }
1132
+ };
1133
+ return h('section', { className: 'cortex-detail-section' }, [
1134
+ h('h4', {}, t('extractTitle')),
1135
+ h('div', { className: 'cortex-note' }, t('extractHint')),
1136
+ h('div', { className: 'cortex-line' }, [
1137
+ h('input', { className: 'cortex-input', value: uri, placeholder: 'file:///C:/path/rice.png', onChange: (event) => setUri(event.target.value) }),
1138
+ h('select', { className: 'cortex-input', value: mode, onChange: (event) => setMode(event.target.value) }, [
1139
+ h('option', { key: 'ocr', value: 'ocr' }, 'OCR'),
1140
+ h('option', { key: 'asr', value: 'asr' }, 'ASR'),
1141
+ ]),
1142
+ h('input', { className: 'cortex-input', value: language, placeholder: mode === 'ocr' ? 'zh-Hans' : 'zh', onChange: (event) => setLanguage(event.target.value) }),
1143
+ h('select', { className: 'cortex-input', value: layout, title: t('extractLayout'), onChange: (event) => setLayout(event.target.value) }, [
1144
+ h('option', { key: 'auto', value: 'auto' }, t('layoutAuto')),
1145
+ h('option', { key: 'single', value: 'single' }, t('layoutSingle')),
1146
+ h('option', { key: 'two-column', value: 'two-column' }, t('layoutTwoColumn')),
1147
+ h('option', { key: 'handwriting', value: 'handwriting' }, t('layoutHandwriting')),
1148
+ ]),
1149
+ ]),
1150
+ h('label', { className: 'cortex-line', style: { marginTop: 8 } }, [
1151
+ t('extractTarget'),
1152
+ h('select', { className: 'cortex-input', value: targetId, onChange: (event) => setTargetId(event.target.value) }, candidates.map((item) => h('option', { key: item.id, value: item.id }, `${item.title} · ${item.id}`))),
1153
+ ]),
1154
+ h('div', { className: 'cortex-line' }, [
1155
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: run }, t('extractRun')),
1156
+ h('span', { className: 'cortex-meta' }, targetId === trace.id ? t('extractSuggested') : t('extractChanged')),
1157
+ ]),
1158
+ note ? h('div', { className: 'cortex-meta' }, note) : null,
1159
+ ]);
1160
+ }
1161
+
980
1162
  function TraceDetail({ id, t, onClose, onTimeline }) {
981
1163
  const [trace, setTrace] = useState(null);
982
1164
  const [loading, setLoading] = useState(true);
@@ -1106,6 +1288,12 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1106
1288
  h('h4', {}, t('detailTags')),
1107
1289
  h('div', { className: 'cortex-chips' }, trace.tags.map((tag, index) => h(Badge, { key: `${tag}-${index}` }, tag))),
1108
1290
  ]) : null,
1291
+ (trace.metadata?.attachments ?? []).length ? h('section', { key: 'attachments', className: 'cortex-detail-section' }, [
1292
+ h('h4', {}, t('detailAttachments')),
1293
+ h('div', { className: 'cortex-note' }, trace.metadata.attachments.map((item, index) => h('div', { key: `${item.uri}-${index}` }, `${item.kind} · ${item.name ?? item.uri}${item.mime ? ` · ${item.mime}` : ''}${item.bytes ? ` · ${item.bytes} B` : ''}`))),
1294
+ ]) : null,
1295
+ h(AttachmentComposer, { key: 'attach', trace, t, onSaved: loadTrace }),
1296
+ h(ExtractionComposer, { key: 'extract', trace, t, onSaved: loadTrace }),
1109
1297
  (trace.keywords ?? []).length ? h('section', { key: 'keywords', className: 'cortex-detail-section' }, [
1110
1298
  h('h4', {}, t('detailKeywords')),
1111
1299
  h('div', { className: 'cortex-chips' }, trace.keywords.map((keyword, index) => h(Badge, { key: `${keyword}-${index}` }, keyword))),
@@ -1444,12 +1632,12 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1444
1632
  useEffect(() => { load(); }, [load]);
1445
1633
  const record = async () => {
1446
1634
  if (!text.trim()) return;
1447
- try { await api('/emotion/record', { method: 'POST', body: JSON.stringify({ text, target: '用户' }) }); setText(''); load(); } catch (issue) { setError(String(issue.message ?? issue)); }
1635
+ try { await api('/emotion/record', { method: 'POST', body: JSON.stringify({ text, target: '主人' }) }); setText(''); load(); } catch (issue) { setError(String(issue.message ?? issue)); }
1448
1636
  };
1449
1637
  return h('div', {}, [
1450
1638
  error ? h(ErrorBlock, { key: 'err', message: error, prefix: t('requestFailed') }) : null,
1451
1639
  h('div', { key: 'line', className: 'cortex-line' }, [
1452
- h('input', { key: 'i', className: 'cortex-input', value: text, placeholder: '助手现在有点…', 'aria-label': '情绪记录', onChange: (event) => setText(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter') record(); } }),
1640
+ h('input', { key: 'i', className: 'cortex-input', value: text, placeholder: '本鲸现在有点…', 'aria-label': '情绪记录', onChange: (event) => setText(event.target.value), onKeyDown: (event) => { if (event.key === 'Enter') record(); } }),
1453
1641
  h('button', { key: 'b', type: 'button', className: 'cortex-btn primary', onClick: record }, '记一下'),
1454
1642
  ]),
1455
1643
  data ? h('div', {}, [
@@ -1629,14 +1817,32 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1629
1817
  function GraphTab({ t }) {
1630
1818
  const { data, error, busy, reload } = useEndpoint('/graph');
1631
1819
  const { data: edgeData } = useEndpoint('/graph/edges');
1820
+ const candidates = useEndpoint('/graph/candidates');
1821
+ const [candidateNotice, setCandidateNotice] = useState('');
1822
+ const [candidateBusy, setCandidateBusy] = useState('');
1632
1823
  const top = Array.isArray(data?.top) ? data.top : [];
1633
1824
  const layout = buildGraphLayout(top);
1634
1825
  const positions = new Map(layout.map((node) => [node.id, node]));
1635
1826
  const edges = Array.isArray(edgeData?.edges) ? edgeData.edges.filter((edge) => positions.has(edge.from) && positions.has(edge.to)).slice(0, 120) : [];
1827
+ const pendingEdges = Array.isArray(candidates.data) ? candidates.data : [];
1636
1828
  const shortLabel = (id) => {
1637
1829
  const value = String(id ?? '');
1638
1830
  return value.length > 18 ? `${value.slice(0, 16)}…` : value;
1639
1831
  };
1832
+ const decideCandidate = async (id, action) => {
1833
+ setCandidateBusy(id);
1834
+ setCandidateNotice('');
1835
+ try {
1836
+ await api('/graph/candidates/action', { method: 'POST', body: JSON.stringify({ action, id }) });
1837
+ candidates.reload();
1838
+ reload();
1839
+ setCandidateNotice(action === 'approve' ? t('graphApprove') : t('graphReject'));
1840
+ } catch (issue) {
1841
+ setCandidateNotice(String(issue.message ?? issue));
1842
+ } finally {
1843
+ setCandidateBusy('');
1844
+ }
1845
+ };
1640
1846
  return h('div', {}, [
1641
1847
  h('div', { key: 'hint', className: 'cortex-note' }, t('graphHint')),
1642
1848
  h('div', { key: 'line', className: 'cortex-line' }, [
@@ -1644,6 +1850,8 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1644
1850
  ]),
1645
1851
  error ? h(ErrorBlock, { key: 'err', message: error, prefix: t('requestFailed'), onRetry: reload, retryLabel: t('retry') }) : null,
1646
1852
  busy ? h(LoadingBlock, { key: 'loading', label: t('loading') }) : null,
1853
+ candidates.error ? h(ErrorBlock, { key: 'cerr', message: candidates.error, prefix: t('requestFailed'), onRetry: candidates.reload, retryLabel: t('retry') }) : null,
1854
+ candidateNotice ? h('div', { key: 'cnotice', className: 'cortex-meta' }, candidateNotice) : null,
1647
1855
  data ? h('div', { key: 'data' }, [
1648
1856
  h('div', { key: 'stats', className: 'cortex-grid' }, [
1649
1857
  h('div', { key: 'nodes', className: 'cortex-card cortex-stat' }, [h('h4', {}, t('graphNodes')), h('div', { className: 'cortex-big' }, data.nodes ?? 0)]),
@@ -1692,6 +1900,36 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1692
1900
  ]))),
1693
1901
  ]) : null,
1694
1902
  ]) : null,
1903
+ h('div', { key: 'candidates', className: 'cortex-card', style: { marginTop: 10 } }, [
1904
+ h('div', { key: 'head', className: 'cortex-card-head' }, [
1905
+ h('h4', {}, t('graphCandidatesTitle')),
1906
+ h('span', { key: 'count', className: 'cortex-meta' }, pendingEdges.length),
1907
+ ]),
1908
+ h('div', { key: 'hint', className: 'cortex-meta' }, t('graphCandidatesHint')),
1909
+ candidates.busy ? h(LoadingBlock, { key: 'loading', label: t('loading') }) : null,
1910
+ pendingEdges.length ? h('div', { key: 'list', style: { marginTop: 6 } }, pendingEdges.map((edge) => h('div', {
1911
+ key: edge.id,
1912
+ className: 'cortex-item',
1913
+ style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
1914
+ }, [
1915
+ h('span', { key: 'label', style: { flex: '1 1 240px', minWidth: 0 } },
1916
+ `${shortLabel(edge.fromId)} → ${shortLabel(edge.toId)} · ${edge.rel} · ${t('graphCandidateConfidence')} ${formatNumber(edge.metadata?.confidence ?? edge.weight ?? 0, 2)}`),
1917
+ h('button', {
1918
+ key: 'approve',
1919
+ type: 'button',
1920
+ className: 'cortex-btn',
1921
+ disabled: candidateBusy === edge.id,
1922
+ onClick: () => decideCandidate(edge.id, 'approve'),
1923
+ }, t('graphApprove')),
1924
+ h('button', {
1925
+ key: 'reject',
1926
+ type: 'button',
1927
+ className: 'cortex-btn',
1928
+ disabled: candidateBusy === edge.id,
1929
+ onClick: () => decideCandidate(edge.id, 'reject'),
1930
+ }, t('graphReject')),
1931
+ ]))) : h(EmptyBlock, { key: 'empty' }, t('graphCandidatesEmpty')),
1932
+ ]),
1695
1933
  ]);
1696
1934
  }
1697
1935
 
@@ -1782,7 +2020,28 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1782
2020
  function SettingsTab({ t }) {
1783
2021
  const settings = useEndpoint('/settings');
1784
2022
  const metrics = useEndpoint('/metrics');
2023
+ const grants = useEndpoint('/security/grants');
1785
2024
  const [notice, setNotice] = useState('');
2025
+ const [grantName, setGrantName] = useState('shared:agent-a');
2026
+ const [grantUntil, setGrantUntil] = useState('');
2027
+ const [grantNotice, setGrantNotice] = useState('');
2028
+ const [grantBusy, setGrantBusy] = useState(false);
2029
+ const addGrant = async () => {
2030
+ setGrantBusy(true); setGrantNotice('');
2031
+ try {
2032
+ await api('/security/grants', { method: 'POST', body: JSON.stringify({ action: 'add', grant: grantName.trim(), until: grantUntil.trim() || null }) });
2033
+ grants.reload();
2034
+ setGrantNotice(t('scopeGrantSaved'));
2035
+ } catch (issue) { setGrantNotice(String(issue.message ?? issue)); } finally { setGrantBusy(false); }
2036
+ };
2037
+ const revokeGrant = async (grant) => {
2038
+ setGrantBusy(true); setGrantNotice('');
2039
+ try {
2040
+ await api('/security/grants', { method: 'POST', body: JSON.stringify({ action: 'revoke', grant }) });
2041
+ grants.reload();
2042
+ setGrantNotice(t('scopeGrantSaved'));
2043
+ } catch (issue) { setGrantNotice(String(issue.message ?? issue)); } finally { setGrantBusy(false); }
2044
+ };
1786
2045
  const refreshAll = () => { settings.reload(); metrics.reload(); };
1787
2046
  const changePermission = async (key, value) => {
1788
2047
  if (!window.confirm(t('permissionConfirm'))) return;
@@ -1817,6 +2076,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1817
2076
  ['scope', t('scope'), setting.scope],
1818
2077
  ['dataRoot', t('dataRoot'), setting.dataRoot],
1819
2078
  ['dbFile', t('dbFile'), setting.dbFile],
2079
+ ['readOnlyScopes', t('readOnlyScopes'), (setting.readOnlyScopes ?? []).join(', ') || '—'],
1820
2080
  ['contextBudget', t('contextBudget'), setting.contextBudget],
1821
2081
  ['promptBudget', t('promptBudget'), setting.promptBudget],
1822
2082
  ].map(([key, label, value]) => [key, label, displayValue(value)]) }),
@@ -1871,6 +2131,28 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1871
2131
  ]),
1872
2132
  notice ? h('div', { className: 'cortex-note' }, notice) : null,
1873
2133
  ]),
2134
+ h('section', { key: 'grants', className: 'cortex-card' }, [
2135
+ h('h4', {}, t('scopeGrants')),
2136
+ h('p', { className: 'cortex-note' }, t('scopeGrantsHint')),
2137
+ grants.error ? h(ErrorBlock, { key: 'gerr', message: grants.error, prefix: t('requestFailed'), onRetry: grants.reload, retryLabel: t('retry') }) : null,
2138
+ grants.busy ? h(LoadingBlock, { key: 'gload', label: t('loading') }) : null,
2139
+ (grants.data?.grants ?? []).length
2140
+ ? h('div', { key: 'glist', className: 'cortex-grid' }, grants.data.grants.map((item) => h('div', { key: item.grant, className: 'cortex-row', style: { justifyContent: 'space-between', gap: 8 } }, [
2141
+ h('code', { key: 'g', className: 'cortex-code' }, item.grant),
2142
+ h('span', { key: 'u', className: 'cortex-meta' }, item.until ? formatTime(item.until) : t('scopeGrantForever')),
2143
+ h('button', { key: 'r', type: 'button', className: 'cortex-btn', disabled: grantBusy, onClick: () => revokeGrant(item.grant) }, t('scopeGrantRevoke')),
2144
+ ])))
2145
+ : h(EmptyBlock, { key: 'gempty' }, t('scopeGrantEmpty')),
2146
+ h('div', { key: 'gadd', className: 'cortex-grid', style: { marginTop: 8 } }, [
2147
+ h('label', { key: 'name' }, [t('scopeGrantName'), h('input', { className: 'cortex-input', value: grantName, placeholder: 'shared:agent-a', onChange: (event) => setGrantName(event.target.value) })]),
2148
+ h('label', { key: 'until' }, [t('scopeGrantUntil'), h('input', { className: 'cortex-input', value: grantUntil, placeholder: '2026-12-31T00:00:00Z', onChange: (event) => setGrantUntil(event.target.value) })]),
2149
+ ]),
2150
+ h('div', { key: 'gline', className: 'cortex-line' }, [
2151
+ h('button', { key: 'add', type: 'button', className: 'cortex-btn', disabled: grantBusy, onClick: addGrant }, t('scopeGrantAdd')),
2152
+ grantNotice ? h('span', { key: 'n', className: 'cortex-meta' }, grantNotice) : null,
2153
+ ]),
2154
+ (grants.data?.revoked ?? []).length ? h('div', { key: 'revoked', className: 'cortex-note' }, `${t('scopeGrantRevoked')}: ${grants.data.revoked.join(', ')}`) : null,
2155
+ ]),
1874
2156
  h('section', { key: 'vector', className: 'cortex-card' }, [
1875
2157
  h('h4', {}, t('vector')),
1876
2158
  setting.vector
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-cortex bundle patch.
2
2
  # Host row + client panel. The engine owns its own data root (~/.dsh/cortex),
3
- # never a legacy profile's data.
3
+ # never shiori's data.
4
4
  - insert:
5
5
  - id: cortex
6
6
  name: 'dsh-cortex-memory'