dsh-prime-memory 0.11.0 → 0.12.1

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 (47) hide show
  1. package/CHANGELOG.en.md +36 -1
  2. package/CHANGELOG.ja.md +36 -1
  3. package/CHANGELOG.ko.md +36 -1
  4. package/CHANGELOG.md +40 -0
  5. package/README.en.md +28 -0
  6. package/README.ja.md +25 -0
  7. package/README.ko.md +25 -0
  8. package/README.md +22 -0
  9. package/dist/client.js +54 -30
  10. package/dist/config.d.ts +39 -1
  11. package/dist/config.js +16 -0
  12. package/dist/conflict-service.d.ts +38 -0
  13. package/dist/conflict-service.js +65 -0
  14. package/dist/graph/search.d.ts +15 -0
  15. package/dist/graph/search.js +54 -1
  16. package/dist/hooks/recall.js +4 -0
  17. package/dist/index.d.ts +28 -1
  18. package/dist/index.js +12 -3
  19. package/dist/pipeline/l1.d.ts +7 -1
  20. package/dist/pipeline/l1.js +165 -20
  21. package/dist/pipeline/runner.js +6 -1
  22. package/dist/prompts/l1-dedup.d.ts +22 -1
  23. package/dist/prompts/l1-dedup.js +61 -4
  24. package/dist/stats.d.ts +12 -0
  25. package/dist/stats.js +222 -33
  26. package/dist/store/conflicts.d.ts +62 -0
  27. package/dist/store/conflicts.js +69 -0
  28. package/dist/store/graph-store.d.ts +72 -1
  29. package/dist/store/graph-store.js +165 -1
  30. package/dist/store/l1.d.ts +97 -4
  31. package/dist/store/l1.js +163 -21
  32. package/dist/store/receipts.d.ts +157 -0
  33. package/dist/store/receipts.js +139 -0
  34. package/dist/store/search-utils.d.ts +15 -0
  35. package/dist/store/search-utils.js +20 -0
  36. package/dist/store/session-modes.d.ts +7 -0
  37. package/dist/store/session-modes.js +9 -0
  38. package/dist/store/sqlite.d.ts +116 -8
  39. package/dist/store/sqlite.js +366 -46
  40. package/dist/store/vec-utils.d.ts +16 -0
  41. package/dist/store/vec-utils.js +24 -0
  42. package/dist/tools/index.js +204 -31
  43. package/dist/types.d.ts +48 -0
  44. package/dist/types.js +40 -0
  45. package/dist/workspace.d.ts +46 -0
  46. package/dist/workspace.js +105 -0
  47. package/package.json +9 -3
