dsh-engram 0.3.5 → 0.3.7
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/README.md +198 -6
- package/README.zh.md +137 -4
- package/docs/RELEASE-NOTES.TEMPLATE.md +68 -0
- package/lib/api.js +127 -4
- package/lib/client.js +47 -26
- package/lib/index-block.js +92 -1
- package/lib/index.js +38 -2
- package/lib/settings.js +49 -23
- package/lib/store.js +287 -0
- package/lib/tools.js +48 -14
- package/lib/usage.js +146 -5
- package/package.json +17 -13
- package/scripts/dsh-engram.mjs +0 -0
- package/scripts/gen-release-notes.mjs +64 -0
- package/scripts/prepublish-check.mjs +4 -4
- package/scripts/setup-links.mjs +21 -11
package/lib/api.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* wears the loopback-only trust fence (copied from the SSH family): these
|
|
6
6
|
* routes expose the on-disk memory store, so a LAN-exposed dsh web must not
|
|
7
7
|
* serve them. All reads go straight at the opened storage-domain handle
|
|
8
|
-
* (synchronous after the first open); the
|
|
9
|
-
* go through the same store methods the
|
|
8
|
+
* (synchronous after the first open); the mutations (archive / delete, and
|
|
9
|
+
* the settings card's PUT /settings) go through the same store methods the
|
|
10
|
+
* agent tools use (settings writes through the `service.settings` seam).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import { uuid, shortId } from "./util.js";
|
|
13
14
|
import { collectToolCounts, collectUsageStats } from "./usage.js";
|
|
15
|
+
import { SETTINGS_NS } from "./settings.js";
|
|
14
16
|
|
|
15
17
|
/** Route family prefix (kept in sync with the client half). */
|
|
16
18
|
export const API_PREFIX = "/api/dsh-engram";
|
|
@@ -18,6 +20,9 @@ export const API_PREFIX = "/api/dsh-engram";
|
|
|
18
20
|
/** Cap on JSON request bodies (archives/deletes are tiny). */
|
|
19
21
|
const MAX_JSON_BODY_BYTES = 32 * 1024;
|
|
20
22
|
|
|
23
|
+
/** Import dumps carry whole workspaces of memories/tasks — allow up to 32 MiB. */
|
|
24
|
+
const MAX_IMPORT_BODY_BYTES = 32 * 1024 * 1024;
|
|
25
|
+
|
|
21
26
|
/** Loopback literal check plus browser same-origin markers. */
|
|
22
27
|
function isLoopbackRequest(request, trustedHosts = []) {
|
|
23
28
|
const address = request.socket?.remoteAddress;
|
|
@@ -67,13 +72,13 @@ function readQuery(req) {
|
|
|
67
72
|
return url.searchParams;
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
function readJsonBody(req) {
|
|
75
|
+
function readJsonBody(req, maxBytes = MAX_JSON_BODY_BYTES) {
|
|
71
76
|
return new Promise((resolve) => {
|
|
72
77
|
let size = 0;
|
|
73
78
|
const chunks = [];
|
|
74
79
|
req.on("data", (chunk) => {
|
|
75
80
|
size += chunk.length;
|
|
76
|
-
if (size >
|
|
81
|
+
if (size > maxBytes) {
|
|
77
82
|
resolve({ error: "body too large" });
|
|
78
83
|
req.destroy();
|
|
79
84
|
return;
|
|
@@ -300,6 +305,56 @@ export function makeEngramRoutes(service) {
|
|
|
300
305
|
}
|
|
301
306
|
};
|
|
302
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Data contract — dump every row (memories/tasks/links/entities, archived
|
|
310
|
+
* included) of one workspace (`?workspace=`) or of every workspace, in the
|
|
311
|
+
* machine-readable `dsh-engram/export` v1 format that `POST /import`
|
|
312
|
+
* round-trips. Export is the stable, format-versioned exit door: the memory
|
|
313
|
+
* corpus never depends on engram's internal tables, and a thin adapter
|
|
314
|
+
* (another harness, MCP, a plain backup script) only needs this shape.
|
|
315
|
+
*/
|
|
316
|
+
const exportData = async (req, res) => {
|
|
317
|
+
const domain = await withDomain(res);
|
|
318
|
+
if (domain === void 0) return;
|
|
319
|
+
const workspace = readQuery(req).get("workspace") || void 0;
|
|
320
|
+
try {
|
|
321
|
+
writeJson(res, 200, domain.exportAll(workspace));
|
|
322
|
+
} catch (error) {
|
|
323
|
+
writeError(res, 500, String(error?.message ?? error));
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** Data contract — restore a `dsh-engram/export` dump (see store.importAll:
|
|
328
|
+
* merge = idempotent add-missing, replace = wipe workspace then restore;
|
|
329
|
+
* `dryRun` plans without writing; `confirm: "restore"` is required for the
|
|
330
|
+
* destructive replace mode). Memory text passes the same secret redactor as
|
|
331
|
+
* every other write path. */
|
|
332
|
+
const importData = async (req, res) => {
|
|
333
|
+
const domain = await withDomain(res);
|
|
334
|
+
if (domain === void 0) return;
|
|
335
|
+
const body = await readJsonBody(req, MAX_IMPORT_BODY_BYTES);
|
|
336
|
+
if (body.error) return writeError(res, 400, body.error);
|
|
337
|
+
const { payload, mode, dryRun, workspace, confirm } = body.value ?? {};
|
|
338
|
+
if (mode !== "merge" && mode !== "replace") {
|
|
339
|
+
return writeError(res, 400, "mode must be 'merge' or 'replace'");
|
|
340
|
+
}
|
|
341
|
+
if (mode === "replace" && confirm !== "restore") {
|
|
342
|
+
return writeError(res, 400, "replace mode is destructive — pass confirm: \"restore\" to proceed");
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const report = await domain.importAll(payload, {
|
|
346
|
+
mode: mode ?? "merge",
|
|
347
|
+
dryRun: dryRun === true,
|
|
348
|
+
workspace: typeof workspace === "string" && workspace.length > 0 ? workspace : void 0,
|
|
349
|
+
config: service.config ?? {},
|
|
350
|
+
});
|
|
351
|
+
writeJson(res, 200, report);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
const status = error instanceof Error && error.code === "ENGRAM_INVALID_ARGS" ? 400 : 500;
|
|
354
|
+
writeError(res, status, String(error?.message ?? error));
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
|
|
303
358
|
const guard = (req, res, method, handler) => {
|
|
304
359
|
if ((req.method ?? "GET").toUpperCase() !== method) {
|
|
305
360
|
writeError(res, 405, "method not allowed");
|
|
@@ -488,6 +543,71 @@ export function makeEngramRoutes(service) {
|
|
|
488
543
|
writeJson(res, 200, { items: list });
|
|
489
544
|
};
|
|
490
545
|
|
|
546
|
+
/** Settings card transport — read the `dsh-engram` namespace through the
|
|
547
|
+
* host settings service (see settings.js for the normalized `service.settings`
|
|
548
|
+
* seam). Same-origin fetch, like every other route in this family; this is
|
|
549
|
+
* what lets the web config card be DSH-generation-agnostic (no
|
|
550
|
+
* connection/remote RPC), at the cost of the same loopback/trustedHosts
|
|
551
|
+
* fence the memory viewer already wears. */
|
|
552
|
+
const getSettings = (_req, res) => {
|
|
553
|
+
const settings = service.settings;
|
|
554
|
+
if (!settings) {
|
|
555
|
+
writeError(res, 503, "settings service not available");
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
// dsh-settings `describe()` returns a BARE array of namespace descriptors
|
|
560
|
+
// (in registration order); older adapters may wrap it as {namespaces:[...]}.
|
|
561
|
+
// Normalize both onto the HTTP contract {namespaces:[...]} the client uses.
|
|
562
|
+
const payload = settings.describe();
|
|
563
|
+
const namespaces = Array.isArray(payload) ? payload : (payload?.namespaces ?? []);
|
|
564
|
+
const view = namespaces.find((n) => n && n.ns === SETTINGS_NS);
|
|
565
|
+
writeJson(res, 200, view ? { namespaces: [view] } : { namespaces: [] });
|
|
566
|
+
} catch (error) {
|
|
567
|
+
writeError(res, 500, String(error?.message ?? error));
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/** Settings card transport — mutate the `dsh-engram` namespace. Accepts
|
|
572
|
+
* either path ops (`ops: [{op:"set"|"unset", path:[...], value?}]`, the
|
|
573
|
+
* exact shape the card used through the RPC) or a plain partial patch
|
|
574
|
+
* (`patch: object`). The provider validates and persists through the same
|
|
575
|
+
* document the GUI card always wrote to. */
|
|
576
|
+
const putSettings = async (req, res) => {
|
|
577
|
+
const settings = service.settings;
|
|
578
|
+
if (!settings) {
|
|
579
|
+
writeError(res, 503, "settings service not available");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const body = await readJsonBody(req);
|
|
583
|
+
if (body.error) return writeError(res, 400, body.error);
|
|
584
|
+
const { ops, patch, expectedRevision } = body.value ?? {};
|
|
585
|
+
if (!Array.isArray(ops) && !(patch !== null && typeof patch === "object")) {
|
|
586
|
+
return writeError(res, 400, "body must provide ops (array of {op,path[,value]}) or patch (object)");
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
if (Array.isArray(ops)) {
|
|
590
|
+
await settings.mutate(SETTINGS_NS, ops, expectedRevision);
|
|
591
|
+
} else {
|
|
592
|
+
await settings.update(SETTINGS_NS, patch, expectedRevision);
|
|
593
|
+
}
|
|
594
|
+
writeJson(res, 200, { ok: true });
|
|
595
|
+
} catch (error) {
|
|
596
|
+
if (error?.code === "SETTINGS_CONFLICT" || error?.name === "SettingsConflictError") {
|
|
597
|
+
writeJson(res, 409, {
|
|
598
|
+
error: {
|
|
599
|
+
code: "SETTINGS_CONFLICT",
|
|
600
|
+
message: String(error?.message ?? "settings moved since read"),
|
|
601
|
+
expected: error?.expected,
|
|
602
|
+
actual: error?.actual,
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
writeError(res, 400, String(error?.message ?? error));
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
491
611
|
const make = (method, path, handler) => ({
|
|
492
612
|
kind: "exact",
|
|
493
613
|
path: `${API_PREFIX}${path}`,
|
|
@@ -532,9 +652,12 @@ export function makeEngramRoutes(service) {
|
|
|
532
652
|
make("GET", "/model", model),
|
|
533
653
|
make("GET", "/preview", preview),
|
|
534
654
|
make("GET", "/config", readConfig),
|
|
655
|
+
makeUnion("/settings", { GET: getSettings, PUT: putSettings }),
|
|
535
656
|
make("POST", "/memories/archive", archive),
|
|
536
657
|
make("POST", "/memories/delete", remove),
|
|
537
658
|
make("POST", "/tasks/close", closeTask),
|
|
538
659
|
make("POST", "/gc", gcRun),
|
|
660
|
+
make("GET", "/export", exportData),
|
|
661
|
+
make("POST", "/import", importData),
|
|
539
662
|
];
|
|
540
663
|
}
|
package/lib/client.js
CHANGED
|
@@ -153,6 +153,34 @@ var EngramApi = class {
|
|
|
153
153
|
})
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
|
+
/** Settings card transport — read the `dsh-engram` namespace through our
|
|
157
|
+
* own HTTP API instead of the host connection/remote RPC. */
|
|
158
|
+
async getSettings() {
|
|
159
|
+
return readJson(await fetch(`${API_PREFIX}/settings`));
|
|
160
|
+
}
|
|
161
|
+
/** Settings card transport — persist one or more path ops into the
|
|
162
|
+
* `dsh-engram` namespace. Non-2xx surfaces the server's message. */
|
|
163
|
+
async updateSettings(ops, expectedRevision) {
|
|
164
|
+
const response = await fetch(`${API_PREFIX}/settings`, {
|
|
165
|
+
method: "PUT",
|
|
166
|
+
headers: { "content-type": "application/json" },
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
ops,
|
|
169
|
+
...expectedRevision !== void 0 ? { expectedRevision } : {}
|
|
170
|
+
})
|
|
171
|
+
});
|
|
172
|
+
let body = null;
|
|
173
|
+
try {
|
|
174
|
+
body = await response.json();
|
|
175
|
+
} catch {
|
|
176
|
+
}
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const err = body;
|
|
179
|
+
const message = typeof err?.error === "string" ? err.error : typeof err?.error === "object" && err.error !== null && typeof err.error.message === "string" ? err.error.message : `HTTP ${response.status}`;
|
|
180
|
+
throw new EngramApiError(message);
|
|
181
|
+
}
|
|
182
|
+
return body ?? { ok: true };
|
|
183
|
+
}
|
|
156
184
|
};
|
|
157
185
|
|
|
158
186
|
// client/src/EngramTaskDock.tsx
|
|
@@ -3341,6 +3369,7 @@ var import_react8 = require("react");
|
|
|
3341
3369
|
var import_jsx_runtime8 = require("react/jsx-runtime");
|
|
3342
3370
|
var COMMON_FIELDS = [
|
|
3343
3371
|
{ key: "autoCapture", label: "\u81EA\u52A8\u6355\u83B7", hint: "\u96F6 LLM \u4ECE\u5DE5\u5177\u7ED3\u679C\u63D0\u53D6\u8BB0\u5FC6\uFF08git/\u5173\u952E\u6587\u4EF6/\u9519\u8BEF\uFF09", kind: "bool" },
|
|
3372
|
+
{ key: "autoRecallOnStart", label: "\u4F1A\u8BDD\u542F\u52A8\u81EA\u52A8\u53EC\u56DE", hint: "\u65B0\u4F1A\u8BDD\u6309\u9996\u6761\u6D88\u606F\u81EA\u52A8\u628A\u6700\u76F8\u5173\u8BB0\u5FC6\u6CE8\u5165 [RECALL] \u5757\uFF08\u65E0\u9700\u6A21\u578B\u60F3\u8D77\u8C03 engram_recall\uFF09\uFF1B\u5173\u6389=\u56DE\u5230\u7EAF\u62C9\u53D6", kind: "bool" },
|
|
3344
3373
|
{ key: "maxRecallPerSession", label: "\u6BCF\u4F1A\u8BDD\u53BB\u91CD\u4E0A\u9650", hint: "recall \u6BCF\u4F1A\u8BDD\u6700\u591A\u4FDD\u7559 N \u6761\uFF08\u8DE8\u4F1A\u8BDD\u53BB\u91CD\uFF09", kind: "num", min: 1, max: 10 },
|
|
3345
3374
|
{ key: "expireDays", label: "TTL\uFF08\u5929\uFF09", hint: "0 = \u4E0D\u8FC7\u671F", kind: "num", min: 0, max: 3650 },
|
|
3346
3375
|
{ key: "indexMaxChars", label: "\u7D22\u5F15\u5B57\u7B26\u4E0A\u9650", hint: "[ENGRAM] \u5757 token \u9884\u7B97", kind: "num", min: 0, max: 4e3 }
|
|
@@ -3352,7 +3381,9 @@ var ADVANCED_GROUPS = [
|
|
|
3352
3381
|
description: "\u5176\u4F59\u6355\u83B7/\u641C\u7D22\u65CB\u94AE",
|
|
3353
3382
|
fields: [
|
|
3354
3383
|
{ key: "autoCapturePerSession", label: "\u6BCF\u4F1A\u8BDD\u6355\u83B7\u4E0A\u9650", hint: "\u5355\u4F1A\u8BDD\u81EA\u52A8\u6355\u83B7\u6761\u6570\u4E0A\u9650", kind: "num", min: 0, max: 1e3 },
|
|
3355
|
-
{ key: "
|
|
3384
|
+
{ key: "autoRecallLimit", label: "\u81EA\u52A8\u53EC\u56DE\u6761\u6570", hint: "[RECALL] \u5757\u6700\u591A\u6CE8\u5165\u7684\u8BB0\u5FC6\u6761\u6570\uFF08\u5C11\u800C\u51C6\uFF09", kind: "num", min: 1, max: 8 },
|
|
3385
|
+
{ key: "sessionSearch", label: "\u4F1A\u8BDD\u5386\u53F2\u641C\u7D22", hint: "engram_recall \u652F\u6301\u8DE8\u4F1A\u8BDD FTS \u515C\u5E95", kind: "bool" },
|
|
3386
|
+
{ key: "recallScope", label: "\u53EC\u56DE\u8303\u56F4", hint: "workspace=\u4E25\u683C\u5F53\u524D\u5DE5\u4F5C\u533A\u9694\u79BB\uFF08\u9ED8\u8BA4\uFF0Ctoken \u7EAA\u5F8B\uFF09\uFF1Bglobal=\u672C\u5DE5\u4F5C\u533A\u547D\u4E2D\u5F31/\u7A7A\u65F6\u4ECE\u5176\u5B83\u5DE5\u4F5C\u533A\u6C60\u53EC\u56DE\u5E76\u6807 [W:<workspace>] \u6765\u6E90\uFF08\u4F1A\u6253\u7834\u9694\u79BB\uFF09", kind: "select", options: ["workspace", "global"] }
|
|
3356
3387
|
]
|
|
3357
3388
|
},
|
|
3358
3389
|
{
|
|
@@ -3651,6 +3682,15 @@ function EngramConfigCard({ scope }) {
|
|
|
3651
3682
|
disabled: !writable,
|
|
3652
3683
|
onChange: (e) => setField(field.key, e.target.checked)
|
|
3653
3684
|
}
|
|
3685
|
+
) : field.kind === "select" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
3686
|
+
"select",
|
|
3687
|
+
{
|
|
3688
|
+
style: { ...s3.input, width: field.width ?? 160 },
|
|
3689
|
+
value: raw === void 0 ? "" : String(raw),
|
|
3690
|
+
disabled: !writable,
|
|
3691
|
+
onChange: (e) => setField(field.key, e.target.value),
|
|
3692
|
+
children: (field.options ?? []).map((opt) => /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("option", { value: opt, children: opt }, opt))
|
|
3693
|
+
}
|
|
3654
3694
|
) : field.kind === "text" ? /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
|
|
3655
3695
|
"input",
|
|
3656
3696
|
{
|
|
@@ -3812,14 +3852,7 @@ var EngramScopeImpl = class {
|
|
|
3812
3852
|
this.snapshot = LOADING;
|
|
3813
3853
|
this.notify();
|
|
3814
3854
|
try {
|
|
3815
|
-
const
|
|
3816
|
-
const result = response?.result;
|
|
3817
|
-
if (!result || result.ok !== true) {
|
|
3818
|
-
const err = result && "error" in result ? result.error : { code: "settings.describe", message: "unreachable" };
|
|
3819
|
-
this.fail(`settings.describe failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
|
|
3820
|
-
return;
|
|
3821
|
-
}
|
|
3822
|
-
const payload = result.value;
|
|
3855
|
+
const payload = await this.api.getSettings();
|
|
3823
3856
|
const view = payload.namespaces?.find((n) => n.ns === this.ns);
|
|
3824
3857
|
if (!view) {
|
|
3825
3858
|
this.fail(`the '${this.ns}' settings namespace is not served by this host.`);
|
|
@@ -3833,27 +3866,16 @@ var EngramScopeImpl = class {
|
|
|
3833
3866
|
base: view.base ?? view.value,
|
|
3834
3867
|
user: view.user,
|
|
3835
3868
|
revision: view.revision ?? 0,
|
|
3836
|
-
writable: this.opts.writable !== false &&
|
|
3869
|
+
writable: this.opts.writable !== false && applied
|
|
3837
3870
|
};
|
|
3838
3871
|
this.notify();
|
|
3839
3872
|
} catch (error) {
|
|
3840
|
-
this.fail(`settings
|
|
3873
|
+
this.fail(`settings fetch failed: ${String(error instanceof Error ? error.message : error)}`);
|
|
3841
3874
|
}
|
|
3842
3875
|
}
|
|
3843
3876
|
async mutate(op) {
|
|
3844
3877
|
const revision = this.snapshot.revision;
|
|
3845
|
-
|
|
3846
|
-
ns: this.ns,
|
|
3847
|
-
ops: [op],
|
|
3848
|
-
...revision ? { expectedRevision: revision } : {}
|
|
3849
|
-
});
|
|
3850
|
-
const result = response?.result;
|
|
3851
|
-
if (!result || result.ok !== true) {
|
|
3852
|
-
const err = result && "error" in result ? result.error : { code: "settings.mutate", message: "unreachable" };
|
|
3853
|
-
throw new Error(`settings.mutate failed: ${String(err?.code ?? err)}: ${String(err?.message ?? "")}`);
|
|
3854
|
-
}
|
|
3855
|
-
const view = result.value;
|
|
3856
|
-
if (view) this.snapshot = { ...this.snapshot, revision: view.revision ?? revision };
|
|
3878
|
+
await this.api.updateSettings([op], revision > 0 ? revision : void 0);
|
|
3857
3879
|
}
|
|
3858
3880
|
async set(key, value) {
|
|
3859
3881
|
await this.mutate({ op: "set", path: [String(key)], value });
|
|
@@ -4912,7 +4934,7 @@ var en = {
|
|
|
4912
4934
|
refresh: "Refresh",
|
|
4913
4935
|
error: "Load failed"
|
|
4914
4936
|
};
|
|
4915
|
-
var inject = ["slots", "locale", "
|
|
4937
|
+
var inject = ["slots", "locale", "sessions", "workspaces"];
|
|
4916
4938
|
function apply(ctx) {
|
|
4917
4939
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-engram: dictionaries");
|
|
4918
4940
|
const api = new EngramApi();
|
|
@@ -4953,8 +4975,7 @@ function apply(ctx) {
|
|
|
4953
4975
|
} catch (error) {
|
|
4954
4976
|
console.warn("[dsh-engram] settings.section registration failed:", error);
|
|
4955
4977
|
}
|
|
4956
|
-
const
|
|
4957
|
-
const scope = new EngramScopeImpl(connection.api, "dsh-engram");
|
|
4978
|
+
const scope = new EngramScopeImpl(api, "dsh-engram");
|
|
4958
4979
|
const cardInjected = () => ({ scope });
|
|
4959
4980
|
try {
|
|
4960
4981
|
ctx.slots.inject(
|
package/lib/index-block.js
CHANGED
|
@@ -115,7 +115,7 @@ export function renderIndex(store, workspace, cwd, config) {
|
|
|
115
115
|
if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
|
|
116
116
|
for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
|
|
117
117
|
for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
|
|
118
|
-
lines.push("drill:
|
|
118
|
+
lines.push("drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)");
|
|
119
119
|
return enforceCharCap(lines, config.indexMaxChars);
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -133,6 +133,97 @@ function enforceCharCap(lines, maxChars) {
|
|
|
133
133
|
return joined;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/** Text of the session's first user message (the opening instruction of the
|
|
137
|
+
* conversation). Walk `session.events` for the first `user/message` event and
|
|
138
|
+
* join its `data.content` text blocks; fall back to the first text-bearing
|
|
139
|
+
* event ("goal"/reminder start) guardedly. Returns "" when there is none.
|
|
140
|
+
* Pure, deterministic — the auto-recall (B) keys its query off this. */
|
|
141
|
+
export function firstUserMessageText(session) {
|
|
142
|
+
if (session === null || typeof session !== "object") return "";
|
|
143
|
+
const events = Array.isArray(session.events) ? session.events : [];
|
|
144
|
+
if (events.length === 0) return "";
|
|
145
|
+
const textOf = (data) => {
|
|
146
|
+
if (data === null || typeof data !== "object") return "";
|
|
147
|
+
const blocks = Array.isArray(data.content) ? data.content : [];
|
|
148
|
+
if (blocks.length > 0) {
|
|
149
|
+
return blocks
|
|
150
|
+
.map((b) => (b !== null && typeof b === "object" && typeof b.text === "string" ? b.text : ""))
|
|
151
|
+
.join(" ")
|
|
152
|
+
.trim();
|
|
153
|
+
}
|
|
154
|
+
return typeof data.text === "string" ? data.text.trim() : "";
|
|
155
|
+
};
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (event === null || typeof event !== "object") continue;
|
|
158
|
+
if (event.type !== "user/message") continue;
|
|
159
|
+
const text = textOf(event.data);
|
|
160
|
+
if (text.length > 0) return text;
|
|
161
|
+
}
|
|
162
|
+
// Fallback: no user/message yet — first event carrying text (goal-driven,
|
|
163
|
+
// headless or reminder-seeded starts) still gives the recall a key.
|
|
164
|
+
for (const event of events) {
|
|
165
|
+
if (event === null || typeof event !== "object" || event.type === "user/message") continue;
|
|
166
|
+
const text = textOf(event.data);
|
|
167
|
+
if (text.length > 0) return text;
|
|
168
|
+
}
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Host-side session-start auto-recall (方案 B): at the first assembly of a new
|
|
174
|
+
* session, deterministically key a bounded BM25 recall off the session's first
|
|
175
|
+
* user message and inject the top hits as a `[RECALL]` block — so relevant
|
|
176
|
+
* memory reaches the model even when it never calls `engram_recall` on its own
|
|
177
|
+
* (the observed real-world gap). Pure rule, zero LLM, no `hits` bumps (a pure
|
|
178
|
+
* read). Falls back to the newest high-signal memories when the query is empty
|
|
179
|
+
* and `allowRecencyFallback` is on. Returns "" when nothing useful to show.
|
|
180
|
+
*/
|
|
181
|
+
export function renderStartRecall(store, workspace, config = {}, opts = {}) {
|
|
182
|
+
if (store === null || typeof store?.recall !== "function") return "";
|
|
183
|
+
const limit = Math.max(1, Math.min(8, Number.isFinite(config.autoRecallLimit) ? Math.trunc(config.autoRecallLimit) : 3));
|
|
184
|
+
const maxChars = Number.isFinite(config.autoRecallMaxChars) && config.autoRecallMaxChars > 0 ? Math.trunc(config.autoRecallMaxChars) : 700;
|
|
185
|
+
const rawQuery = String(opts.query ?? "").trim().slice(0, 300);
|
|
186
|
+
const query = rawQuery.length > 0 ? rawQuery : (opts.allowRecencyFallback ?? true) ? null : "";
|
|
187
|
+
const items = query === null
|
|
188
|
+
? (store.listMemories?.(workspace, limit) ?? []).slice(0, limit)
|
|
189
|
+
: store.recall(workspace, query, limit, { maxPerSession: limit });
|
|
190
|
+
const hits = Array.isArray(items) ? items.filter((m) => m !== null && m !== void 0) : [];
|
|
191
|
+
// "Few and precise" (agentmemory 候选①): never let a superseded stale row —
|
|
192
|
+
// which `recall` demotes to the tail of an unbounded list — occupy one of the
|
|
193
|
+
// ≤ N auto-injected slots. Same policy as the [ENGRAM] index. The stale row
|
|
194
|
+
// stays re-fetchable via engram_detail, just not auto-pushed.
|
|
195
|
+
const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
|
|
196
|
+
const fresh = hits.filter((m) => !supersededBy.has(m.id));
|
|
197
|
+
if (fresh.length === 0) return "";
|
|
198
|
+
|
|
199
|
+
const lines = [];
|
|
200
|
+
const source = query === null ? "newest memory" : "recall";
|
|
201
|
+
lines.push(`[RECALL] ${source} · ${fresh.length} hit(s)${query ? ` · first msg: ${escapeLt(truncate(query, 40))}` : " · no user text yet"}`);
|
|
202
|
+
for (const m of fresh) {
|
|
203
|
+
const px = prefixFor(m.kind);
|
|
204
|
+
const mark = isProvenProcedure(m, config) ? `${px}✓` : px;
|
|
205
|
+
const date = fmtDate(m.createdAt ?? m.updatedAt);
|
|
206
|
+
const text = truncate(String(m.text).replace(/\s*\n+\s*/g, " ").trim(), 140);
|
|
207
|
+
lines.push(`- [${mark}] ${date} ${escapeLt(text)} #${shortId(m.id)}${(m.hits ?? 0) > 0 ? ` ×${m.hits}` : ""}`);
|
|
208
|
+
}
|
|
209
|
+
lines.push("context: use above · engram_detail <id> · engram_recall <query>");
|
|
210
|
+
return enforceStartRecallCap(lines, maxChars);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Keep the [RECALL] header + context tail, drop hits until under budget;
|
|
214
|
+
* hard-truncate with an ellipsis when even the shell exceeds the cap. */
|
|
215
|
+
function enforceStartRecallCap(lines, maxChars) {
|
|
216
|
+
let joined = lines.join("\n");
|
|
217
|
+
while (joined.length > maxChars && lines.length > 2) {
|
|
218
|
+
lines.splice(lines.length - 2, 1);
|
|
219
|
+
joined = lines.join("\n");
|
|
220
|
+
}
|
|
221
|
+
if (joined.length > maxChars) {
|
|
222
|
+
joined = `${joined.slice(0, Math.max(1, maxChars - 1))}…`;
|
|
223
|
+
}
|
|
224
|
+
return joined;
|
|
225
|
+
}
|
|
226
|
+
|
|
136
227
|
/**
|
|
137
228
|
* One-line escalation reminder from an arbitrary balance view
|
|
138
229
|
* ({ memCalls, esrCalls, days }). Surfaces the workspace real recent
|
package/lib/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { openEngramDomain } from "./store.js";
|
|
|
23
23
|
import { registerTools } from "./tools.js";
|
|
24
24
|
import { makeCaptureHandler } from "./capture.js";
|
|
25
25
|
import { installGoalCapture } from "./goal-capture.js";
|
|
26
|
-
import { renderIndex, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
26
|
+
import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
|
|
27
27
|
import { makeTriggerRecorder } from "./trigger.js";
|
|
28
28
|
import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
|
|
29
29
|
import { installEngramSettings } from "./settings.js";
|
|
@@ -48,9 +48,25 @@ const DEFAULTS = {
|
|
|
48
48
|
autoCaptureGlobalCap: 500,
|
|
49
49
|
// recall
|
|
50
50
|
sessionSearch: true,
|
|
51
|
+
// opt-in cross-workspace recall ("workspace" = current default, strict
|
|
52
|
+
// per-workspace isolation; "global" = when the local pool has weak/no hits,
|
|
53
|
+
// also rank entries from other workspaces' pools with a recency/origin-aware
|
|
54
|
+
// penalty and surface a [W:<workspace>] origin marker). Token-discipline
|
|
55
|
+
// default is preserved; this is strictly an enable knob.
|
|
56
|
+
recallScope: "workspace",
|
|
51
57
|
// session-diversified recall: max memories per session before cross-session
|
|
52
58
|
// evidence takes over (agentmemory 候选①; "few and precise")
|
|
53
59
|
maxRecallPerSession: 3,
|
|
60
|
+
// Host-side session-start auto-recall (方案 B): at the FIRST assembly of a
|
|
61
|
+
// new session, key a bounded BM25 recall off the session's first user message
|
|
62
|
+
// and inject the top hits as a [RECALL] block — relevant memory reaches the
|
|
63
|
+
// model even when it never calls engram_recall on its own (the observed gap:
|
|
64
|
+
// pull-only recall left everything to model initiative and almost never
|
|
65
|
+
// fired). Pure rule, zero LLM, per-session frozen with the [ENGRAM] block.
|
|
66
|
+
// "few and precise" — agentmemory 候选①'s lesson from the coding leaderboard.
|
|
67
|
+
autoRecallOnStart: true,
|
|
68
|
+
autoRecallLimit: 3,
|
|
69
|
+
autoRecallMaxChars: 700,
|
|
54
70
|
// [ENGRAM] index budget (token discipline)
|
|
55
71
|
indexMaxLines: 12,
|
|
56
72
|
indexMaxChars: 700,
|
|
@@ -242,7 +258,7 @@ export function apply(ctx, config) {
|
|
|
242
258
|
|
|
243
259
|
// User settings (web GUI card): merges stored values onto the live config.
|
|
244
260
|
// Non-blocking — when the settings service is absent this never runs.
|
|
245
|
-
installEngramSettings(ctx, resolved, resolved);
|
|
261
|
+
installEngramSettings(ctx, resolved, resolved, service);
|
|
246
262
|
|
|
247
263
|
// Web API for the GUI: deferred on the webServer service, so headless
|
|
248
264
|
// profiles keep running the plugin without it. `register` returns the
|
|
@@ -282,12 +298,32 @@ export function apply(ctx, config) {
|
|
|
282
298
|
return "";
|
|
283
299
|
}
|
|
284
300
|
let block = "";
|
|
301
|
+
let pendingRecall = false;
|
|
285
302
|
try {
|
|
286
303
|
const workspace = resolvePath(cwd);
|
|
287
304
|
block = renderIndex(opened, workspace, cwd, resolved);
|
|
305
|
+
// Host-side session-start auto-recall (方案 B): append the [RECALL]
|
|
306
|
+
// block keyed on the session's FIRST user message. The cache only freezes
|
|
307
|
+
// once there is a query to key on — if the user text isn't in the events
|
|
308
|
+
// yet (exotic pre-message assemblies), return the index alone and let the
|
|
309
|
+
// next assembly retry, so a new conversation never permanently misses its
|
|
310
|
+
// recalled memory.
|
|
311
|
+
if (resolved.autoRecallOnStart !== false) {
|
|
312
|
+
const query = firstUserMessageText(session);
|
|
313
|
+
if (query.length === 0) {
|
|
314
|
+
pendingRecall = true;
|
|
315
|
+
} else {
|
|
316
|
+
const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
|
|
317
|
+
block = recall.length > 0 ? `${block}\n${recall}`.trim() : block;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
288
320
|
} catch (error) {
|
|
289
321
|
log?.warn?.(`engram index render failed: ${String(error)}`);
|
|
290
322
|
}
|
|
323
|
+
// Never cache a render that skipped auto-recall for lack of an opening
|
|
324
|
+
// message — the next assembly will have it and can still materialize the
|
|
325
|
+
// [RECALL] block.
|
|
326
|
+
if (pendingRecall) return block;
|
|
291
327
|
sessionCache.set(session, block);
|
|
292
328
|
return block;
|
|
293
329
|
};
|
package/lib/settings.js
CHANGED
|
@@ -12,16 +12,20 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import z from "@deepseek-ai/schemastery";
|
|
15
|
-
import { settingsNamespace, installSettingsSection } from "@deepseek-ai/dsh-settings";
|
|
16
15
|
|
|
17
16
|
/** Namespace join key — must match the client card's `key`. */
|
|
18
17
|
export const SETTINGS_NS_NAME = "dsh-engram";
|
|
19
|
-
|
|
18
|
+
// dsh-settings 0.1.5 exposes the namespace as a validated string at the
|
|
19
|
+
// provider boundary; the legacy branding helper was removed.
|
|
20
|
+
export const SETTINGS_NS = SETTINGS_NS_NAME;
|
|
20
21
|
|
|
21
22
|
/** Fields the GUI exposes (a curated subset of the plugin DEFAULTS). */
|
|
22
23
|
export const SETTINGS_KEYS = [
|
|
23
24
|
"autoCapture",
|
|
24
25
|
"sessionSearch",
|
|
26
|
+
"recallScope",
|
|
27
|
+
"autoRecallOnStart",
|
|
28
|
+
"autoRecallLimit",
|
|
25
29
|
"maxRecallPerSession",
|
|
26
30
|
"autoCapturePerSession",
|
|
27
31
|
"indexMaxLines",
|
|
@@ -55,6 +59,9 @@ export function makeSettingsSchema(defaults) {
|
|
|
55
59
|
return z.object({
|
|
56
60
|
autoCapture: z.boolean().default(defaults.autoCapture),
|
|
57
61
|
sessionSearch: z.boolean().default(defaults.sessionSearch),
|
|
62
|
+
recallScope: z.union(["workspace", "global"]).default(defaults.recallScope ?? "workspace").description("召回范围:workspace=严格按当前工作区隔离(默认、token 纪律);global=本工作区命中弱/空时,从其它工作区池按 recency/origin 加权召回并标 [W:<workspace>] 来源。开启会打破跨工作区隔离,请按需选用"),
|
|
63
|
+
autoRecallOnStart: z.boolean().default(defaults.autoRecallOnStart).description("新会话开始时,宿主按首条消息对工作区做确定性召回,把命中记忆直接注入 [RECALL] 块(无需模型自己想起调 engram_recall;关掉=回到纯拉取)"),
|
|
64
|
+
autoRecallLimit: z.natural().min(1).max(8).default(defaults.autoRecallLimit).description("[RECALL] 块最多注入的记忆条数(agentmemory 候选①:少而准)"),
|
|
58
65
|
maxRecallPerSession: z.natural().min(1).max(10).default(defaults.maxRecallPerSession),
|
|
59
66
|
autoCapturePerSession: z.natural().max(1000).default(defaults.autoCapturePerSession),
|
|
60
67
|
indexMaxLines: z.natural().max(50).default(defaults.indexMaxLines),
|
|
@@ -80,30 +87,49 @@ export function makeSettingsSchema(defaults) {
|
|
|
80
87
|
/**
|
|
81
88
|
* Wire the settings section into `ctx` and keep `live` (the plugin's mutable
|
|
82
89
|
* config object) merged with what the user stored. Called from `apply()`.
|
|
90
|
+
*
|
|
91
|
+
* When a `service` object is supplied, a normalized `service.settings` seam
|
|
92
|
+
* ({ describe, mutate, update }) is exposed for the web API — this is the
|
|
93
|
+
* single host-side adapter point: both the current and the legacy DSH
|
|
94
|
+
* settings APIs reduce onto the same three calls, so `/api/dsh-engram/settings`
|
|
95
|
+
* never needs to know which generation it is talking to.
|
|
83
96
|
*/
|
|
84
|
-
export function installEngramSettings(ctx, defaults, live) {
|
|
97
|
+
export function installEngramSettings(ctx, defaults, live, service) {
|
|
85
98
|
const base = settingsBaseFrom(defaults);
|
|
86
99
|
const schema = makeSettingsSchema(defaults);
|
|
87
100
|
let source = () => base;
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
102
|
+
// dsh-settings 0.1.5 moved the optional-consumer wiring onto the provider
|
|
103
|
+
// instance. Keeping the registration on the scoped context preserves the
|
|
104
|
+
// provider attach/detach lifecycle while avoiding a removed helper export.
|
|
105
|
+
const provider = settingsCtx.settings;
|
|
106
|
+
if (service) {
|
|
107
|
+
service.settings = {
|
|
108
|
+
describe: () => provider.describe({ redactSecrets: true }),
|
|
109
|
+
mutate: (ns, ops, expectedRevision) => provider.mutate(ns, ops, expectedRevision),
|
|
110
|
+
update: (ns, patch, expectedRevision) => provider.update(ns, patch, expectedRevision),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
provider.installSection(ctx, SETTINGS_NS, schema, base, {
|
|
114
|
+
setSource(current) {
|
|
115
|
+
source = current;
|
|
116
|
+
},
|
|
117
|
+
onChange() {
|
|
118
|
+
let value;
|
|
119
|
+
try {
|
|
120
|
+
value = source();
|
|
121
|
+
} catch {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (value === null || typeof value !== "object") return;
|
|
125
|
+
for (const key of SETTINGS_KEYS) {
|
|
126
|
+
if (value[key] !== void 0 && live[key] !== value[key]) live[key] = value[key];
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
validate(value) {
|
|
130
|
+
// The schema already constrains every exposed field; refuse nothing here.
|
|
131
|
+
void value;
|
|
132
|
+
},
|
|
133
|
+
});
|
|
108
134
|
});
|
|
109
135
|
}
|