@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +42 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +306 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FORK NOTICE — dsh-memory-local (self-maintained fork of the official `dsh-memory`).
|
|
3
|
+
*
|
|
4
|
+
* Upstream base : dsh-memory@0.1.0 (npm dist, MIT)
|
|
5
|
+
* Base file : lib/index.js
|
|
6
|
+
* sourceSha256: 494ca272f674918f5acfcf32327c164c6eba26fa08fc28bef50e085754c6f28c
|
|
7
|
+
* Base lines : 421
|
|
8
|
+
* Forked : 2026-09-20T16:15:20.970Z
|
|
9
|
+
* Fork owner : dsh-memory-toolkit maintainers
|
|
10
|
+
*
|
|
11
|
+
* Everything below this notice is the upstream file, byte-identical. Local edits are
|
|
12
|
+
* marked with `FORK-EDIT(<date>):` line comments so upstream merges stay a diff away
|
|
13
|
+
* (merge procedure: README.md §4).
|
|
14
|
+
*
|
|
15
|
+
* This is the SOURCE OF TRUTH for the fork; `lib/index.js` is generated by `npm run build`
|
|
16
|
+
* (build = syntax check + copy + fingerprint — the upstream publishes compiled JS only,
|
|
17
|
+
* there is no TypeScript source in the npm tarball, so this fork keeps JS as its source).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ══ FORK-EDIT(2026-10-14): R1 解耦 —— 主包「运行时符号」不再静态 import ══════════════════════
|
|
21
|
+
// 依据:`tools\check-decoupling.mjs` R1(禁 `@deepseek-ai/*` 的运行时静态 import,只允许 `import type`)
|
|
22
|
+
// + `docs\升级方案-2026-09-10.md` §5.2(改法 = 动态 import + 运行时可 try/catch 降级)。
|
|
23
|
+
// 语义(正常宿主)与旧写法**逐点等价**:同两个包、同两个符号、同四处使用点;两个包都在 ⇒ 行为一字不变。
|
|
24
|
+
// 只有主包把符号删/改名时才走降级分支(缺 schemastery ⇒ 内置最小 schema;缺 defineTool ⇒ 跳过工具注册)
|
|
25
|
+
// —— 插件**永不因主包改符号而崩 boot**。
|
|
26
|
+
// ⚠️ 本模块因此含 top-level await:dsh 的插件加载器实测是 `await import()`
|
|
27
|
+
// (cordis-plugin-loader lib/index.js L275/L279 · dsh-app-boot lib/index.js L185)⇒ TLA 安全;
|
|
28
|
+
// 同步 `require()` 本模块会抛 `ERR_REQUIRE_ASYNC_MODULE`(本包只经 ESM import 加载)。
|
|
29
|
+
const HOST_OPTIONAL_LOAD_ERRORS = [];
|
|
30
|
+
let hostZ = null;
|
|
31
|
+
let defineTool = null;
|
|
32
|
+
try {
|
|
33
|
+
const mod = await import("@deepseek-ai/schemastery");
|
|
34
|
+
// ESM(exports.import → lib/index.mjs)/ CJS(exports.require → lib/index.cjs)两种形态都取;
|
|
35
|
+
// 双层 default 也兜(打包器把 default 再包一层时)。
|
|
36
|
+
for (const candidate of [mod?.default, mod?.default?.default, mod?.["module.exports"]]) {
|
|
37
|
+
if (typeof candidate === "function" && typeof candidate.object === "function") {
|
|
38
|
+
hostZ = candidate;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!hostZ) HOST_OPTIONAL_LOAD_ERRORS.push("@deepseek-ai/schemastery: 导出里没有可用的 schema 工厂");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
HOST_OPTIONAL_LOAD_ERRORS.push(`@deepseek-ai/schemastery: ${error?.message ?? error}`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const mod = await import("@deepseek-ai/dsh-tools");
|
|
48
|
+
const candidate = typeof mod?.defineTool === "function"
|
|
49
|
+
? mod.defineTool
|
|
50
|
+
: (typeof mod?.default?.defineTool === "function" ? mod.default.defineTool : null);
|
|
51
|
+
if (candidate) defineTool = candidate;
|
|
52
|
+
else HOST_OPTIONAL_LOAD_ERRORS.push("@deepseek-ai/dsh-tools: 导出里没有 defineTool");
|
|
53
|
+
} catch (error) {
|
|
54
|
+
HOST_OPTIONAL_LOAD_ERRORS.push(`@deepseek-ai/dsh-tools: ${error?.message ?? error}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 最小 schema 兼容面 —— **仅在**主包拿不到 schemastery 时兜底(正常路径永不执行)。
|
|
58
|
+
* 只实现本文件用到的四个方法;默认值与真 schemastery 逐字段一致(同一段 shape)。
|
|
59
|
+
*/
|
|
60
|
+
function fallbackSchemaField(kind) {
|
|
61
|
+
const field = { kind, dflt: void 0, req: false };
|
|
62
|
+
field.required = () => {
|
|
63
|
+
field.req = true;
|
|
64
|
+
return field;
|
|
65
|
+
};
|
|
66
|
+
field.default = (value) => {
|
|
67
|
+
field.dflt = value;
|
|
68
|
+
return field;
|
|
69
|
+
};
|
|
70
|
+
return field;
|
|
71
|
+
}
|
|
72
|
+
const fallbackZ = {
|
|
73
|
+
string: () => fallbackSchemaField("string"),
|
|
74
|
+
number: () => fallbackSchemaField("number"),
|
|
75
|
+
boolean: () => fallbackSchemaField("boolean"),
|
|
76
|
+
object: (shape) => {
|
|
77
|
+
const resolve = (input) => {
|
|
78
|
+
const source = input && typeof input === "object" ? input : {};
|
|
79
|
+
const collected = {};
|
|
80
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
81
|
+
const value = source[key];
|
|
82
|
+
if (value !== void 0) {
|
|
83
|
+
collected[key] = value;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (field.req) throw new Error(`memory: config.${key} is required`);
|
|
87
|
+
collected[key] = field.dflt;
|
|
88
|
+
}
|
|
89
|
+
return collected;
|
|
90
|
+
};
|
|
91
|
+
resolve.toString = () => `{ ${Object.entries(shape).map(([k, f]) => `${k}${f.req ? "" : "?"}: ${f.kind}`).join(", ")} }`;
|
|
92
|
+
return resolve;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const z = hostZ ?? fallbackZ;
|
|
96
|
+
import { mkdirSync } from "node:fs";
|
|
97
|
+
import { dirname } from "node:path";
|
|
98
|
+
import { DatabaseSync } from "node:sqlite";
|
|
99
|
+
// FORK-EDIT(2026-09-21): 记忆引擎 v2 · S2b —— 指针注入 + memory_get(方案 §3.4/§6.1/§7)
|
|
100
|
+
import os from "node:os";
|
|
101
|
+
import path from "node:path";
|
|
102
|
+
import { createRecallLogger } from "./recall-log.mjs";
|
|
103
|
+
import { createModuleGate } from "./module-gate.mjs";
|
|
104
|
+
import { createRecallInjector, lastUserText, SOURCE_KIND, SOURCE_VERSION } from "./injector.mjs";
|
|
105
|
+
import { createUserMessage } from "./user-message.mjs";
|
|
106
|
+
// FORK-EDIT(2026-09-21): 记忆引擎 v2 · S3b —— 表结构自检(`owner_user` / `visibility` 两列 + 索引,方案 §3.8/§3.9)
|
|
107
|
+
import { ensureMemoryColumns, writeSchemaLog } from "./schema-migrate.mjs";
|
|
108
|
+
// FORK-EDIT(2026-09-22): U3 adapter 接入 —— 宿主事实(ctx 服务名)收敛到适配层唯一源。
|
|
109
|
+
// `inject` 是宿主契约声明、名字无法用"语义调用"消除,只能从 SVC 取常量(方案 §10.2 解法 A)。
|
|
110
|
+
import { SVC } from "./host-adapter.js";
|
|
111
|
+
|
|
112
|
+
//#region src/store.ts
|
|
113
|
+
/** Monotonic on-disk schema version; a mismatch rebuilds the derived index. */
|
|
114
|
+
const SCHEMA_VERSION = 1;
|
|
115
|
+
const SCHEMA = `
|
|
116
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
117
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
text TEXT NOT NULL,
|
|
119
|
+
tags TEXT NOT NULL DEFAULT '',
|
|
120
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
121
|
+
created_at INTEGER NOT NULL,
|
|
122
|
+
updated_at INTEGER NOT NULL
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS memories_recent ON memories (updated_at DESC, id DESC);
|
|
125
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
|
126
|
+
USING fts5(text, tags, content='memories', content_rowid='id');
|
|
127
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
128
|
+
INSERT INTO memories_fts (rowid, text, tags) VALUES (new.id, new.text, new.tags);
|
|
129
|
+
END;
|
|
130
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
131
|
+
INSERT INTO memories_fts (memories_fts, rowid, text, tags) VALUES ('delete', old.id, old.text, old.tags);
|
|
132
|
+
END;
|
|
133
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
134
|
+
INSERT INTO memories_fts (memories_fts, rowid, text, tags) VALUES ('delete', old.id, old.text, old.tags);
|
|
135
|
+
INSERT INTO memories_fts (rowid, text, tags) VALUES (new.id, new.text, new.tags);
|
|
136
|
+
END;
|
|
137
|
+
`;
|
|
138
|
+
/**
|
|
139
|
+
* Normalize a tag list to the lowercase, deduplicated, space-joined form the
|
|
140
|
+
* FTS index stores, so `Foo`, `foo`, and a repeated `foo` all match `foo`.
|
|
141
|
+
* @param tags - tags as supplied by the model or config.
|
|
142
|
+
* @returns the normalized space-joined list, empty when nothing survives.
|
|
143
|
+
*/
|
|
144
|
+
function normalizeTags(tags) {
|
|
145
|
+
const seen = /* @__PURE__ */ new Set();
|
|
146
|
+
for (const tag of tags) {
|
|
147
|
+
const normalized = tag.trim().toLowerCase().replaceAll(/\s+/g, "-");
|
|
148
|
+
if (normalized.length > 0) seen.add(normalized);
|
|
149
|
+
}
|
|
150
|
+
return [...seen].join(" ");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Compile a free-text query into an FTS5 MATCH expression. Every token is
|
|
154
|
+
* quoted, so FTS5 operators a model happens to type (`OR`, `*`, `-`, `"`) are
|
|
155
|
+
* matched literally instead of changing the query's meaning or raising a
|
|
156
|
+
* syntax error mid-tool-call.
|
|
157
|
+
* @param query - the raw query text.
|
|
158
|
+
* @returns the MATCH expression, or undefined when the query has no usable token.
|
|
159
|
+
*/
|
|
160
|
+
function compileMatch(query) {
|
|
161
|
+
const tokens = query.split(/[^\p{L}\p{N}_]+/u).filter((token) => token.length > 0).map((token) => `"${token}"`);
|
|
162
|
+
return tokens.length > 0 ? tokens.join(" ") : void 0;
|
|
163
|
+
}
|
|
164
|
+
/** Map one row to the record shape, converting SQLite's integer boolean. */
|
|
165
|
+
function toRecord(row) {
|
|
166
|
+
return {
|
|
167
|
+
id: row.id,
|
|
168
|
+
text: row.text,
|
|
169
|
+
tags: row.tags,
|
|
170
|
+
pinned: row.pinned !== 0,
|
|
171
|
+
createdAt: row.created_at,
|
|
172
|
+
updatedAt: row.updated_at
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The durable memory store. One instance owns one SQLite connection; `close()`
|
|
177
|
+
* is idempotent and runs from the plugin's disposer.
|
|
178
|
+
*/
|
|
179
|
+
var MemoryStore = class {
|
|
180
|
+
#db;
|
|
181
|
+
#closed = false;
|
|
182
|
+
/** FORK-EDIT(2026-09-21): 库不可用原因(null = 正常)。不可用时**只读接口返回空、写接口抛明确错误**。 */
|
|
183
|
+
#unavailable = null;
|
|
184
|
+
/**
|
|
185
|
+
* Open (creating if absent) the store at `path`, applying the schema.
|
|
186
|
+
* @param path - database file path, or `:memory:` for an ephemeral store.
|
|
187
|
+
*/
|
|
188
|
+
constructor(path) {
|
|
189
|
+
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
|
190
|
+
this.openWarnings = [];
|
|
191
|
+
const reason = (e) => (e && e.message ? e.message : String(e));
|
|
192
|
+
// ── FORK-EDIT(2026-09-21): 记忆引擎 v2 · S3b「绝不阻断 DSH 启动」加固 ──────────────────
|
|
193
|
+
// 背景(ROG 真机实测 2026-09-21,证据见 `docs\记忆引擎v2-S3b-交付-20260921.md` §B/C 与 §E2):
|
|
194
|
+
// · 库**只读**(只读挂载 / 目录 ACL 拒写)时,上游那行 `PRAGMA user_version = N` 会抛
|
|
195
|
+
// `unable to open database file` ⇒ 构造函数抛 ⇒ 插件 apply 抛 ⇒ **表结构自检根本跑不到**;
|
|
196
|
+
// · 库**损坏**(非 SQLite 内容)时,上游的 `SCHEMA` exec 抛 ⇒ 插件 apply 抛 ⇒
|
|
197
|
+
// 实测后果不是"记忆功能没了",而是 **cordis 整个插件树加载失败**(3090 实例上 10 个
|
|
198
|
+
// client 插件全部不加载,GUI 侧边栏按钮/看板/面板全消失)—— 实质等于阻断 DSH。
|
|
199
|
+
// 处置(本 fork 的行为差异,只在这两类异常库上生效,正常库上与上游逐字一致):
|
|
200
|
+
// ① `journal_mode` / `foreign_keys` / `user_version` 逐个软着陆(连接级设置 + 只写不读的标记);
|
|
201
|
+
// ② `DatabaseSync` 打开失败 或 `SCHEMA` 建表失败 ⇒ 收敛为**降级 store**(`#unavailable`):
|
|
202
|
+
// 构造函数不抛,插件正常注册,只读接口返回空、写接口给明确错误 —— 记忆功能不可用,
|
|
203
|
+
// 但 DSH 与其余插件照常(并在日志留痕,避免静默失效)。
|
|
204
|
+
// ──────────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
const softExec = (label, sql) => {
|
|
206
|
+
try {
|
|
207
|
+
this.#db.exec(sql);
|
|
208
|
+
} catch (e) {
|
|
209
|
+
this.openWarnings.push(`${label}: ${reason(e)}`);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
try {
|
|
213
|
+
this.#db = new DatabaseSync(path);
|
|
214
|
+
} catch (e) {
|
|
215
|
+
this.#db = null;
|
|
216
|
+
this.#unavailable = `打开失败:${reason(e)}`;
|
|
217
|
+
}
|
|
218
|
+
if (this.#db) {
|
|
219
|
+
softExec("journal_mode", "PRAGMA journal_mode = DELETE; -- 2026-09-07 baidu-sync fix: WAL shm/wal files break BaiduSync; DELETE has no shm/wal");
|
|
220
|
+
softExec("foreign_keys", "PRAGMA foreign_keys = ON");
|
|
221
|
+
try {
|
|
222
|
+
this.#db.exec(SCHEMA);
|
|
223
|
+
} catch (e) {
|
|
224
|
+
this.#unavailable = `建表失败:${reason(e)}`;
|
|
225
|
+
}
|
|
226
|
+
softExec("user_version", `PRAGMA user_version = ${SCHEMA_VERSION}`);
|
|
227
|
+
}
|
|
228
|
+
// FORK-EDIT(2026-09-21): 记忆引擎 v2 · S3b —— 表结构自检(方案 §3.8 / §3.9)
|
|
229
|
+
// 幂等补 `owner_user` / `visibility` 两列 + `memories_visibility` 索引。
|
|
230
|
+
// **永不抛**:任何失败只落日志(`<home>/logs/memory-schema.log`)并置 `degraded=true`,
|
|
231
|
+
// 绝不阻断 DSH 启动(§3.8 风险与对策③);结果挂在 `schemaCheck()` 上供上层判降级。
|
|
232
|
+
// `:memory:` 跳过 —— 临时库没有"既存库迁移"语义,跳过同时保证 fork 与上游在内存库上
|
|
233
|
+
// 逐字一致(`test/regression.mjs` 的同输入同输出对照依赖这一点)。
|
|
234
|
+
if (path === ":memory:") {
|
|
235
|
+
this.schemaCheckResult = {
|
|
236
|
+
ok: true, degraded: false, added: [], present: [], indexCreated: false, statements: [],
|
|
237
|
+
ownerUser: null, ownerSource: null, notes: [], error: null, skipped: "内存库跳过表结构自检"
|
|
238
|
+
};
|
|
239
|
+
} else if (!this.#db) {
|
|
240
|
+
this.schemaCheckResult = {
|
|
241
|
+
ok: false, degraded: true, added: [], present: [], indexCreated: false, statements: [],
|
|
242
|
+
ownerUser: null, ownerSource: null, notes: [], error: this.#unavailable, skipped: null
|
|
243
|
+
};
|
|
244
|
+
} else {
|
|
245
|
+
this.schemaCheckResult = ensureMemoryColumns(this.#db, { dbPath: path });
|
|
246
|
+
}
|
|
247
|
+
// FORK-EDIT(2026-09-21): 打开阶段的异常一律落一行日志(否则"库坏了/只读了"会与"库健康"
|
|
248
|
+
// 看起来一模一样 —— 无人抽检就静默失效)。
|
|
249
|
+
if (path !== ":memory:" && (this.#unavailable || this.openWarnings.length > 0)) {
|
|
250
|
+
writeSchemaLog({ dbPath: path }, {
|
|
251
|
+
event: this.#unavailable ? "open-unavailable" : "open-warning",
|
|
252
|
+
ok: false,
|
|
253
|
+
unavailable: this.#unavailable,
|
|
254
|
+
warnings: this.openWarnings
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Store one memory.
|
|
260
|
+
* @param text - the fact to remember.
|
|
261
|
+
* @param tags - normalized tag list.
|
|
262
|
+
* @param pinned - whether it always renders in the prompt section.
|
|
263
|
+
* @returns the new record.
|
|
264
|
+
*/
|
|
265
|
+
write(text, tags, pinned) {
|
|
266
|
+
// FORK-EDIT(2026-09-21): 降级 store(库打不开/建表失败)⇒ 写接口给**明确原因**的错误,
|
|
267
|
+
// 不静默丢弃主人的记忆(上层工具会把这条错误原样带回模型)。
|
|
268
|
+
if (this.#isDown()) throw new Error(`memory: 记忆库不可用(${this.#unavailable})—— 本次写入未落库`);
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
return toRecord(this.#db.prepare("INSERT INTO memories (text, tags, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?) RETURNING *").get(text, tags, pinned ? 1 : 0, now, now));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Full-text search over memory text and tags, best match first.
|
|
274
|
+
* @param query - free-text query; FTS operators in it are matched literally.
|
|
275
|
+
* @param limit - maximum hits to return.
|
|
276
|
+
* @returns the ranked matches, empty when the query has no usable token.
|
|
277
|
+
*/
|
|
278
|
+
search(query, limit) {
|
|
279
|
+
// FORK-EDIT(2026-09-21): 降级 store ⇒ 返回空结果(**绝不抛**:搜索失败不该打断对话回合)
|
|
280
|
+
if (this.#isDown()) return [];
|
|
281
|
+
const match = compileMatch(query);
|
|
282
|
+
if (match === void 0) return [];
|
|
283
|
+
return this.#db.prepare(`
|
|
284
|
+
SELECT m.*, memories_fts.rank AS rank
|
|
285
|
+
FROM memories_fts JOIN memories m ON m.id = memories_fts.rowid
|
|
286
|
+
WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?
|
|
287
|
+
`).all(match, limit).map((row) => ({
|
|
288
|
+
...toRecord(row),
|
|
289
|
+
rank: row.rank ?? 0
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Read one memory in full by id.
|
|
294
|
+
* FORK-EDIT(2026-09-21): `memory_get` 的读取口 —— 读的是本插件的权威库
|
|
295
|
+
* (与 memory_write / memory_search 同一个 `path`),不是召回侧的只读副本连接
|
|
296
|
+
* (方案 §3.4 关 B1:指针指向的那扇门必须由本包自己开,且开的必须是同一间屋)。
|
|
297
|
+
* @param id - the record id.
|
|
298
|
+
* @returns the record, or undefined when absent.
|
|
299
|
+
*/
|
|
300
|
+
get(id) {
|
|
301
|
+
// FORK-EDIT(2026-09-21): 降级 store ⇒ 等同于"没有这条"(**绝不抛**)
|
|
302
|
+
if (this.#isDown()) return void 0;
|
|
303
|
+
const row = this.#db.prepare("SELECT * FROM memories WHERE id = ?").get(Number(id));
|
|
304
|
+
return row === void 0 ? void 0 : toRecord(row);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The memories the prompt section renders: pinned first, then most recently
|
|
308
|
+
* updated, with no record repeated.
|
|
309
|
+
* @param recentCount - how many unpinned recent memories to include.
|
|
310
|
+
* @returns pinned records followed by recent ones.
|
|
311
|
+
*/
|
|
312
|
+
forPrompt(recentCount) {
|
|
313
|
+
// FORK-EDIT(2026-09-21): 降级 store ⇒ 段渲染为空(**最关键的一处**:本方法每轮都被
|
|
314
|
+
// `memory:recall` 段调用,这里抛错会把每一轮对话都带崩,比插件加载失败更糟)
|
|
315
|
+
if (this.#isDown()) return [];
|
|
316
|
+
const pinned = this.#db.prepare("SELECT * FROM memories WHERE pinned = 1 ORDER BY updated_at DESC, id DESC").all();
|
|
317
|
+
const recent = this.#db.prepare("SELECT * FROM memories WHERE pinned = 0 ORDER BY updated_at DESC, id DESC LIMIT ?").all(recentCount);
|
|
318
|
+
return [...pinned, ...recent].map(toRecord);
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Delete one memory.
|
|
322
|
+
* @param id - the record id.
|
|
323
|
+
* @returns whether a record was deleted.
|
|
324
|
+
*/
|
|
325
|
+
forget(id) {
|
|
326
|
+
// FORK-EDIT(2026-09-21): 降级 store ⇒ 等同于"没删掉任何东西"(**不谎报成功**)
|
|
327
|
+
if (this.#isDown()) return false;
|
|
328
|
+
return this.#db.prepare("DELETE FROM memories WHERE id = ?").run(id).changes > 0;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Total stored memories.
|
|
332
|
+
* @returns the row count.
|
|
333
|
+
*/
|
|
334
|
+
count() {
|
|
335
|
+
// FORK-EDIT(2026-09-21): 降级 store ⇒ 0 条(**绝不抛**)
|
|
336
|
+
if (this.#isDown()) return 0;
|
|
337
|
+
return this.#db.prepare("SELECT COUNT(*) AS n FROM memories").get().n;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* FORK-EDIT(2026-09-21): 库是否**不可用** —— 打不开(没有连接)**或** 建表失败(连接在、但表建不出)。
|
|
341
|
+
* 两者都必须走降级路径:只判"连接是否为 null"会漏掉后者,实测后果是坏库上
|
|
342
|
+
* `search` / 段渲染真去执行 SQL 并抛错(每轮对话都会被带崩),见交付报告 §E-C。
|
|
343
|
+
* @returns {boolean}
|
|
344
|
+
*/
|
|
345
|
+
#isDown() {
|
|
346
|
+
return this.#db === null || this.#unavailable !== null;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* FORK-EDIT(2026-09-21): 库是否处于降级(不可用)状态。
|
|
350
|
+
* @returns {boolean} true = 库打不开或建表失败,本 store 只提供空结果/明确错误。
|
|
351
|
+
*/
|
|
352
|
+
isUnavailable() {
|
|
353
|
+
return this.#isDown();
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* FORK-EDIT(2026-09-21): 库不可用的原因(正常时 null)。
|
|
357
|
+
* @returns {string|null}
|
|
358
|
+
*/
|
|
359
|
+
unavailableReason() {
|
|
360
|
+
return this.#unavailable;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* FORK-EDIT(2026-09-21): 启动自检结果(只读快照,方案 §3.8)。
|
|
364
|
+
* `degraded === true` ⇒ 两列没加上(库被锁 / 只读 / 损坏),此时依赖 `owner_user`/`visibility`
|
|
365
|
+
* 的功能必须自我降级,**不许**假设列存在;其余功能照常。
|
|
366
|
+
* @returns {object} {@link ensureMemoryColumns} 的返回值。
|
|
367
|
+
*/
|
|
368
|
+
schemaCheck() {
|
|
369
|
+
return this.schemaCheckResult;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* FORK-EDIT(2026-09-21): 打开阶段的软着陆告警(只读库上 `PRAGMA` 写不进去等)。
|
|
373
|
+
* 空数组 = 打开阶段的设置全部生效。
|
|
374
|
+
* @returns {string[]} 形如 `["user_version: unable to open database file"]`。
|
|
375
|
+
*/
|
|
376
|
+
openState() {
|
|
377
|
+
return this.openWarnings ?? [];
|
|
378
|
+
}
|
|
379
|
+
/** Close the connection; idempotent, so plugin disposal and tests may both call it. */
|
|
380
|
+
close() {
|
|
381
|
+
if (this.#closed) return;
|
|
382
|
+
this.#closed = true;
|
|
383
|
+
// FORK-EDIT(2026-09-21): 降级 store(没有连接)也要能安全关闭 —— 否则 disposer 会抛
|
|
384
|
+
try {
|
|
385
|
+
this.#db?.close();
|
|
386
|
+
} catch {
|
|
387
|
+
/* 关闭失败不影响插件卸载 */
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/index.ts
|
|
394
|
+
const name = "memory";
|
|
395
|
+
// FORK-EDIT(2026-09-22): U3 —— inject 里的两个裸服务名字符串改为适配层常量(值恒等,零语义变化)。
|
|
396
|
+
const inject = [SVC.tools, SVC.systemPrompt];
|
|
397
|
+
const Config = z.object({
|
|
398
|
+
path: z.string().required(),
|
|
399
|
+
promptRecentCount: z.number().default(10),
|
|
400
|
+
promptMaxChars: z.number().default(2e3),
|
|
401
|
+
maxTextChars: z.number().default(2e3),
|
|
402
|
+
searchLimitDefault: z.number().default(10),
|
|
403
|
+
searchLimitMax: z.number().default(50),
|
|
404
|
+
promptOrder: z.number().default(50),
|
|
405
|
+
// FORK-EDIT(2026-10-16): `memory:recall` 段的「会话内冻结」(harness 成本与路由线 · 主人拍板 ㈡档)。
|
|
406
|
+
// true = 每个会话只渲染一次该段文本,会话内后续 step 复用同一份(前缀缓存稳定);
|
|
407
|
+
// false = 旧行为(每次 assemble 重算 —— 写一次记忆,之后每一轮的前缀都从这一段起失效)。
|
|
408
|
+
promptFreezeSystem: z.boolean().default(true),
|
|
409
|
+
// 冻结快照的条数上限(本进程长跑,必须有界;超限淘汰最旧的键)。512 条 × 单段上限 2000 字 ≈ 1 MB 量级。
|
|
410
|
+
promptFreezeMax: z.number().default(512),
|
|
411
|
+
// FORK-EDIT(2026-09-21): 指针注入配置(记忆引擎 v2 §3.4)。
|
|
412
|
+
// 全部带默认值 ⇒ 缺省**不改变既有行为**;`recallEnabled` 默认 false(未过验收前不上生产,
|
|
413
|
+
// 与 L2 模块位 `memory-recall` 默认关同口径:两处都开才注入)。
|
|
414
|
+
recallEnabled: z.boolean().default(false),
|
|
415
|
+
recallHome: z.string().default(""),
|
|
416
|
+
recallMemoryDb: z.string().default(""),
|
|
417
|
+
recallVectorsDb: z.string().default(""),
|
|
418
|
+
recallBridgeHost: z.string().default("127.0.0.1"),
|
|
419
|
+
recallBridgePort: z.number().default(8201),
|
|
420
|
+
recallBridgePortScanMax: z.number().default(10),
|
|
421
|
+
recallTimeoutMs: z.number().default(300),
|
|
422
|
+
recallEncodeTimeoutMs: z.number().default(250),
|
|
423
|
+
recallMaxItems: z.number().default(8),
|
|
424
|
+
recallMaxChars: z.number().default(800),
|
|
425
|
+
recallMaxTokens: z.number().default(600),
|
|
426
|
+
recallMaxPerSession: z.number().default(3),
|
|
427
|
+
recallSummaryChars: z.number().default(80),
|
|
428
|
+
recallQueryMaxChars: z.number().default(512),
|
|
429
|
+
recallVectorTopK: z.number().default(20),
|
|
430
|
+
recallFtsTopK: z.number().default(20),
|
|
431
|
+
recallRrfK: z.number().default(60),
|
|
432
|
+
recallResultTopK: z.number().default(8),
|
|
433
|
+
recallIndexTtlMs: z.number().default(6e4)
|
|
434
|
+
});
|
|
435
|
+
const WRITE_DESCRIPTION = "Remember one durable fact across sessions: a user preference, a project convention, a decision and its reason, or a hard-won detail about this codebase. Write one self-contained fact per call — it will be read back with no surrounding conversation. Do NOT store transient task state (use the todo list), secrets, or anything the repository already records.";
|
|
436
|
+
const SEARCH_DESCRIPTION = "Search stored memories by keyword. Pinned and recent memories already appear in your context, so search when you need something older or more specific than what you can already see.";
|
|
437
|
+
const FORGET_DESCRIPTION = "Delete one stored memory by id, for a fact that is now wrong or obsolete. Ids come from memory_search or memory_write.";
|
|
438
|
+
/**
|
|
439
|
+
* Render one memory as a prompt line.
|
|
440
|
+
* @param record - the memory to render.
|
|
441
|
+
* @returns a single line carrying the id, tags, and text.
|
|
442
|
+
*/
|
|
443
|
+
function promptLine(record) {
|
|
444
|
+
const tags = record.tags.length > 0 ? ` [${record.tags}]` : "";
|
|
445
|
+
return `- (#${record.id}${record.pinned ? ", pinned" : ""})${tags} ${record.text}`;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Render the prompt section body under a character budget. Pinned memories are
|
|
449
|
+
* emitted first, so a budget too small for everything keeps what the deployment
|
|
450
|
+
* explicitly marked as always-relevant.
|
|
451
|
+
* @param records - pinned records followed by recent ones.
|
|
452
|
+
* @param maxChars - the budget.
|
|
453
|
+
* @returns the section text, or an empty string when nothing fits or nothing is stored.
|
|
454
|
+
*/
|
|
455
|
+
function renderPrompt(records, maxChars) {
|
|
456
|
+
if (records.length === 0) return "";
|
|
457
|
+
const header = "Memories you previously stored (use memory_search for anything not listed):\n";
|
|
458
|
+
const lines = [];
|
|
459
|
+
let used = 76;
|
|
460
|
+
let dropped = 0;
|
|
461
|
+
for (const record of records) {
|
|
462
|
+
const line = promptLine(record);
|
|
463
|
+
if (used + line.length + 1 > maxChars) {
|
|
464
|
+
dropped++;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
lines.push(line);
|
|
468
|
+
used += line.length + 1;
|
|
469
|
+
}
|
|
470
|
+
if (lines.length === 0) return "";
|
|
471
|
+
const tail = dropped > 0 ? `\n(${dropped} more memories not shown; use memory_search)` : "";
|
|
472
|
+
return header + lines.join("\n") + tail;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Validate the bounds the schema cannot express, so an unusable configuration
|
|
476
|
+
* fails at plugin load rather than at the first tool call.
|
|
477
|
+
* @param config - the schema-validated config.
|
|
478
|
+
* @throws when a bound is not a positive integer, or the default search limit exceeds its cap.
|
|
479
|
+
*/
|
|
480
|
+
function validateConfig(config) {
|
|
481
|
+
const bounds = [
|
|
482
|
+
["promptRecentCount", config.promptRecentCount],
|
|
483
|
+
["promptMaxChars", config.promptMaxChars],
|
|
484
|
+
["maxTextChars", config.maxTextChars],
|
|
485
|
+
["searchLimitDefault", config.searchLimitDefault],
|
|
486
|
+
["searchLimitMax", config.searchLimitMax]
|
|
487
|
+
];
|
|
488
|
+
for (const [field, value] of bounds) if (!Number.isInteger(value) || value < 1) throw new Error(`memory: invalid ${field} ${value} — must be an integer >= 1`);
|
|
489
|
+
if (config.searchLimitDefault > config.searchLimitMax) throw new Error(`memory: searchLimitDefault ${config.searchLimitDefault} exceeds searchLimitMax ${config.searchLimitMax}`);
|
|
490
|
+
if (config.path.length === 0) throw new Error("memory: `path` must not be empty");
|
|
491
|
+
// FORK-EDIT(2026-09-21): 注入预算参数必须是正整数(误配在加载期就炸,不拖到第一个回合)
|
|
492
|
+
const recallBounds = [
|
|
493
|
+
["recallBridgePort", config.recallBridgePort],
|
|
494
|
+
["recallBridgePortScanMax", config.recallBridgePortScanMax],
|
|
495
|
+
["recallTimeoutMs", config.recallTimeoutMs],
|
|
496
|
+
["recallEncodeTimeoutMs", config.recallEncodeTimeoutMs],
|
|
497
|
+
["recallMaxItems", config.recallMaxItems],
|
|
498
|
+
["recallMaxChars", config.recallMaxChars],
|
|
499
|
+
["recallMaxTokens", config.recallMaxTokens],
|
|
500
|
+
["recallMaxPerSession", config.recallMaxPerSession],
|
|
501
|
+
["recallSummaryChars", config.recallSummaryChars],
|
|
502
|
+
["recallQueryMaxChars", config.recallQueryMaxChars],
|
|
503
|
+
["recallVectorTopK", config.recallVectorTopK],
|
|
504
|
+
["recallFtsTopK", config.recallFtsTopK],
|
|
505
|
+
["recallRrfK", config.recallRrfK],
|
|
506
|
+
["recallResultTopK", config.recallResultTopK],
|
|
507
|
+
["recallIndexTtlMs", config.recallIndexTtlMs]
|
|
508
|
+
];
|
|
509
|
+
for (const [field, value] of recallBounds) if (!Number.isInteger(value) || value < 1) throw new Error(`memory: invalid ${field} ${value} — must be an integer >= 1`);
|
|
510
|
+
if (config.recallEncodeTimeoutMs > config.recallTimeoutMs) throw new Error(`memory: recallEncodeTimeoutMs ${config.recallEncodeTimeoutMs} must not exceed recallTimeoutMs ${config.recallTimeoutMs}`);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* FORK-EDIT(2026-09-21): 解析指针注入配置(记忆引擎 v2 §3.4)。
|
|
514
|
+
* 路径一律按 `DSH_HOME` 解析(跨机/隔离实例口径:不写死机器名与盘符);测试可全部覆盖。
|
|
515
|
+
* 召回开关的**权威源 = 插件配置**(方案 §5.1 载体表),环境变量 `MEMORY_RECALL_ENABLED`
|
|
516
|
+
* 只做**临时**覆盖(不落盘)。
|
|
517
|
+
* @param {object} config - schema 校验后的配置。
|
|
518
|
+
* @returns {object} 注入器配置。
|
|
519
|
+
*/
|
|
520
|
+
function resolveRecallConfig(config) {
|
|
521
|
+
const home = config.recallHome || process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
|
|
522
|
+
const envFlag = process.env.MEMORY_RECALL_ENABLED;
|
|
523
|
+
const envEnabled = envFlag === undefined ? null : /^(1|true|yes|on)$/i.test(String(envFlag));
|
|
524
|
+
return {
|
|
525
|
+
enabled: envEnabled === null ? Boolean(config.recallEnabled) : envEnabled,
|
|
526
|
+
envOverride: envEnabled !== null,
|
|
527
|
+
home,
|
|
528
|
+
memoryDb: config.recallMemoryDb || config.path,
|
|
529
|
+
vectorsDb: config.recallVectorsDb || path.join(home, "memory-vectors", "vectors.db"),
|
|
530
|
+
logsDir: path.join(home, "logs"),
|
|
531
|
+
bridgeHost: config.recallBridgeHost,
|
|
532
|
+
bridgePort: config.recallBridgePort,
|
|
533
|
+
bridgePortScanMax: config.recallBridgePortScanMax,
|
|
534
|
+
timeoutMs: config.recallTimeoutMs,
|
|
535
|
+
encodeTimeoutMs: config.recallEncodeTimeoutMs,
|
|
536
|
+
maxItems: config.recallMaxItems,
|
|
537
|
+
maxChars: config.recallMaxChars,
|
|
538
|
+
maxTokens: config.recallMaxTokens,
|
|
539
|
+
maxPerSession: config.recallMaxPerSession,
|
|
540
|
+
summaryChars: config.recallSummaryChars,
|
|
541
|
+
queryMaxChars: config.recallQueryMaxChars,
|
|
542
|
+
vectorTopK: config.recallVectorTopK,
|
|
543
|
+
ftsTopK: config.recallFtsTopK,
|
|
544
|
+
rrfK: config.recallRrfK,
|
|
545
|
+
resultTopK: config.recallResultTopK,
|
|
546
|
+
indexTtlMs: config.recallIndexTtlMs
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Open the store, register the three tools, and contribute the recall section.
|
|
551
|
+
* @param ctx - plugin context; the store, tools, and section are disposed with it.
|
|
552
|
+
* @param config - validated {@link Config}.
|
|
553
|
+
*/
|
|
554
|
+
function apply(ctx, config) {
|
|
555
|
+
validateConfig(config);
|
|
556
|
+
/**
|
|
557
|
+
* FORK-EDIT(2026-10-14): R1 解耦 —— 工具注册的唯一入口。
|
|
558
|
+
* `defineTool` 来自顶层动态探测;不可用(主包删/改名该符号)⇒ **跳过注册**(插件降级、不崩 boot)。
|
|
559
|
+
* 正常宿主下与 `ctx.tools.register(defineTool(def))` 逐点等价。
|
|
560
|
+
* @param definition - defineTool 的入参。
|
|
561
|
+
* @returns 是否真的注册了。
|
|
562
|
+
*/
|
|
563
|
+
const registerTool = (definition) => {
|
|
564
|
+
if (typeof defineTool !== "function") return false;
|
|
565
|
+
ctx.tools.register(defineTool(definition));
|
|
566
|
+
return true;
|
|
567
|
+
};
|
|
568
|
+
// 主包可选依赖缺失时留一行可查痕迹(不抛、不阻断 apply)。
|
|
569
|
+
if (HOST_OPTIONAL_LOAD_ERRORS.length > 0) {
|
|
570
|
+
console.warn(`[memory] 主包可选依赖降级:${HOST_OPTIONAL_LOAD_ERRORS.join(";")}`);
|
|
571
|
+
}
|
|
572
|
+
// FORK-EDIT(2026-09-21): 指针注入接线(记忆引擎 v2 · S2b · 方案 §3.4/§6.1/§7)---------
|
|
573
|
+
// 结构:logger(JSON 行 + 轮转 3×30 天)→ moduleGate(L2 模块位自我 drain)→ injector(编排)
|
|
574
|
+
const recallConfig = resolveRecallConfig(config);
|
|
575
|
+
const recallLogger = createRecallLogger({ dir: recallConfig.logsDir });
|
|
576
|
+
const moduleGate = createModuleGate({ home: recallConfig.home });
|
|
577
|
+
const injector = createRecallInjector({ config: recallConfig, logger: recallLogger, moduleGate });
|
|
578
|
+
ctx.effect(() => () => {
|
|
579
|
+
injector.close();
|
|
580
|
+
recallLogger.sweep();
|
|
581
|
+
}, "dsh-memory-local.injector");
|
|
582
|
+
// FORK-EDIT(2026-09-21): 启动后预热(后台、非阻塞)——把向量矩阵与桥探活成本挪出首个回合,
|
|
583
|
+
// 免得上线后第一次注入就顶破 300 ms 预算(真机实测踩过 524 ms 的那一次)。
|
|
584
|
+
if (recallConfig.enabled) {
|
|
585
|
+
const timer = setTimeout(() => {
|
|
586
|
+
injector.warmup().then((r) => {
|
|
587
|
+
if (!r.ok) recallLogger.log({ event: "warmup", ok: false, reason: r.reason });
|
|
588
|
+
}).catch(() => {});
|
|
589
|
+
}, 2000);
|
|
590
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
591
|
+
}
|
|
592
|
+
let store;
|
|
593
|
+
ctx.effect(() => {
|
|
594
|
+
store = new MemoryStore(config.path);
|
|
595
|
+
return () => {
|
|
596
|
+
store?.close();
|
|
597
|
+
store = void 0;
|
|
598
|
+
};
|
|
599
|
+
});
|
|
600
|
+
/**
|
|
601
|
+
* The open store, or a loud failure. Reached only while the fiber is active,
|
|
602
|
+
* so an absent store is a lifecycle bug rather than an expected state.
|
|
603
|
+
* @returns the live store.
|
|
604
|
+
*/
|
|
605
|
+
function open() {
|
|
606
|
+
if (!store) throw new Error("memory: store is not open");
|
|
607
|
+
return store;
|
|
608
|
+
}
|
|
609
|
+
// FORK-EDIT(2026-10-16): `memory:recall` 段「会话内冻结」(harness 成本与路由线 · 主人拍板 ㈡档)。
|
|
610
|
+
//
|
|
611
|
+
// 为什么:下面这段 text() 在**每个 model step** 的 assemble 里都会被调用(宿主
|
|
612
|
+
// `dsh-agent-loop` → `systemPrompt.assemble(assembleContextFor(agent, signal))`,该 context
|
|
613
|
+
// 带 `{ agent, scope, signal }`),而它的内容是「最近 N 条记忆」—— 本会话里写一条记忆,
|
|
614
|
+
// 这一段就变一次,**前缀缓存从这一段起全部失效**。冻结 = 同一会话内渲染一次后复用,
|
|
615
|
+
// 缓存只在**新会话**第一轮重建。
|
|
616
|
+
//
|
|
617
|
+
// 保留(本次**不动**):紧随其后的 `agent/pre-step` 指针注入 —— 它 append 在 messages
|
|
618
|
+
// 尾部,本来就缓存友好(上游姿势也是"前缀稳定 + append-only")。
|
|
619
|
+
//
|
|
620
|
+
// 代价(主人在案):本会话内**新写的记忆**在 system 常驻段看不见;尾部召回指针仍在,
|
|
621
|
+
// 实际可见性损失比"整个记忆系统冻结"小得多。
|
|
622
|
+
//
|
|
623
|
+
// 边界:① 新会话(新 agentId)按最新记忆重新渲染;② 无 agent 上下文的 assemble(宿主
|
|
624
|
+
// 内部调用)按旧行为每次重算;③ 只有**渲染成功**的结果才入缓存(抛错照旧抛、下次重试,
|
|
625
|
+
// 不改 `#isDown()` 的降级语义);④ 快照有上限(`promptFreezeMax`,超限淘汰最旧键),
|
|
626
|
+
// 长跑进程不累积。
|
|
627
|
+
const frozenSections = new Map();
|
|
628
|
+
function renderRecallSection() {
|
|
629
|
+
return renderPrompt(open().forPrompt(config.promptRecentCount), config.promptMaxChars);
|
|
630
|
+
}
|
|
631
|
+
function recallSectionText(context) {
|
|
632
|
+
if (!config.promptFreezeSystem) return renderRecallSection();
|
|
633
|
+
const agentId = context && context.agent && context.agent.id !== void 0 ? String(context.agent.id) : null;
|
|
634
|
+
if (agentId === null) return renderRecallSection();
|
|
635
|
+
const cached = frozenSections.get(agentId);
|
|
636
|
+
if (cached !== void 0) return cached;
|
|
637
|
+
const text = renderRecallSection();
|
|
638
|
+
if (frozenSections.size >= Math.max(1, config.promptFreezeMax)) {
|
|
639
|
+
const oldest = frozenSections.keys().next();
|
|
640
|
+
if (!oldest.done) frozenSections.delete(oldest.value);
|
|
641
|
+
}
|
|
642
|
+
frozenSections.set(agentId, text);
|
|
643
|
+
return text;
|
|
644
|
+
}
|
|
645
|
+
ctx.systemPrompt.section({
|
|
646
|
+
name: "memory:recall",
|
|
647
|
+
order: config.promptOrder,
|
|
648
|
+
text: recallSectionText
|
|
649
|
+
});
|
|
650
|
+
// FORK-EDIT(2026-09-21): 指针注入 —— 每回合第一个 step 注入一条 ≤800 字召回指针。
|
|
651
|
+
// 语义(方案 §3.4):先委派下游拿最终决策;被拒绝/信号已中止则不注入;只在 step===1;
|
|
652
|
+
// 全异常捕获(fail-open)——最坏结果 = 不注入,绝不阻塞对话。
|
|
653
|
+
ctx.on("agent/pre-step", async ({ agent, messages, turn, step, signal }, next) => {
|
|
654
|
+
const decision = await next();
|
|
655
|
+
try {
|
|
656
|
+
if (!decision || decision.kind === "reject") return decision;
|
|
657
|
+
if (signal && signal.aborted) return decision;
|
|
658
|
+
if (step !== 1) return decision;
|
|
659
|
+
const query = lastUserText(messages);
|
|
660
|
+
if (!query) return decision;
|
|
661
|
+
const sessionId = agent && agent.id ? String(agent.id) : null;
|
|
662
|
+
const result = await injector.decideWithTimeout({ query, agent, turn, step, sessionId });
|
|
663
|
+
if (result.action !== "inject") return decision;
|
|
664
|
+
return {
|
|
665
|
+
kind: "enter",
|
|
666
|
+
messages: [...decision.messages, createUserMessage({
|
|
667
|
+
content: [{ type: "text", text: result.text }],
|
|
668
|
+
// source 形态写死(方案 §3.4 v4 拍板):自定义 kind,**不复用 form**(form:'recall' 已被 dsh-session-reference 占用)
|
|
669
|
+
source: {
|
|
670
|
+
kind: SOURCE_KIND,
|
|
671
|
+
version: SOURCE_VERSION,
|
|
672
|
+
items: result.meta.ids,
|
|
673
|
+
chars: result.meta.chars,
|
|
674
|
+
tokens: result.meta.estTokens,
|
|
675
|
+
model: result.meta.model ?? null
|
|
676
|
+
}
|
|
677
|
+
})]
|
|
678
|
+
};
|
|
679
|
+
} catch (error) {
|
|
680
|
+
// fail-open:注入链任何异常都不影响对话(方案 §7)
|
|
681
|
+
try {
|
|
682
|
+
recallLogger.log({ event: "skip", reason: "handler-exception", error: String(error && error.message ? error.message : error) });
|
|
683
|
+
} catch {
|
|
684
|
+
/* 写日志失败也不得影响对话 */
|
|
685
|
+
}
|
|
686
|
+
return decision;
|
|
687
|
+
}
|
|
688
|
+
}, { prepend: true });
|
|
689
|
+
registerTool({
|
|
690
|
+
name: "memory_write",
|
|
691
|
+
description: WRITE_DESCRIPTION,
|
|
692
|
+
parameters: {
|
|
693
|
+
text: {
|
|
694
|
+
type: "string",
|
|
695
|
+
required: true,
|
|
696
|
+
description: "The self-contained fact to remember."
|
|
697
|
+
},
|
|
698
|
+
tags: {
|
|
699
|
+
type: "array",
|
|
700
|
+
description: "Optional labels for later retrieval, e.g. [\"preference\", \"build\"].",
|
|
701
|
+
items: { type: "string" }
|
|
702
|
+
},
|
|
703
|
+
pinned: {
|
|
704
|
+
type: "boolean",
|
|
705
|
+
description: "Always show this memory in context. Reserve it for facts that matter in every session."
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
output: {
|
|
709
|
+
schema: {
|
|
710
|
+
type: "object",
|
|
711
|
+
additionalProperties: false,
|
|
712
|
+
properties: {
|
|
713
|
+
id: {
|
|
714
|
+
type: "integer",
|
|
715
|
+
required: true
|
|
716
|
+
},
|
|
717
|
+
tags: {
|
|
718
|
+
type: "string",
|
|
719
|
+
required: true
|
|
720
|
+
},
|
|
721
|
+
pinned: {
|
|
722
|
+
type: "boolean",
|
|
723
|
+
required: true
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
render: (_args, value) => [{
|
|
728
|
+
type: "text",
|
|
729
|
+
text: `Stored memory #${value.id}${value.pinned ? " (pinned)" : ""}.`
|
|
730
|
+
}]
|
|
731
|
+
},
|
|
732
|
+
presentCall: (args) => ({
|
|
733
|
+
card: "generic",
|
|
734
|
+
title: "memory_write",
|
|
735
|
+
kind: "edit",
|
|
736
|
+
rawInput: args
|
|
737
|
+
}),
|
|
738
|
+
async execute(args) {
|
|
739
|
+
const text = args.text.trim();
|
|
740
|
+
if (text.length === 0) throw new Error("memory_write: `text` must not be blank");
|
|
741
|
+
if (text.length > config.maxTextChars) throw new Error(`memory_write: \`text\` is ${text.length} chars, over the ${config.maxTextChars} limit`);
|
|
742
|
+
const record = open().write(text, normalizeTags(args.tags ?? []), args.pinned ?? false);
|
|
743
|
+
return {
|
|
744
|
+
id: record.id,
|
|
745
|
+
tags: record.tags,
|
|
746
|
+
pinned: record.pinned
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
});
|
|
750
|
+
registerTool({
|
|
751
|
+
name: "memory_search",
|
|
752
|
+
description: SEARCH_DESCRIPTION,
|
|
753
|
+
parameters: {
|
|
754
|
+
query: {
|
|
755
|
+
type: "string",
|
|
756
|
+
required: true,
|
|
757
|
+
description: "Keywords to look for in memory text and tags."
|
|
758
|
+
},
|
|
759
|
+
limit: {
|
|
760
|
+
type: "number",
|
|
761
|
+
description: `Maximum results. Defaults to ${config.searchLimitDefault}.`
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
output: {
|
|
765
|
+
schema: {
|
|
766
|
+
type: "object",
|
|
767
|
+
additionalProperties: false,
|
|
768
|
+
properties: { matches: {
|
|
769
|
+
type: "array",
|
|
770
|
+
required: true,
|
|
771
|
+
items: {
|
|
772
|
+
type: "object",
|
|
773
|
+
additionalProperties: false,
|
|
774
|
+
properties: {
|
|
775
|
+
id: {
|
|
776
|
+
type: "integer",
|
|
777
|
+
required: true
|
|
778
|
+
},
|
|
779
|
+
text: {
|
|
780
|
+
type: "string",
|
|
781
|
+
required: true
|
|
782
|
+
},
|
|
783
|
+
tags: {
|
|
784
|
+
type: "string",
|
|
785
|
+
required: true
|
|
786
|
+
},
|
|
787
|
+
pinned: {
|
|
788
|
+
type: "boolean",
|
|
789
|
+
required: true
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} }
|
|
794
|
+
},
|
|
795
|
+
render: (args, value) => [{
|
|
796
|
+
type: "text",
|
|
797
|
+
text: value.matches.length === 0 ? `No memories match ${JSON.stringify(args.query)}.` : value.matches.map((match) => promptLine({
|
|
798
|
+
...match,
|
|
799
|
+
createdAt: 0,
|
|
800
|
+
updatedAt: 0
|
|
801
|
+
})).join("\n")
|
|
802
|
+
}],
|
|
803
|
+
presentationMeta: (_args, value) => ({ count: value.matches.length })
|
|
804
|
+
},
|
|
805
|
+
presentCall: (args) => ({
|
|
806
|
+
card: "generic",
|
|
807
|
+
title: `memory_search ${args.query}`,
|
|
808
|
+
kind: "search"
|
|
809
|
+
}),
|
|
810
|
+
async execute(args) {
|
|
811
|
+
const requested = args.limit ?? config.searchLimitDefault;
|
|
812
|
+
if (!Number.isInteger(requested) || requested < 1) throw new Error(`memory_search: \`limit\` must be an integer >= 1 (got ${requested})`);
|
|
813
|
+
return { matches: open().search(args.query, Math.min(requested, config.searchLimitMax)).map(({ id, text, tags, pinned }) => ({
|
|
814
|
+
id,
|
|
815
|
+
text,
|
|
816
|
+
tags,
|
|
817
|
+
pinned
|
|
818
|
+
})) };
|
|
819
|
+
}
|
|
820
|
+
});
|
|
821
|
+
registerTool({
|
|
822
|
+
name: "memory_forget",
|
|
823
|
+
description: FORGET_DESCRIPTION,
|
|
824
|
+
parameters: { id: {
|
|
825
|
+
type: "integer",
|
|
826
|
+
required: true,
|
|
827
|
+
description: "The memory id to delete."
|
|
828
|
+
} },
|
|
829
|
+
output: {
|
|
830
|
+
schema: {
|
|
831
|
+
type: "object",
|
|
832
|
+
additionalProperties: false,
|
|
833
|
+
properties: {
|
|
834
|
+
id: {
|
|
835
|
+
type: "integer",
|
|
836
|
+
required: true
|
|
837
|
+
},
|
|
838
|
+
forgotten: {
|
|
839
|
+
type: "boolean",
|
|
840
|
+
required: true
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
},
|
|
844
|
+
render: (_args, value) => [{
|
|
845
|
+
type: "text",
|
|
846
|
+
text: value.forgotten ? `Forgot memory #${value.id}.` : `No memory #${value.id} to forget.`
|
|
847
|
+
}]
|
|
848
|
+
},
|
|
849
|
+
async execute(args) {
|
|
850
|
+
return {
|
|
851
|
+
id: args.id,
|
|
852
|
+
forgotten: open().forget(args.id)
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
// FORK-EDIT(2026-09-21): `memory_get` —— 指针指向的那扇门必须由本包自己开(方案 §3.4 关 B1)。
|
|
857
|
+
// 依据:官方 dsh-memory@0.1.0 只注册 memory_write / memory_search / memory_forget,`memory_get` 零命中。
|
|
858
|
+
// 只读;超 `maxTextChars` 截断并提示;每次调用留一行 `event:"expand"` 日志(展开率统计,方案 §8.2)。
|
|
859
|
+
registerTool({
|
|
860
|
+
name: "memory_get",
|
|
861
|
+
description: "Read one stored memory in full by id. Recall pointers in your context list ids and one-line summaries; call this to expand the ones you need instead of guessing their content. Read-only.",
|
|
862
|
+
parameters: { id: {
|
|
863
|
+
type: "integer",
|
|
864
|
+
required: true,
|
|
865
|
+
description: "The memory id to read, as listed in a recall pointer or returned by memory_search / memory_write."
|
|
866
|
+
} },
|
|
867
|
+
output: {
|
|
868
|
+
schema: {
|
|
869
|
+
type: "object",
|
|
870
|
+
additionalProperties: false,
|
|
871
|
+
properties: {
|
|
872
|
+
id: {
|
|
873
|
+
type: "integer",
|
|
874
|
+
required: true
|
|
875
|
+
},
|
|
876
|
+
text: {
|
|
877
|
+
type: "string",
|
|
878
|
+
required: true
|
|
879
|
+
},
|
|
880
|
+
tags: {
|
|
881
|
+
type: "string",
|
|
882
|
+
required: true
|
|
883
|
+
},
|
|
884
|
+
pinned: {
|
|
885
|
+
type: "boolean",
|
|
886
|
+
required: true
|
|
887
|
+
},
|
|
888
|
+
truncated: {
|
|
889
|
+
type: "boolean",
|
|
890
|
+
required: true
|
|
891
|
+
},
|
|
892
|
+
chars: {
|
|
893
|
+
type: "integer",
|
|
894
|
+
required: true
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
render: (_args, value) => [{
|
|
899
|
+
type: "text",
|
|
900
|
+
text: value.text.length > 0 ? (value.truncated ? `${value.text}\n\n(truncated: memory #${value.id} is ${value.chars} chars, showing the first ${value.text.length})` : value.text) : `No memory #${value.id}.`
|
|
901
|
+
}],
|
|
902
|
+
presentationMeta: (_args, value) => ({
|
|
903
|
+
chars: value.chars,
|
|
904
|
+
truncated: value.truncated
|
|
905
|
+
})
|
|
906
|
+
},
|
|
907
|
+
presentCall: (args) => ({
|
|
908
|
+
card: "generic",
|
|
909
|
+
title: `memory_get #${args.id}`,
|
|
910
|
+
kind: "read"
|
|
911
|
+
}),
|
|
912
|
+
async execute(args, exec) {
|
|
913
|
+
const id = Number(args.id);
|
|
914
|
+
if (!Number.isInteger(id) || id < 1) throw new Error(`memory_get: \`id\` must be a positive integer (got ${String(args.id)})`);
|
|
915
|
+
const sessionId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : null;
|
|
916
|
+
const record = open().get(id);
|
|
917
|
+
if (record === void 0) {
|
|
918
|
+
injector.logExpand({ id, chars: 0, truncated: false, found: false, sessionId });
|
|
919
|
+
return {
|
|
920
|
+
id,
|
|
921
|
+
text: "",
|
|
922
|
+
tags: "",
|
|
923
|
+
pinned: false,
|
|
924
|
+
truncated: false,
|
|
925
|
+
chars: 0
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
const full = String(record.text ?? "");
|
|
929
|
+
const truncated = full.length > config.maxTextChars;
|
|
930
|
+
injector.logExpand({ id, chars: full.length, truncated, found: true, sessionId });
|
|
931
|
+
return {
|
|
932
|
+
id,
|
|
933
|
+
text: truncated ? full.slice(0, config.maxTextChars) : full,
|
|
934
|
+
tags: String(record.tags ?? ""),
|
|
935
|
+
pinned: Boolean(record.pinned),
|
|
936
|
+
truncated,
|
|
937
|
+
chars: full.length
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
//#endregion
|
|
944
|
+
export { Config, apply, inject, name };
|