package/dist/stats.js CHANGED
@@ -1,10 +1,12 @@
1
1
  /**
2
- * 状态面板数据通道:Host 侧注册 /rpc 通道的 dsh-memory/* 端点,
3
- * Client 设置页通过 ctx.connection.rpc.call('/rpc', 'dsh-memory/xxx') 拉取。
2
+ * 状态面板数据通道(0.1.5 契约):Host 半直接向 webServer 注册 prefix 路由
3
+ * `POST /dsh-memory/rpc/<method>`,Client 设置页用浏览器原生 fetch 拉取,
4
+ * 信封即 RpcResult({ok,value} | {ok,error})——不经过 connection.rpc
5
+ * (handle 前缀通道 0.1.5 静默 405;/api interceptor 单槽会被他插件抢占)。
4
6
  *
5
- * connection 是可选服务且可能晚于本插件就绪:先探测一次,未就绪则监听
7
+ * webServer 是可选服务且可能晚于本插件就绪:先探测一次,未就绪则监听
6
8
  * internal/service(事件携带 (name, impl),impl=undefined 即下线),服务
7
- * 上线、下线、替换实例三种迁移都会正确释放/重挂 RPC 注册。
9
+ * 上线、下线、替换实例三种迁移都会正确释放/重挂路由注册。
8
10
  *
9
11
  * 机密纪律:directApiKey / embedRemoteApiKey 永不出现在任何 RPC 响应与日志
10
12
  * (settings-get/set 走 sanitizeSettings 脱敏)。
@@ -17,10 +19,119 @@ import { effectiveCfg } from './pipeline/runner.js';
17
19
  import { emptyRecallStats } from './hooks/recall.js';
18
20
  import { buildRouteChain, decideSendableEffort, LAYER_DEFAULT_BUDGETS, layerChainOrNull, resolveModelContextWindow, resolveModelEfforts, resolveModelRoute } from './llm.js';
19
21
  import { projectDistillChain, validateDistillChain } from './settings.js';
22
+ import { RECEIPTS_QUERY_LIMIT_MAX, dimensionOf, toReceiptView } from './store/receipts.js';
23
+ import { resolveConflictPair } from './conflict-service.js';
20
24
  import { errDetail } from './util/filelog.js';
21
25
  import { snapshotTokenCost } from './token-cost.js';
22
26
  const require = createRequire(import.meta.url);
23
27
  export const PLUGIN_VERSION = require('../package.json').version;
28
+ /**
29
+ * 端点全集运行时清单(31 个,与 tests/contract-keys.test.ts 的 ENDPOINTS 及
30
+ * contract.ts 类型映射表三方对齐,漂移由键集 diff 测试暴露)。
31
+ * 注意:本清单同时是 HTTP 前缀路由 `/dsh-memory/rpc/<短名>` 的**放行白名单**
32
+ * (见下方 SHORT_ENDPOINTS),漏一条 = 该端点在面板里静默消失(404 被客户端
33
+ * rpc 的 catch 吞掉,无任何报错),故必须与 contract.ts 的 DshMemoryRequestMap
34
+ * 严格逐项一致。
35
+ * 用途:0.1.5 共享通道 /api 的 interceptor 是单槽(多插件会抛 already has an
36
+ * interceptor),精确 Fetch 路由按路径 key 可共存且分发优先于 interceptor——
37
+ * 因此逐端点注册 `POST /api/<endpoint>` 精确路由,彻底绕开槽位竞争。
38
+ */
39
+ export const MEMORY_ENDPOINTS = [
40
+ 'dsh-memory/stats',
41
+ 'dsh-memory/token-cost',
42
+ 'dsh-memory/session-mode-get',
43
+ 'dsh-memory/session-mode-set',
44
+ 'dsh-memory/session-stats',
45
+ 'dsh-memory/settings-get',
46
+ 'dsh-memory/settings-set',
47
+ 'dsh-memory/list-records',
48
+ 'dsh-memory/records-delete',
49
+ 'dsh-memory/receipts',
50
+ 'dsh-memory/conflict-resolve',
51
+ 'dsh-memory/graph-search',
52
+ 'dsh-memory/graph-node-get',
53
+ 'dsh-memory/scenes',
54
+ 'dsh-memory/persona',
55
+ 'dsh-memory/log-tail',
56
+ 'dsh-memory/rebuild-status',
57
+ 'dsh-memory/rebuild-start',
58
+ 'dsh-memory/rebuild-cancel',
59
+ 'dsh-memory/ruminate-status',
60
+ 'dsh-memory/ruminate-start',
61
+ 'dsh-memory/ruminate-cancel',
62
+ 'dsh-memory/llm-providers',
63
+ 'dsh-memory/llm-models',
64
+ 'dsh-memory/embedding-state-get',
65
+ 'dsh-memory/embedding-source-set',
66
+ 'dsh-memory/embedding-download-start',
67
+ 'dsh-memory/embedding-download-cancel',
68
+ 'dsh-memory/embedding-model-delete',
69
+ 'dsh-memory/embedding-runtime-cancel',
70
+ 'dsh-memory/embedding-reindex-cancel',
71
+ ];
72
+ /** HTTP 路由前缀(客户端 fetch `/dsh-memory/rpc/<短方法名>`)。 */
73
+ const RPC_ROUTE_PREFIX = '/dsh-memory/rpc';
74
+ /** 短方法名视图(MEMORY_ENDPOINTS 去掉 'dsh-memory/' 前缀,URL 段用)。 */
75
+ const SHORT_ENDPOINTS = MEMORY_ENDPOINTS.map((e) => e.slice('dsh-memory/'.length));
76
+ /** Host 头是否为 loopback 主机名(localhost / [::1] / 127.x.x.x)。 */
77
+ function isLoopbackHostname(hostname) {
78
+ if (hostname === 'localhost' || hostname === '[::1]')
79
+ return true;
80
+ const parts = hostname.split('.');
81
+ return parts.length === 4 && parts[0] === '127' && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
82
+ }
83
+ /**
84
+ * API 信任围栏:DNS-rebinding / 跨站防护(非用户鉴权),语义对齐 connection
85
+ * /api 网关的 fence——Host 头必须指向 loopback。面板数据非机密(密钥字段
86
+ * 宿主侧已脱敏),故 loopback 即放行;跨站浏览器标记随 Host 校验一并拒绝。
87
+ */
88
+ function apiFence(req) {
89
+ const host = req.headers.host;
90
+ if (typeof host !== 'string' || host.length === 0)
91
+ return false;
92
+ // 不写初值:catch 分支直接 return,初值在任何路径下都不会被读取
93
+ // (原 `let hostname = host` 是死存储,eslint no-useless-assignment)。
94
+ let hostname;
95
+ try {
96
+ hostname = new URL(`http://${host}`).hostname;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ return isLoopbackHostname(hostname);
102
+ }
103
+ function writeJson(res, status, body) {
104
+ res.writeHead(status, { 'content-type': 'application/json' });
105
+ res.end(JSON.stringify(body));
106
+ }
107
+ /** 读取 JSON 请求体(上限 8MB:面板最大载荷为 list-records 分页与图谱检索)。 */
108
+ function readJsonBody(req) {
109
+ return new Promise((resolve, reject) => {
110
+ const chunks = [];
111
+ let size = 0;
112
+ req.on('data', (chunk) => {
113
+ size += chunk.length;
114
+ if (size > 8 * 1024 * 1024) {
115
+ reject(new Error('request body too large'));
116
+ req.destroy();
117
+ return;
118
+ }
119
+ chunks.push(chunk);
120
+ });
121
+ req.on('end', () => {
122
+ const raw = Buffer.concat(chunks).toString('utf8');
123
+ if (raw.length === 0)
124
+ return resolve({});
125
+ try {
126
+ resolve(JSON.parse(raw));
127
+ }
128
+ catch {
129
+ reject(new Error('body is not JSON'));
130
+ }
131
+ });
132
+ req.on('error', reject);
133
+ });
134
+ }
24
135
  /**
25
136
  * 组装端点 deps。抽成函数是为了让"哪个控制器落入哪个字段"成为可测接缝:
26
137
  * 反刍端点读 deps.ruminate,若此处漏注入,端点会静默恒返 {supported:false}(面板整块不渲染)。
@@ -49,35 +160,79 @@ ruminate) {
49
160
  const tryRegister = () => {
50
161
  if (holding)
51
162
  return;
52
- const connection = ctx.get('connection');
53
- if (!connection)
163
+ const webServer = ctx.get('webServer');
164
+ if (!webServer || typeof webServer.register !== 'function')
54
165
  return;
55
- holding = true;
56
- let active = true;
57
- // handle() 同步注册并返回异步 disposer(() => Promise<void>)。
58
- const dispose = connection.rpc.handle('/rpc', async (endpoint, payload) => {
59
- try {
60
- const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger }, { status, live, modes, dataDir, rebuild, embedManager, sessionInfo }, ruminate));
61
- return { ok: true, value };
62
- }
63
- catch (err) {
64
- return {
65
- ok: false,
66
- error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
67
- };
166
+ let dispose = () => { };
167
+ try {
168
+ holding = true;
169
+ let active = true;
170
+ // 端点处理器:HTTP 层与旧 connection.rpc 的 handler 共用同一分发。
171
+ const rpcHandler = async (endpoint, payload) => {
172
+ try {
173
+ const value = await handleEndpoint(endpoint, payload, buildEndpointDeps({ ctx, cfg, stores, logger }, { status, live, modes, dataDir, rebuild, embedManager, sessionInfo }, ruminate));
174
+ return { ok: true, value };
175
+ }
176
+ catch (err) {
177
+ return {
178
+ ok: false,
179
+ error: { code: 'internal', message: err instanceof Error ? err.message : String(err), details: {} },
180
+ };
181
+ }
182
+ };
183
+ // 0.1.5 契约(参考 better-sidebar main 分支的已验证实现):不再经过
184
+ // connection.rpc —— handle 前缀通道在 0.1.5 webServer 分发层静默 405
185
+ // (讨论区 #6337),/api 共享通道 interceptor 是单槽、会被其他 0.1.5 插件
186
+ // (如 dsh-live-token-stats)抢占。直接在插件自己的 fiber 上向 webServer
187
+ // 注册 prefix 路由(plain req/res + 自有 RpcResult 信封)。
188
+ // 鉴权面:同源浏览器的 DNS-rebinding 防护(Host 须 loopback),与
189
+ // connection /api 网关的 fence 语义一致;不做用户鉴权——面板数据非机密,
190
+ // 机密字段(directApiKey 等)在 settings-get 已由 sanitizeSettings 脱敏。
191
+ const registered = webServer.register({
192
+ kind: 'prefix',
193
+ path: RPC_ROUTE_PREFIX,
194
+ handler: async (req, res) => {
195
+ if (!apiFence(req))
196
+ return writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } });
197
+ if (req.method !== 'POST') {
198
+ return writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } });
199
+ }
200
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
201
+ if (!pathname.startsWith(`${RPC_ROUTE_PREFIX}/`)) {
202
+ return writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown api path' } });
203
+ }
204
+ const method = pathname.slice(RPC_ROUTE_PREFIX.length + 1);
205
+ if (method.includes('/') || !SHORT_ENDPOINTS.includes(method)) {
206
+ return writeJson(res, 404, { ok: false, error: { code: 'not-found', message: `unknown api method "${method}"` } });
207
+ }
208
+ try {
209
+ const payload = await readJsonBody(req);
210
+ const result = await rpcHandler(`dsh-memory/${method}`, payload);
211
+ writeJson(res, 200, result);
212
+ }
213
+ catch (err) {
214
+ writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: err instanceof Error ? err.message : String(err) } });
215
+ }
216
+ },
217
+ });
218
+ dispose = typeof registered === 'function' ? registered : () => { };
219
+ registeredImpl = webServer;
220
+ if (!active) {
221
+ void dispose();
222
+ holding = false;
223
+ return;
68
224
  }
69
- }, { authority: 'loopback' });
70
- registeredImpl = connection;
71
- if (!active) {
72
- void dispose();
73
- return;
225
+ logger.info('[memory] 状态 API 已注册(POST /dsh-memory/rpc/<method>)');
226
+ disposers.push(() => {
227
+ active = false;
228
+ holding = false;
229
+ void dispose();
230
+ });
74
231
  }
75
- logger.debug?.('[memory] 状态 RPC 已注册(/rpc → dsh-memory/*)');
76
- disposers.push(() => {
77
- active = false;
232
+ catch (err) {
78
233
  holding = false;
79
- void dispose();
80
- });
234
+ logger.warn(`[memory] 状态 API 注册失败(设置面板将不可用,其余功能不受影响): ${err instanceof Error ? err.message : String(err)}`);
235
+ }
81
236
  };
82
237
  /** 释放全部持有注册(handle 随旧服务实例失效,holding 复位以允许重挂)。 */
83
238
  const release = () => {
@@ -88,14 +243,14 @@ ruminate) {
88
243
  ctx.effect(() => {
89
244
  tryRegister();
90
245
  const off = ctx.on('internal/service', (name, impl) => {
91
- if (name !== 'connection')
246
+ if (name !== 'webServer')
92
247
  return;
93
248
  if (!impl) {
94
- // 服务下线:旧 handle 已随旧服务实例失效——主动释放并复位,
95
- // 服务恢复时本事件再触发即可重挂(否则 holding 恒真 → RPC 永久失联)
249
+ // 服务下线:旧路由注册已随旧服务实例失效——主动释放并复位,
250
+ // 服务恢复时本事件再触发即可重挂(否则 holding 恒真 → API 永久失联)
96
251
  release();
97
252
  registeredImpl = undefined;
98
- logger.debug?.('[memory] connection 服务下线,RPC 注册已释放(待恢复重挂)');
253
+ logger.debug?.('[memory] webServer 服务下线,状态 API 注册已释放(待恢复重挂)');
99
254
  return;
100
255
  }
101
256
  if (impl !== registeredImpl) {
@@ -543,6 +698,40 @@ export async function handleEndpoint(endpoint, payload, deps) {
543
698
  };
544
699
  return resp;
545
700
  }
701
+ // ── §B 决策凭证回溯(task_19):与 memory_receipts 工具共用同一形状 ──
702
+ // 端点层**不给"提示文案"这个出口**:工具是给模型用的,拒答必须变成可读的一句话;
703
+ // 端点是给程序/面板用的,缺参就是调用错误,静默返回空会让调用方以为"确实没有"。
704
+ case 'dsh-memory/receipts': {
705
+ const p = (payload ?? {});
706
+ const query = {
707
+ recordId: typeof p.recordId === 'string' && p.recordId.trim() ? p.recordId.trim() : undefined,
708
+ runId: typeof p.runId === 'string' && p.runId.trim() ? p.runId.trim() : undefined,
709
+ };
710
+ const dimension = dimensionOf(query);
711
+ if (dimension === 'none')
712
+ throw new Error('需要 recordId 或 runId 至少一个(不支持查全部凭证)');
713
+ const limit = Math.min(Math.max(Math.floor(Number(p.limit)) || 20, 1), RECEIPTS_QUERY_LIMIT_MAX);
714
+ const rows = stores.l1.listReceipts({ ...query, limit });
715
+ const resp = {
716
+ dimension,
717
+ items: rows.map(toReceiptView),
718
+ total: stores.l1.countReceipts(query),
719
+ };
720
+ return resp;
721
+ }
722
+ // ── §C 矛盾冻结裁决(task_25):与 memory_resolve_conflict 工具共用同一形状 ──
723
+ // 端点层同样不给"提示文案"出口的例外只有一条:**队列未开启**不是调用错误而是
724
+ // 部署状态,故它走返回体(带 notice)而非抛错;pair_id/outcome 缺参才抛。
725
+ case 'dsh-memory/conflict-resolve': {
726
+ const p = (payload ?? {});
727
+ const pairId = typeof p.pairId === 'string' ? p.pairId.trim() : '';
728
+ const outcome = typeof p.outcome === 'string' ? p.outcome.trim() : '';
729
+ if (!pairId)
730
+ throw new Error('需要 pairId(待裁决对的 pair_id)');
731
+ if (!outcome)
732
+ throw new Error('需要 outcome(winner | loser | both)');
733
+ return await resolveConflictPair({ l1: stores.l1, conflictFreezeEnabled: cfg.conflictFreeze?.enabled === true }, pairId, outcome);
734
+ }
546
735
  case 'dsh-memory/records-delete': {
547
736
  // 面板高权限删除指定记忆;写入删权限门(memoryMutate)防御
548
737
  if (!live?.get().memoryMutate) {
@@ -0,0 +1,62 @@
1
+ /** 冻结对的格式版本。进入 pair_id 的摘要输入,使算法演进时不会静默让新旧 id 混同。 */
2
+ export declare const CONFLICT_FORMAT = "c-conflict-pending/v1";
3
+ /**
4
+ * 冻结对的稳定 id。
5
+ *
6
+ * **确定性**是刻意的:`runId + winner + loser` 三元组恒产同一 pair_id,
7
+ * 于是同一轮重复落盘被 `INSERT OR IGNORE` 吃掉——幂等来自**主键**而非调用方自觉。
8
+ * 沿用 §B 的 `receiptIdFor` 手法(同一哈希、同一长度前缀编码),不另造一套。
9
+ */
10
+ export declare function conflictPairId(runId: string, winnerId: string, loserId: string): string;
11
+ /**
12
+ * 裁决结论。
13
+ * - `winner` / `loser`:人工判定哪一方为真(另一方从检索库退场);
14
+ * - `both`:两条都保留——人工判定它们其实是**各自独立的事实**,不是矛盾
15
+ * (LLM 判错的情形,必须有出口,否则只能被迫删掉一条正确记忆);
16
+ * - `auto`:**机器**按 LLM 给出的 winner/loser 自行了结(task_24 安全阀:
17
+ * 队列满或超时)。刻意与人工取值分开——§C 存在的理由就是"机器不该替人裁决",
18
+ * 若自动了结在人眼里与人工结论无从区分,那个行为会以"悄悄发生"的形式回来。
19
+ */
20
+ export type ConflictResolution = 'winner' | 'loser' | 'both' | 'auto';
21
+ /** 未裁决时 `resolved_at` / `resolution` 的取值(空串,不用 NULL)。 */
22
+ export declare const CONFLICT_UNRESOLVED = "";
23
+ /** 一条待裁决冲突对(与 `conflict_pending` 表一行同形)。 */
24
+ export interface ConflictPair {
25
+ pairId: string;
26
+ /** 产生该冻结的 L1 蒸馏批次 id(接 §B 凭证链)。 */
27
+ runId: string;
28
+ /** LLM 建议的胜方 id。**只是进入待裁决对时的排序位,不代表最终结论**。 */
29
+ winnerId: string;
30
+ /** LLM 建议的败方 id。同上。 */
31
+ loserId: string;
32
+ createdAt: string;
33
+ /** 空串 = 未裁决。 */
34
+ resolvedAt: string;
35
+ /** 空串 = 未裁决;否则为 {@link ConflictResolution}。 */
36
+ resolution: string;
37
+ }
38
+ /** 构建冻结对所需的输入。 */
39
+ export interface ConflictPairInput {
40
+ runId: string;
41
+ winnerId: string;
42
+ loserId: string;
43
+ createdAt: string;
44
+ }
45
+ /** 由输入构造一行待裁决冲突对(未裁决态)。纯函数,无 I/O。 */
46
+ export declare function buildConflictPair(input: ConflictPairInput): ConflictPair;
47
+ /**
48
+ * 校验 LLM 给出的 conflict 决策是否**够得着一条冻结对**。
49
+ *
50
+ * 三条都必需,缺一即无法停放,调用方须回落 `store`(信息绝不丢):
51
+ * ① winner / loser 都是非空 id;
52
+ * ② 二者**不同**——指向同一条记录是无效输出(承自 mneme 的 `validateDecisions`);
53
+ * ③ 其中**恰有一方是本条新记忆**(`recordId`),另一方是候选池里的已有记录
54
+ * (由调用方用 `knownIds` 判定存活)。否则"对"无从成立:要么新记忆没有对手,
55
+ * 要么对侧是模型幻觉出来的 id。
56
+ *
57
+ * 返回规范化后的 `{ winnerId, loserId }`,或 `null`(表示不构成冻结对)。
58
+ */
59
+ export declare function validateConflictPair(recordId: string, winner: unknown, loser: unknown, knownIds: ReadonlySet<string>): {
60
+ winnerId: string;
61
+ loserId: string;
62
+ } | null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * §C 矛盾冻结:待裁决冲突对(pending conflict pairs)。
3
+ *
4
+ * 语义承自 mneme 的 dream layer:*"the dream layer does **NOT** auto-adjudicate
5
+ * winner/loser — the conflicting pair is parked here until a human reviews it."*
6
+ * ——冻结**不是"拦住写入"**,而是"不自动裁决":新记忆照常入 L1,与冲突的旧记忆
7
+ * 作为**一对**停放在 `conflict_pending`,双方内容都不被改写,直到人工裁决。
8
+ *
9
+ * 与 §B 的关系:冻结产生的裁决结果需要凭证链才能审计——`run_id` 直接沿用该轮
10
+ * L1 蒸馏的 `runId`,故一条待裁决对被 `memory_receipts(run_id)` 一查即可看到
11
+ * 「这一轮到底判了什么」(findings.md §3 / §9)。
12
+ *
13
+ * 本文件承载契约中最纯的那一半:pair_id 的构造。
14
+ */
15
+ import { inputDigest } from './receipts.js';
16
+ /** 冻结对的格式版本。进入 pair_id 的摘要输入,使算法演进时不会静默让新旧 id 混同。 */
17
+ export const CONFLICT_FORMAT = 'c-conflict-pending/v1';
18
+ /**
19
+ * 冻结对的稳定 id。
20
+ *
21
+ * **确定性**是刻意的:`runId + winner + loser` 三元组恒产同一 pair_id,
22
+ * 于是同一轮重复落盘被 `INSERT OR IGNORE` 吃掉——幂等来自**主键**而非调用方自觉。
23
+ * 沿用 §B 的 `receiptIdFor` 手法(同一哈希、同一长度前缀编码),不另造一套。
24
+ */
25
+ export function conflictPairId(runId, winnerId, loserId) {
26
+ return inputDigest([CONFLICT_FORMAT, runId, winnerId, loserId]);
27
+ }
28
+ /** 未裁决时 `resolved_at` / `resolution` 的取值(空串,不用 NULL)。 */
29
+ export const CONFLICT_UNRESOLVED = '';
30
+ /** 由输入构造一行待裁决冲突对(未裁决态)。纯函数,无 I/O。 */
31
+ export function buildConflictPair(input) {
32
+ return {
33
+ pairId: conflictPairId(input.runId, input.winnerId, input.loserId),
34
+ runId: input.runId,
35
+ winnerId: input.winnerId,
36
+ loserId: input.loserId,
37
+ createdAt: input.createdAt,
38
+ resolvedAt: CONFLICT_UNRESOLVED,
39
+ resolution: CONFLICT_UNRESOLVED,
40
+ };
41
+ }
42
+ /**
43
+ * 校验 LLM 给出的 conflict 决策是否**够得着一条冻结对**。
44
+ *
45
+ * 三条都必需,缺一即无法停放,调用方须回落 `store`(信息绝不丢):
46
+ * ① winner / loser 都是非空 id;
47
+ * ② 二者**不同**——指向同一条记录是无效输出(承自 mneme 的 `validateDecisions`);
48
+ * ③ 其中**恰有一方是本条新记忆**(`recordId`),另一方是候选池里的已有记录
49
+ * (由调用方用 `knownIds` 判定存活)。否则"对"无从成立:要么新记忆没有对手,
50
+ * 要么对侧是模型幻觉出来的 id。
51
+ *
52
+ * 返回规范化后的 `{ winnerId, loserId }`,或 `null`(表示不构成冻结对)。
53
+ */
54
+ export function validateConflictPair(recordId, winner, loser, knownIds) {
55
+ if (typeof winner !== 'string' || typeof loser !== 'string')
56
+ return null;
57
+ const w = winner.trim();
58
+ const l = loser.trim();
59
+ if (!w || !l || w === l)
60
+ return null;
61
+ // ③ 恰有一方是新记忆,另一方必须是已知候选
62
+ const sides = w === recordId ? [[w, l]] : l === recordId ? [[l, w]] : [];
63
+ if (sides.length === 0)
64
+ return null;
65
+ const other = sides[0][0] === w ? l : w;
66
+ if (!knownIds.has(other))
67
+ return null;
68
+ return { winnerId: w, loserId: l };
69
+ }
@@ -1,6 +1,16 @@
1
1
  import type { DatabaseSync } from 'node:sqlite';
2
2
  import type { GraphEdge, GraphNode, GraphNodeSearchResult, GraphProjectionJob, GraphProjectionResult } from '../graph/types.js';
3
3
  import type { MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
4
+ /**
5
+ * 图谱**向量路**单条命中。刻意不复用词法路的 `GraphNodeSearchResult`:
6
+ * 那个类型要求 `matchedFields`(命中在哪些字段)与 `matchReason`(中文解释),
7
+ * 而向量检索**没有"命中哪个字段"这个概念**——塞一个空数组进去会违反该类型
8
+ * "无命中不返回、空数组不出现"的既有约定,把两种语义混成一个类型。
9
+ */
10
+ export interface GraphNodeVecHit {
11
+ node: GraphNode;
12
+ score: number;
13
+ }
4
14
  /** claim 的产出:job 元数据 + 本批真实存在的来源记录(已剔除被删者)。 */
5
15
  export interface GraphClaim {
6
16
  job: GraphProjectionJob;
@@ -16,13 +26,54 @@ export declare class GraphStore {
16
26
  private logger;
17
27
  private stmtInsertNode;
18
28
  private stmtInsertEdge;
29
+ /**
30
+ * §F 图谱节点向量列(vec0)。**结构性可选**:维度未定或 vec0 不可用时这三个
31
+ * 语句就是 `undefined`,该路整体 no-op——既不影响图谱的词法检索,也不向上抛。
32
+ */
33
+ private stmtInsertNodeVec?;
34
+ private stmtDeleteNodeVec?;
35
+ private stmtSearchNodeVec?;
19
36
  /** init 是否就绪(未就绪 = 图谱域整体 no-op,不抛错不传染)。 */
20
37
  get ready(): boolean;
21
38
  /**
22
39
  * 建表 + 语句缓存(MemoryDb.initSchema 内调用)。任何一步失败都只告警并保持
23
40
  * 未就绪——图谱域整体降级 no-op,检索主链路(L0/L1/FTS/向量)不受影响。
41
+ *
42
+ * @param vec §F 节点向量列的维度(来自 MemoryDb 的**既有**能力探测结果;
43
+ * 缺省/0 = 部署未启用向量 → 该路结构性不存在,不建表也不报错)。
44
+ */
45
+ init(db: DatabaseSync, logger?: MemoryLogger, vec?: {
46
+ dimensions: number;
47
+ }): void;
48
+ /**
49
+ * §F 图谱节点向量列:与 `l1_vec` **同模式**的 vec0 虚拟表——同一个 `vec-utils`
50
+ * 编码、同一种 `float[N] distance_metric=cosine` 声明。
51
+ *
52
+ * **内层 try/catch 是刻意的**(task_34):vec0 扩展缺失时 `CREATE VIRTUAL TABLE`
53
+ * 会抛。若让它冒到外层,图谱会从"向量路不可用"退化成"**整个图谱域不可用**"——
54
+ * 词法检索、投影、裁决全部陪葬。外层那层 catch 是给"图谱表建不起来"用的,
55
+ * 不该被一个**可选**的向量列触发。
56
+ */
57
+ private prepareNodeVec;
58
+ /** 节点向量路是否可用。未就绪时下方两个方法的调用方**无需分支**——它们自身 no-op。 */
59
+ get nodeVecReady(): boolean;
60
+ /**
61
+ * 写入/覆盖节点向量(先删后插:vec0 不支持 ON CONFLICT)。
62
+ * 未就绪 / 零向量 / 单条失败 → **静默跳过**,绝不抛。
63
+ * @returns 实际写入条数(供调用方记账,不用于控制流)。
64
+ */
65
+ upsertNodeVectors(rows: ReadonlyArray<{
66
+ nodeId: string;
67
+ embedding: Float32Array;
68
+ updatedAt?: string;
69
+ }>): number;
70
+ /** 删除节点向量(节点删/合并时用)。未就绪 no-op。 */
71
+ deleteNodeVectors(nodeIds: readonly string[]): void;
72
+ /**
73
+ * 向量检索节点(按 cosine 距离升序,score = 1 - distance,与 L1 向量路同口径)。
74
+ * 未就绪返回**空数组**——调用方无需判断 `nodeVecReady`,降级是内建的。
24
75
  */
25
- init(db: DatabaseSync, logger?: MemoryLogger): void;
76
+ searchNodesByVector(embedding: Float32Array, topK: number): GraphNodeVecHit[];
26
77
  /** 插件停机时清空连接引用(dispose 序调用,防悬空引用)。 */
27
78
  close(): void;
28
79
  /** 统一事务边界(immediate 供 complete 全程持写锁)。 */
@@ -89,6 +140,26 @@ export declare class GraphStore {
89
140
  * 之后;按 L1 存活集判定(而非本次删除集合),跨多次删除与历史孤儿一并收敛。
90
141
  */
91
142
  markSourcesDeleted(deletedIds: readonly string[]): void;
143
+ /**
144
+ * §C 矛盾冻结:把图谱的 `disputed` 状态**同步**到给定冲突集。
145
+ *
146
+ * 为什么是"同步"而不是"标记":裁决会**撤销**争议。只做单向标记的话,
147
+ * 一对已被人工裁决的对,其节点会永远停在 `disputed`——那是**派生投影在说谎**。
148
+ * 图谱是本仓库反复确认的 L1 **派生投影**,派生字段就必须**由当前事实重算**,
149
+ * 而不是靠一串增量事件累积(后者一旦漏一次就永久跑偏)。
150
+ *
151
+ * 判据(与 {@link markSourcesDeleted} 方向相反:那边问"来源是否**全部**消失",
152
+ * 这边问"来源是否**命中**冲突集",命中一条即存疑):
153
+ * - `active` 且来源命中冲突集 → `disputed`
154
+ * - `disputed` 且来源**不**命中冲突集 → 复原为 `active`
155
+ * - `archived` 墓碑两边都不动(墓碑是删除传播的产物,与争议无关)
156
+ *
157
+ * @returns 本次标记 / 复原的节点数。
158
+ */
159
+ syncDisputed(disputedRecordIds: readonly string[]): {
160
+ marked: number;
161
+ cleared: number;
162
+ };
92
163
  /** 清空全部图谱数据(L1 重建时调用——图谱是 L1 的投影,记录清空即图谱作废)。 */
93
164
  resetAll(): void;
94
165
  }