@avantf/dsh-mem 0.1.0
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/LICENSE +21 -0
- package/README.md +140 -0
- package/lib/client.js +5901 -0
- package/lib/client.js.map +1 -0
- package/lib/dsh-build.json +7 -0
- package/lib/index.js +14329 -0
- package/lib/types/client/domains.d.ts +52 -0
- package/lib/types/client/domains.d.ts.map +1 -0
- package/lib/types/client/domains.js +25 -0
- package/lib/types/client/domains.js.map +1 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +1351 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/ingest.d.ts +24 -0
- package/lib/types/client/ingest.d.ts.map +1 -0
- package/lib/types/client/ingest.js +30 -0
- package/lib/types/client/ingest.js.map +1 -0
- package/lib/types/client/paging.d.ts +30 -0
- package/lib/types/client/paging.d.ts.map +1 -0
- package/lib/types/client/paging.js +36 -0
- package/lib/types/client/paging.js.map +1 -0
- package/lib/types/client/summarize.d.ts +11 -0
- package/lib/types/client/summarize.d.ts.map +1 -0
- package/lib/types/client/summarize.js +79 -0
- package/lib/types/client/summarize.js.map +1 -0
- package/lib/types/envinit-bootstrap.d.ts +43 -0
- package/lib/types/envinit-bootstrap.js +323 -0
- package/lib/types/envinit.d.ts +145 -0
- package/lib/types/envinit.d.ts.map +1 -0
- package/lib/types/envinit.js +495 -0
- package/lib/types/envinit.js.map +1 -0
- package/lib/types/hints.d.ts +42 -0
- package/lib/types/hints.d.ts.map +1 -0
- package/lib/types/hints.js +54 -0
- package/lib/types/hints.js.map +1 -0
- package/lib/types/index.d.ts +74 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +708 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/inject.d.ts +14 -0
- package/lib/types/inject.d.ts.map +1 -0
- package/lib/types/inject.js +14 -0
- package/lib/types/inject.js.map +1 -0
- package/lib/types/open.d.ts +27 -0
- package/lib/types/open.d.ts.map +1 -0
- package/lib/types/open.js +133 -0
- package/lib/types/open.js.map +1 -0
- package/lib/types/prompt.d.ts +49 -0
- package/lib/types/prompt.d.ts.map +1 -0
- package/lib/types/prompt.js +65 -0
- package/lib/types/prompt.js.map +1 -0
- package/lib/types/provision.d.ts +117 -0
- package/lib/types/provision.d.ts.map +1 -0
- package/lib/types/provision.js +233 -0
- package/lib/types/provision.js.map +1 -0
- package/lib/types/remote.d.ts +17 -0
- package/lib/types/remote.d.ts.map +1 -0
- package/lib/types/remote.js +233 -0
- package/lib/types/remote.js.map +1 -0
- package/lib/types/tool_schema.d.ts +40 -0
- package/lib/types/tool_schema.d.ts.map +1 -0
- package/lib/types/tool_schema.js +149 -0
- package/lib/types/tool_schema.js.map +1 -0
- package/package.json +109 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
var __runInitializers = (this && this.__runInitializers) || function (thisArg, initializers, value) {
|
|
2
|
+
var useValue = arguments.length > 2;
|
|
3
|
+
for (var i = 0; i < initializers.length; i++) {
|
|
4
|
+
value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
5
|
+
}
|
|
6
|
+
return useValue ? value : void 0;
|
|
7
|
+
};
|
|
8
|
+
var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
9
|
+
function accept(f) { if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected"); return f; }
|
|
10
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
11
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
12
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
13
|
+
var _, done = false;
|
|
14
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
15
|
+
var context = {};
|
|
16
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
17
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
18
|
+
context.addInitializer = function (f) { if (done) throw new TypeError("Cannot add initializers after decoration has completed"); extraInitializers.push(accept(f || null)); };
|
|
19
|
+
var result = (0, decorators[i])(kind === "accessor" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);
|
|
20
|
+
if (kind === "accessor") {
|
|
21
|
+
if (result === void 0) continue;
|
|
22
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
23
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
24
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
25
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
26
|
+
}
|
|
27
|
+
else if (_ = accept(result)) {
|
|
28
|
+
if (kind === "field") initializers.unshift(_);
|
|
29
|
+
else descriptor[key] = _;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
33
|
+
done = true;
|
|
34
|
+
};
|
|
35
|
+
import { existsSync } from 'node:fs';
|
|
36
|
+
import { KB_EDIT_PROMPT_SECTION, KNOWLEDGE_PROMPT_SECTION, MEMORY_PROMPT_SECTION } from './prompt.js';
|
|
37
|
+
import { hintLines, messageText } from './hints.js';
|
|
38
|
+
import z from '@deepseek-ai/schemastery';
|
|
39
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
40
|
+
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol';
|
|
41
|
+
import { buildRuntime, dispatchToolKey, provisionToolchainAsync, supportsToolKey, warmSemanticAsync, warmTokenizerAsync, } from '@avantf/mem';
|
|
42
|
+
import { REMEMBER_TOOL, RECALL_TOOL, ADMIN_TOOL, KB_TOOL, QUERY_TOOL, TOOL_SPECS, createConsoleLogger, describeError, modelFacingToolResult, toolOk, toolErr, validationError, remoteErrorText, } from '@avantf/mem-contract';
|
|
43
|
+
import { hostContribution } from './remote.js';
|
|
44
|
+
import { provision, registerCompatMegaphone, verifyRegisteredFaces } from './provision.js';
|
|
45
|
+
import { managedModelSpec, MODEL_ITEM, PANDOC_ITEM, loadEnvinit, } from './envinit.js';
|
|
46
|
+
import { defaultToolsDir, parseToolsConfig, resolveToolsDir } from '@avantf/mem-provision';
|
|
47
|
+
import { resetPandocResolution, setPandocProvisioning } from '@avantf/mem-convert';
|
|
48
|
+
import { openDocumentPath } from './open.js';
|
|
49
|
+
import { browseDirectory, classifySource } from '@avantf/mem';
|
|
50
|
+
import { flattenToolSpec } from './tool_schema.js';
|
|
51
|
+
export const name = 'avantf-mem';
|
|
52
|
+
export { inject } from './inject.js';
|
|
53
|
+
// The section text lives in its own module so it is testable without this entry's harness imports.
|
|
54
|
+
export { KNOWLEDGE_PROMPT_SECTION, MEMORY_PROMPT_SECTION } from './prompt.js';
|
|
55
|
+
export const Config = z.object({
|
|
56
|
+
mode: z.union([z.const('cordis'), z.const('mcp')]).default('cordis'),
|
|
57
|
+
// Deliberately NO default: a literal `~/.avantf` here would be passed to the
|
|
58
|
+
// runtime as an EXPLICIT data home (layer ⑤) and would then outrank
|
|
59
|
+
// `AVANTF_HOME` (layer ④) for everyone who never configured this field.
|
|
60
|
+
// Left unset, the runtime applies ⑤ → ④ → default itself.
|
|
61
|
+
dataHome: z.string(),
|
|
62
|
+
});
|
|
63
|
+
/**
|
|
64
|
+
* Facts whose entity/triple rows the heartbeat may rebuild per beat.
|
|
65
|
+
*
|
|
66
|
+
* Tagging is ~0.02 ms for an ordinary fact (nodejieba, measured), so this batch is tens of
|
|
67
|
+
* milliseconds on a quiet moment — small enough for a 60-minute heartbeat and large enough to
|
|
68
|
+
* adopt a whole corpus over a few beats after an extraction change.
|
|
69
|
+
*/
|
|
70
|
+
const ENTITY_SWEEP_PER_HEARTBEAT = 2000;
|
|
71
|
+
/** Minimum spacing between corpus drift checks (`tools/result` fires for EVERY tool). */
|
|
72
|
+
const RECONCILE_MIN_INTERVAL_MS = 2000;
|
|
73
|
+
// ─── dispatch: validate with the contract schema, never leak a raw exception ──────────────────
|
|
74
|
+
//
|
|
75
|
+
// The key → runtime table is the ENGINE's (`@avantf/mem`'s `dispatchToolKey`), shared with the MCP
|
|
76
|
+
// server. It used to be duplicated here, and the copy that drifted was the MCP one: the four `kb_*`
|
|
77
|
+
// tools were advertised to the model and answered `unknown tool key` on every call.
|
|
78
|
+
async function runToolSpec(spec, rt, args) {
|
|
79
|
+
const parsed = spec.input.safeParse(args ?? {});
|
|
80
|
+
if (!parsed.success) {
|
|
81
|
+
return validationError(spec.name, parsed.error.issues);
|
|
82
|
+
}
|
|
83
|
+
if (!supportsToolKey(spec.key))
|
|
84
|
+
return toolErr(`unknown tool ${spec.key}`);
|
|
85
|
+
try {
|
|
86
|
+
const value = await dispatchToolKey(rt, spec.key, parsed.data);
|
|
87
|
+
// Uniform agent-facing envelope, identical to the MCP surface (`ToolEnvelope`):
|
|
88
|
+
// every success is `{ok:true,result}` regardless of the payload's shape.
|
|
89
|
+
// `mem_admin`'s fact views lose the retention diagnostics here; the Remote gateway that
|
|
90
|
+
// drives the 记忆/知识 tabs calls the same table and keeps them.
|
|
91
|
+
const shaped = modelFacingToolResult(spec.key, parsed.data.action, value);
|
|
92
|
+
return toolOk(shaped);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return toolErr(error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const OUTPUT = {
|
|
99
|
+
schema: { type: 'object', additionalProperties: true },
|
|
100
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
101
|
+
};
|
|
102
|
+
function present(spec, args) {
|
|
103
|
+
return { card: 'generic', title: spec.name, kind: 'other', rawInput: args };
|
|
104
|
+
}
|
|
105
|
+
/** Drop wire keys explicitly set to undefined so they cannot clobber defaults. */
|
|
106
|
+
function defined(args) {
|
|
107
|
+
return Object.fromEntries(Object.entries(args ?? {}).filter(([, value]) => value !== undefined));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Pass-through gateway mirroring the five agent tools. Every payload is
|
|
111
|
+
* re-validated with the contract zod union before dispatch (same guarantee the
|
|
112
|
+
* tools give the model), and results are wrapped in the contract's
|
|
113
|
+
* `{ok:true,value}` / `{ok:false,error,violations}` RemoteEnvelope the client decodes.
|
|
114
|
+
*/
|
|
115
|
+
let AvantfMemGateway = (() => {
|
|
116
|
+
let _classSuper = TypertRemoteService;
|
|
117
|
+
let _instanceExtraInitializers = [];
|
|
118
|
+
let _remember_decorators;
|
|
119
|
+
let _recall_decorators;
|
|
120
|
+
let _admin_decorators;
|
|
121
|
+
let _kb_decorators;
|
|
122
|
+
let _query_decorators;
|
|
123
|
+
let _classifySource_decorators;
|
|
124
|
+
let _browseDir_decorators;
|
|
125
|
+
let _kbDomains_decorators;
|
|
126
|
+
let _kbAddDomain_decorators;
|
|
127
|
+
let _openDoc_decorators;
|
|
128
|
+
return class AvantfMemGateway extends _classSuper {
|
|
129
|
+
static {
|
|
130
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
131
|
+
_remember_decorators = [Remote('remember')];
|
|
132
|
+
_recall_decorators = [Remote('recall')];
|
|
133
|
+
_admin_decorators = [Remote('admin')];
|
|
134
|
+
_kb_decorators = [Remote('kb')];
|
|
135
|
+
_query_decorators = [Remote('query')];
|
|
136
|
+
_classifySource_decorators = [Remote('classifySource')];
|
|
137
|
+
_browseDir_decorators = [Remote('browseDir')];
|
|
138
|
+
_kbDomains_decorators = [Remote('kbDomains')];
|
|
139
|
+
_kbAddDomain_decorators = [Remote('kbAddDomain')];
|
|
140
|
+
_openDoc_decorators = [Remote('openDoc')];
|
|
141
|
+
__esDecorate(this, null, _remember_decorators, { kind: "method", name: "remember", static: false, private: false, access: { has: obj => "remember" in obj, get: obj => obj.remember }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
142
|
+
__esDecorate(this, null, _recall_decorators, { kind: "method", name: "recall", static: false, private: false, access: { has: obj => "recall" in obj, get: obj => obj.recall }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
143
|
+
__esDecorate(this, null, _admin_decorators, { kind: "method", name: "admin", static: false, private: false, access: { has: obj => "admin" in obj, get: obj => obj.admin }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
144
|
+
__esDecorate(this, null, _kb_decorators, { kind: "method", name: "kb", static: false, private: false, access: { has: obj => "kb" in obj, get: obj => obj.kb }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
145
|
+
__esDecorate(this, null, _query_decorators, { kind: "method", name: "query", static: false, private: false, access: { has: obj => "query" in obj, get: obj => obj.query }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
146
|
+
__esDecorate(this, null, _classifySource_decorators, { kind: "method", name: "classifySource", static: false, private: false, access: { has: obj => "classifySource" in obj, get: obj => obj.classifySource }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
147
|
+
__esDecorate(this, null, _browseDir_decorators, { kind: "method", name: "browseDir", static: false, private: false, access: { has: obj => "browseDir" in obj, get: obj => obj.browseDir }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
148
|
+
__esDecorate(this, null, _kbDomains_decorators, { kind: "method", name: "kbDomains", static: false, private: false, access: { has: obj => "kbDomains" in obj, get: obj => obj.kbDomains }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
149
|
+
__esDecorate(this, null, _kbAddDomain_decorators, { kind: "method", name: "kbAddDomain", static: false, private: false, access: { has: obj => "kbAddDomain" in obj, get: obj => obj.kbAddDomain }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
150
|
+
__esDecorate(this, null, _openDoc_decorators, { kind: "method", name: "openDoc", static: false, private: false, access: { has: obj => "openDoc" in obj, get: obj => obj.openDoc }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
151
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
152
|
+
}
|
|
153
|
+
rt = __runInitializers(this, _instanceExtraInitializers);
|
|
154
|
+
constructor(ctx, rt) {
|
|
155
|
+
super(ctx, 'avantfMem');
|
|
156
|
+
this.rt = rt;
|
|
157
|
+
}
|
|
158
|
+
async call(key, spec, payload) {
|
|
159
|
+
const parsed = spec.input.safeParse(payload);
|
|
160
|
+
if (!parsed.success) {
|
|
161
|
+
// Same message + violations format the agent tools return (contract helper).
|
|
162
|
+
return validationError(spec.name, parsed.error.issues);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const value = await dispatchToolKey(this.rt, key, parsed.data);
|
|
166
|
+
return { ok: true, value: value ?? null };
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
remember(args) {
|
|
173
|
+
const payload = defined(args);
|
|
174
|
+
return this.call('remember', REMEMBER_TOOL, { action: 'add', ...payload });
|
|
175
|
+
}
|
|
176
|
+
recall(args) {
|
|
177
|
+
const payload = defined(args);
|
|
178
|
+
return this.call('recall', RECALL_TOOL, { action: 'search', ...payload });
|
|
179
|
+
}
|
|
180
|
+
admin(args) {
|
|
181
|
+
const payload = defined(args);
|
|
182
|
+
return this.call('admin', ADMIN_TOOL, { action: 'list', ...payload });
|
|
183
|
+
}
|
|
184
|
+
kb(args) {
|
|
185
|
+
// No safe default action for kb — a missing action fails contract validation
|
|
186
|
+
// with an explicit violation instead of guessing.
|
|
187
|
+
return this.call('kb', KB_TOOL, defined(args));
|
|
188
|
+
}
|
|
189
|
+
query(args) {
|
|
190
|
+
return this.call('query', QUERY_TOOL, defined(args));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* UI-only: what IS this string? Answered against the ingestion boundary, so the UI can label the
|
|
194
|
+
* input and pick the right `kb_manage` action (URL → fetch, file → ingest, directory → import,
|
|
195
|
+
* text → ingest the string itself, missing → refuse and say why).
|
|
196
|
+
*/
|
|
197
|
+
async classifySource(args) {
|
|
198
|
+
try {
|
|
199
|
+
const payload = defined(args);
|
|
200
|
+
const text = typeof payload.text === 'string' ? payload.text : '';
|
|
201
|
+
return { ok: true, value: classifySource(text, this.rt.config.knowledge.ingest) };
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** UI-only: one directory listing for the 选择 picker (same boundary as ingestion). */
|
|
208
|
+
async browseDir(args) {
|
|
209
|
+
try {
|
|
210
|
+
const payload = defined(args);
|
|
211
|
+
const path = typeof payload.path === 'string' && payload.path !== '' ? payload.path : undefined;
|
|
212
|
+
return { ok: true, value: browseDirectory(path, this.rt.config.knowledge.ingest) };
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* UI-only: the 知识页 domain picker's options — the configured `knowledge.domains` allowlist
|
|
220
|
+
* together with the domains the library already holds — plus whether the allowlist is active
|
|
221
|
+
* (non-empty). The host is the only side that knows either half.
|
|
222
|
+
*/
|
|
223
|
+
async kbDomains(_args) {
|
|
224
|
+
try {
|
|
225
|
+
return { ok: true, value: this.rt.knowledge.domainCatalog() };
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* UI-only: add one 知识域 to the store config's `domains` allowlist AND to the live set, so the
|
|
233
|
+
* 知识 tab's 「+」 works without a restart. No agent tool mirrors this on purpose — the model must
|
|
234
|
+
* stay bounded by the configured list, while the user can widen it.
|
|
235
|
+
*/
|
|
236
|
+
async kbAddDomain(args) {
|
|
237
|
+
try {
|
|
238
|
+
const payload = defined(args);
|
|
239
|
+
const domain = typeof payload.domain === 'string' ? payload.domain : '';
|
|
240
|
+
return { ok: true, value: this.rt.knowledge.addDomain(domain) };
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* UI-only: hand one managed document file — or the directory that holds it — to the user's
|
|
248
|
+
* editor. No agent tool mirrors this, so it carries its own envelope instead of going through
|
|
249
|
+
* `dispatchToolKey`; the path is resolved from `doc_id` here, so nothing the client sends is
|
|
250
|
+
* trusted.
|
|
251
|
+
*/
|
|
252
|
+
async openDoc(args) {
|
|
253
|
+
try {
|
|
254
|
+
const payload = defined(args);
|
|
255
|
+
const docId = typeof payload.doc_id === 'number' ? payload.doc_id : undefined;
|
|
256
|
+
if (docId === undefined)
|
|
257
|
+
return { ok: false, error: 'openDoc 需要 doc_id' };
|
|
258
|
+
const path = this.rt.knowledge.docFilePath(docId);
|
|
259
|
+
if (path === null)
|
|
260
|
+
return { ok: false, error: `文档 #${String(docId)} 不存在` };
|
|
261
|
+
const target = payload.target === 'dir' ? 'dir' : 'file';
|
|
262
|
+
if (target === 'file' && !existsSync(path)) {
|
|
263
|
+
return { ok: false, error: `受管文件不存在:${path}(重新摄入会重建它)` };
|
|
264
|
+
}
|
|
265
|
+
const outcome = openDocumentPath(path, target, this.rt.config.knowledge.open.editor);
|
|
266
|
+
return { ok: true, value: outcome };
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return { ok: false, error: remoteErrorText(error) };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
})();
|
|
274
|
+
export { AvantfMemGateway };
|
|
275
|
+
/**
|
|
276
|
+
* Startup/lifecycle logging sink.
|
|
277
|
+
*
|
|
278
|
+
* DSH's `ctx.logger` currently has only a buffered exporter, so lines written
|
|
279
|
+
* through it never reach the terminal. Startup diagnostics therefore go to
|
|
280
|
+
* stderr (the CLI/MCP convention, keeping stdout free for data) and are mirrored
|
|
281
|
+
* into the host logger for any in-app log surface.
|
|
282
|
+
*/
|
|
283
|
+
function hostLogger(ctx) {
|
|
284
|
+
const local = createConsoleLogger('[avantf-mem]');
|
|
285
|
+
const host = ctx.logger;
|
|
286
|
+
return {
|
|
287
|
+
info: (message) => { local.info(message); host.info(message); },
|
|
288
|
+
warn: (message) => { local.warn(message); host.warn(message); },
|
|
289
|
+
error: (message) => { local.error(message); host.error(message); },
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Whether this plugin's fiber is still alive after the environment await.
|
|
294
|
+
*
|
|
295
|
+
* The environment gate can take the whole startup budget (15 s on a first run) and the framework may
|
|
296
|
+
* even fetch the gate's base during it. A profile reload or unload landing inside that window clears
|
|
297
|
+
* the fiber's uid, after which every Cordis call throws `INACTIVE_EFFECT` — the plugin would then
|
|
298
|
+
* half-register (the Remote face cannot be rolled back) instead of stopping cleanly. An unknown
|
|
299
|
+
* shape is "cannot tell", so only an explicitly cleared uid counts as disposed.
|
|
300
|
+
*/
|
|
301
|
+
function stillActive(ctx) {
|
|
302
|
+
const fiber = ctx.fiber;
|
|
303
|
+
return fiber === undefined || fiber.uid !== null;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Declare the expensive resources through the family framework, warm the tokenizer and the embedding
|
|
307
|
+
* model, and continue initialisation as each item settles.
|
|
308
|
+
*
|
|
309
|
+
* Everything here is fire-and-forget: `background` items never occupy the startup budget, and the
|
|
310
|
+
* callbacks ARE the "continue once the resource is there" step (framework DESIGN §7.2 step 5).
|
|
311
|
+
*
|
|
312
|
+
* **What the framework owns here.** `mem:pandoc` and `mem:model` are framework items. The embedding
|
|
313
|
+
* model is a `model-cache` in the framework's `flat` layout, so its files land at
|
|
314
|
+
* `<home>/models/<repo>/<file>` — the tree `semantic.cache_dir` resolves to (the family root, via
|
|
315
|
+
* `managedRoots`) and the shape `@huggingface/transformers` reads under `env.cacheDir`. The runtime
|
|
316
|
+
* therefore loads the framework's copy instead of fetching a second one.
|
|
317
|
+
*
|
|
318
|
+
* The model is NOT declared when the runtime's cache dir is somewhere else (`AVANTF_MEM_MODEL_CACHE`
|
|
319
|
+
* — `managedModelSpec` decides): the framework's root would then collect a copy nothing reads. In
|
|
320
|
+
* that case, and whenever no item will warm the model, the warm is started here directly.
|
|
321
|
+
*
|
|
322
|
+
* Outcomes handled below:
|
|
323
|
+
*
|
|
324
|
+
* - `mem:model` settles (present/installed) → warm the semantic path from the managed root, now
|
|
325
|
+
* that the files are on disk. That ordering is the whole point: the runtime's constructor warm is
|
|
326
|
+
* deferred while the framework is up (`deferWarm`), so nothing can race the install, and the
|
|
327
|
+
* explicit warm here is what starts the model.
|
|
328
|
+
* - `mem:model` failed/skipped → one WARNING, then STILL warm: with `semantic.auto_download` on the
|
|
329
|
+
* runtime takes its own download path, and with it off the semantic leg degrades to FTS+entity.
|
|
330
|
+
* Either way the mount is untouched.
|
|
331
|
+
* - `mem:pandoc` unavailable → point the converter back at the pre-migration tools directory, so an
|
|
332
|
+
* already-installed copy (or one on PATH) keeps document ingestion working.
|
|
333
|
+
* - anything failed/skipped → one WARNING, with the framework's stable reason code.
|
|
334
|
+
*
|
|
335
|
+
* @param env - the loaded framework runtime.
|
|
336
|
+
* @param rt - the built engine runtime (its resolved config drives the specs).
|
|
337
|
+
* @param logger - the plugin's logger.
|
|
338
|
+
* @param waitFor - the host's readiness signal, for the tokenizer warm.
|
|
339
|
+
*/
|
|
340
|
+
function startManagedResources(env, rt, logger, waitFor) {
|
|
341
|
+
const { semantic, tools } = rt.config.common;
|
|
342
|
+
// `undefined` in two cases: the semantic backend is not a local model, or the runtime reads models
|
|
343
|
+
// from an operator override instead of the framework root (see `managedModelSpec`).
|
|
344
|
+
const model = managedModelSpec(semantic, env.roots.models);
|
|
345
|
+
const plan = {
|
|
346
|
+
pandoc: tools.auto_install,
|
|
347
|
+
archiveMirrors: tools.mirror,
|
|
348
|
+
// The item is still declared when downloads are off — its `skipped (policy/download-disabled)`
|
|
349
|
+
// line is the diagnosis — but the framework then probes disk only, never the network.
|
|
350
|
+
modelAutoDownload: semantic.auto_download,
|
|
351
|
+
model,
|
|
352
|
+
};
|
|
353
|
+
// The tokenizer needs no provisioning — nodejieba is an optional npm dependency — so it warms
|
|
354
|
+
// behind the same host-readiness signal the legacy sweep used.
|
|
355
|
+
warmTokenizerAsync(rt, { ...(waitFor === undefined ? {} : { waitFor }) });
|
|
356
|
+
if (model === undefined && semantic.backend === 'local_bge') {
|
|
357
|
+
// No `mem:model` item owns the model: nobody will call back, so warm from wherever the runtime
|
|
358
|
+
// reads (the `AVANTF_MEM_MODEL_CACHE` directory). `semantic.auto_download` decides whether a
|
|
359
|
+
// missing cache is fetched or degrades.
|
|
360
|
+
warmSemanticAsync(rt);
|
|
361
|
+
}
|
|
362
|
+
env.provisionResources(plan, (entry) => {
|
|
363
|
+
if (entry.id === MODEL_ITEM) {
|
|
364
|
+
// The model files are (or are not) on disk now; only now may the runtime look. On success it
|
|
365
|
+
// reads `<repo>/<file>` from the managed root; on failure it takes its own download or degrades
|
|
366
|
+
// to FTS+entity. Warming on both paths is deliberate — the warning below is the diagnosis.
|
|
367
|
+
warmSemanticAsync(rt);
|
|
368
|
+
}
|
|
369
|
+
const unavailable = entry.action === 'failed' || entry.action === 'skipped';
|
|
370
|
+
if (unavailable) {
|
|
371
|
+
logger.warn(`envinit: ${entry.id} is ${entry.action} (${String(entry.code)}${entry.reason === undefined ? '' : ` — ${entry.reason}`})`);
|
|
372
|
+
if (entry.id === PANDOC_ITEM) {
|
|
373
|
+
const fallback = legacyToolsDir(rt, env);
|
|
374
|
+
logger.warn(`envinit: falling back to ${fallback} for pandoc (override with tools.dir or AVANTF_PANDOC)`);
|
|
375
|
+
setPandocProvisioning({
|
|
376
|
+
toolsDir: fallback,
|
|
377
|
+
mirror: rt.config.common.tools.mirror,
|
|
378
|
+
autoInstall: rt.config.common.tools.auto_install,
|
|
379
|
+
logger,
|
|
380
|
+
});
|
|
381
|
+
resetPandocResolution();
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
logger.info(`envinit: ${entry.id} ${entry.action} (${entry.source}${entry.version === undefined ? '' : ` ${entry.version}`})`);
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The tools directory to use when the framework did NOT deliver pandoc.
|
|
390
|
+
*
|
|
391
|
+
* Resolved through the provisioning layer's own `resolveToolsDir`, so the environment hatch
|
|
392
|
+
* (`AVANTF_TOOLS_DIR`) and the `~` expansion behave here exactly as they do everywhere else — the
|
|
393
|
+
* converter hands `settings.toolsDir` to `ensurePandoc` VERBATIM (`pandocBinary`), so whatever this
|
|
394
|
+
* returns is the final word for the process. `managedRoots` replaced the built-in default, so
|
|
395
|
+
* `config.common.tools.dir` equals the family root even when the operator never touched it; only a
|
|
396
|
+
* resolved value that is STILL the family root means "nobody chose one", and only then may the
|
|
397
|
+
* legacy default apply.
|
|
398
|
+
*/
|
|
399
|
+
function legacyToolsDir(rt, env) {
|
|
400
|
+
const resolved = resolveToolsDir(parseToolsConfig(rt.config.common.tools));
|
|
401
|
+
return resolved === env.roots.tools ? defaultToolsDir() : resolved;
|
|
402
|
+
}
|
|
403
|
+
export async function apply(ctx, config) {
|
|
404
|
+
const logger = hostLogger(ctx);
|
|
405
|
+
// ── environment initialisation — FIRST, before the runtime allocates anything ────────────────
|
|
406
|
+
// The family framework's fixed sequence (its DESIGN §7.2): bootstrap (inlined) → load
|
|
407
|
+
// `@avantf/dsh-envinit` → declare the blocking set → wait for it → this plugin's own pre-mount
|
|
408
|
+
// check. The check is the family compatibility gate, and it runs before anything is registered,
|
|
409
|
+
// so "do not load" costs nothing to unwind.
|
|
410
|
+
//
|
|
411
|
+
// The base (`@avantf/dsh-compat`) is obtained by the FRAMEWORK now — this plugin no longer carries
|
|
412
|
+
// its own self-heal through `@avantf/mem-provision`, whose private root the framework deliberately
|
|
413
|
+
// does not read. When the framework itself is unavailable the plugin keeps mounting (see below) and
|
|
414
|
+
// falls back to its legacy provisioning path, whose default directories are the pre-migration ones.
|
|
415
|
+
//
|
|
416
|
+
// When the base cannot be obtained at all we do NOT refuse: a `compat:` WARNING is logged and the
|
|
417
|
+
// plugin mounts normally (tools/service/prompt/remote all register), because the gate is a safety
|
|
418
|
+
// net and "cannot tell" is not "incompatible". Only a PROVEN break (`probe-failed`) refuses.
|
|
419
|
+
const env = await loadEnvinit({ log: logger });
|
|
420
|
+
// The await above can last the whole startup budget (15 s on a first run). A profile reload or
|
|
421
|
+
// unload during it clears this fiber's uid, and every later Cordis call — `ctx.effect`, `ctx.on`,
|
|
422
|
+
// `ctx.tools.register` — then throws `INACTIVE_EFFECT`. Re-assert liveness and stop cleanly here
|
|
423
|
+
// rather than half-registering (the Remote face cannot be rolled back).
|
|
424
|
+
if (!stillActive(ctx)) {
|
|
425
|
+
logger.warn('unmounted while preparing the environment; stopping before registering anything');
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const compat = env?.compat;
|
|
429
|
+
let verdict;
|
|
430
|
+
if (compat === undefined) {
|
|
431
|
+
logger.warn('plugin mount: the dsh compatibility gate is ABSENT (@avantf/dsh-compat unavailable — see the `compat:` WARNING above); mounting the full plugin anyway');
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
const run = provision(ctx, logger, compat);
|
|
435
|
+
if (!run.verdict.load) {
|
|
436
|
+
registerCompatMegaphone(ctx, run.verdict, logger, compat);
|
|
437
|
+
logger.warn('plugin not loaded: the host dsh API is incompatible (compat check above) — nothing was registered; rebuild with `pnpm build:dsh`');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
verdict = run.verdict;
|
|
441
|
+
}
|
|
442
|
+
// The model-facing set comes from the contract's `TOOL_SPECS` — ONE list, so adding or splitting a
|
|
443
|
+
// tool cannot leave this file behind. (It WAS hardcoded here, and that is exactly what happened:
|
|
444
|
+
// splitting `kb_manage` into `kb_add`/`kb_list`/`kb_remove`/`kb_reindex` left the plugin still
|
|
445
|
+
// registering the old five until this line was found.)
|
|
446
|
+
const toolSpecs = TOOL_SPECS;
|
|
447
|
+
if (config.mode === 'mcp') {
|
|
448
|
+
// The MCP entry is a standalone stdio process (@avantf/mem-mcp); an in-process
|
|
449
|
+
// DSH plugin cannot own the host's stdio. Warn and mount the cordis toolset.
|
|
450
|
+
logger.warn('config mode=mcp is not served in-process — mounting the cordis toolset; run @avantf/mem-mcp standalone for MCP');
|
|
451
|
+
}
|
|
452
|
+
// The RESOLVED home is logged by the runtime init line; here we only report what
|
|
453
|
+
// this plugin was configured with, so "unset" reads as unset rather than as a value.
|
|
454
|
+
logger.info(`plugin mount: mode=${config.mode ?? 'cordis'} `
|
|
455
|
+
+ `dataHome=${config.dataHome ?? '(unset → AVANTF_HOME, else ~/.avantf)'}`);
|
|
456
|
+
let rt;
|
|
457
|
+
try {
|
|
458
|
+
rt = buildRuntime({
|
|
459
|
+
dataHome: config.dataHome,
|
|
460
|
+
logger,
|
|
461
|
+
// The family framework owns pandoc and the embedding model when it is up: its managed roots
|
|
462
|
+
// become this process's built-in defaults, so the converter and the semantic backend look
|
|
463
|
+
// where the framework publishes. A `tools.dir` / `semantic.cache_dir` in config.yaml (or an
|
|
464
|
+
// `AVANTF_*` escape hatch) still wins — the framework roots are only layer ①.
|
|
465
|
+
...(env === undefined ? {} : { managedRoots: { tools: env.roots.tools, models: env.roots.models } }),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
// A memory plugin must not be able to stop the host from booting. Everything that can fail here
|
|
470
|
+
// is environmental — a corrupt or locked database, an unwritable data home, or a database
|
|
471
|
+
// written by a NEWER version (`SchemaDowngradeError`) — and the loader treats a throwing `apply`
|
|
472
|
+
// as a failed row, which is enough to fail the whole `dsh web` boot (observed). Degrade instead:
|
|
473
|
+
// the five tools stay registered and each answers with the REASON, so the model and the operator
|
|
474
|
+
// get a diagnosis instead of a missing tool and a silent absence.
|
|
475
|
+
const reason = describeError(error);
|
|
476
|
+
logger.error(`runtime unavailable — mounting DEGRADED (tools answer with the reason): ${reason}`);
|
|
477
|
+
const message = `memory unavailable: ${reason}`;
|
|
478
|
+
for (const spec of toolSpecs) {
|
|
479
|
+
ctx.tools.register(defineTool({
|
|
480
|
+
name: spec.name,
|
|
481
|
+
description: spec.description,
|
|
482
|
+
parameters: flattenToolSpec(spec),
|
|
483
|
+
output: OUTPUT,
|
|
484
|
+
execute: () => Promise.resolve(toolErr(message)),
|
|
485
|
+
presentCall: (args) => present(spec, args),
|
|
486
|
+
}));
|
|
487
|
+
logger.info(`tool registered (DEGRADED): ${spec.name}`);
|
|
488
|
+
}
|
|
489
|
+
logger.warn('client tabs unavailable: no runtime to serve the `avantfMem` remote (fix the error above, then restart)');
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
ctx.provide('avantfMemory', rt);
|
|
493
|
+
logger.info('service registered: avantfMemory');
|
|
494
|
+
// ONE initialization path for everything this plugin must PROVIDE: the pandoc binary every
|
|
495
|
+
// document conversion goes through, and the embedding model + tokenizer warm-up.
|
|
496
|
+
//
|
|
497
|
+
// WHICH mechanism does the fetching is decided by whether the family framework came up:
|
|
498
|
+
//
|
|
499
|
+
// - framework available → `mem:pandoc` and `mem:model` are declared as BACKGROUND items and
|
|
500
|
+
// dispatched now; the plugin keeps initialising and continues on each `onSettled`. Nothing here
|
|
501
|
+
// waits for a 35 MB archive or a ~90 MB model. The engine's managed roots were already pointed
|
|
502
|
+
// at the family root above, so the semantic backend reads the very tree the framework fills,
|
|
503
|
+
// and the warm starts from the model item's settle callback rather than racing it.
|
|
504
|
+
// - framework unavailable → the legacy `@avantf/mem-provision` sweep, over the pre-migration
|
|
505
|
+
// default directories, exactly as before.
|
|
506
|
+
//
|
|
507
|
+
// nodejieba's dictionary parse is ~1.2 s of SYNCHRONOUS main-thread work, so it must not run
|
|
508
|
+
// while the host is still booting: measured, `dsh web` printed its URL line 0.09 s after our
|
|
509
|
+
// "tokenizer ready" line, i.e. the parse was the last thing gating startup. The host's own
|
|
510
|
+
// readiness signal is its loader tree settling (`loader.await()` — what the web app awaits before
|
|
511
|
+
// printing the URL); both paths warm the tokenizer behind it.
|
|
512
|
+
const loader = ctx.get('loader');
|
|
513
|
+
const waitFor = loader?.await === undefined ? undefined : loader.await();
|
|
514
|
+
if (env !== undefined) {
|
|
515
|
+
startManagedResources(env, rt, logger, waitFor);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
// The verdict the front gate JUST computed is handed to the sweep, whose first step reads `load`
|
|
519
|
+
// (see `provisionToolchainAsync`): an incompatible host provisions NOTHING. Dispatched, not
|
|
520
|
+
// awaited. Omitted when the gate could not run at all (`compat === undefined`), in which case
|
|
521
|
+
// "cannot tell" means the sweep runs as usual.
|
|
522
|
+
void provisionToolchainAsync(rt, {
|
|
523
|
+
...(waitFor === undefined ? {} : { waitFor }),
|
|
524
|
+
...(verdict === undefined ? {} : { dshCompat: verdict }),
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// Derived state follows the RULES, not the write: a fact written before an extraction change
|
|
528
|
+
// keeps the old entity/triple rows (and the HRR bundle derived from them) until a sweep visits
|
|
529
|
+
// it. ONE bounded batch per call — the store's in-flight guard drops an overlapping call rather
|
|
530
|
+
// than letting two passes select the same rows — and the remainder is picked up next time.
|
|
531
|
+
const sweepEntities = () => {
|
|
532
|
+
void rt.memory.reindexEntities(ENTITY_SWEEP_PER_HEARTBEAT)
|
|
533
|
+
.then((swept) => {
|
|
534
|
+
if (swept.rebuilt > 0) {
|
|
535
|
+
logger.info(`entity sweep: rebuilt ${swept.rebuilt} fact(s) written by older rules (${swept.deferred} still stale)`);
|
|
536
|
+
}
|
|
537
|
+
})
|
|
538
|
+
.catch((error) => {
|
|
539
|
+
logger.warn(`entity sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
// FIRST trigger is here, at mount — not on the first beat. A rule change used to reach old facts
|
|
543
|
+
// only after `heartbeat_minutes` (60 by default), and never at all when that was 0.
|
|
544
|
+
sweepEntities();
|
|
545
|
+
// Trust heartbeat: the active-day clock advances on presence, and the tick performs
|
|
546
|
+
// the settle/forget/idle/purge sweeps (TRUST_MODEL.md §5). `0` = startup pass only.
|
|
547
|
+
const heartbeatMinutes = rt.config.common.trust.presence.heartbeat_minutes;
|
|
548
|
+
if (heartbeatMinutes > 0) {
|
|
549
|
+
ctx.effect(() => {
|
|
550
|
+
const timer = setInterval(() => {
|
|
551
|
+
try {
|
|
552
|
+
rt.memory.trustTick();
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
logger.warn(`trust tick failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
556
|
+
}
|
|
557
|
+
sweepEntities();
|
|
558
|
+
}, heartbeatMinutes * 60_000);
|
|
559
|
+
const unref = timer.unref;
|
|
560
|
+
if (typeof unref === 'function')
|
|
561
|
+
unref.call(timer);
|
|
562
|
+
logger.info(`trust heartbeat: every ${heartbeatMinutes}min (active-day clock + lifecycle sweep)`);
|
|
563
|
+
return () => clearInterval(timer);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Three usage sections, and only usage: when a fact is worth writing / when to read memory before
|
|
567
|
+
// answering, when the document library is worth querying, and how to CHANGE a document. Retention mechanics stay out of
|
|
568
|
+
// both (the store's business, and the only action such text invites is rewriting a fact to
|
|
569
|
+
// refresh it — which defeats the policy it describes); the module-level reasons are on
|
|
570
|
+
// MEMORY_PROMPT_SECTION and KNOWLEDGE_PROMPT_SECTION.
|
|
571
|
+
for (const section of [MEMORY_PROMPT_SECTION, KNOWLEDGE_PROMPT_SECTION, KB_EDIT_PROMPT_SECTION]) {
|
|
572
|
+
ctx.systemPrompt.section(section);
|
|
573
|
+
logger.info(`prompt section registered: ${section.name} (order ${String(section.order)})`);
|
|
574
|
+
}
|
|
575
|
+
// The CONDITIONAL hints, one contribution per store (DESIGN §12). Recomputed synchronously the
|
|
576
|
+
// moment the agent is handed a message, so they are in place before THIS step's prompt is
|
|
577
|
+
// assembled: the provider below cannot await, which is exactly why the probe is the lexical one
|
|
578
|
+
// (`@avantf/mem`'s `relevance` — the semantic leg needs an encode and would land a step late).
|
|
579
|
+
const hints = new WeakMap();
|
|
580
|
+
ctx.on('agent/inbox/inserted', (payload) => {
|
|
581
|
+
const agent = payload.agent;
|
|
582
|
+
if (agent === undefined)
|
|
583
|
+
return;
|
|
584
|
+
const text = messageText(payload.message);
|
|
585
|
+
// Never let a probe failure reach the event bus: a missing hint is harmless (the usage
|
|
586
|
+
// sections still tell the model to query), a throwing listener is only log noise.
|
|
587
|
+
try {
|
|
588
|
+
hints.set(agent, text === '' ? 'none' : rt.relevance(text));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
hints.set(agent, 'none');
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
for (const [name, order, store] of [
|
|
595
|
+
['avantf:memory-hint', 130, 'memory'],
|
|
596
|
+
['avantf:knowledge-hint', 131, 'knowledge'],
|
|
597
|
+
]) {
|
|
598
|
+
ctx.systemPrompt.context({
|
|
599
|
+
name,
|
|
600
|
+
order,
|
|
601
|
+
// The assembly scope IS the agent object (`dsh-agent`'s `assembleContextFor` passes
|
|
602
|
+
// `scope: agent`), so keying by it keeps two sessions from seeing each other's hint. No
|
|
603
|
+
// state for this agent (or `none`) renders as empty text, which contributes nothing.
|
|
604
|
+
text: (context) => {
|
|
605
|
+
const hit = context.scope === undefined ? undefined : hints.get(context.scope);
|
|
606
|
+
return hit === undefined ? '' : hintLines(hit)[store];
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
logger.info(`prompt context registered: ${name} (order ${String(order)})`);
|
|
610
|
+
}
|
|
611
|
+
// Corpus reconciliation, done PRECISELY rather than on a timer or a watcher: the agent reaches
|
|
612
|
+
// the corpus through tool calls, so a finished tool call is the moment to ask "did anything under
|
|
613
|
+
// `knowledge.docs.dir` change?" — and `corpusDrift()` answers that with one `stat` per document,
|
|
614
|
+
// reading nothing. Only the files whose stamp moved get re-ingested, which is what makes this
|
|
615
|
+
// cheap enough to run after EVERY tool result.
|
|
616
|
+
let reconciling = false;
|
|
617
|
+
// THROTTLE, with a trailing run. Drift checks run after EVERY tool result — including other
|
|
618
|
+
// plugins' — so on a busy session a full stat pass plus a recursive readdir would fire many times
|
|
619
|
+
// a second. Skipping alone would risk dropping the LAST edit (the one that matters), so a
|
|
620
|
+
// throttled call schedules exactly one trailing check instead of being discarded.
|
|
621
|
+
let lastDriftAt = 0;
|
|
622
|
+
let trailingDrift = null;
|
|
623
|
+
const reconcile = async (full) => {
|
|
624
|
+
if (reconciling)
|
|
625
|
+
return;
|
|
626
|
+
if (!full) {
|
|
627
|
+
const wait = RECONCILE_MIN_INTERVAL_MS - (Date.now() - lastDriftAt);
|
|
628
|
+
if (wait > 0) {
|
|
629
|
+
if (trailingDrift === null) {
|
|
630
|
+
trailingDrift = setTimeout(() => { trailingDrift = null; void reconcile(false); }, wait);
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
lastDriftAt = Date.now();
|
|
635
|
+
}
|
|
636
|
+
reconciling = true;
|
|
637
|
+
try {
|
|
638
|
+
if (full) {
|
|
639
|
+
await rt.knowledge.sync({});
|
|
640
|
+
// Take the baseline AFTER the sync: seeding it before would hide an edit that landed in
|
|
641
|
+
// between, which is the one window this whole mechanism exists to close.
|
|
642
|
+
rt.knowledge.corpusDrift();
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
const drift = rt.knowledge.corpusDrift();
|
|
646
|
+
if (drift.changed.length === 0 && drift.missing.length === 0 && !drift.fileSetChanged)
|
|
647
|
+
return;
|
|
648
|
+
if (drift.fileSetChanged || drift.missing.length > 0) {
|
|
649
|
+
// A new, vanished or frontmatter-destroyed `.md` is a corpus-level question (orphan /
|
|
650
|
+
// missing), not a doc-level one — so escalate to the full reconcile, which is the surface
|
|
651
|
+
// that reports it honestly instead of silently leaving the document un-ingested.
|
|
652
|
+
await rt.knowledge.sync({});
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
for (const docId of drift.changed)
|
|
656
|
+
await rt.knowledge.sync({ docId });
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
logger.warn(`知识库自动同步失败(不影响已有内容):${error instanceof Error ? error.message : String(error)}`);
|
|
660
|
+
}
|
|
661
|
+
finally {
|
|
662
|
+
reconciling = false;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
// Once at mount, for whatever changed while the host was not running; then after every tool call.
|
|
666
|
+
void reconcile(true);
|
|
667
|
+
ctx.on('tools/result', () => {
|
|
668
|
+
void reconcile(false);
|
|
669
|
+
});
|
|
670
|
+
for (const spec of toolSpecs) {
|
|
671
|
+
ctx.tools.register(defineTool({
|
|
672
|
+
name: spec.name,
|
|
673
|
+
description: spec.description,
|
|
674
|
+
// Structurally a ParameterSchemaSpec; the derivation lives in `tool_schema.ts`
|
|
675
|
+
// so it can be unit-tested without a harness checkout.
|
|
676
|
+
parameters: flattenToolSpec(spec),
|
|
677
|
+
output: OUTPUT,
|
|
678
|
+
execute: args => runToolSpec(spec, rt, args),
|
|
679
|
+
presentCall: (args) => present(spec, args),
|
|
680
|
+
}));
|
|
681
|
+
logger.info(`tool registered: ${spec.name}`);
|
|
682
|
+
}
|
|
683
|
+
new AvantfMemGateway(ctx, rt); // binder for the client `ctx.remote.avantfMem.*` bridge
|
|
684
|
+
logger.info('remote registered: avantfMem (client ctx.remote.avantfMem.*)');
|
|
685
|
+
// Strict host wire face: hand-written descriptors registered through the
|
|
686
|
+
// documented manual path (see src/remote.ts). Without this the gateway falls
|
|
687
|
+
// back to SRC discovery, which needs the generator workspace.
|
|
688
|
+
const disposeTypert = ctx.typert.register(hostContribution);
|
|
689
|
+
logger.info('typert registered: avantfMem host face (5 tools + 5 UI-only)');
|
|
690
|
+
// Post-registration health check: every REAL schema key (and every real tool name) must have
|
|
691
|
+
// landed, matched by exact key — never by a bare `list()` total, which a not-yet-withdrawn probe
|
|
692
|
+
// could inflate. A failure here is a WARNING only; the plugin is already mounted, so there is no
|
|
693
|
+
// clean "do not load" left to take. Skipped when the gate itself could not run (no base).
|
|
694
|
+
if (compat !== undefined) {
|
|
695
|
+
verifyRegisteredFaces({ ctx, toolNames: toolSpecs.map(spec => spec.name), log: logger, compat });
|
|
696
|
+
}
|
|
697
|
+
ctx.effect(() => () => {
|
|
698
|
+
logger.info('plugin unmount: disposing runtime (closing databases)');
|
|
699
|
+
rt.shutdown();
|
|
700
|
+
void disposeTypert();
|
|
701
|
+
// Release the framework's leases (and drop its per-process cache) with the fiber: a disposed
|
|
702
|
+
// plugin must not keep a managed copy marked as in use.
|
|
703
|
+
env?.dispose();
|
|
704
|
+
logger.info('plugin unmount: runtime disposed');
|
|
705
|
+
});
|
|
706
|
+
logger.info(`plugin ready: ${toolSpecs.length} tools + avantfMemory service + avantfMem remote`);
|
|
707
|
+
}
|
|
708
|
+
//# sourceMappingURL=index.js.map
|