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

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 +269 -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 +6 -13
  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 +144 -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 +62 -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,19 @@ 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
+ extractRun: '提取并写入',
109
+ extractSaved: '已写入派生记忆',
110
+ extractSuggested: '建议:当前这条',
111
+ extractChanged: '已改选目标',
99
112
  detailKeywords: '关键词',
100
113
  detailFeedback: '这条记忆有用吗',
101
114
  feedbackUsed: '用到了',
@@ -156,6 +169,12 @@ window.__ModuleLoader__.load({
156
169
  graphRank: 'PageRank',
157
170
  graphScaleNote: '节点越大,PageRank 越高;轨道只是布局参考,不是边。',
158
171
  graphTopCount: '展示',
172
+ graphCandidatesTitle: '候选边(待审核)',
173
+ graphCandidatesHint: '候选边不会进入图谱,批准后才生效。',
174
+ graphCandidatesEmpty: '没有待审核的候选边。',
175
+ graphApprove: '批准',
176
+ graphReject: '拒绝',
177
+ graphCandidateConfidence: '置信',
159
178
  settingsTitle: '运行设置',
160
179
  settingsHint: '设置与指标均为只读快照。',
161
180
  runtime: '运行时',
@@ -168,6 +187,16 @@ window.__ModuleLoader__.load({
168
187
  permissionConfirm: '修改权限会写入本机 cortex 数据目录,重启后生效。继续吗?',
169
188
  permissionSaved: '已保存;重启 DSH 后生效。',
170
189
  permissionRestart: '重启后生效',
190
+ scopeGrants: '共享写授权',
191
+ scopeGrantsHint: '只对带 x-cortex-agent 的 API 生效;撤销立即生效并持久化。工具路径不带 agent。',
192
+ scopeGrantName: '授权(scope:agent)',
193
+ scopeGrantUntil: '到期(ISO,可空)',
194
+ scopeGrantAdd: '添加授权',
195
+ scopeGrantRevoke: '撤销',
196
+ scopeGrantEmpty: '没有共享写授权。',
197
+ scopeGrantForever: '永久',
198
+ scopeGrantRevoked: '已撤销',
199
+ scopeGrantSaved: '已保存',
171
200
  vectorEnable: '启用向量',
172
201
  vectorDisabled: '向量默认关闭。启用后使用内置 hash-256(不需要下载模型),重启后生效。',
173
202
  modelTitle: '嵌入 / 重排模型',
@@ -195,6 +224,7 @@ window.__ModuleLoader__.load({
195
224
  vector: '向量',
196
225
  scope: 'scope',
197
226
  dataRoot: 'dataRoot',
227
+ readOnlyScopes: '只读 scope',
198
228
  dbFile: 'dbFile',
199
229
  contextBudget: 'contextBudget',
200
230
  promptBudget: 'promptBudget',
@@ -324,6 +354,19 @@ window.__ModuleLoader__.load({
324
354
  detailContent: 'Content',
325
355
  detailSummary: 'Summary',
326
356
  detailTags: 'Tags',
357
+ detailAttachments: 'Attachments (metadata only)',
358
+ attachmentAddTitle: 'Attach a local file',
359
+ attachmentAddHint: 'Enter a file:// path. Check uses the local reader (off by default); Attach only stores reference metadata.',
360
+ attachmentCheck: 'Check',
361
+ attachmentSave: 'Attach',
362
+ attachmentSaved: 'Attached',
363
+ extractTitle: 'Extract into memory',
364
+ extractHint: 'OCR/ASR output is stored as a derived trace; the original trace is unchanged.',
365
+ extractTarget: 'Target trace',
366
+ extractRun: 'Extract and write',
367
+ extractSaved: 'Derived trace written',
368
+ extractSuggested: 'Suggested: current trace',
369
+ extractChanged: 'Target changed',
327
370
  detailKeywords: 'Keywords',
328
371
  detailFeedback: 'Was this memory useful?',
329
372
  feedbackUsed: 'Used',
@@ -384,6 +427,12 @@ window.__ModuleLoader__.load({
384
427
  graphRank: 'PageRank',
385
428
  graphScaleNote: 'Bigger nodes have higher PageRank; the orbits are layout guides, not edges.',
386
429
  graphTopCount: 'Showing',
430
+ graphCandidatesTitle: 'Candidate edges (pending)',
431
+ graphCandidatesHint: 'Candidate edges never enter the graph until approved.',
432
+ graphCandidatesEmpty: 'No pending candidate edges.',
433
+ graphApprove: 'Approve',
434
+ graphReject: 'Reject',
435
+ graphCandidateConfidence: 'Confidence',
387
436
  settingsTitle: 'Runtime settings',
388
437
  settingsHint: 'Settings and metrics are read-only snapshots.',
389
438
  runtime: 'Runtime',
@@ -396,6 +445,16 @@ window.__ModuleLoader__.load({
396
445
  permissionConfirm: 'Permission changes write a local override and take effect after restarting DSH. Continue?',
397
446
  permissionSaved: 'Saved; restart DSH to apply.',
398
447
  permissionRestart: 'restart required',
448
+ scopeGrants: 'Shared write grants',
449
+ scopeGrantsHint: 'Only applies to API calls with x-cortex-agent; revoke is immediate and persisted. Tool calls do not send an agent header.',
450
+ scopeGrantName: 'Grant (scope:agent)',
451
+ scopeGrantUntil: 'Until (ISO, optional)',
452
+ scopeGrantAdd: 'Add grant',
453
+ scopeGrantRevoke: 'Revoke',
454
+ scopeGrantEmpty: 'No shared write grants.',
455
+ scopeGrantForever: 'forever',
456
+ scopeGrantRevoked: 'Revoked',
457
+ scopeGrantSaved: 'Saved',
399
458
  vectorEnable: 'Enable vector',
400
459
  vectorDisabled: 'Vector is off by default. Enabling uses the local hash-256 embedder (no model download); restart required.',
401
460
  modelTitle: 'Embedding / rerank models',
@@ -423,6 +482,7 @@ window.__ModuleLoader__.load({
423
482
  vector: 'Vector',
424
483
  scope: 'scope',
425
484
  dataRoot: 'dataRoot',
485
+ readOnlyScopes: 'Read-only scopes',
426
486
  dbFile: 'dbFile',
427
487
  contextBudget: 'contextBudget',
428
488
  promptBudget: 'promptBudget',
@@ -879,7 +939,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
879
939
  h(Field, { key: 'tag', label: t('filterTag'), hint: t('filterTagsHint') }, h('input', {
880
940
  className: 'cortex-input',
881
941
  value: filters.tag,
882
- placeholder: '米饭, 用户',
942
+ placeholder: '白饭, 主人',
883
943
  'aria-label': t('filterTag'),
884
944
  onChange: (event) => change('tag', event.target.value),
885
945
  onKeyDown: enter,
@@ -941,6 +1001,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
941
1001
  h('div', { key: 'head', className: 'cortex-result-head' }, [
942
1002
  h(Badge, { key: 'layer' }, item.layer ?? item.type ?? 'trace'),
943
1003
  h('b', { key: 'title', className: 'cortex-result-title' }, item.title ?? item.id),
1004
+ item.attachments ? h('span', { key: 'attachments', className: 'cortex-meta' }, `📎 ${item.attachments}`) : null,
944
1005
  h('span', { key: 'score', className: 'cortex-result-score' }, `score ${formatNumber(item.score, 3)}`),
945
1006
  ]),
946
1007
  h('div', { key: 'meta', className: 'cortex-meta' }, `${item.id} · ${formatTime(item.recordedAt)}${item.why ? ` · ${item.why}` : ''}`),
@@ -977,6 +1038,110 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
977
1038
  ].map(([key, label, value]) => [key, label, displayValue(value)]);
978
1039
  }
979
1040
 
1041
+ function AttachmentComposer({ trace, t, onSaved }) {
1042
+ const [uri, setUri] = useState('');
1043
+ const [note, setNote] = useState('');
1044
+ const [busy, setBusy] = useState(false);
1045
+ const inspect = async () => {
1046
+ if (!uri.trim()) return;
1047
+ setBusy(true); setNote('');
1048
+ try {
1049
+ const result = await api('/attachments/inspect', { method: 'POST', body: JSON.stringify({ uri: uri.trim() }) });
1050
+ setNote(`${result.name} · ${result.bytes} B · ${result.sha256.slice(0, 16)}…`);
1051
+ } catch (issue) {
1052
+ setNote(String(issue.message ?? issue));
1053
+ } finally { setBusy(false); }
1054
+ };
1055
+ const save = async () => {
1056
+ if (!uri.trim()) return;
1057
+ setBusy(true); setNote('');
1058
+ try {
1059
+ await api('/trace/attachments', { method: 'POST', body: JSON.stringify({ id: trace.id, attachments: [{ kind: 'file', uri: uri.trim() }] }) });
1060
+ setUri('');
1061
+ setNote(t('attachmentSaved'));
1062
+ onSaved?.();
1063
+ } catch (issue) {
1064
+ setNote(String(issue.message ?? issue));
1065
+ } finally { setBusy(false); }
1066
+ };
1067
+ return h('section', { className: 'cortex-detail-section' }, [
1068
+ h('h4', {}, t('attachmentAddTitle')),
1069
+ h('div', { className: 'cortex-note' }, t('attachmentAddHint')),
1070
+ h('div', { className: 'cortex-line' }, [
1071
+ h('input', { className: 'cortex-input', value: uri, placeholder: 'file:///C:/path/rice.png', onChange: (event) => setUri(event.target.value) }),
1072
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: inspect }, t('attachmentCheck')),
1073
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: save }, t('attachmentSave')),
1074
+ ]),
1075
+ note ? h('div', { className: 'cortex-meta' }, note) : null,
1076
+ ]);
1077
+ }
1078
+
1079
+ function ExtractionComposer({ trace, t, onSaved }) {
1080
+ const [uri, setUri] = useState('');
1081
+ const [mode, setMode] = useState('ocr');
1082
+ const [language, setLanguage] = useState('');
1083
+ const [targetId, setTargetId] = useState(trace.id);
1084
+ const [candidates, setCandidates] = useState([{ id: trace.id, title: trace.title || trace.snippet || trace.id }]);
1085
+ const [busy, setBusy] = useState(false);
1086
+ const [note, setNote] = useState('');
1087
+ useEffect(() => {
1088
+ let alive = true;
1089
+ api(`/timeline?id=${encodeURIComponent(trace.id)}&before=5&after=5`)
1090
+ .then((value) => {
1091
+ if (!alive) return;
1092
+ const list = [];
1093
+ const seen = new Set();
1094
+ const push = (item) => {
1095
+ if (!item?.id || seen.has(item.id)) return;
1096
+ seen.add(item.id);
1097
+ list.push({ id: item.id, title: item.title || item.snippet || item.id });
1098
+ };
1099
+ push(value?.anchor);
1100
+ for (const item of value?.items ?? []) push(item);
1101
+ if (!list.length) push({ id: trace.id, title: trace.title || trace.id });
1102
+ setCandidates(list.slice(0, 8));
1103
+ })
1104
+ .catch(() => {});
1105
+ return () => { alive = false; };
1106
+ }, [trace.id]);
1107
+ const run = async () => {
1108
+ if (!uri.trim()) return;
1109
+ setBusy(true); setNote('');
1110
+ try {
1111
+ const result = await api('/attachments/extract', {
1112
+ method: 'POST',
1113
+ body: JSON.stringify({ uri: uri.trim(), mode, language: language.trim() || undefined, traceId: targetId }),
1114
+ });
1115
+ setNote(`${t('extractSaved')} ${result.extractedTraceId ?? ''}`.trim());
1116
+ setUri('');
1117
+ onSaved?.();
1118
+ } catch (issue) {
1119
+ setNote(String(issue.message ?? issue));
1120
+ } finally { setBusy(false); }
1121
+ };
1122
+ return h('section', { className: 'cortex-detail-section' }, [
1123
+ h('h4', {}, t('extractTitle')),
1124
+ h('div', { className: 'cortex-note' }, t('extractHint')),
1125
+ h('div', { className: 'cortex-line' }, [
1126
+ h('input', { className: 'cortex-input', value: uri, placeholder: 'file:///C:/path/rice.png', onChange: (event) => setUri(event.target.value) }),
1127
+ h('select', { className: 'cortex-input', value: mode, onChange: (event) => setMode(event.target.value) }, [
1128
+ h('option', { key: 'ocr', value: 'ocr' }, 'OCR'),
1129
+ h('option', { key: 'asr', value: 'asr' }, 'ASR'),
1130
+ ]),
1131
+ h('input', { className: 'cortex-input', value: language, placeholder: mode === 'ocr' ? 'zh-Hans' : 'zh', onChange: (event) => setLanguage(event.target.value) }),
1132
+ ]),
1133
+ h('label', { className: 'cortex-line', style: { marginTop: 8 } }, [
1134
+ t('extractTarget'),
1135
+ 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}`))),
1136
+ ]),
1137
+ h('div', { className: 'cortex-line' }, [
1138
+ h('button', { type: 'button', className: 'cortex-btn', disabled: busy || !uri.trim(), onClick: run }, t('extractRun')),
1139
+ h('span', { className: 'cortex-meta' }, targetId === trace.id ? t('extractSuggested') : t('extractChanged')),
1140
+ ]),
1141
+ note ? h('div', { className: 'cortex-meta' }, note) : null,
1142
+ ]);
1143
+ }
1144
+
980
1145
  function TraceDetail({ id, t, onClose, onTimeline }) {
981
1146
  const [trace, setTrace] = useState(null);
982
1147
  const [loading, setLoading] = useState(true);
@@ -1106,6 +1271,12 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1106
1271
  h('h4', {}, t('detailTags')),
1107
1272
  h('div', { className: 'cortex-chips' }, trace.tags.map((tag, index) => h(Badge, { key: `${tag}-${index}` }, tag))),
1108
1273
  ]) : null,
1274
+ (trace.metadata?.attachments ?? []).length ? h('section', { key: 'attachments', className: 'cortex-detail-section' }, [
1275
+ h('h4', {}, t('detailAttachments')),
1276
+ 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` : ''}`))),
1277
+ ]) : null,
1278
+ h(AttachmentComposer, { key: 'attach', trace, t, onSaved: loadTrace }),
1279
+ h(ExtractionComposer, { key: 'extract', trace, t, onSaved: loadTrace }),
1109
1280
  (trace.keywords ?? []).length ? h('section', { key: 'keywords', className: 'cortex-detail-section' }, [
1110
1281
  h('h4', {}, t('detailKeywords')),
1111
1282
  h('div', { className: 'cortex-chips' }, trace.keywords.map((keyword, index) => h(Badge, { key: `${keyword}-${index}` }, keyword))),
@@ -1444,12 +1615,12 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1444
1615
  useEffect(() => { load(); }, [load]);
1445
1616
  const record = async () => {
1446
1617
  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)); }
1618
+ try { await api('/emotion/record', { method: 'POST', body: JSON.stringify({ text, target: '主人' }) }); setText(''); load(); } catch (issue) { setError(String(issue.message ?? issue)); }
1448
1619
  };
1449
1620
  return h('div', {}, [
1450
1621
  error ? h(ErrorBlock, { key: 'err', message: error, prefix: t('requestFailed') }) : null,
1451
1622
  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(); } }),
1623
+ 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
1624
  h('button', { key: 'b', type: 'button', className: 'cortex-btn primary', onClick: record }, '记一下'),
1454
1625
  ]),
1455
1626
  data ? h('div', {}, [
@@ -1629,14 +1800,32 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1629
1800
  function GraphTab({ t }) {
1630
1801
  const { data, error, busy, reload } = useEndpoint('/graph');
1631
1802
  const { data: edgeData } = useEndpoint('/graph/edges');
1803
+ const candidates = useEndpoint('/graph/candidates');
1804
+ const [candidateNotice, setCandidateNotice] = useState('');
1805
+ const [candidateBusy, setCandidateBusy] = useState('');
1632
1806
  const top = Array.isArray(data?.top) ? data.top : [];
1633
1807
  const layout = buildGraphLayout(top);
1634
1808
  const positions = new Map(layout.map((node) => [node.id, node]));
1635
1809
  const edges = Array.isArray(edgeData?.edges) ? edgeData.edges.filter((edge) => positions.has(edge.from) && positions.has(edge.to)).slice(0, 120) : [];
1810
+ const pendingEdges = Array.isArray(candidates.data) ? candidates.data : [];
1636
1811
  const shortLabel = (id) => {
1637
1812
  const value = String(id ?? '');
1638
1813
  return value.length > 18 ? `${value.slice(0, 16)}…` : value;
1639
1814
  };
1815
+ const decideCandidate = async (id, action) => {
1816
+ setCandidateBusy(id);
1817
+ setCandidateNotice('');
1818
+ try {
1819
+ await api('/graph/candidates/action', { method: 'POST', body: JSON.stringify({ action, id }) });
1820
+ candidates.reload();
1821
+ reload();
1822
+ setCandidateNotice(action === 'approve' ? t('graphApprove') : t('graphReject'));
1823
+ } catch (issue) {
1824
+ setCandidateNotice(String(issue.message ?? issue));
1825
+ } finally {
1826
+ setCandidateBusy('');
1827
+ }
1828
+ };
1640
1829
  return h('div', {}, [
1641
1830
  h('div', { key: 'hint', className: 'cortex-note' }, t('graphHint')),
1642
1831
  h('div', { key: 'line', className: 'cortex-line' }, [
@@ -1644,6 +1833,8 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1644
1833
  ]),
1645
1834
  error ? h(ErrorBlock, { key: 'err', message: error, prefix: t('requestFailed'), onRetry: reload, retryLabel: t('retry') }) : null,
1646
1835
  busy ? h(LoadingBlock, { key: 'loading', label: t('loading') }) : null,
1836
+ candidates.error ? h(ErrorBlock, { key: 'cerr', message: candidates.error, prefix: t('requestFailed'), onRetry: candidates.reload, retryLabel: t('retry') }) : null,
1837
+ candidateNotice ? h('div', { key: 'cnotice', className: 'cortex-meta' }, candidateNotice) : null,
1647
1838
  data ? h('div', { key: 'data' }, [
1648
1839
  h('div', { key: 'stats', className: 'cortex-grid' }, [
1649
1840
  h('div', { key: 'nodes', className: 'cortex-card cortex-stat' }, [h('h4', {}, t('graphNodes')), h('div', { className: 'cortex-big' }, data.nodes ?? 0)]),
@@ -1692,6 +1883,36 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1692
1883
  ]))),
1693
1884
  ]) : null,
1694
1885
  ]) : null,
1886
+ h('div', { key: 'candidates', className: 'cortex-card', style: { marginTop: 10 } }, [
1887
+ h('div', { key: 'head', className: 'cortex-card-head' }, [
1888
+ h('h4', {}, t('graphCandidatesTitle')),
1889
+ h('span', { key: 'count', className: 'cortex-meta' }, pendingEdges.length),
1890
+ ]),
1891
+ h('div', { key: 'hint', className: 'cortex-meta' }, t('graphCandidatesHint')),
1892
+ candidates.busy ? h(LoadingBlock, { key: 'loading', label: t('loading') }) : null,
1893
+ pendingEdges.length ? h('div', { key: 'list', style: { marginTop: 6 } }, pendingEdges.map((edge) => h('div', {
1894
+ key: edge.id,
1895
+ className: 'cortex-item',
1896
+ style: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
1897
+ }, [
1898
+ h('span', { key: 'label', style: { flex: '1 1 240px', minWidth: 0 } },
1899
+ `${shortLabel(edge.fromId)} → ${shortLabel(edge.toId)} · ${edge.rel} · ${t('graphCandidateConfidence')} ${formatNumber(edge.metadata?.confidence ?? edge.weight ?? 0, 2)}`),
1900
+ h('button', {
1901
+ key: 'approve',
1902
+ type: 'button',
1903
+ className: 'cortex-btn',
1904
+ disabled: candidateBusy === edge.id,
1905
+ onClick: () => decideCandidate(edge.id, 'approve'),
1906
+ }, t('graphApprove')),
1907
+ h('button', {
1908
+ key: 'reject',
1909
+ type: 'button',
1910
+ className: 'cortex-btn',
1911
+ disabled: candidateBusy === edge.id,
1912
+ onClick: () => decideCandidate(edge.id, 'reject'),
1913
+ }, t('graphReject')),
1914
+ ]))) : h(EmptyBlock, { key: 'empty' }, t('graphCandidatesEmpty')),
1915
+ ]),
1695
1916
  ]);
1696
1917
  }
1697
1918
 
@@ -1782,7 +2003,28 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1782
2003
  function SettingsTab({ t }) {
1783
2004
  const settings = useEndpoint('/settings');
1784
2005
  const metrics = useEndpoint('/metrics');
2006
+ const grants = useEndpoint('/security/grants');
1785
2007
  const [notice, setNotice] = useState('');
2008
+ const [grantName, setGrantName] = useState('shared:agent-a');
2009
+ const [grantUntil, setGrantUntil] = useState('');
2010
+ const [grantNotice, setGrantNotice] = useState('');
2011
+ const [grantBusy, setGrantBusy] = useState(false);
2012
+ const addGrant = async () => {
2013
+ setGrantBusy(true); setGrantNotice('');
2014
+ try {
2015
+ await api('/security/grants', { method: 'POST', body: JSON.stringify({ action: 'add', grant: grantName.trim(), until: grantUntil.trim() || null }) });
2016
+ grants.reload();
2017
+ setGrantNotice(t('scopeGrantSaved'));
2018
+ } catch (issue) { setGrantNotice(String(issue.message ?? issue)); } finally { setGrantBusy(false); }
2019
+ };
2020
+ const revokeGrant = async (grant) => {
2021
+ setGrantBusy(true); setGrantNotice('');
2022
+ try {
2023
+ await api('/security/grants', { method: 'POST', body: JSON.stringify({ action: 'revoke', grant }) });
2024
+ grants.reload();
2025
+ setGrantNotice(t('scopeGrantSaved'));
2026
+ } catch (issue) { setGrantNotice(String(issue.message ?? issue)); } finally { setGrantBusy(false); }
2027
+ };
1786
2028
  const refreshAll = () => { settings.reload(); metrics.reload(); };
1787
2029
  const changePermission = async (key, value) => {
1788
2030
  if (!window.confirm(t('permissionConfirm'))) return;
@@ -1817,6 +2059,7 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1817
2059
  ['scope', t('scope'), setting.scope],
1818
2060
  ['dataRoot', t('dataRoot'), setting.dataRoot],
1819
2061
  ['dbFile', t('dbFile'), setting.dbFile],
2062
+ ['readOnlyScopes', t('readOnlyScopes'), (setting.readOnlyScopes ?? []).join(', ') || '—'],
1820
2063
  ['contextBudget', t('contextBudget'), setting.contextBudget],
1821
2064
  ['promptBudget', t('promptBudget'), setting.promptBudget],
1822
2065
  ].map(([key, label, value]) => [key, label, displayValue(value)]) }),
@@ -1871,6 +2114,28 @@ button.cortex-badge:disabled{cursor:not-allowed;opacity:.55}
1871
2114
  ]),
1872
2115
  notice ? h('div', { className: 'cortex-note' }, notice) : null,
1873
2116
  ]),
2117
+ h('section', { key: 'grants', className: 'cortex-card' }, [
2118
+ h('h4', {}, t('scopeGrants')),
2119
+ h('p', { className: 'cortex-note' }, t('scopeGrantsHint')),
2120
+ grants.error ? h(ErrorBlock, { key: 'gerr', message: grants.error, prefix: t('requestFailed'), onRetry: grants.reload, retryLabel: t('retry') }) : null,
2121
+ grants.busy ? h(LoadingBlock, { key: 'gload', label: t('loading') }) : null,
2122
+ (grants.data?.grants ?? []).length
2123
+ ? 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 } }, [
2124
+ h('code', { key: 'g', className: 'cortex-code' }, item.grant),
2125
+ h('span', { key: 'u', className: 'cortex-meta' }, item.until ? formatTime(item.until) : t('scopeGrantForever')),
2126
+ h('button', { key: 'r', type: 'button', className: 'cortex-btn', disabled: grantBusy, onClick: () => revokeGrant(item.grant) }, t('scopeGrantRevoke')),
2127
+ ])))
2128
+ : h(EmptyBlock, { key: 'gempty' }, t('scopeGrantEmpty')),
2129
+ h('div', { key: 'gadd', className: 'cortex-grid', style: { marginTop: 8 } }, [
2130
+ h('label', { key: 'name' }, [t('scopeGrantName'), h('input', { className: 'cortex-input', value: grantName, placeholder: 'shared:agent-a', onChange: (event) => setGrantName(event.target.value) })]),
2131
+ 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) })]),
2132
+ ]),
2133
+ h('div', { key: 'gline', className: 'cortex-line' }, [
2134
+ h('button', { key: 'add', type: 'button', className: 'cortex-btn', disabled: grantBusy, onClick: addGrant }, t('scopeGrantAdd')),
2135
+ grantNotice ? h('span', { key: 'n', className: 'cortex-meta' }, grantNotice) : null,
2136
+ ]),
2137
+ (grants.data?.revoked ?? []).length ? h('div', { key: 'revoked', className: 'cortex-note' }, `${t('scopeGrantRevoked')}: ${grants.data.revoked.join(', ')}`) : null,
2138
+ ]),
1874
2139
  h('section', { key: 'vector', className: 'cortex-card' }, [
1875
2140
  h('h4', {}, t('vector')),
1876
2141
  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'