dsh-loci 2.0.0 → 2.0.2
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 +32 -0
- package/client.js +1367 -1367
- package/docs/ACCEPTANCE.md +279 -279
- package/docs/PRIVACY.md +93 -93
- package/package.json +2 -2
- package/src/host/index.js +290 -290
- package/src/host/store/audit.js +208 -208
- package/src/host/store/store.js +1538 -1538
- package/src/mcp/store-facade.js +1 -1
package/src/host/index.js
CHANGED
|
@@ -1,290 +1,290 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Host wiring for Loci.
|
|
3
|
-
*
|
|
4
|
-
* One `apply` owns the store, the injected index, five tools, deterministic
|
|
5
|
-
* capture, the owner-only panel API, `/loci`, and a background maintenance
|
|
6
|
-
* timer. Everything is an effect of this context, so disabling the plugin
|
|
7
|
-
* removes all of it and leaves the database intact.
|
|
8
|
-
* @module dsh-loci/host
|
|
9
|
-
*/
|
|
10
|
-
import { randomUUID } from 'node:crypto';
|
|
11
|
-
import { cfg, effectiveConfig } from './config.js';
|
|
12
|
-
import { ensureMemoryRoot, layout, resolveDshHome, resolveMemoryRoot } from './paths.js';
|
|
13
|
-
import { readScopeKeys, resolveScopes } from './scope.js';
|
|
14
|
-
import { Store } from './store/store.js';
|
|
15
|
-
import { Capture } from './capture/capture.js';
|
|
16
|
-
import { createIndexSection, renderAssistBlock } from './inject/index-section.js';
|
|
17
|
-
import { registerTools } from './tools/tools.js';
|
|
18
|
-
import { registerApiRoutes } from './api/routes.js';
|
|
19
|
-
import { UpdateChecker } from './update.js';
|
|
20
|
-
import { Extractor, completeViaHost } from './extract/extractor.js';
|
|
21
|
-
import { registerCommands } from './commands.js';
|
|
22
|
-
import { runDoctor } from './doctor.js';
|
|
23
|
-
|
|
24
|
-
/** Version stamped into the database and diagnostics. */
|
|
25
|
-
export const VERSION = '2.0.
|
|
26
|
-
|
|
27
|
-
/** Optional collaborators; without them the plugin still serves tools. */
|
|
28
|
-
export const inject = ['systemPrompt', 'tools'];
|
|
29
|
-
|
|
30
|
-
/** Cap on the per-activation assist bookkeeping. */
|
|
31
|
-
const ASSIST_MEMORY = 500;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Build a logger that never throws and never depends on an optional service.
|
|
35
|
-
* @param ctx - plugin context.
|
|
36
|
-
* @returns `{info, warn, error, debug}`.
|
|
37
|
-
*/
|
|
38
|
-
function makeLogger(ctx) {
|
|
39
|
-
const logger = ctx.logger ?? (typeof ctx.get === 'function' ? ctx.get('logger') : undefined);
|
|
40
|
-
const write = (level, message) => {
|
|
41
|
-
try {
|
|
42
|
-
if (typeof logger?.[level] === 'function') logger[level](message);
|
|
43
|
-
else if (level === 'error' || level === 'warn') console.error(message);
|
|
44
|
-
} catch {
|
|
45
|
-
/* logging must never break a turn */
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
return {
|
|
49
|
-
info: (message) => write('info', message),
|
|
50
|
-
warn: (message) => write('warn', message),
|
|
51
|
-
error: (message) => write('error', message),
|
|
52
|
-
debug: (message) => write('debug', message),
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Extract the first user-authored text from a pre-step message list.
|
|
58
|
-
* @param messages - claimed inbox messages.
|
|
59
|
-
* @returns the joined text, or `''`.
|
|
60
|
-
*/
|
|
61
|
-
function firstUserText(messages) {
|
|
62
|
-
for (const message of messages ?? []) {
|
|
63
|
-
if (message?.role !== 'user') continue;
|
|
64
|
-
const content = message.content;
|
|
65
|
-
if (typeof content === 'string') return content;
|
|
66
|
-
if (!Array.isArray(content)) continue;
|
|
67
|
-
const text = content
|
|
68
|
-
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
69
|
-
.map((block) => block.text)
|
|
70
|
-
.join('\n')
|
|
71
|
-
.trim();
|
|
72
|
-
if (text.length > 0) return text;
|
|
73
|
-
}
|
|
74
|
-
return '';
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Activate the plugin.
|
|
79
|
-
* @param ctx - Cordis plugin context.
|
|
80
|
-
* @param rawConfig - the Loader row config (volatile fields are live refs).
|
|
81
|
-
*/
|
|
82
|
-
export function apply(ctx, rawConfig = {}) {
|
|
83
|
-
const log = makeLogger(ctx);
|
|
84
|
-
const config = effectiveConfig(rawConfig);
|
|
85
|
-
const read = (key) => cfg(rawConfig, key);
|
|
86
|
-
|
|
87
|
-
// Capability self-check: fail loudly at activation instead of registering
|
|
88
|
-
// tools that would throw on first call in a changed Host.
|
|
89
|
-
if (typeof ctx.systemPrompt?.section !== 'function' || typeof ctx.tools?.register !== 'function') {
|
|
90
|
-
log.error('loci: this Host does not expose systemPrompt.section + tools.register; refusing to activate');
|
|
91
|
-
throw new Error('loci: incompatible Host (missing systemPrompt.section or tools.register)');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const home = resolveDshHome(config.home);
|
|
95
|
-
const memoryRoot = resolveMemoryRoot(home);
|
|
96
|
-
ensureMemoryRoot(memoryRoot);
|
|
97
|
-
const paths = layout(memoryRoot);
|
|
98
|
-
log.info(`loci: using ${memoryRoot}`);
|
|
99
|
-
|
|
100
|
-
let capture;
|
|
101
|
-
const store = new Store({
|
|
102
|
-
memoryRoot,
|
|
103
|
-
paths,
|
|
104
|
-
config,
|
|
105
|
-
log,
|
|
106
|
-
pluginVersion: VERSION,
|
|
107
|
-
live: read,
|
|
108
|
-
quoteVerifier: (quote, options) => capture?.verifyQuote(quote, options) ?? false,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
const updates = new UpdateChecker({
|
|
112
|
-
currentVersion: VERSION,
|
|
113
|
-
registry: read('updateRegistry'),
|
|
114
|
-
log,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
const extractor = new Extractor({ store, config: read, log });
|
|
118
|
-
capture = new Capture({
|
|
119
|
-
store,
|
|
120
|
-
config: read,
|
|
121
|
-
log,
|
|
122
|
-
resolveScopes,
|
|
123
|
-
extractor,
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const indexSection = createIndexSection({
|
|
127
|
-
store,
|
|
128
|
-
config: read,
|
|
129
|
-
log,
|
|
130
|
-
resolveContext: (assembly) => {
|
|
131
|
-
const session = assembly?.agent?.session;
|
|
132
|
-
if (session === undefined) return undefined;
|
|
133
|
-
if ((session.header?.delegationDepth ?? 0) > 0) return undefined;
|
|
134
|
-
const scopes = resolveScopes({ sessionId: session.id, cwd: session.header?.cwd });
|
|
135
|
-
return {
|
|
136
|
-
sessionId: session.id,
|
|
137
|
-
scopeKey: scopes.workspace,
|
|
138
|
-
scopeKeys: readScopeKeys(scopes, { includeGlobal: read('includeGlobal') }),
|
|
139
|
-
scopeLabel: `workspace:${scopes.labels.workspace}`,
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const disposers = [];
|
|
145
|
-
// Disposal runs in reverse registration order, so this must be registered
|
|
146
|
-
// FIRST to run LAST: the capture layer flushes session summaries through the
|
|
147
|
-
// store on unload, and closing the database before that turned every summary
|
|
148
|
-
// into a logged-and-lost error. The integrity check moved to `doctor()` for
|
|
149
|
-
// the same reason — a synchronous check over every row on the agent's event
|
|
150
|
-
// loop is not something a shutdown should do.
|
|
151
|
-
disposers.push(
|
|
152
|
-
ctx.effect(
|
|
153
|
-
() => () => {
|
|
154
|
-
try {
|
|
155
|
-
store.close();
|
|
156
|
-
} catch (error) {
|
|
157
|
-
log.warn(`loci: store close failed: ${String(error?.message ?? error)}`);
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
'loci: store close',
|
|
161
|
-
),
|
|
162
|
-
);
|
|
163
|
-
|
|
164
|
-
disposers.push(ctx.effect(() => ctx.systemPrompt.section(indexSection), 'loci: index section'));
|
|
165
|
-
disposers.push(ctx.effect(() => registerTools(ctx, { store, config: read, log, capture }), 'loci: tools'));
|
|
166
|
-
disposers.push(ctx.effect(() => capture.attach(ctx), 'loci: capture'));
|
|
167
|
-
// Optional collaborators come and go with the composition: acquire them
|
|
168
|
-
// through `ctx.inject` so the panel API and `/loci` mount whenever the
|
|
169
|
-
// service appears, instead of being skipped forever because it was not yet
|
|
170
|
-
// there at activation time (measured: the Web profile mounts `connection`
|
|
171
|
-
// after a profile-installed bundle activates).
|
|
172
|
-
disposers.push(
|
|
173
|
-
ctx.inject(['connection'], (child) =>
|
|
174
|
-
child.effect(() => registerApiRoutes(child, { store, paths, config, log, updates, version: VERSION }), 'loci: panel api'),
|
|
175
|
-
),
|
|
176
|
-
);
|
|
177
|
-
disposers.push(
|
|
178
|
-
ctx.inject(['commands'], (child) =>
|
|
179
|
-
child.effect(() => registerCommands(child, { store, paths, config, log }), 'loci: commands'),
|
|
180
|
-
),
|
|
181
|
-
);
|
|
182
|
-
// The extractor needs a model route. `llm` is provided by whichever provider
|
|
183
|
-
// bundle the profile installed; when it is absent the feature simply stays
|
|
184
|
-
// off (and reports `no-model` if someone switches it on).
|
|
185
|
-
disposers.push(
|
|
186
|
-
ctx.inject(['llm'], (child) =>
|
|
187
|
-
child.effect(() => {
|
|
188
|
-
const complete = completeViaHost(child.llm, {
|
|
189
|
-
provider: child.agent?.config?.provider ?? undefined,
|
|
190
|
-
model: child.agent?.config?.model ?? undefined,
|
|
191
|
-
});
|
|
192
|
-
if (complete === undefined) return () => {};
|
|
193
|
-
extractor.complete = complete;
|
|
194
|
-
return () => {
|
|
195
|
-
extractor.complete = undefined;
|
|
196
|
-
};
|
|
197
|
-
}, 'loci: extractor model route'),
|
|
198
|
-
),
|
|
199
|
-
);
|
|
200
|
-
|
|
201
|
-
// Optional, bounded, once-per-session recall assist.
|
|
202
|
-
const assisted = new Set();
|
|
203
|
-
disposers.push(
|
|
204
|
-
ctx.effect(
|
|
205
|
-
() =>
|
|
206
|
-
ctx.on('agent/pre-step', async (payload, next) => {
|
|
207
|
-
const decision = await next();
|
|
208
|
-
try {
|
|
209
|
-
if (read('recallAssist') !== 'session-start') return decision;
|
|
210
|
-
if (decision?.kind !== 'enter' || payload?.step !== 1) return decision;
|
|
211
|
-
const session = payload?.agent?.session;
|
|
212
|
-
if (session === undefined || (session.header?.delegationDepth ?? 0) > 0) return decision;
|
|
213
|
-
if (assisted.has(session.id)) return decision;
|
|
214
|
-
const query = firstUserText(decision.messages);
|
|
215
|
-
if (query.length < 2) return decision;
|
|
216
|
-
const scopes = resolveScopes({ sessionId: session.id, cwd: session.header?.cwd });
|
|
217
|
-
const text = await renderAssistBlock({
|
|
218
|
-
store,
|
|
219
|
-
scopeKeys: readScopeKeys(scopes, { includeGlobal: read('includeGlobal') }),
|
|
220
|
-
query,
|
|
221
|
-
maxBytes: read('recallAssistBytes'),
|
|
222
|
-
});
|
|
223
|
-
assisted.add(session.id);
|
|
224
|
-
if (assisted.size > ASSIST_MEMORY) assisted.clear();
|
|
225
|
-
if (text.length === 0) return decision;
|
|
226
|
-
return {
|
|
227
|
-
...decision,
|
|
228
|
-
messages: [
|
|
229
|
-
...decision.messages,
|
|
230
|
-
{
|
|
231
|
-
id: `loci-recall-${randomUUID()}`,
|
|
232
|
-
role: 'user',
|
|
233
|
-
content: [{ type: 'text', text }],
|
|
234
|
-
source: { kind: 'plugin:loci', form: 'block', summary: 'memory recall' },
|
|
235
|
-
},
|
|
236
|
-
],
|
|
237
|
-
};
|
|
238
|
-
} catch (error) {
|
|
239
|
-
log.warn(`loci: recall assist failed: ${String(error?.message ?? error)}`);
|
|
240
|
-
return decision;
|
|
241
|
-
}
|
|
242
|
-
}),
|
|
243
|
-
'loci: recall assist',
|
|
244
|
-
),
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
// Background maintenance: TTLs, index repair, query-planner statistics, and
|
|
248
|
-
// (when enabled) embedding backfill. Never on the request path.
|
|
249
|
-
if (read('maintenanceMode') === 'sweep') {
|
|
250
|
-
const intervalMs = Math.max(30 * 60 * 1000, read('maintenanceIntervalHours') * 3600 * 1000);
|
|
251
|
-
const run = async () => {
|
|
252
|
-
try {
|
|
253
|
-
store.maintenance();
|
|
254
|
-
await store.backfillEmbeddings();
|
|
255
|
-
} catch (error) {
|
|
256
|
-
log.warn(`loci: maintenance failed: ${String(error?.message ?? error)}`);
|
|
257
|
-
}
|
|
258
|
-
};
|
|
259
|
-
const first = setTimeout(run, Math.min(60_000, intervalMs));
|
|
260
|
-
first.unref?.();
|
|
261
|
-
const timer = setInterval(run, intervalMs);
|
|
262
|
-
timer.unref?.();
|
|
263
|
-
disposers.push(
|
|
264
|
-
ctx.effect(
|
|
265
|
-
() => () => {
|
|
266
|
-
clearTimeout(first);
|
|
267
|
-
clearInterval(timer);
|
|
268
|
-
},
|
|
269
|
-
'loci: maintenance timer',
|
|
270
|
-
),
|
|
271
|
-
);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
store,
|
|
277
|
-
capture,
|
|
278
|
-
paths,
|
|
279
|
-
doctor: () => runDoctor({ store, paths, config }),
|
|
280
|
-
dispose: () => {
|
|
281
|
-
for (const dispose of disposers.reverse()) {
|
|
282
|
-
try {
|
|
283
|
-
dispose?.();
|
|
284
|
-
} catch {
|
|
285
|
-
/* one failing disposer must not block the rest */
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Host wiring for Loci.
|
|
3
|
+
*
|
|
4
|
+
* One `apply` owns the store, the injected index, five tools, deterministic
|
|
5
|
+
* capture, the owner-only panel API, `/loci`, and a background maintenance
|
|
6
|
+
* timer. Everything is an effect of this context, so disabling the plugin
|
|
7
|
+
* removes all of it and leaves the database intact.
|
|
8
|
+
* @module dsh-loci/host
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { cfg, effectiveConfig } from './config.js';
|
|
12
|
+
import { ensureMemoryRoot, layout, resolveDshHome, resolveMemoryRoot } from './paths.js';
|
|
13
|
+
import { readScopeKeys, resolveScopes } from './scope.js';
|
|
14
|
+
import { Store } from './store/store.js';
|
|
15
|
+
import { Capture } from './capture/capture.js';
|
|
16
|
+
import { createIndexSection, renderAssistBlock } from './inject/index-section.js';
|
|
17
|
+
import { registerTools } from './tools/tools.js';
|
|
18
|
+
import { registerApiRoutes } from './api/routes.js';
|
|
19
|
+
import { UpdateChecker } from './update.js';
|
|
20
|
+
import { Extractor, completeViaHost } from './extract/extractor.js';
|
|
21
|
+
import { registerCommands } from './commands.js';
|
|
22
|
+
import { runDoctor } from './doctor.js';
|
|
23
|
+
|
|
24
|
+
/** Version stamped into the database and diagnostics. */
|
|
25
|
+
export const VERSION = '2.0.2';
|
|
26
|
+
|
|
27
|
+
/** Optional collaborators; without them the plugin still serves tools. */
|
|
28
|
+
export const inject = ['systemPrompt', 'tools'];
|
|
29
|
+
|
|
30
|
+
/** Cap on the per-activation assist bookkeeping. */
|
|
31
|
+
const ASSIST_MEMORY = 500;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build a logger that never throws and never depends on an optional service.
|
|
35
|
+
* @param ctx - plugin context.
|
|
36
|
+
* @returns `{info, warn, error, debug}`.
|
|
37
|
+
*/
|
|
38
|
+
function makeLogger(ctx) {
|
|
39
|
+
const logger = ctx.logger ?? (typeof ctx.get === 'function' ? ctx.get('logger') : undefined);
|
|
40
|
+
const write = (level, message) => {
|
|
41
|
+
try {
|
|
42
|
+
if (typeof logger?.[level] === 'function') logger[level](message);
|
|
43
|
+
else if (level === 'error' || level === 'warn') console.error(message);
|
|
44
|
+
} catch {
|
|
45
|
+
/* logging must never break a turn */
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
info: (message) => write('info', message),
|
|
50
|
+
warn: (message) => write('warn', message),
|
|
51
|
+
error: (message) => write('error', message),
|
|
52
|
+
debug: (message) => write('debug', message),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Extract the first user-authored text from a pre-step message list.
|
|
58
|
+
* @param messages - claimed inbox messages.
|
|
59
|
+
* @returns the joined text, or `''`.
|
|
60
|
+
*/
|
|
61
|
+
function firstUserText(messages) {
|
|
62
|
+
for (const message of messages ?? []) {
|
|
63
|
+
if (message?.role !== 'user') continue;
|
|
64
|
+
const content = message.content;
|
|
65
|
+
if (typeof content === 'string') return content;
|
|
66
|
+
if (!Array.isArray(content)) continue;
|
|
67
|
+
const text = content
|
|
68
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
69
|
+
.map((block) => block.text)
|
|
70
|
+
.join('\n')
|
|
71
|
+
.trim();
|
|
72
|
+
if (text.length > 0) return text;
|
|
73
|
+
}
|
|
74
|
+
return '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Activate the plugin.
|
|
79
|
+
* @param ctx - Cordis plugin context.
|
|
80
|
+
* @param rawConfig - the Loader row config (volatile fields are live refs).
|
|
81
|
+
*/
|
|
82
|
+
export function apply(ctx, rawConfig = {}) {
|
|
83
|
+
const log = makeLogger(ctx);
|
|
84
|
+
const config = effectiveConfig(rawConfig);
|
|
85
|
+
const read = (key) => cfg(rawConfig, key);
|
|
86
|
+
|
|
87
|
+
// Capability self-check: fail loudly at activation instead of registering
|
|
88
|
+
// tools that would throw on first call in a changed Host.
|
|
89
|
+
if (typeof ctx.systemPrompt?.section !== 'function' || typeof ctx.tools?.register !== 'function') {
|
|
90
|
+
log.error('loci: this Host does not expose systemPrompt.section + tools.register; refusing to activate');
|
|
91
|
+
throw new Error('loci: incompatible Host (missing systemPrompt.section or tools.register)');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const home = resolveDshHome(config.home);
|
|
95
|
+
const memoryRoot = resolveMemoryRoot(home);
|
|
96
|
+
ensureMemoryRoot(memoryRoot);
|
|
97
|
+
const paths = layout(memoryRoot);
|
|
98
|
+
log.info(`loci: using ${memoryRoot}`);
|
|
99
|
+
|
|
100
|
+
let capture;
|
|
101
|
+
const store = new Store({
|
|
102
|
+
memoryRoot,
|
|
103
|
+
paths,
|
|
104
|
+
config,
|
|
105
|
+
log,
|
|
106
|
+
pluginVersion: VERSION,
|
|
107
|
+
live: read,
|
|
108
|
+
quoteVerifier: (quote, options) => capture?.verifyQuote(quote, options) ?? false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const updates = new UpdateChecker({
|
|
112
|
+
currentVersion: VERSION,
|
|
113
|
+
registry: read('updateRegistry'),
|
|
114
|
+
log,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const extractor = new Extractor({ store, config: read, log });
|
|
118
|
+
capture = new Capture({
|
|
119
|
+
store,
|
|
120
|
+
config: read,
|
|
121
|
+
log,
|
|
122
|
+
resolveScopes,
|
|
123
|
+
extractor,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const indexSection = createIndexSection({
|
|
127
|
+
store,
|
|
128
|
+
config: read,
|
|
129
|
+
log,
|
|
130
|
+
resolveContext: (assembly) => {
|
|
131
|
+
const session = assembly?.agent?.session;
|
|
132
|
+
if (session === undefined) return undefined;
|
|
133
|
+
if ((session.header?.delegationDepth ?? 0) > 0) return undefined;
|
|
134
|
+
const scopes = resolveScopes({ sessionId: session.id, cwd: session.header?.cwd });
|
|
135
|
+
return {
|
|
136
|
+
sessionId: session.id,
|
|
137
|
+
scopeKey: scopes.workspace,
|
|
138
|
+
scopeKeys: readScopeKeys(scopes, { includeGlobal: read('includeGlobal') }),
|
|
139
|
+
scopeLabel: `workspace:${scopes.labels.workspace}`,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const disposers = [];
|
|
145
|
+
// Disposal runs in reverse registration order, so this must be registered
|
|
146
|
+
// FIRST to run LAST: the capture layer flushes session summaries through the
|
|
147
|
+
// store on unload, and closing the database before that turned every summary
|
|
148
|
+
// into a logged-and-lost error. The integrity check moved to `doctor()` for
|
|
149
|
+
// the same reason — a synchronous check over every row on the agent's event
|
|
150
|
+
// loop is not something a shutdown should do.
|
|
151
|
+
disposers.push(
|
|
152
|
+
ctx.effect(
|
|
153
|
+
() => () => {
|
|
154
|
+
try {
|
|
155
|
+
store.close();
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log.warn(`loci: store close failed: ${String(error?.message ?? error)}`);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
'loci: store close',
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
disposers.push(ctx.effect(() => ctx.systemPrompt.section(indexSection), 'loci: index section'));
|
|
165
|
+
disposers.push(ctx.effect(() => registerTools(ctx, { store, config: read, log, capture }), 'loci: tools'));
|
|
166
|
+
disposers.push(ctx.effect(() => capture.attach(ctx), 'loci: capture'));
|
|
167
|
+
// Optional collaborators come and go with the composition: acquire them
|
|
168
|
+
// through `ctx.inject` so the panel API and `/loci` mount whenever the
|
|
169
|
+
// service appears, instead of being skipped forever because it was not yet
|
|
170
|
+
// there at activation time (measured: the Web profile mounts `connection`
|
|
171
|
+
// after a profile-installed bundle activates).
|
|
172
|
+
disposers.push(
|
|
173
|
+
ctx.inject(['connection'], (child) =>
|
|
174
|
+
child.effect(() => registerApiRoutes(child, { store, paths, config, log, updates, version: VERSION }), 'loci: panel api'),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
disposers.push(
|
|
178
|
+
ctx.inject(['commands'], (child) =>
|
|
179
|
+
child.effect(() => registerCommands(child, { store, paths, config, log }), 'loci: commands'),
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
// The extractor needs a model route. `llm` is provided by whichever provider
|
|
183
|
+
// bundle the profile installed; when it is absent the feature simply stays
|
|
184
|
+
// off (and reports `no-model` if someone switches it on).
|
|
185
|
+
disposers.push(
|
|
186
|
+
ctx.inject(['llm'], (child) =>
|
|
187
|
+
child.effect(() => {
|
|
188
|
+
const complete = completeViaHost(child.llm, {
|
|
189
|
+
provider: child.agent?.config?.provider ?? undefined,
|
|
190
|
+
model: child.agent?.config?.model ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
if (complete === undefined) return () => {};
|
|
193
|
+
extractor.complete = complete;
|
|
194
|
+
return () => {
|
|
195
|
+
extractor.complete = undefined;
|
|
196
|
+
};
|
|
197
|
+
}, 'loci: extractor model route'),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Optional, bounded, once-per-session recall assist.
|
|
202
|
+
const assisted = new Set();
|
|
203
|
+
disposers.push(
|
|
204
|
+
ctx.effect(
|
|
205
|
+
() =>
|
|
206
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
207
|
+
const decision = await next();
|
|
208
|
+
try {
|
|
209
|
+
if (read('recallAssist') !== 'session-start') return decision;
|
|
210
|
+
if (decision?.kind !== 'enter' || payload?.step !== 1) return decision;
|
|
211
|
+
const session = payload?.agent?.session;
|
|
212
|
+
if (session === undefined || (session.header?.delegationDepth ?? 0) > 0) return decision;
|
|
213
|
+
if (assisted.has(session.id)) return decision;
|
|
214
|
+
const query = firstUserText(decision.messages);
|
|
215
|
+
if (query.length < 2) return decision;
|
|
216
|
+
const scopes = resolveScopes({ sessionId: session.id, cwd: session.header?.cwd });
|
|
217
|
+
const text = await renderAssistBlock({
|
|
218
|
+
store,
|
|
219
|
+
scopeKeys: readScopeKeys(scopes, { includeGlobal: read('includeGlobal') }),
|
|
220
|
+
query,
|
|
221
|
+
maxBytes: read('recallAssistBytes'),
|
|
222
|
+
});
|
|
223
|
+
assisted.add(session.id);
|
|
224
|
+
if (assisted.size > ASSIST_MEMORY) assisted.clear();
|
|
225
|
+
if (text.length === 0) return decision;
|
|
226
|
+
return {
|
|
227
|
+
...decision,
|
|
228
|
+
messages: [
|
|
229
|
+
...decision.messages,
|
|
230
|
+
{
|
|
231
|
+
id: `loci-recall-${randomUUID()}`,
|
|
232
|
+
role: 'user',
|
|
233
|
+
content: [{ type: 'text', text }],
|
|
234
|
+
source: { kind: 'plugin:loci', form: 'block', summary: 'memory recall' },
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
log.warn(`loci: recall assist failed: ${String(error?.message ?? error)}`);
|
|
240
|
+
return decision;
|
|
241
|
+
}
|
|
242
|
+
}),
|
|
243
|
+
'loci: recall assist',
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Background maintenance: TTLs, index repair, query-planner statistics, and
|
|
248
|
+
// (when enabled) embedding backfill. Never on the request path.
|
|
249
|
+
if (read('maintenanceMode') === 'sweep') {
|
|
250
|
+
const intervalMs = Math.max(30 * 60 * 1000, read('maintenanceIntervalHours') * 3600 * 1000);
|
|
251
|
+
const run = async () => {
|
|
252
|
+
try {
|
|
253
|
+
store.maintenance();
|
|
254
|
+
await store.backfillEmbeddings();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
log.warn(`loci: maintenance failed: ${String(error?.message ?? error)}`);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const first = setTimeout(run, Math.min(60_000, intervalMs));
|
|
260
|
+
first.unref?.();
|
|
261
|
+
const timer = setInterval(run, intervalMs);
|
|
262
|
+
timer.unref?.();
|
|
263
|
+
disposers.push(
|
|
264
|
+
ctx.effect(
|
|
265
|
+
() => () => {
|
|
266
|
+
clearTimeout(first);
|
|
267
|
+
clearInterval(timer);
|
|
268
|
+
},
|
|
269
|
+
'loci: maintenance timer',
|
|
270
|
+
),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
store,
|
|
277
|
+
capture,
|
|
278
|
+
paths,
|
|
279
|
+
doctor: () => runDoctor({ store, paths, config }),
|
|
280
|
+
dispose: () => {
|
|
281
|
+
for (const dispose of disposers.reverse()) {
|
|
282
|
+
try {
|
|
283
|
+
dispose?.();
|
|
284
|
+
} catch {
|
|
285
|
+
/* one failing disposer must not block the rest */
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|