dsh-plugin-soulbridge 0.0.102 → 0.0.106
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/index.js +2402 -2402
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -1,2402 +1,2402 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-plugin-soulbridge — DSH adapter for the soulbridge personality layer.
|
|
3
|
-
*
|
|
4
|
-
* Division of labour (ADR 0024): core owns state and wording; this plugin owns
|
|
5
|
-
* timing. It does three things and nothing else:
|
|
6
|
-
* 1. puts core's context text into the system prompt (verb 1)
|
|
7
|
-
* 2. hands every human message to observe() (verb 2)
|
|
8
|
-
* 3. records a heartbeat so "is it working?" is answerable without log spelunking
|
|
9
|
-
*
|
|
10
|
-
* Every failure degrades: the host must never break because this plugin is
|
|
11
|
-
* unhappy. What was injected, observed, or skipped is written to
|
|
12
|
-
* ~/.soulbridge/plugin-state.json.
|
|
13
|
-
*/
|
|
14
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
16
|
-
import { basename, join, resolve } from 'node:path';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import {
|
|
19
|
-
openSqlite,
|
|
20
|
-
ensureConfig,
|
|
21
|
-
resolveDbPath,
|
|
22
|
-
Soul,
|
|
23
|
-
detectCorrection,
|
|
24
|
-
renderConsolidationPrompt,
|
|
25
|
-
renderRelationPrompt,
|
|
26
|
-
renderRuleClassifyPrompt,
|
|
27
|
-
renderProfileMergePrompt,
|
|
28
|
-
parseProfileMerge,
|
|
29
|
-
groupProfileCandidates,
|
|
30
|
-
parseRuleClassifications,
|
|
31
|
-
selectRules,
|
|
32
|
-
renderVerificationPrompt,
|
|
33
|
-
parseRelationDraft,
|
|
34
|
-
normalizeStatement,
|
|
35
|
-
parseConclusionDraft,
|
|
36
|
-
parseVerificationDraft,
|
|
37
|
-
readVerification,
|
|
38
|
-
escapeAttr,
|
|
39
|
-
escapeBlockText,
|
|
40
|
-
} from 'soulbridge';
|
|
41
|
-
|
|
42
|
-
export const name = 'dsh-plugin-soulbridge';
|
|
43
|
-
export const inject = ['systemPrompt'];
|
|
44
|
-
|
|
45
|
-
const HOME = process.env.SOULBRIDGE_HOME ?? join(homedir(), '.soulbridge');
|
|
46
|
-
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
47
|
-
const STATE_FILE = join(HOME, 'plugin-state.json');
|
|
48
|
-
/**
|
|
49
|
-
* The model's raw replies, complete. Written as JSON strings so newlines stay
|
|
50
|
-
* one line per entry; nothing is cut, because a truncated failure message is
|
|
51
|
-
* what made an earlier diagnosis impossible.
|
|
52
|
-
*/
|
|
53
|
-
const LLM_RAW_FILE = join(HOME, 'llm-raw.log');
|
|
54
|
-
|
|
55
|
-
function logLlmRaw(raw) {
|
|
56
|
-
try {
|
|
57
|
-
mkdirSync(HOME, { recursive: true });
|
|
58
|
-
appendFileSync(LLM_RAW_FILE, `${new Date().toISOString()} ${JSON.stringify(String(raw))}\n`);
|
|
59
|
-
} catch (error) {
|
|
60
|
-
log(`llm raw write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Structured trace (brief 0005): one jsonl line per decision, for reconciliation
|
|
66
|
-
* rather than reading. Never contains user text — ids, sizes and usage only.
|
|
67
|
-
*
|
|
68
|
-
* The first consumer is "is the context running away": a 134-turn session
|
|
69
|
-
* degenerated into repeated tokens and the database had no trace of it.
|
|
70
|
-
*/
|
|
71
|
-
const TRACE_FILE = join(HOME, 'trace.jsonl');
|
|
72
|
-
const TRACE_MAX_BYTES = 10 * 1024 * 1024;
|
|
73
|
-
|
|
74
|
-
function trace(record) {
|
|
75
|
-
if (readConfig().traceEnabled === false) return;
|
|
76
|
-
try {
|
|
77
|
-
mkdirSync(HOME, { recursive: true });
|
|
78
|
-
try {
|
|
79
|
-
if (statSync(TRACE_FILE).size > TRACE_MAX_BYTES) renameSync(TRACE_FILE, `${TRACE_FILE}.1`);
|
|
80
|
-
} catch {
|
|
81
|
-
// No file yet: nothing to rotate.
|
|
82
|
-
}
|
|
83
|
-
appendFileSync(TRACE_FILE, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`);
|
|
84
|
-
} catch (error) {
|
|
85
|
-
log(`trace write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Prompt assembly can run several times per step; do not hit SQLite every time. */
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Optional plugin config: ~/.soulbridge/plugin-config.json
|
|
93
|
-
* { "extract": "off" | "rules" | "llm", "provider": "...", "model": "...", "maxLlmChars": 2000 }
|
|
94
|
-
*
|
|
95
|
-
* One switch decides whether the host model exists for this plugin. It used to be
|
|
96
|
-
* two: `distill` created the model while the four stages checked `extract`, so the
|
|
97
|
-
* default (distill:'rules') left every stage believing a model was configured and
|
|
98
|
-
* silently returning false. `distill` is retired (docs/retirements.json).
|
|
99
|
-
*/
|
|
100
|
-
const CONFIG_FILE = join(HOME, 'plugin-config.json');
|
|
101
|
-
const CONFIG_DEFAULTS = {
|
|
102
|
-
provider: 'deepseek-account',
|
|
103
|
-
model: 'deepseek-flash',
|
|
104
|
-
maxLlmChars: 2000,
|
|
105
|
-
/** off | rules | llm — who writes entities/facts from a finished turn (ADR 0029). */
|
|
106
|
-
extract: 'llm',
|
|
107
|
-
/** Turns of silence that end a topic thread. Lowered temporarily for tests. */
|
|
108
|
-
threadIdleTurns: 6,
|
|
109
|
-
/** Minutes of silence after which another session counts as finished. */
|
|
110
|
-
threadIdleMinutes: 1440,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/** The extraction mode: `off` writes nothing, `rules` uses the deterministic path. */
|
|
114
|
-
export function llmMode(config = {}) {
|
|
115
|
-
const mode = config.extract ?? CONFIG_DEFAULTS.extract;
|
|
116
|
-
return mode === 'off' || mode === 'rules' ? mode : 'llm';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Is the host model available at all? Only `extract: 'llm'` provides it. */
|
|
120
|
-
export function usesModel(config = {}) {
|
|
121
|
-
return llmMode(config) === 'llm';
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function readConfig() {
|
|
125
|
-
try {
|
|
126
|
-
return { ...CONFIG_DEFAULTS, ...JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) };
|
|
127
|
-
} catch {
|
|
128
|
-
return { ...CONFIG_DEFAULTS };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Adapter side of the core LlmFn port: the host model, called inside a turn. */
|
|
133
|
-
let currentTurn;
|
|
134
|
-
let currentStep;
|
|
135
|
-
|
|
136
|
-
// Measured, not guessed: 279 calls, p50=1.7s p90=6.8s p99=46.7s, max=142.9s.
|
|
137
|
-
// 90s is ~2x p99 and caps a hung call without cutting the ordinary tail.
|
|
138
|
-
// Re-measure with scripts/latency.mjs before changing it.
|
|
139
|
-
const LLM_TIMEOUT_MS = 95_000;
|
|
140
|
-
/** Three times one call, so a slow stage cannot hold the chain past a retry cycle. */
|
|
141
|
-
const CHAIN_TIMEOUT_MS = 285_000;
|
|
142
|
-
/** A hung model call must fail, not hold the whole stage chain forever. */
|
|
143
|
-
function withTimeout(promise, ms, label) {
|
|
144
|
-
return new Promise((resolve, reject) => {
|
|
145
|
-
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
146
|
-
promise.then(
|
|
147
|
-
(value) => {
|
|
148
|
-
clearTimeout(timer);
|
|
149
|
-
resolve(value);
|
|
150
|
-
},
|
|
151
|
-
(error) => {
|
|
152
|
-
clearTimeout(timer);
|
|
153
|
-
reject(error);
|
|
154
|
-
},
|
|
155
|
-
);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
export function makeLlm(ctx, config) {
|
|
160
|
-
if (!usesModel(config)) return undefined;
|
|
161
|
-
// Resolve the service on first use: at apply() time the llm plugin may not be
|
|
162
|
-
// registered yet, and the probe showed the call only works inside a turn.
|
|
163
|
-
return async (prompt) => {
|
|
164
|
-
const service = ctx.get('llm');
|
|
165
|
-
if (!service) throw new Error('llm service unavailable');
|
|
166
|
-
// Probe: the same call succeeds and fails minutes apart, and we only ever
|
|
167
|
-
// recorded the outcome. Start/end plus the live turn/step show which window
|
|
168
|
-
// a call actually lands in.
|
|
169
|
-
const started = Date.now();
|
|
170
|
-
log(`llm probe: start turn=${currentTurn ?? '?'} step=${currentStep ?? '?'} chars=${prompt.length}`);
|
|
171
|
-
try {
|
|
172
|
-
const text = await withTimeout(
|
|
173
|
-
collectText(
|
|
174
|
-
service.stream({
|
|
175
|
-
provider: config.provider,
|
|
176
|
-
model: config.model,
|
|
177
|
-
messages: [
|
|
178
|
-
{
|
|
179
|
-
id: `distill_${Date.now()}`,
|
|
180
|
-
role: 'user',
|
|
181
|
-
content: [{ type: 'text', text: prompt }],
|
|
182
|
-
source: { kind: 'user' },
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
}),
|
|
186
|
-
),
|
|
187
|
-
LLM_TIMEOUT_MS,
|
|
188
|
-
'llm call',
|
|
189
|
-
);
|
|
190
|
-
log(
|
|
191
|
-
`llm probe: end ok ms=${Date.now() - started} out=${typeof text === 'string' ? text.length : -1}` +
|
|
192
|
-
` step=${currentStep ?? '?'}`,
|
|
193
|
-
);
|
|
194
|
-
return text;
|
|
195
|
-
} catch (error) {
|
|
196
|
-
log(
|
|
197
|
-
`llm probe: end fail ms=${Date.now() - started} step=${currentStep ?? '?'}` +
|
|
198
|
-
` err=${error instanceof Error ? error.message : String(error)}`,
|
|
199
|
-
);
|
|
200
|
-
throw error;
|
|
201
|
-
}
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Collect the stream's text exactly once.
|
|
207
|
-
*
|
|
208
|
-
* The host emits `text-delta` chunks and then a `block-end` carrying the whole
|
|
209
|
-
* block. Concatenating both doubled every reply, which turned valid JSON into
|
|
210
|
-
* two concatenated objects and made every distillation look like `failed`.
|
|
211
|
-
*
|
|
212
|
-
* A failed call is not an empty answer: the host ends the stream with
|
|
213
|
-
* `finish{reason:{kind:'error'|'aborted'}}` instead of throwing, so without this
|
|
214
|
-
* check the failure came back as `''` and the caller read it as "the model said
|
|
215
|
-
* nothing". `collectText` throws unless some text was produced before the failure.
|
|
216
|
-
*/
|
|
217
|
-
export async function collectText(stream) {
|
|
218
|
-
let text = '';
|
|
219
|
-
let sawDelta = false;
|
|
220
|
-
let failure;
|
|
221
|
-
for await (const chunk of stream) {
|
|
222
|
-
if (chunk.type === 'text-delta') {
|
|
223
|
-
text += chunk.text;
|
|
224
|
-
sawDelta = true;
|
|
225
|
-
} else if (!sawDelta && chunk.type === 'block-end' && chunk.block?.type === 'text') {
|
|
226
|
-
text += chunk.block.text ?? '';
|
|
227
|
-
} else if (chunk.type === 'finish' && (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted')) {
|
|
228
|
-
failure = chunk.reason;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
if (failure && text.length === 0) {
|
|
232
|
-
const detail = failure.message ? `: ${failure.message}` : '';
|
|
233
|
-
throw new Error(`model call ended without output (${failure.kind}${detail})`);
|
|
234
|
-
}
|
|
235
|
-
return text;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* What the consolidation call actually returned.
|
|
240
|
-
*
|
|
241
|
-
* `parseConclusionDraft` returns null for both "nothing to conclude" and "could not
|
|
242
|
-
* read the reply". Treating them the same dropped the thread's conclusion whenever
|
|
243
|
-
* the call failed — the failure looked exactly like a decision.
|
|
244
|
-
*/
|
|
245
|
-
export function readConsolidationReply(raw) {
|
|
246
|
-
const text = String(raw ?? '').trim();
|
|
247
|
-
if (text === '') return { kind: 'empty' };
|
|
248
|
-
const draft = parseConclusionDraft(text);
|
|
249
|
-
if (draft) return { kind: 'draft', draft };
|
|
250
|
-
// An explicit "无" is an answer; anything else that does not parse is a failure.
|
|
251
|
-
if (/^(无|没有|none)/m.test(text)) return { kind: 'none' };
|
|
252
|
-
return { kind: 'unparseable' };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function log(line) {
|
|
256
|
-
try {
|
|
257
|
-
mkdirSync(HOME, { recursive: true });
|
|
258
|
-
appendFileSync(join(HOME, 'plugin.log'), `${new Date().toISOString()} ${line}\n`);
|
|
259
|
-
} catch {
|
|
260
|
-
// Logging must never break the host.
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function readState() {
|
|
265
|
-
try {
|
|
266
|
-
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
267
|
-
} catch {
|
|
268
|
-
return {};
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function writeState(patch) {
|
|
273
|
-
try {
|
|
274
|
-
mkdirSync(HOME, { recursive: true });
|
|
275
|
-
const next = { ...readState(), ...patch, version: VERSION, updatedAt: Date.now() };
|
|
276
|
-
writeFileSync(STATE_FILE, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
277
|
-
} catch (error) {
|
|
278
|
-
log(`state write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
let soul = null;
|
|
283
|
-
let soulError = null;
|
|
284
|
-
/** Last reply text, so a correction can be stored with what it targets. */
|
|
285
|
-
let lastAssistantText;
|
|
286
|
-
/** Injected by apply(); the Soul reads them once at construction. */
|
|
287
|
-
let injectedLlm;
|
|
288
|
-
let injectedMaxLlmChars = 2000;
|
|
289
|
-
|
|
290
|
-
/** Open the soul once. Throws only the first time; callers degrade to no injection. */
|
|
291
|
-
function ensureSoul() {
|
|
292
|
-
if (soul) return soul;
|
|
293
|
-
if (soulError) throw soulError;
|
|
294
|
-
try {
|
|
295
|
-
const config = ensureConfig();
|
|
296
|
-
soul = new Soul({
|
|
297
|
-
store: openSqlite(resolveDbPath()),
|
|
298
|
-
soulId: config.soulId,
|
|
299
|
-
userId: config.userId,
|
|
300
|
-
name: config.name,
|
|
301
|
-
deviceId: config.deviceId,
|
|
302
|
-
llm: injectedLlm,
|
|
303
|
-
llmMaxChars: injectedMaxLlmChars,
|
|
304
|
-
onLlmRaw: logLlmRaw,
|
|
305
|
-
});
|
|
306
|
-
log(`soul ready: db=${resolveDbPath()} soul=${config.soulId} user=${config.userId}`);
|
|
307
|
-
writeState({ appliedAt: readState().appliedAt ?? Date.now(), soulId: config.soulId, userId: config.userId });
|
|
308
|
-
return soul;
|
|
309
|
-
} catch (error) {
|
|
310
|
-
soulError = error instanceof Error ? error : new Error(String(error));
|
|
311
|
-
log(`soul init failed: ${soulError.message}`);
|
|
312
|
-
writeState({ lastError: `soul init failed: ${soulError.message}` });
|
|
313
|
-
throw soulError;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Last system-prompt text hash, so a changed section is visible in the trace. */
|
|
318
|
-
let lastSectionHash = '';
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* The first whole sentence, used when the model gave no topic for a new thread.
|
|
322
|
-
*
|
|
323
|
-
* A character prefix cuts mid-clause ("这件事我们应该先" + cut), while a sentence is a
|
|
324
|
-
* complete unit — the same unit-level rule the rest of the prompt evidence follows.
|
|
325
|
-
*/
|
|
326
|
-
export function firstSentence(text) {
|
|
327
|
-
const trimmed = String(text ?? '').trim();
|
|
328
|
-
if (!trimmed) return '';
|
|
329
|
-
const match = /^[\s\S]*?[。!?!?\n]/.exec(trimmed);
|
|
330
|
-
return (match ? match[0] : trimmed).trim();
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** FNV-1a: short, dependency-free, good enough to detect change. */
|
|
334
|
-
function hashText(text) {
|
|
335
|
-
let hash = 0x811c9dc5;
|
|
336
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
337
|
-
hash ^= text.charCodeAt(i);
|
|
338
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
339
|
-
}
|
|
340
|
-
return hash.toString(16);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Anchors: the few rules that should always be visible. Kept in the system
|
|
345
|
-
* prompt because that section is evaluated before the message arrives, so it
|
|
346
|
-
* cannot rank anything.
|
|
347
|
-
*/
|
|
348
|
-
const ANCHOR = { types: ['strategy', 'fact', 'trait'], minImportance: 8, maxItems: 3 };
|
|
349
|
-
/**
|
|
350
|
-
* Related injections come from `turn_pairs` (RFC 0007): every exchange scored
|
|
351
|
-
* against the current message, with no length or count cap.
|
|
352
|
-
*
|
|
353
|
-
* No cap on purpose. A long emotional exchange is exactly the thing worth
|
|
354
|
-
* remembering, so a character budget would systematically drop the important
|
|
355
|
-
* ones. Context limits are the host's problem (it compacts); ranking quality is
|
|
356
|
-
* ours. If injection grows too large later, sharpen the ranking or distill the
|
|
357
|
-
* pairs — do not go back to cutting length.
|
|
358
|
-
*/
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* The system-prompt section: identity and style only.
|
|
362
|
-
*
|
|
363
|
-
* DSH declares `systemPromptUpdate: "in-history"` for this provider, and the
|
|
364
|
-
* loop appends every *changed* prompt as a new system node that keeps counting
|
|
365
|
-
* until compaction. Anchor memories drift with recency, so putting them here
|
|
366
|
-
* would grow the prompt version by version over a long session. Anything that
|
|
367
|
-
* changes per turn goes through the pre-step message instead.
|
|
368
|
-
*
|
|
369
|
-
* Synchronous by necessity: PromptSection.text cannot return a promise.
|
|
370
|
-
*/
|
|
371
|
-
function buildInjection() {
|
|
372
|
-
try {
|
|
373
|
-
// includeAffect:false — the affect sample changes almost every turn, and this
|
|
374
|
-
// section is `in-history`: DSH appends a new prompt version whenever the text
|
|
375
|
-
// changes. Injected affect therefore goes through the pre-step message (where
|
|
376
|
-
// the anchors call puts it), not here.
|
|
377
|
-
const result = ensureSoul().context({ track: false, includeAnchors: false, includeAffect: false });
|
|
378
|
-
const text = result.text;
|
|
379
|
-
const hash = hashText(text);
|
|
380
|
-
if (hash !== lastSectionHash) {
|
|
381
|
-
const changed = lastSectionHash !== '';
|
|
382
|
-
lastSectionHash = hash;
|
|
383
|
-
log(`section: ${changed ? 'changed' : 'first'} hash=${hash} chars=${text.length}`);
|
|
384
|
-
trace({ event: 'section', hash, changed, chars: text.length });
|
|
385
|
-
}
|
|
386
|
-
return text;
|
|
387
|
-
} catch (error) {
|
|
388
|
-
log(`section: failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
-
return '';
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function safeKeys(value) {
|
|
394
|
-
if (!value || typeof value !== 'object') return '';
|
|
395
|
-
try {
|
|
396
|
-
return Object.keys(value).slice(0, 12).join(',');
|
|
397
|
-
} catch {
|
|
398
|
-
return '';
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** UserMessage.content is a ContentBlock[]; only text blocks are user prose. */
|
|
403
|
-
function reasoningOf(content) {
|
|
404
|
-
if (!Array.isArray(content)) return '';
|
|
405
|
-
return content
|
|
406
|
-
.filter((block) => block && block.type === 'reasoning' && typeof block.text === 'string')
|
|
407
|
-
.map((block) => block.text)
|
|
408
|
-
.join('\n')
|
|
409
|
-
.trim();
|
|
410
|
-
}
|
|
411
|
-
function textOf(content) {
|
|
412
|
-
if (typeof content === 'string') return content;
|
|
413
|
-
if (!Array.isArray(content)) return '';
|
|
414
|
-
return content
|
|
415
|
-
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
416
|
-
.map((block) => block.text)
|
|
417
|
-
.join('\n')
|
|
418
|
-
.trim();
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Block inventory of one assistant message, for measuring what a reply really
|
|
423
|
-
* contains. Types, sizes and tool names only — never the text itself.
|
|
424
|
-
*/
|
|
425
|
-
export function summarizeBlocks(content) {
|
|
426
|
-
if (!Array.isArray(content)) return [];
|
|
427
|
-
return content.map((block) => {
|
|
428
|
-
const type = typeof block?.type === 'string' ? block.type : '?';
|
|
429
|
-
const entry = { type };
|
|
430
|
-
if (typeof block?.text === 'string') entry.chars = block.text.length;
|
|
431
|
-
if (typeof block?.reasoning === 'string') entry.chars = block.reasoning.length;
|
|
432
|
-
const name = block?.name ?? block?.toolName ?? block?.tool?.name;
|
|
433
|
-
if (typeof name === 'string') entry.name = name;
|
|
434
|
-
if (block?.arguments !== undefined) {
|
|
435
|
-
const raw = typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments);
|
|
436
|
-
entry.argsChars = raw.length;
|
|
437
|
-
}
|
|
438
|
-
return entry;
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Exchanges ranked against the current message, for the pre-step injection.
|
|
444
|
-
*
|
|
445
|
-
* Source is `turn_pairs`, not `memories`: one question and one answer is the
|
|
446
|
-
* unit that can answer "what did we talk about last time" (RFC 0007). The
|
|
447
|
-
* system prompt keeps `memories` for now — anchors become the picture in MVP2.
|
|
448
|
-
*
|
|
449
|
-
* `used` carries the pair ids so an injection can be reviewed afterwards.
|
|
450
|
-
* Every related pair is injected, whatever its length.
|
|
451
|
-
*/
|
|
452
|
-
/**
|
|
453
|
-
* The cheapest possible memory: one line per fact, with the id needed to expand.
|
|
454
|
-
*
|
|
455
|
-
* Injection should not replay whole conversations — that decides for the model
|
|
456
|
-
* what is worth reading. It should say "this happened", and leave the detail
|
|
457
|
-
* behind `soul_detail`. When the current message names someone we know, we send
|
|
458
|
-
* that person's timeline ends: the earliest fact (how it started) and the most
|
|
459
|
-
* recent ones (where it stands).
|
|
460
|
-
*/
|
|
461
|
-
const LATIN_NAME = /[a-z0-9]/i;
|
|
462
|
-
|
|
463
|
-
/**
|
|
464
|
-
* Does this message name the entity?
|
|
465
|
-
*
|
|
466
|
-
* A plain `includes` is wrong for short Latin names: an entity called "a" would
|
|
467
|
-
* match "chat", "data" and every English word. Latin names need boundaries; CJK
|
|
468
|
-
* names of two or more characters are matched as substrings, and a one-character
|
|
469
|
-
* CJK name is skipped rather than guessed.
|
|
470
|
-
*/
|
|
471
|
-
export function mentions(text, entity) {
|
|
472
|
-
const names = [entity?.name, ...(Array.isArray(entity?.aliases) ? entity.aliases : [])].filter(Boolean);
|
|
473
|
-
for (const name of names) {
|
|
474
|
-
if (LATIN_NAME.test(name)) {
|
|
475
|
-
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
476
|
-
if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(text)) return true;
|
|
477
|
-
continue;
|
|
478
|
-
}
|
|
479
|
-
if (name.length >= 2 && text.includes(name)) return true;
|
|
480
|
-
}
|
|
481
|
-
return false;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
export function buildFactHooks(text, options = {}) {
|
|
485
|
-
const parts = [];
|
|
486
|
-
const used = [];
|
|
487
|
-
try {
|
|
488
|
-
const known = ensureSoul().findEntities('', 50);
|
|
489
|
-
// The entity timeline already covers the subject of an entity question;
|
|
490
|
-
// injecting both produced two copies of the same facts in one prompt.
|
|
491
|
-
const skip = new Set(options.skip ?? []);
|
|
492
|
-
const mentioned = known.filter((entity) => mentions(text, entity) && !skip.has(entity.name));
|
|
493
|
-
for (const entity of mentioned.slice(0, 3)) {
|
|
494
|
-
const earliest = ensureSoul().factsAbout(entity.id, { order: 'asc', limit: 1 });
|
|
495
|
-
const recent = ensureSoul().factsAbout(entity.id, { limit: 4 });
|
|
496
|
-
const seen = new Set();
|
|
497
|
-
const lines = [];
|
|
498
|
-
for (const fact of [...earliest, ...recent]) {
|
|
499
|
-
if (seen.has(fact.id)) continue;
|
|
500
|
-
seen.add(fact.id);
|
|
501
|
-
const when = fact.atText ?? (fact.at ? new Date(fact.at).toISOString().slice(0, 10) : '');
|
|
502
|
-
lines.push(`- [${fact.id}] ${escapeBlockText(fact.statement)}${when ? `(${escapeBlockText(when)})` : ''}`);
|
|
503
|
-
used.push(fact.id);
|
|
504
|
-
}
|
|
505
|
-
if (lines.length > 0) {
|
|
506
|
-
parts.push(`<about name="${escapeAttr(entity.name)}">\n${lines.join('\n')}\n</about>`);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
} catch (error) {
|
|
510
|
-
log(`prestep: hooks failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
511
|
-
}
|
|
512
|
-
if (parts.length === 0) return '';
|
|
513
|
-
return { text: parts.join('\n\n'), used };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Which injection blocks a plan allows.
|
|
518
|
-
*
|
|
519
|
-
* When the question names a subject, the entity timeline is the answer and the
|
|
520
|
-
* related-pairs search is the contamination: it is how "上次和 a 吃饭" ends up
|
|
521
|
-
* answered with something about b. So the plan chooses one or the other.
|
|
522
|
-
*/
|
|
523
|
-
export function planBlocks(plan) {
|
|
524
|
-
if (!plan) return { related: true, timeline: false };
|
|
525
|
-
if (plan.primitive === 'entity') return { related: false, timeline: true };
|
|
526
|
-
return { related: true, timeline: false };
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const PROFILE_CORE_DIMENSION = '价值观';
|
|
530
|
-
/** A facet is injected only when the message is about that dimension. */
|
|
531
|
-
const FACET_TRIGGERS = {
|
|
532
|
-
工作方式: /代码|项目|实现|方案|架构|接口|测试|bug|重构/i,
|
|
533
|
-
沟通偏好: /聊天|回答|回复|表达|说话|解释/,
|
|
534
|
-
情绪: /累|烦|难受|焦虑|压力|开心|情绪|睡|难受/,
|
|
535
|
-
人际关系: /她|他|朋友|家里|关系|联系|见面|同事/,
|
|
536
|
-
生活兴趣: /吃|玩|旅行|电影|书|音乐|运动|兴趣|周末/,
|
|
537
|
-
边界: /不要|别|讨厌|雷区|底线|受不了/,
|
|
538
|
-
};
|
|
539
|
-
|
|
540
|
-
function profileDimension(fact) {
|
|
541
|
-
const slot = fact.slots?.find((item) => item.slot === '维度' && item.value?.kind === 'text');
|
|
542
|
-
return slot?.value?.value ?? '';
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Core lines are always on; facets appear only when the message is about them.
|
|
547
|
-
*
|
|
548
|
-
* A whole person does not fit in a prompt, and a longer list of always-on lines is
|
|
549
|
-
* how a profile becomes wallpaper. Unlabelled lines (written before dimensions) stay
|
|
550
|
-
* in the core so nothing silently disappears.
|
|
551
|
-
*/
|
|
552
|
-
export function selectProfileFacts(facts, text, limit = 8) {
|
|
553
|
-
const picked = [];
|
|
554
|
-
for (const fact of facts) {
|
|
555
|
-
const dimension = profileDimension(fact);
|
|
556
|
-
if (!dimension || dimension === PROFILE_CORE_DIMENSION) {
|
|
557
|
-
picked.push(fact);
|
|
558
|
-
continue;
|
|
559
|
-
}
|
|
560
|
-
const trigger = FACET_TRIGGERS[dimension];
|
|
561
|
-
if (trigger && trigger.test(String(text ?? ''))) picked.push(fact);
|
|
562
|
-
}
|
|
563
|
-
return picked.slice(0, limit);
|
|
564
|
-
}
|
|
565
|
-
const RULE_LIMIT = 5;
|
|
566
|
-
|
|
567
|
-
/** Session counters a rule can key on; unknown flags are simply false. */
|
|
568
|
-
export function buildRuleState(state = {}) {
|
|
569
|
-
const turn = Number(state.turn ?? 0);
|
|
570
|
-
return { 'long-session': Number.isFinite(turn) && turn > 100 };
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** Render the fired rules as a requirement block, closest to the reply. */
|
|
574
|
-
export function renderRulesBlock(rules) {
|
|
575
|
-
if (!Array.isArray(rules) || rules.length === 0) return '';
|
|
576
|
-
const ids = rules.map((rule) => rule.id).join(',');
|
|
577
|
-
// Rule statements derive from lessons, which come from user text: escape them like
|
|
578
|
-
// any other content, or a lesson can close the block and forge a new requirement.
|
|
579
|
-
const lines = rules.map((rule) => `- ${escapeBlockText(rule.statement)}`).join('\n');
|
|
580
|
-
return `<rules fired="${ids}">\n${lines}\n</rules>`;
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function buildRelatedContext(text, state = {}) {
|
|
584
|
-
const parts = [];
|
|
585
|
-
const used = [];
|
|
586
|
-
// The profile is the merged description of the person; it is the only block that
|
|
587
|
-
// is always on, so it goes first and stays short (ADR 0033).
|
|
588
|
-
try {
|
|
589
|
-
const profile = selectProfileFacts(ensureSoul().profile(30), text, 8);
|
|
590
|
-
if (profile.length > 0) {
|
|
591
|
-
const lines = profile.map((fact) => `- ${escapeBlockText(fact.statement)}`).join('\n');
|
|
592
|
-
parts.push(`<profile>\n${lines}\n</profile>`);
|
|
593
|
-
used.push(...profile.map((fact) => fact.id));
|
|
594
|
-
}
|
|
595
|
-
} catch (error) {
|
|
596
|
-
log(`profile block failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
597
|
-
}
|
|
598
|
-
// The plan is computed first because it decides which blocks are allowed.
|
|
599
|
-
let plan = null;
|
|
600
|
-
try {
|
|
601
|
-
plan = ensureSoul().planRetrieval(text);
|
|
602
|
-
log(`router: ${plan.primitive}${plan.subject ? `(${plan.subject})` : ''} — ${plan.reason}`);
|
|
603
|
-
trace({ event: 'router', primitive: plan.primitive, subject: plan.subject ?? null, reason: plan.reason });
|
|
604
|
-
} catch (error) {
|
|
605
|
-
log(`router failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
606
|
-
}
|
|
607
|
-
const policy = planBlocks(plan);
|
|
608
|
-
try {
|
|
609
|
-
// Anchors moved here from the system prompt: a message is counted once and
|
|
610
|
-
// compacted normally, while a changed prompt becomes a new prompt version.
|
|
611
|
-
const anchors = ensureSoul().context({
|
|
612
|
-
track: false,
|
|
613
|
-
includeSoul: false,
|
|
614
|
-
includeStyle: false,
|
|
615
|
-
...ANCHOR,
|
|
616
|
-
});
|
|
617
|
-
if (anchors.text.trim()) {
|
|
618
|
-
parts.push(anchors.text.trim());
|
|
619
|
-
used.push(...anchors.used);
|
|
620
|
-
}
|
|
621
|
-
} catch (error) {
|
|
622
|
-
log(`prestep: anchors failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
623
|
-
}
|
|
624
|
-
const hooks = buildFactHooks(text, { skip: policy.timeline && plan?.subject ? [plan.subject] : [] });
|
|
625
|
-
if (hooks && hooks.text) {
|
|
626
|
-
parts.push(hooks.text);
|
|
627
|
-
used.push(...hooks.used);
|
|
628
|
-
}
|
|
629
|
-
if (policy.timeline && plan?.subject) {
|
|
630
|
-
try {
|
|
631
|
-
// The question, not just the subject: an entity with many facts must return
|
|
632
|
-
// the ones this question is about, not its three most recent.
|
|
633
|
-
const timeline = ensureSoul().latestAbout(plan.subject, { query: text, limit: 3 });
|
|
634
|
-
if (timeline.length > 0) {
|
|
635
|
-
const body = timeline
|
|
636
|
-
.map(
|
|
637
|
-
(fact) =>
|
|
638
|
-
`- [${fact.id}] ${fact.atText ? `${escapeBlockText(fact.atText)} ` : ''}${escapeBlockText(fact.statement)}`,
|
|
639
|
-
)
|
|
640
|
-
.join('\n');
|
|
641
|
-
parts.push(`<about-now name="${escapeAttr(plan.subject)}">\n${body}\n</about-now>`);
|
|
642
|
-
used.push(...timeline.map((fact) => fact.id));
|
|
643
|
-
} else {
|
|
644
|
-
// Saying "nothing" explicitly is better than letting a relevance search
|
|
645
|
-
// fill the gap with somebody else's memories.
|
|
646
|
-
parts.push(`<about-now name="${escapeAttr(plan.subject)}">(没有任何关于它的记忆)</about-now>`);
|
|
647
|
-
}
|
|
648
|
-
} catch (error) {
|
|
649
|
-
log(`timeline failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
if (policy.related) {
|
|
653
|
-
const pairs = ensureSoul().recallTurnPairs(text);
|
|
654
|
-
if (pairs.length > 0) {
|
|
655
|
-
const body = pairs
|
|
656
|
-
.map((pair) => `- [Q] ${escapeBlockText(pair.userText)}\n [A] ${escapeBlockText(pair.assistantText)}`)
|
|
657
|
-
.join('\n');
|
|
658
|
-
parts.push(`<related>\n${body}\n</related>`);
|
|
659
|
-
used.push(...pairs.map((pair) => pair.id));
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
// Rules last, and only the ones whose situation applies: the block sits closest
|
|
663
|
-
// to the reply and reads as a requirement, not as background material.
|
|
664
|
-
try {
|
|
665
|
-
const fired = selectRules(ensureSoul().rules(), text, buildRuleState(state), RULE_LIMIT);
|
|
666
|
-
if (fired.length > 0) {
|
|
667
|
-
parts.push(renderRulesBlock(fired));
|
|
668
|
-
log(`rules fired: ${fired.map((rule) => rule.id).join(',')}`);
|
|
669
|
-
trace({ event: 'rule_fired', rules: fired.map((rule) => rule.id), turn: state.turn ?? null });
|
|
670
|
-
}
|
|
671
|
-
} catch (error) {
|
|
672
|
-
log(`rules failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
673
|
-
}
|
|
674
|
-
if (parts.length === 0) return '';
|
|
675
|
-
// Retrieval practice: a fact that keeps being surfaced decays more slowly.
|
|
676
|
-
try {
|
|
677
|
-
ensureSoul().recordRecalls([...new Set(used)].filter((id) => String(id).startsWith('fc_')));
|
|
678
|
-
} catch (error) {
|
|
679
|
-
log(`recordRecalls failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
-
}
|
|
681
|
-
return { text: parts.join('\n\n'), used };
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Build the agent/pre-step middleware (RFC 0006).
|
|
686
|
-
*
|
|
687
|
-
* Idempotence is by construction, not cleanup: one injection per (session,
|
|
688
|
-
* turn), and the injected message carries a source kind the observer ignores —
|
|
689
|
-
* the probe showed that using `user` there fed injected text back into memory.
|
|
690
|
-
*
|
|
691
|
-
* @param deps.getContext - returns the memory block for one message, or ''.
|
|
692
|
-
*/
|
|
693
|
-
/**
|
|
694
|
-
* The "where were we" block for a new session, or '' when there is nothing to
|
|
695
|
-
* hand over. Pure: the caller decides when, this decides what.
|
|
696
|
-
*/
|
|
697
|
-
export function renderHandoff(last, sessionId) {
|
|
698
|
-
if (!last || last.thread.sessionId === sessionId) return '';
|
|
699
|
-
const when = new Date(last.thread.closedAt ?? Date.now()).toISOString().slice(0, 10);
|
|
700
|
-
// Topic and turn text are user words: escape them, or a pasted `</handoff>` ends
|
|
701
|
-
// the block and everything after it reads as ordinary conversation.
|
|
702
|
-
// Whole lines, never prefixes: the handoff exists so the model knows what was said,
|
|
703
|
-
// and a cut line can drop the clause that made it matter. Size is bounded by how
|
|
704
|
-
// many turns the caller carries, not by cutting each one.
|
|
705
|
-
const lines = [`<handoff topic="${escapeAttr(last.thread.topic)}" closed="${when}">`];
|
|
706
|
-
if (last.conclusion) lines.push(` 上次的结论:${escapeBlockText(last.conclusion.statement)}`);
|
|
707
|
-
// Both sides (ADR 0035): "where were we" includes where I was in the conversation,
|
|
708
|
-
// not only what the user asked. The reply is usually where the decision landed.
|
|
709
|
-
for (const turn of last.turns) {
|
|
710
|
-
lines.push(` ${turn.turnNo}. 用户:${escapeBlockText(turn.userText)}`);
|
|
711
|
-
if (turn.assistantText) lines.push(` ${turn.turnNo}. 我:${escapeBlockText(turn.assistantText)}`);
|
|
712
|
-
}
|
|
713
|
-
lines.push('</handoff>');
|
|
714
|
-
return lines.join('\n');
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* The sessions the user has archived in the workspace.
|
|
719
|
-
*
|
|
720
|
-
* `workspaceRegistry` is a Cordis service on the host context; archive is its
|
|
721
|
-
* durable set, not an event, so it is read at each turn. Absent service means an
|
|
722
|
-
* older or different host — the caller then falls back to the inactivity rule.
|
|
723
|
-
*/
|
|
724
|
-
function readArchivedSessions(ctx) {
|
|
725
|
-
try {
|
|
726
|
-
const registry = ctx.get?.('workspaceRegistry') ?? ctx.workspaceRegistry;
|
|
727
|
-
const ids = registry?.archivedSessionIds;
|
|
728
|
-
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
|
|
729
|
-
} catch (error) {
|
|
730
|
-
log(`archive list unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
731
|
-
return [];
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
/**
|
|
735
|
-
* Run the per-step stages in priority order, stopping at the first that did work.
|
|
736
|
-
*
|
|
737
|
-
* A stage's boolean means "a model call was made", not "I was invoked": a stage
|
|
738
|
-
* that skips its work must return false so the next stage gets the one call this
|
|
739
|
-
* step is allowed. This function exists because that contract was implicit in an
|
|
740
|
-
* `if`, and a skip returning true starved the classifier for an hour.
|
|
741
|
-
*/
|
|
742
|
-
export async function runStageChain(stages) {
|
|
743
|
-
for (const stage of stages) {
|
|
744
|
-
if (await stage()) return true;
|
|
745
|
-
}
|
|
746
|
-
return false;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
export function createPreStepHandler(deps) {
|
|
750
|
-
/** sessionId -> last turn that received an injection */
|
|
751
|
-
const injectedTurn = new Map();
|
|
752
|
-
/** Sessions that already got the handoff block once. */
|
|
753
|
-
const handedOff = new Set();
|
|
754
|
-
|
|
755
|
-
/**
|
|
756
|
-
* The "where were we" block: the last topic that finished.
|
|
757
|
-
*
|
|
758
|
-
* Deliberately once per session and only when that topic belongs to a
|
|
759
|
-
* different session — repeating it every turn would make it wallpaper, and
|
|
760
|
-
* re-reading the previous session's own topic is not a handoff.
|
|
761
|
-
*/
|
|
762
|
-
function takeHandoff(sessionId) {
|
|
763
|
-
if (handedOff.has(sessionId)) return '';
|
|
764
|
-
try {
|
|
765
|
-
const last = ensureSoul().lastClosedThread();
|
|
766
|
-
const block = renderHandoff(last, sessionId);
|
|
767
|
-
// Only mark it once the read succeeded: a transient failure on the first turn
|
|
768
|
-
// used to exclude the session from handoff forever.
|
|
769
|
-
handedOff.add(sessionId);
|
|
770
|
-
if (block) {
|
|
771
|
-
log(`handoff: "${last.thread.topic}" summary=${last.conclusion ? 'yes' : 'no'}`);
|
|
772
|
-
trace({
|
|
773
|
-
event: 'handoff',
|
|
774
|
-
thread: last.thread.id,
|
|
775
|
-
theme: last.thread.topic,
|
|
776
|
-
hasSummary: Boolean(last.conclusion),
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
return block;
|
|
780
|
-
} catch (error) {
|
|
781
|
-
log(`handoff failed (will retry next turn): ${error instanceof Error ? error.message : String(error)}`);
|
|
782
|
-
return '';
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
return async (payload, next) => {
|
|
787
|
-
const decision = await next();
|
|
788
|
-
try {
|
|
789
|
-
if (decision?.kind !== 'enter') return decision;
|
|
790
|
-
// We are inside a live turn now, which is the only place the host model is
|
|
791
|
-
// callable. Stages run in priority order and a stage that skipped its work
|
|
792
|
-
// must not claim the call — that is how the classifier starved for an hour.
|
|
793
|
-
//
|
|
794
|
-
// Single flight: the handler returns before the chain finishes, so without
|
|
795
|
-
// this guard a slow stage lets the next pre-step start a second chain and two
|
|
796
|
-
// model calls overlap in one step (live log showed two verify calls in flight).
|
|
797
|
-
if (stageChainBusy) {
|
|
798
|
-
log('background: chain still running, skipping this step');
|
|
799
|
-
} else {
|
|
800
|
-
stageChainBusy = true;
|
|
801
|
-
void (async () => {
|
|
802
|
-
try {
|
|
803
|
-
// Fairness: a long consolidation backlog used to consume every step, so
|
|
804
|
-
// the profile merge never got a turn. The profile alternates with the
|
|
805
|
-
// rest of the chain instead of always queueing behind it.
|
|
806
|
-
// "Pending" means a dimension group has at least two judgments in it: a
|
|
807
|
-
// queue of singletons has nothing to collapse and must not hold a slot.
|
|
808
|
-
const profilePending = hasMergeableGroup(peekProfileQueue());
|
|
809
|
-
const stages = [];
|
|
810
|
-
const extractQueue = readExtractQueue();
|
|
811
|
-
const consolidationPending = readConsolidationQueue().length > 0;
|
|
812
|
-
// Live conversation before everything; understanding before the backfill.
|
|
813
|
-
// Fixed priority (extract always first) plus a long backfill is a starvation
|
|
814
|
-
// bug, not a preference: 29 closed threads waited while the backfill drained.
|
|
815
|
-
if (extractRunsFirst(extractQueue, consolidationPending)) stages.push(drainExtractionQueue);
|
|
816
|
-
stages.push(runConsolidationStep);
|
|
817
|
-
stages.push(drainExtractionQueue);
|
|
818
|
-
if (profilePending && stageTick % 2 === 0) stages.push(runProfileStep);
|
|
819
|
-
stages.push(runClassifyStep);
|
|
820
|
-
if (profilePending && stageTick % 2 === 1) stages.push(runProfileStep);
|
|
821
|
-
stageTick += 1;
|
|
822
|
-
// The timeout has to be cancelled when the chain wins the race. Left
|
|
823
|
-
// alone it stays a live timer for the full 285s — a leak per step, and
|
|
824
|
-
// the reason `node --test` printed every result and then never exited.
|
|
825
|
-
let chainTimer;
|
|
826
|
-
const chainDeadline = new Promise((_, reject) => {
|
|
827
|
-
chainTimer = setTimeout(() => reject(new Error('stage chain timed out')), CHAIN_TIMEOUT_MS);
|
|
828
|
-
});
|
|
829
|
-
try {
|
|
830
|
-
await Promise.race([runStageChain(stages), chainDeadline]);
|
|
831
|
-
} finally {
|
|
832
|
-
clearTimeout(chainTimer);
|
|
833
|
-
}
|
|
834
|
-
} catch (error) {
|
|
835
|
-
log(`background: ${error instanceof Error ? error.message : String(error)}`);
|
|
836
|
-
} finally {
|
|
837
|
-
stageChainBusy = false;
|
|
838
|
-
}
|
|
839
|
-
})();
|
|
840
|
-
}
|
|
841
|
-
// Control switch for the A/B test: plugin-config.json { "injectEnabled": false }
|
|
842
|
-
if (readConfig().injectEnabled === false) {
|
|
843
|
-
log('prestep: inject disabled by config');
|
|
844
|
-
return decision;
|
|
845
|
-
}
|
|
846
|
-
// Topics the conversation has moved past, closed once per turn. Counting
|
|
847
|
-
// actual turns — the host's turn numbers jump by 10–30 per exchange, so a
|
|
848
|
-
// numeric difference closed a topic one message after it opened.
|
|
849
|
-
const sessionId = payload?.agent?.id ?? payload?.agent?.session?.id ?? 'default';
|
|
850
|
-
try {
|
|
851
|
-
const closeConfig = readConfig();
|
|
852
|
-
const closed = ensureSoul().closeIdleThreads(sessionId, {
|
|
853
|
-
idleTurns: closeConfig.threadIdleTurns ?? 6,
|
|
854
|
-
idleMs: (closeConfig.threadIdleMinutes ?? 30) * 60_000,
|
|
855
|
-
// The host's own archive list: a session the user put away is finished
|
|
856
|
-
// by declaration, so its topics close immediately.
|
|
857
|
-
archivedSessionIds: deps.archivedSessions?.() ?? [],
|
|
858
|
-
at: Date.now(),
|
|
859
|
-
});
|
|
860
|
-
if (closed.length > 0) {
|
|
861
|
-
// The reason is logged because a threshold that fires too early must be
|
|
862
|
-
// visible: a topic closed by a guess is a partial conclusion written to
|
|
863
|
-
// durable memory.
|
|
864
|
-
log(`threads closed: ${closed.map((thread) => `${thread.id}(${thread.reason})`).join(',')}`);
|
|
865
|
-
trace({ event: 'threads-closed', closed });
|
|
866
|
-
// A closed thread is the unit consolidation runs on — never a batch of
|
|
867
|
-
// facts, which can mix two topics into one conclusion.
|
|
868
|
-
// Without a model there is nothing to consolidate, and enqueuing anyway
|
|
869
|
-
// only fills the queue until the cap silently drops the newest threads.
|
|
870
|
-
if (usesModel(closeConfig)) {
|
|
871
|
-
enqueueConsolidation(closed.map((thread) => thread.id));
|
|
872
|
-
} else if (closed.length > 0) {
|
|
873
|
-
log(`consolidate: ${closed.length} closed thread(s) not queued (extract=${closeConfig.extract})`);
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
} catch (error) {
|
|
877
|
-
log(`threads close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
878
|
-
}
|
|
879
|
-
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
880
|
-
const text = textOf(messages[messages.length - 1]?.content);
|
|
881
|
-
if (text.length === 0) return decision;
|
|
882
|
-
|
|
883
|
-
const turn = payload?.turn;
|
|
884
|
-
// A non-finite turn cannot identify a step; injecting once per undefined turn
|
|
885
|
-
// would compare undefined === undefined and skip every pre-step silently.
|
|
886
|
-
if (turn !== undefined && injectedTurn.get(sessionId) === turn) return decision;
|
|
887
|
-
|
|
888
|
-
const ctx = deps.getContext(text, { turn });
|
|
889
|
-
const related = typeof ctx === 'string' ? ctx : (ctx?.text ?? '');
|
|
890
|
-
const used = typeof ctx === 'string' ? [] : (ctx?.used ?? []);
|
|
891
|
-
// "Where were we" is answered by the last topic that finished, not by the
|
|
892
|
-
// previous session: one session can hold several topics, and a topic can
|
|
893
|
-
// span sessions. Injected once per session, when it is a different one.
|
|
894
|
-
const handoff = takeHandoff(sessionId);
|
|
895
|
-
const block = [handoff, related].filter(Boolean).join('\n\n');
|
|
896
|
-
injectedTurn.set(sessionId, turn);
|
|
897
|
-
if (injectedTurn.size > 16) {
|
|
898
|
-
// Keep the map bounded: drop the oldest entry, not the newest.
|
|
899
|
-
const oldest = injectedTurn.keys().next().value;
|
|
900
|
-
if (oldest !== undefined) injectedTurn.delete(oldest);
|
|
901
|
-
}
|
|
902
|
-
if (!block) {
|
|
903
|
-
log(`prestep: no related memory (turn=${turn})`);
|
|
904
|
-
return decision;
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
const note = {
|
|
908
|
-
id: `memctx_${turn}`,
|
|
909
|
-
role: 'user',
|
|
910
|
-
content: [{ type: 'text', text: block }],
|
|
911
|
-
source: { kind: 'memory-context' },
|
|
912
|
-
};
|
|
913
|
-
const idPart = used.length > 0 ? ` ids=[${used.join(',')}]` : '';
|
|
914
|
-
log(`prestep: inject turn=${turn} chars=${block.length}${idPart} handoff=${handoff ? 'yes' : 'no'}`);
|
|
915
|
-
trace({ event: 'inject', session: sessionId, turn, chars: block.length, ids: used, handoff: Boolean(handoff) });
|
|
916
|
-
return { ...decision, messages: [...decision.messages, note] };
|
|
917
|
-
} catch (error) {
|
|
918
|
-
log(`prestep: skipped after error: ${error instanceof Error ? error.message : String(error)}`);
|
|
919
|
-
return decision;
|
|
920
|
-
}
|
|
921
|
-
};
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
/* ------------------------------------------------------------------ *
|
|
925
|
-
* Turn pairs (RFC 0007): one exchange is the unit of memory.
|
|
926
|
-
* ------------------------------------------------------------------ */
|
|
927
|
-
|
|
928
|
-
/** Exchanges awaiting their reply to finish: sessionId -> { turn, userText, at, assistantParts }. */
|
|
929
|
-
const pendingTurns = new Map();
|
|
930
|
-
|
|
931
|
-
/**
|
|
932
|
-
* The turn boundary is an event, not a silence.
|
|
933
|
-
*
|
|
934
|
-
* This used to write a turn after two seconds without a new assistant message.
|
|
935
|
-
* That looked harmless until the data said otherwise: a tool call often runs
|
|
936
|
-
* longer than two seconds, so the timer fired mid-turn, wrote a truncated pair
|
|
937
|
-
* and dropped everything after it. One recorded write landed 1m47s before the
|
|
938
|
-
* same turn's `turn/end`.
|
|
939
|
-
*
|
|
940
|
-
* Now the only triggers are `turn/end` and the next user message. If the app is
|
|
941
|
-
* killed mid-turn, that turn is lost — a missing turn is better than a turn cut
|
|
942
|
-
* in half, and the loss is visible (the next turn's text starts mid-story).
|
|
943
|
-
*/
|
|
944
|
-
function noteUserTurn(sessionId, event, text) {
|
|
945
|
-
pendingTurns.set(sessionId, {
|
|
946
|
-
turn: Number(event?.data?.turn ?? event?.seq ?? 0),
|
|
947
|
-
userText: text,
|
|
948
|
-
at: Number(event?.time ?? Date.now()),
|
|
949
|
-
assistantParts: [],
|
|
950
|
-
});
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function noteAssistantPart(sessionId, text) {
|
|
954
|
-
const pending = pendingTurns.get(sessionId);
|
|
955
|
-
if (!pending) return;
|
|
956
|
-
pending.assistantParts.push(text);
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
/** One step of the reply, kept in order: reasoning, text, tool. */
|
|
960
|
-
function noteAssistantStep(sessionId, kind, text) {
|
|
961
|
-
const pending = pendingTurns.get(sessionId);
|
|
962
|
-
if (!pending) return;
|
|
963
|
-
if (!pending.steps) pending.steps = [];
|
|
964
|
-
pending.steps.push({ idx: pending.steps.length, kind, text, at: Date.now() });
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
/**
|
|
968
|
-
* Write the pending exchange. Called on `turn/end` and, as a fallback, when the
|
|
969
|
-
* next user message arrives. No assistant text means the turn never completed —
|
|
970
|
-
* drop it rather than write a pair with an empty side (RFC 0007 §2.2).
|
|
971
|
-
*/
|
|
972
|
-
function flushTurnPair(sessionId) {
|
|
973
|
-
const pending = pendingTurns.get(sessionId);
|
|
974
|
-
if (!pending) return;
|
|
975
|
-
pendingTurns.delete(sessionId);
|
|
976
|
-
if (pending.assistantParts.length === 0) {
|
|
977
|
-
log(`turn pair dropped (no reply) session=${sessionId} turn=${pending.turn}`);
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
try {
|
|
981
|
-
const turnId = ensureSoul().recordTurnPair({
|
|
982
|
-
sessionId,
|
|
983
|
-
turn: pending.turn,
|
|
984
|
-
userText: pending.userText,
|
|
985
|
-
assistantText: pending.assistantParts.join('\n'),
|
|
986
|
-
at: pending.at,
|
|
987
|
-
steps: pending.steps ?? [],
|
|
988
|
-
});
|
|
989
|
-
const chars = pending.userText.length + pending.assistantParts.join('').length;
|
|
990
|
-
log(`turn pair recorded session=${sessionId} turn=${pending.turn} chars=${chars}`);
|
|
991
|
-
// Extraction is queued, not run here: outside a live turn the host model is
|
|
992
|
-
// not callable (every attempt returned an empty stream today). The queue is
|
|
993
|
-
// drained at the start of the next turn, which is inside one.
|
|
994
|
-
enqueueExtraction(turnId, pending.turn);
|
|
995
|
-
} catch (error) {
|
|
996
|
-
log(`turn pair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/* ------------------------------------------------------------------ *
|
|
1001
|
-
* Extraction queue: written at turn end, drained inside the next turn
|
|
1002
|
-
* ------------------------------------------------------------------ */
|
|
1003
|
-
|
|
1004
|
-
const EXTRACT_QUEUE_FILE = join(HOME, 'pending-extract.json');
|
|
1005
|
-
const EXTRACT_MAX_ATTEMPTS = 3;
|
|
1006
|
-
|
|
1007
|
-
/**
|
|
1008
|
-
* Read a queue file.
|
|
1009
|
-
*
|
|
1010
|
-
* The four stages keep different items and different caps, but the file handling is
|
|
1011
|
-
* the same shape, and it used to be written out four times. An unreadable file is
|
|
1012
|
-
* not an empty one: treating it as empty silently erases the backlog on the next
|
|
1013
|
-
* write, so it is moved aside and logged.
|
|
1014
|
-
*/
|
|
1015
|
-
function readQueueFile(file) {
|
|
1016
|
-
try {
|
|
1017
|
-
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
1018
|
-
return Array.isArray(raw) ? raw : [];
|
|
1019
|
-
} catch (error) {
|
|
1020
|
-
if (existsSync(file)) {
|
|
1021
|
-
log(`queue unreadable (${file}), setting it aside: ${error instanceof Error ? error.message : String(error)}`);
|
|
1022
|
-
try {
|
|
1023
|
-
renameSync(file, `${file}.corrupt-${Date.now()}`);
|
|
1024
|
-
} catch {
|
|
1025
|
-
/* the log above is the record */
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
return [];
|
|
1029
|
-
}
|
|
1030
|
-
}
|
|
1031
|
-
|
|
1032
|
-
/**
|
|
1033
|
-
* Write a queue file, keeping either the newest or the oldest items over the cap.
|
|
1034
|
-
*
|
|
1035
|
-
* Extraction keeps the newest (the old code kept the oldest and dropped the turns
|
|
1036
|
-
* that had just been added); the rest keep the oldest, because they are FIFO work
|
|
1037
|
-
* lists where the front is the most urgent.
|
|
1038
|
-
*/
|
|
1039
|
-
function writeQueueFile(file, queue, { cap, keep = 'oldest' } = {}) {
|
|
1040
|
-
try {
|
|
1041
|
-
const kept = !cap || queue.length <= cap ? queue : keep === 'newest' ? queue.slice(queue.length - cap) : queue.slice(0, cap);
|
|
1042
|
-
const dropped = queue.length - kept.length;
|
|
1043
|
-
// A cap drop is data loss. It used to be logged only when it happened, which is
|
|
1044
|
-
// exactly when nobody was reading; a backlog also logs its size so a batch that
|
|
1045
|
-
// never drains is visible instead of being inferred from an empty file later.
|
|
1046
|
-
if (dropped > 0) log(`queue over cap (${basename(file)}): wrote ${kept.length}, dropped ${dropped}`);
|
|
1047
|
-
else if (queue.length >= 10) log(`queue backlog (${basename(file)}): ${queue.length}`);
|
|
1048
|
-
writeFileSync(file, JSON.stringify(kept, null, 2), 'utf8');
|
|
1049
|
-
} catch (error) {
|
|
1050
|
-
log(`queue write failed (${file}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
const readExtractQueue = () => readQueueFile(EXTRACT_QUEUE_FILE);
|
|
1055
|
-
const writeExtractQueue = (queue) => writeQueueFile(EXTRACT_QUEUE_FILE, queue, { cap: 50, keep: 'newest' });
|
|
1056
|
-
|
|
1057
|
-
/**
|
|
1058
|
-
* Remove one item by identity, computed from a fresh read.
|
|
1059
|
-
*
|
|
1060
|
-
* The drain used to write a `rest` array captured before its model call, so a turn
|
|
1061
|
-
* enqueued during that call (turn/end arrives while extraction is still running) was
|
|
1062
|
-
* overwritten and lost.
|
|
1063
|
-
*/
|
|
1064
|
-
function removeExtractItem(turnId) {
|
|
1065
|
-
writeExtractQueue(readExtractQueue().filter((item) => item.turnId !== turnId));
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
function bumpExtractAttempt(turnId) {
|
|
1069
|
-
writeExtractQueue(
|
|
1070
|
-
readExtractQueue().map((item) =>
|
|
1071
|
-
item.turnId === turnId ? { ...item, attempts: (item.attempts ?? 0) + 1 } : item,
|
|
1072
|
-
),
|
|
1073
|
-
);
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
/**
|
|
1077
|
-
* Add one turn to the extraction queue.
|
|
1078
|
-
*
|
|
1079
|
-
* Exported so a script can batch-requeue history through the same writer: the last
|
|
1080
|
-
* attempt wrote the file from a separate script and the batch vanished unobserved
|
|
1081
|
-
* (capped at 50 by the writer it bypassed, with no line saying so). One writer, one
|
|
1082
|
-
* cap, one log line.
|
|
1083
|
-
*/
|
|
1084
|
-
export function enqueueExtraction(turnId, turnNo, origin = 'live') {
|
|
1085
|
-
const queue = readExtractQueue();
|
|
1086
|
-
if (queue.some((item) => item.turnId === turnId)) return false;
|
|
1087
|
-
// `origin` separates live turns from a historical backfill. Without it the attribution
|
|
1088
|
-
// experiment counts re-extracted old turns as fresh evidence, and a verdict about the
|
|
1089
|
-
// new default-continue rule would be read off history that never saw that rule.
|
|
1090
|
-
queue.push({ turnId, turnNo, attempts: 0, origin });
|
|
1091
|
-
writeExtractQueue(queue);
|
|
1092
|
-
log(`extract queued turn=${turnNo} origin=${origin} pending=${queue.length} file=${EXTRACT_QUEUE_FILE}`);
|
|
1093
|
-
return true;
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
/**
|
|
1097
|
-
* Does this message carry anything worth extracting?
|
|
1098
|
-
*
|
|
1099
|
-
* Length alone is the wrong test: "做" and "重来" are one and two characters and
|
|
1100
|
-
* are real decisions. Only pure acknowledgements are skipped, and a skip is
|
|
1101
|
-
* always logged — a silent skip is how the pipeline looked dead for an hour.
|
|
1102
|
-
*/
|
|
1103
|
-
function hasSubstance(text) {
|
|
1104
|
-
const trimmed = text.trim();
|
|
1105
|
-
if (trimmed.length === 0) return false;
|
|
1106
|
-
return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
async function runExtraction(text, turnId, turnNo, origin = 'live') {
|
|
1110
|
-
const config = readConfig();
|
|
1111
|
-
if (config.extract === 'off') return 'skipped-off';
|
|
1112
|
-
if (typeof text !== 'string' || !hasSubstance(text)) {
|
|
1113
|
-
log(`extract skipped: no substance (chars=${typeof text === 'string' ? text.length : -1}) turn=${turnNo}`);
|
|
1114
|
-
return 'skipped-empty';
|
|
1115
|
-
}
|
|
1116
|
-
const started = Date.now();
|
|
1117
|
-
try {
|
|
1118
|
-
const useLlm = config.extract === 'llm' && Boolean(injectedLlm);
|
|
1119
|
-
const soul = ensureSoul();
|
|
1120
|
-
const turnRow = soul.turn(turnId);
|
|
1121
|
-
// The context carries both halves of continuity (ADR 0034): the candidates' last
|
|
1122
|
-
// lines, and the previous user line — extraction is per turn, so without it the
|
|
1123
|
-
// model cannot tell "继续" from a new subject.
|
|
1124
|
-
const context = soul.extractionContext(20, { turnNo, sessionId: turnRow?.sessionId });
|
|
1125
|
-
// "The previous turn belonged to this thread" is the cheapest continuity hint
|
|
1126
|
-
// there is: in most cases the answer is simply "the same one again".
|
|
1127
|
-
if (turnRow) {
|
|
1128
|
-
const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
|
|
1129
|
-
if (previousThreadId) context.previousThreadId = previousThreadId;
|
|
1130
|
-
}
|
|
1131
|
-
const draft = await soul.extract(text, {
|
|
1132
|
-
llm: useLlm ? injectedLlm : undefined,
|
|
1133
|
-
mode: useLlm ? 'auto' : 'rules',
|
|
1134
|
-
// The extractor needs the open threads so it can say which one this turn
|
|
1135
|
-
// continues, and how long ago each was last seen.
|
|
1136
|
-
context,
|
|
1137
|
-
// The exchange, not the message (ADR 0035): my reply is where promises and
|
|
1138
|
-
// decisions live, and it is the only way "我的承诺" can ever be extracted.
|
|
1139
|
-
assistantText: turnRow?.assistantText ?? undefined,
|
|
1140
|
-
});
|
|
1141
|
-
const result = soul.commitDrafts(draft, { turnId });
|
|
1142
|
-
// Attribution is written even when nothing was extracted: a turn that says
|
|
1143
|
-
// "无" (nothing worth remembering) can still be part of a thread.
|
|
1144
|
-
const attribution = soul.applyThread(draft.thread, {
|
|
1145
|
-
sessionId: turnRow?.sessionId ?? 'unknown',
|
|
1146
|
-
turnNo,
|
|
1147
|
-
turnId,
|
|
1148
|
-
at: Date.now(),
|
|
1149
|
-
fallbackTopic: firstSentence(text),
|
|
1150
|
-
previousThreadId: context.previousThreadId,
|
|
1151
|
-
});
|
|
1152
|
-
const ms = Date.now() - started;
|
|
1153
|
-
// Provenance is observable (ADR 0035): the fact count alone cannot tell whether
|
|
1154
|
-
// anything came from my side of the exchange.
|
|
1155
|
-
const inferred = result.facts.filter((fact) => fact.source === 'inferred').length;
|
|
1156
|
-
log(
|
|
1157
|
-
`extract ok: by=${draft.by} reason=${draft.reason ?? '-'} entities=${result.entities.length} facts=${result.facts.length}` +
|
|
1158
|
-
` gaps=${result.gaps.length} skipped=${result.skipped.length}` +
|
|
1159
|
-
` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
|
|
1160
|
-
` inferred=${inferred}` +
|
|
1161
|
-
` ms=${ms} turn=${turnNo} origin=${origin}` +
|
|
1162
|
-
` thread=${attribution.threadId}${attribution.created ? '(new)' : ''}` +
|
|
1163
|
-
` join=${attribution.joinReason} threadLine=${draft.threadLine ?? 'rules'}` +
|
|
1164
|
-
`${attribution.topicUpdated ? ' topic=updated' : ''}`,
|
|
1165
|
-
);
|
|
1166
|
-
for (const skipped of result.skipped.slice(0, 3)) {
|
|
1167
|
-
log(` extract skipped: ${skipped.what} (${skipped.reason})`);
|
|
1168
|
-
}
|
|
1169
|
-
for (const slot of result.droppedSlots.slice(0, 3)) {
|
|
1170
|
-
log(` extract slot kept as text: ${slot.slot}=${slot.value} (undeclared)`);
|
|
1171
|
-
}
|
|
1172
|
-
trace({
|
|
1173
|
-
event: 'extract',
|
|
1174
|
-
turn: turnNo,
|
|
1175
|
-
by: draft.by,
|
|
1176
|
-
reason: draft.reason,
|
|
1177
|
-
entities: result.entities.length,
|
|
1178
|
-
facts: result.facts.length,
|
|
1179
|
-
gaps: result.gaps.length,
|
|
1180
|
-
skipped: result.skipped,
|
|
1181
|
-
droppedSlots: result.droppedSlots,
|
|
1182
|
-
holderFallbacks: result.holderFallbacks,
|
|
1183
|
-
thread: attribution.threadId,
|
|
1184
|
-
threadCreated: attribution.created,
|
|
1185
|
-
threadTopic: attribution.topic,
|
|
1186
|
-
threadLine: draft.threadLine ?? null,
|
|
1187
|
-
topicUpdated: attribution.topicUpdated,
|
|
1188
|
-
origin,
|
|
1189
|
-
joinReason: attribution.joinReason,
|
|
1190
|
-
ms,
|
|
1191
|
-
});
|
|
1192
|
-
// Rules mode is a deliberate configuration, not a failed model call.
|
|
1193
|
-
if (!useLlm) return 'rules-mode';
|
|
1194
|
-
return draft.by === 'llm' ? 'ok' : `rules:${draft.reason ?? 'unknown'}`;
|
|
1195
|
-
} catch (error) {
|
|
1196
|
-
log(`extract failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1197
|
-
return `failed:${error instanceof Error ? error.message : String(error)}`;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
let draining = false;
|
|
1202
|
-
/** True while a stage chain is running, so two chains never overlap. */
|
|
1203
|
-
let stageChainBusy = false;
|
|
1204
|
-
/** Alternates the profile merge with the rest of the chain so it cannot be starved. */
|
|
1205
|
-
let stageTick = 0;
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* Which queued turn to extract next.
|
|
1209
|
-
*
|
|
1210
|
-
* Live turns first, then history: FIFO alone would put 20 backfill items ahead of the
|
|
1211
|
-
* conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1212
|
-
*/
|
|
1213
|
-
export function pickExtractionItem(queue) {
|
|
1214
|
-
// Live turns first, then history. FIFO alone would put 20 backfill items ahead of the
|
|
1215
|
-
// conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1216
|
-
return queue.find((item) => (item.origin ?? 'live') !== 'backfill') ?? queue[0] ?? null;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
/**
|
|
1220
|
-
* Should extraction take this step before consolidation?
|
|
1221
|
-
*
|
|
1222
|
-
* A backfill backlog used to keep extraction busy every step, and because a stage that
|
|
1223
|
-
* did work ends the chain, consolidation never got a turn: measured 29 threads queued,
|
|
1224
|
-
* 0 consolidated while the backfill drained. Live conversation still goes first;
|
|
1225
|
-
* when only history is waiting, understanding goes first and the backfill waits.
|
|
1226
|
-
*/
|
|
1227
|
-
export function extractRunsFirst(extractQueue, consolidationPending) {
|
|
1228
|
-
const hasLive = extractQueue.some((item) => (item.origin ?? 'live') !== 'backfill');
|
|
1229
|
-
return hasLive || !consolidationPending;
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
/**
|
|
1233
|
-
* Drain ONE queued turn per step.
|
|
1234
|
-
*
|
|
1235
|
-
* The host model is only callable inside the current step: of three serial
|
|
1236
|
-
* calls in one drain, the middle one succeeded and the other two failed with the
|
|
1237
|
-
* stream gone. One item per step keeps every attempt inside that window; the
|
|
1238
|
-
* queue is a backlog, not a batch.
|
|
1239
|
-
*
|
|
1240
|
-
* A model failure keeps the item, because the rules pass is only a floor: if it
|
|
1241
|
-
* counted as done, that turn would never get a model extraction. Three failures
|
|
1242
|
-
* drop it with the reason.
|
|
1243
|
-
*/
|
|
1244
|
-
export async function drainExtractionQueue() {
|
|
1245
|
-
if (draining) return false;
|
|
1246
|
-
const config = readConfig();
|
|
1247
|
-
if (config.extract === 'off') return false;
|
|
1248
|
-
const queue = readExtractQueue();
|
|
1249
|
-
if (queue.length === 0) return false;
|
|
1250
|
-
draining = true;
|
|
1251
|
-
try {
|
|
1252
|
-
const item = pickExtractionItem(queue);
|
|
1253
|
-
let text = '';
|
|
1254
|
-
try {
|
|
1255
|
-
const turn = ensureSoul().turn(item.turnId);
|
|
1256
|
-
text = turn?.userText ?? '';
|
|
1257
|
-
} catch {
|
|
1258
|
-
text = '';
|
|
1259
|
-
}
|
|
1260
|
-
if (!text) {
|
|
1261
|
-
log(`extract queue: turn ${item.turnId} missing, dropped`);
|
|
1262
|
-
removeExtractItem(item.turnId);
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live');
|
|
1267
|
-
const ok =
|
|
1268
|
-
outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
|
|
1269
|
-
if (ok) {
|
|
1270
|
-
removeExtractItem(item.turnId);
|
|
1271
|
-
// Promotion is decided after the facts exist: a corrected turn's lessons enter
|
|
1272
|
-
// the classification queue here, which is the only live producer it has.
|
|
1273
|
-
feedClassifyFromTurn(item.turnId);
|
|
1274
|
-
trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome });
|
|
1275
|
-
return true;
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
// A rules result caused by a broken model call is not a completed extraction.
|
|
1279
|
-
const attempts = (item.attempts ?? 0) + 1;
|
|
1280
|
-
if (attempts >= EXTRACT_MAX_ATTEMPTS) {
|
|
1281
|
-
log(`extract queue: giving up on turn=${item.turnNo} after ${attempts}: ${outcome}`);
|
|
1282
|
-
removeExtractItem(item.turnId);
|
|
1283
|
-
return true;
|
|
1284
|
-
}
|
|
1285
|
-
log(`extract queue: retry turn=${item.turnNo} attempt=${attempts} (${outcome})`);
|
|
1286
|
-
// Bump in place, computed from a fresh read: an item enqueued during the model
|
|
1287
|
-
// call must not be overwritten by a stale queue snapshot.
|
|
1288
|
-
bumpExtractAttempt(item.turnId);
|
|
1289
|
-
trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome, retry: true });
|
|
1290
|
-
return true;
|
|
1291
|
-
} finally {
|
|
1292
|
-
draining = false;
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
/* ------------------------------------------------------------------ *
|
|
1297
|
-
* Consolidation: observations → one conclusion, checked before it lands
|
|
1298
|
-
* ------------------------------------------------------------------ */
|
|
1299
|
-
|
|
1300
|
-
const CONSOLIDATION_FILE = join(HOME, 'consolidation.json');
|
|
1301
|
-
/** Closed threads waiting to be consolidated, oldest first. */
|
|
1302
|
-
const CONSOLIDATION_QUEUE_FILE = join(HOME, 'pending-threads.json');
|
|
1303
|
-
const CONSOLIDATION_MAX_FACTS = 8;
|
|
1304
|
-
|
|
1305
|
-
function readConsolidationState() {
|
|
1306
|
-
try {
|
|
1307
|
-
const raw = JSON.parse(readFileSync(CONSOLIDATION_FILE, 'utf8'));
|
|
1308
|
-
return { pending: raw?.pending ?? null };
|
|
1309
|
-
} catch {
|
|
1310
|
-
return { pending: null };
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
const readConsolidationQueue = () => readQueueFile(CONSOLIDATION_QUEUE_FILE);
|
|
1315
|
-
const writeConsolidationQueue = (queue) => writeQueueFile(CONSOLIDATION_QUEUE_FILE, queue, { cap: 50 });
|
|
1316
|
-
|
|
1317
|
-
function enqueueConsolidation(threadIds) {
|
|
1318
|
-
if (!Array.isArray(threadIds) || threadIds.length === 0) return;
|
|
1319
|
-
const queue = readConsolidationQueue();
|
|
1320
|
-
for (const id of threadIds) if (!queue.includes(id)) queue.push(id);
|
|
1321
|
-
writeConsolidationQueue(queue);
|
|
1322
|
-
log(`consolidate queued: ${threadIds.join(',')} pending=${queue.length}`);
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
function shiftConsolidationQueue() {
|
|
1326
|
-
const queue = readConsolidationQueue();
|
|
1327
|
-
if (queue.length === 0) return null;
|
|
1328
|
-
const [first, ...rest] = queue;
|
|
1329
|
-
writeConsolidationQueue(rest);
|
|
1330
|
-
return first;
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
/**
|
|
1334
|
-
* How many times a thread has been put back without producing a result.
|
|
1335
|
-
*
|
|
1336
|
-
* A thread whose facts have not arrived yet must be retried, not discarded; the
|
|
1337
|
-
* counter is what stops a permanently unsuitable thread from cycling forever.
|
|
1338
|
-
*/
|
|
1339
|
-
const CONSOLIDATION_ATTEMPTS_FILE = join(HOME, 'consolidation-attempts.json');
|
|
1340
|
-
|
|
1341
|
-
function readConsolidationAttempts() {
|
|
1342
|
-
try {
|
|
1343
|
-
const raw = JSON.parse(readFileSync(CONSOLIDATION_ATTEMPTS_FILE, 'utf8'));
|
|
1344
|
-
return raw && typeof raw === 'object' ? raw : {};
|
|
1345
|
-
} catch {
|
|
1346
|
-
return {};
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
function bumpConsolidationAttempt(threadId) {
|
|
1351
|
-
const attempts = readConsolidationAttempts();
|
|
1352
|
-
const next = Number(attempts[threadId] ?? 0) + 1;
|
|
1353
|
-
attempts[threadId] = next;
|
|
1354
|
-
try {
|
|
1355
|
-
writeFileSync(CONSOLIDATION_ATTEMPTS_FILE, JSON.stringify(attempts, null, 2), 'utf8');
|
|
1356
|
-
} catch (error) {
|
|
1357
|
-
log(`consolidate attempts write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1358
|
-
}
|
|
1359
|
-
return next;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
function clearConsolidationAttempt(threadId) {
|
|
1363
|
-
const attempts = readConsolidationAttempts();
|
|
1364
|
-
if (!(threadId in attempts)) return;
|
|
1365
|
-
delete attempts[threadId];
|
|
1366
|
-
try {
|
|
1367
|
-
writeFileSync(CONSOLIDATION_ATTEMPTS_FILE, JSON.stringify(attempts, null, 2), 'utf8');
|
|
1368
|
-
} catch {
|
|
1369
|
-
/* the counter is an optimisation, not a record */
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
/** Put a thread back at the tail, or give up loudly once it has had enough tries. */
|
|
1374
|
-
function deferConsolidationThread(threadId, why) {
|
|
1375
|
-
const attempts = bumpConsolidationAttempt(threadId);
|
|
1376
|
-
if (attempts >= 3) {
|
|
1377
|
-
clearConsolidationAttempt(threadId);
|
|
1378
|
-
log(`consolidate: giving up on thread ${threadId} after ${attempts} attempts (${why})`);
|
|
1379
|
-
trace({ event: 'consolidate', verdict: 'abandoned', thread: threadId, reason: why, attempts });
|
|
1380
|
-
return;
|
|
1381
|
-
}
|
|
1382
|
-
enqueueConsolidation([threadId]);
|
|
1383
|
-
log(`consolidate: thread ${threadId} deferred after ${attempts} attempt(s) (${why})`);
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
function writeConsolidationState(state) {
|
|
1387
|
-
try {
|
|
1388
|
-
writeFileSync(CONSOLIDATION_FILE, JSON.stringify(state, null, 2), 'utf8');
|
|
1389
|
-
} catch (error) {
|
|
1390
|
-
log(`consolidate: state write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
/**
|
|
1395
|
-
* Write (or merge) the conclusion and close the thread with it.
|
|
1396
|
-
*
|
|
1397
|
-
* Merging never rewrites the held statement — the evidence is what grows, so the
|
|
1398
|
-
* merge stays auditable. Refine and conflict still write a new row and supersede
|
|
1399
|
-
* the old one, which keeps the chain of revisions.
|
|
1400
|
-
*/
|
|
1401
|
-
function finishConclusion(soul, draft, { threadId, batch, relation }) {
|
|
1402
|
-
const allowed = batch.map((fact) => fact.id);
|
|
1403
|
-
let result;
|
|
1404
|
-
if (relation.kind === 'duplicate' && relation.target) {
|
|
1405
|
-
result = soul.recordConclusion(draft, { allowed, mergeInto: relation.target });
|
|
1406
|
-
} else {
|
|
1407
|
-
const effective =
|
|
1408
|
-
(relation.kind === 'refine' || relation.kind === 'conflict') && relation.target
|
|
1409
|
-
? { ...draft, supersedes: relation.target }
|
|
1410
|
-
: draft;
|
|
1411
|
-
result = soul.recordConclusion(effective, { allowed });
|
|
1412
|
-
}
|
|
1413
|
-
const summaryId = result.merged ?? result.fact?.id;
|
|
1414
|
-
if (!result.ok) {
|
|
1415
|
-
// The gate refused it (too few citations, evidence outside the batch, length,
|
|
1416
|
-
// over-generalisation). Before this it logged "consolidate ok written=false",
|
|
1417
|
-
// cleared pending, and left the thread closed with no summary and no retry.
|
|
1418
|
-
log(`consolidate rejected: ${result.reason ?? 'gate'} — "${draft.statement}"`);
|
|
1419
|
-
trace({
|
|
1420
|
-
event: 'consolidate',
|
|
1421
|
-
verdict: 'rejected',
|
|
1422
|
-
ok: false,
|
|
1423
|
-
reason: result.reason,
|
|
1424
|
-
relation: relation.kind,
|
|
1425
|
-
target: relation.target ?? null,
|
|
1426
|
-
statement: draft.statement,
|
|
1427
|
-
thread: threadId,
|
|
1428
|
-
});
|
|
1429
|
-
if (threadId) deferConsolidationThread(threadId, `gate:${result.reason ?? 'unknown'}`);
|
|
1430
|
-
writeConsolidationState({ pending: null });
|
|
1431
|
-
return;
|
|
1432
|
-
}
|
|
1433
|
-
if (threadId) clearConsolidationAttempt(threadId);
|
|
1434
|
-
// The conclusion becomes the thread's summary; a closed thread with no summary
|
|
1435
|
-
// is still closed, it just has nothing to remember it by.
|
|
1436
|
-
if (threadId && summaryId) soul.store.closeThread(threadId, Date.now(), summaryId);
|
|
1437
|
-
log(
|
|
1438
|
-
`consolidate ok: thread=${threadId ?? '-'} relation=${relation.kind} "${draft.statement}" ` +
|
|
1439
|
-
`evidence=${draft.evidence.length} written=${Boolean(result.fact)} merged=${result.merged ?? '-'} ` +
|
|
1440
|
-
`superseded=${result.superseded ?? '-'}`,
|
|
1441
|
-
);
|
|
1442
|
-
trace({
|
|
1443
|
-
event: 'consolidate',
|
|
1444
|
-
verdict: 'supported',
|
|
1445
|
-
ok: result.ok,
|
|
1446
|
-
reason: result.reason,
|
|
1447
|
-
relation: relation.kind,
|
|
1448
|
-
target: relation.target ?? null,
|
|
1449
|
-
statement: draft.statement,
|
|
1450
|
-
thread: threadId,
|
|
1451
|
-
});
|
|
1452
|
-
writeConsolidationState({ pending: null });
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
/* ------------------------------------------------------------------ *
|
|
1456
|
-
* Profile merging: many judgments about the person → a few (ADR 0033)
|
|
1457
|
-
* ------------------------------------------------------------------ */
|
|
1458
|
-
|
|
1459
|
-
const PROFILE_FILE = join(HOME, 'pending-profile.json');
|
|
1460
|
-
|
|
1461
|
-
const readProfileQueue = () => readQueueFile(PROFILE_FILE);
|
|
1462
|
-
const writeProfileQueue = (queue) => writeQueueFile(PROFILE_FILE, queue, { cap: 200 });
|
|
1463
|
-
|
|
1464
|
-
export function peekProfileQueue() {
|
|
1465
|
-
return readProfileQueue();
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
/**
|
|
1469
|
-
* Add one judged-about-the-person item to the merge queue.
|
|
1470
|
-
*
|
|
1471
|
-
* Read and write the file in one go: a snapshot taken before a model call once
|
|
1472
|
-
* overwrote items enqueued while the call was in flight, silently losing turns.
|
|
1473
|
-
* The dimension comes along because the merge step cuts its batches along it.
|
|
1474
|
-
*/
|
|
1475
|
-
export function enqueueProfileCandidate(item) {
|
|
1476
|
-
if (!item?.id) return false;
|
|
1477
|
-
const queue = readProfileQueue();
|
|
1478
|
-
if (queue.some((entry) => entry.id === item.id)) return false;
|
|
1479
|
-
writeProfileQueue([...queue, { id: item.id, statement: item.statement ?? '', dimension: item.dimension ?? '' }]);
|
|
1480
|
-
return true;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
export function commitProfileMerge(ids) {
|
|
1484
|
-
const done = new Set(ids);
|
|
1485
|
-
writeProfileQueue(readProfileQueue().filter((item) => !done.has(item.id)));
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
export function retryProfileMerge(batch) {
|
|
1489
|
-
const retry = new Map(batch.map((item) => [item.id, item]));
|
|
1490
|
-
const out = [];
|
|
1491
|
-
for (const item of readProfileQueue()) {
|
|
1492
|
-
if (!retry.has(item.id)) {
|
|
1493
|
-
out.push(item);
|
|
1494
|
-
continue;
|
|
1495
|
-
}
|
|
1496
|
-
const attempts = (item.attempts ?? 0) + 1;
|
|
1497
|
-
if (attempts >= 3) {
|
|
1498
|
-
log(`profile: giving up on ${item.id} after ${attempts} attempts`);
|
|
1499
|
-
trace({ event: 'profile-abandoned', id: item.id, attempts });
|
|
1500
|
-
continue;
|
|
1501
|
-
}
|
|
1502
|
-
out.push({ ...item, attempts });
|
|
1503
|
-
}
|
|
1504
|
-
writeProfileQueue(out);
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
/**
|
|
1508
|
-
* One merge per quiet step, always inside a single dimension.
|
|
1509
|
-
*
|
|
1510
|
-
* Sending every queued judgment in one call welded unrelated ones into one sentence,
|
|
1511
|
-
* which is why the batches are now cut by dimension before the model sees them. The
|
|
1512
|
-
* biggest group goes first: it holds the most repetition, so it is where a call buys
|
|
1513
|
-
* the most. A group of one has nothing to merge and waits for company.
|
|
1514
|
-
*/
|
|
1515
|
-
async function runProfileStep() {
|
|
1516
|
-
const config = readConfig();
|
|
1517
|
-
if (config.extract === 'off' || !injectedLlm) {
|
|
1518
|
-
log('profile: skipped — model unavailable');
|
|
1519
|
-
return false;
|
|
1520
|
-
}
|
|
1521
|
-
const groups = groupProfileCandidates(peekProfileQueue());
|
|
1522
|
-
const [group] = mergeableGroups(groups);
|
|
1523
|
-
if (!group) {
|
|
1524
|
-
if (groups.length > 0) log(`profile: nothing to merge yet (${describeGroups(groups)})`);
|
|
1525
|
-
return false;
|
|
1526
|
-
}
|
|
1527
|
-
const batch = group.items;
|
|
1528
|
-
const dimension = group.dimension;
|
|
1529
|
-
log(`profile: merging ${describeGroups(groups)} — picking ${dimension} x${batch.length}`);
|
|
1530
|
-
|
|
1531
|
-
let raw;
|
|
1532
|
-
try {
|
|
1533
|
-
raw = await injectedLlm(renderProfileMergePrompt({ items: batch, dimension }));
|
|
1534
|
-
} catch (error) {
|
|
1535
|
-
log(`profile: call failed (${error instanceof Error ? error.message : String(error)}) — retrying`);
|
|
1536
|
-
retryProfileMerge(batch);
|
|
1537
|
-
return true;
|
|
1538
|
-
}
|
|
1539
|
-
const allowed = new Set(batch.map((item) => item.id));
|
|
1540
|
-
const lines = parseProfileMerge(raw, allowed, dimension);
|
|
1541
|
-
if (lines.length === 0) {
|
|
1542
|
-
log('profile: no usable merge in the reply — retrying');
|
|
1543
|
-
retryProfileMerge(batch);
|
|
1544
|
-
return true;
|
|
1545
|
-
}
|
|
1546
|
-
const soul = ensureSoul();
|
|
1547
|
-
const written = soul.applyProfileMerge(lines);
|
|
1548
|
-
// A merge is supposed to cover its whole input. Lines the model did not cite used
|
|
1549
|
-
// to stay active forever, so the profile grew a stale tail nobody saw.
|
|
1550
|
-
const covered = new Set(lines.flatMap((line) => line.sources));
|
|
1551
|
-
const leftovers = batch.map((item) => item.id).filter((id) => !covered.has(id));
|
|
1552
|
-
const successor = written[0]?.id;
|
|
1553
|
-
for (const id of leftovers) {
|
|
1554
|
-
const fact = soul.store.fact(id);
|
|
1555
|
-
if (fact && fact.status !== 'superseded' && successor) soul.store.supersedeFact(id, successor);
|
|
1556
|
-
}
|
|
1557
|
-
commitProfileMerge(batch.map((item) => item.id));
|
|
1558
|
-
log(
|
|
1559
|
-
`profile merged: dimension=${dimension} sources=${batch.length} lines=${written.length}` +
|
|
1560
|
-
` covered=${covered.size} retired=${leftovers.length}`,
|
|
1561
|
-
);
|
|
1562
|
-
trace({
|
|
1563
|
-
event: 'profile',
|
|
1564
|
-
dimension,
|
|
1565
|
-
sources: batch.length,
|
|
1566
|
-
lines: written.length,
|
|
1567
|
-
retired: leftovers.length,
|
|
1568
|
-
});
|
|
1569
|
-
return true;
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/** "工作方式:5,沟通偏好:2,未标注:1" — the queue in one readable line. */
|
|
1573
|
-
export function describeGroups(groups) {
|
|
1574
|
-
return groups
|
|
1575
|
-
.map((group) => `${group.dimension || '未标注'}:${group.items.length}`)
|
|
1576
|
-
.join(',');
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
/**
|
|
1580
|
-
* The groups a merge may run on: one dimension, at least two judgments.
|
|
1581
|
-
*
|
|
1582
|
-
* Unlabelled judgments are deliberately excluded. Merging them would put several
|
|
1583
|
-
* lenses back into one call — the welding the split exists to stop — and assigning a
|
|
1584
|
-
* dimension just to fill a batch would be invention. They are reported instead, and
|
|
1585
|
-
* labelled by hand (scripts/label-profile.mjs) before they can be merged.
|
|
1586
|
-
*/
|
|
1587
|
-
export function mergeableGroups(groups) {
|
|
1588
|
-
return groups.filter((group) => group.dimension && group.items.length >= 2);
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
/** Is there any group with at least two judgments to collapse? */
|
|
1592
|
-
export function hasMergeableGroup(queue) {
|
|
1593
|
-
return mergeableGroups(groupProfileCandidates(queue)).length > 0;
|
|
1594
|
-
}
|
|
1595
|
-
/* ------------------------------------------------------------------ *
|
|
1596
|
-
* Classifying old lessons: rule (with trigger) or profile (ADR 0033)
|
|
1597
|
-
* ------------------------------------------------------------------ */
|
|
1598
|
-
|
|
1599
|
-
const CLASSIFY_FILE = join(HOME, 'pending-classify.json');
|
|
1600
|
-
const CLASSIFY_BATCH = 10;
|
|
1601
|
-
|
|
1602
|
-
const readClassifyQueue = () => readQueueFile(CLASSIFY_FILE);
|
|
1603
|
-
const writeClassifyQueue = (queue) => writeQueueFile(CLASSIFY_FILE, queue, { cap: 400 });
|
|
1604
|
-
|
|
1605
|
-
export function peekClassifyBatch() {
|
|
1606
|
-
return readClassifyQueue().slice(0, CLASSIFY_BATCH);
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
/** Remove items only after their result has been applied. */
|
|
1610
|
-
export function commitClassifyBatch(ids) {
|
|
1611
|
-
const done = new Set(ids);
|
|
1612
|
-
writeClassifyQueue(readClassifyQueue().filter((item) => !done.has(item.id)));
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/**
|
|
1616
|
-
* Put a failed batch back with one more attempt.
|
|
1617
|
-
*
|
|
1618
|
-
* A batch used to be removed before the model call, so a throw or an empty reply
|
|
1619
|
-
* lost up to ten lessons with only a generic background error to show for it.
|
|
1620
|
-
*/
|
|
1621
|
-
export function retryClassifyBatch(batch) {
|
|
1622
|
-
const retry = new Map(batch.map((item) => [item.id, item]));
|
|
1623
|
-
const out = [];
|
|
1624
|
-
for (const item of readClassifyQueue()) {
|
|
1625
|
-
if (!retry.has(item.id)) {
|
|
1626
|
-
out.push(item);
|
|
1627
|
-
continue;
|
|
1628
|
-
}
|
|
1629
|
-
const attempts = (item.attempts ?? 0) + 1;
|
|
1630
|
-
if (attempts >= 3) {
|
|
1631
|
-
log(`classify: giving up ${item.id} after ${attempts} attempts`);
|
|
1632
|
-
trace({ event: 'classify-abandoned', id: item.id, attempts });
|
|
1633
|
-
continue;
|
|
1634
|
-
}
|
|
1635
|
-
out.push({ ...item, attempts });
|
|
1636
|
-
}
|
|
1637
|
-
writeClassifyQueue(out);
|
|
1638
|
-
}
|
|
1639
|
-
|
|
1640
|
-
/**
|
|
1641
|
-
* Put one lesson in the classification queue.
|
|
1642
|
-
*
|
|
1643
|
-
* The reason travels with it so the queue is explainable: "why was this promoted"
|
|
1644
|
-
* must be answerable from the queue file, not reconstructed from the log.
|
|
1645
|
-
*/
|
|
1646
|
-
export function enqueueClassifyCandidate(item) {
|
|
1647
|
-
if (!item?.id) return false;
|
|
1648
|
-
const queue = readClassifyQueue();
|
|
1649
|
-
if (queue.some((entry) => entry.id === item.id)) return false;
|
|
1650
|
-
writeClassifyQueue([
|
|
1651
|
-
...queue,
|
|
1652
|
-
{ id: item.id, statement: item.statement ?? '', reason: item.reason ?? 'unknown' },
|
|
1653
|
-
]);
|
|
1654
|
-
return true;
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
/**
|
|
1658
|
-
* T1 (brief 0023): a corrected turn's lessons are promoted, and nothing else is.
|
|
1659
|
-
*
|
|
1660
|
-
* Being told you were wrong is the consequence an experience comes with, which is
|
|
1661
|
-
* the one promotion signal we can trust without a threshold. Everything else waits.
|
|
1662
|
-
*/
|
|
1663
|
-
export function feedClassifyFromTurn(turnId) {
|
|
1664
|
-
try {
|
|
1665
|
-
// Classification is a model stage. Without a model the candidate would sit in
|
|
1666
|
-
// the queue until the cap drops it, so it is not queued at all.
|
|
1667
|
-
if (!usesModel(readConfig())) return 0;
|
|
1668
|
-
const soul = ensureSoul();
|
|
1669
|
-
if (!soul.wasCorrectedTurn(turnId)) return 0;
|
|
1670
|
-
let added = 0;
|
|
1671
|
-
for (const fact of soul.promotionCandidates(turnId)) {
|
|
1672
|
-
if (enqueueClassifyCandidate({ id: fact.id, statement: fact.statement, reason: 'T1-修正' })) {
|
|
1673
|
-
added += 1;
|
|
1674
|
-
}
|
|
1675
|
-
}
|
|
1676
|
-
if (added > 0) log(`promote: T1 corrected turn → ${added} candidate(s) queued for classification`);
|
|
1677
|
-
trace({ event: 'promote', rule: 'T1', turn: turnId, queued: added });
|
|
1678
|
-
return added;
|
|
1679
|
-
} catch (error) {
|
|
1680
|
-
log(`promote: failed (${error instanceof Error ? error.message : String(error)})`);
|
|
1681
|
-
return 0;
|
|
1682
|
-
}
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
/**
|
|
1686
|
-
* One classification batch per quiet step.
|
|
1687
|
-
*
|
|
1688
|
-
* Returns true when a model call was made, mirroring the extraction contract so the
|
|
1689
|
-
* caller can keep to one call per step.
|
|
1690
|
-
*/
|
|
1691
|
-
async function runClassifyStep() {
|
|
1692
|
-
const config = readConfig();
|
|
1693
|
-
if (config.extract === 'off' || !injectedLlm) return false;
|
|
1694
|
-
const batch = peekClassifyBatch();
|
|
1695
|
-
if (batch.length === 0) return false;
|
|
1696
|
-
|
|
1697
|
-
let raw;
|
|
1698
|
-
try {
|
|
1699
|
-
raw = await injectedLlm(renderRuleClassifyPrompt({ items: batch }));
|
|
1700
|
-
} catch (error) {
|
|
1701
|
-
log(`classify: call failed (${error instanceof Error ? error.message : String(error)}) — retrying batch`);
|
|
1702
|
-
retryClassifyBatch(batch);
|
|
1703
|
-
return true;
|
|
1704
|
-
}
|
|
1705
|
-
const classified = parseRuleClassifications(raw);
|
|
1706
|
-
if (classified.length === 0) {
|
|
1707
|
-
// An empty or unreadable reply is not a classification: keep the lessons.
|
|
1708
|
-
log('classify: empty or unparseable reply — retrying batch');
|
|
1709
|
-
retryClassifyBatch(batch);
|
|
1710
|
-
return true;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
const soul = ensureSoul();
|
|
1714
|
-
let rules = 0;
|
|
1715
|
-
let profiles = 0;
|
|
1716
|
-
let pending = 0;
|
|
1717
|
-
const known = new Map(batch.map((item) => [item.id, item]));
|
|
1718
|
-
for (const item of classified) {
|
|
1719
|
-
if (!known.has(item.id)) continue;
|
|
1720
|
-
if (item.kind === 'rule' && item.trigger) {
|
|
1721
|
-
soul.markRule(item.id, { trigger: item.trigger, action: item.action });
|
|
1722
|
-
rules += 1;
|
|
1723
|
-
} else if (item.kind === 'profile') {
|
|
1724
|
-
soul.markProfile(item.id, { dimension: item.dimension });
|
|
1725
|
-
// The classification is the only moment the dimension is known, so the merge
|
|
1726
|
-
// queue is fed here. Without this the merge stage had no live input at all and
|
|
1727
|
-
// only ever ran from a hand-seeded queue.
|
|
1728
|
-
enqueueProfileCandidate({
|
|
1729
|
-
id: item.id,
|
|
1730
|
-
statement: known.get(item.id).statement,
|
|
1731
|
-
dimension: item.dimension ?? '',
|
|
1732
|
-
});
|
|
1733
|
-
profiles += 1;
|
|
1734
|
-
} else {
|
|
1735
|
-
// Neither bucket is safe for an unclassifiable lesson: a rule would fire
|
|
1736
|
-
// every turn, a profile line would put an imperative into a person.
|
|
1737
|
-
soul.markUnclassified(item.id);
|
|
1738
|
-
pending += 1;
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
commitClassifyBatch(batch.map((item) => item.id));
|
|
1742
|
-
// "Promoted because of what" is a number, not a memory: the brief's acceptance
|
|
1743
|
-
// metric is the reason split, and it can only be counted if it is logged.
|
|
1744
|
-
const reasons = {};
|
|
1745
|
-
for (const item of batch) {
|
|
1746
|
-
const reason = item.reason ?? 'seed';
|
|
1747
|
-
reasons[reason] = (reasons[reason] ?? 0) + 1;
|
|
1748
|
-
}
|
|
1749
|
-
const reasonText = Object.entries(reasons).map(([reason, n]) => `${reason}:${n}`).join(',');
|
|
1750
|
-
log(`classify: batch=${batch.length} rules=${rules} profiles=${profiles} pending=${pending} reasons=${reasonText}`);
|
|
1751
|
-
trace({ event: 'classify', batch: batch.length, rules, profiles, pending, reasons });
|
|
1752
|
-
return true;
|
|
1753
|
-
}
|
|
1754
|
-
/**
|
|
1755
|
-
* One consolidation step per call, never two model calls in one step.
|
|
1756
|
-
*
|
|
1757
|
-
* Consolidation runs on a **closed thread**, not on a batch of facts: a
|
|
1758
|
-
* conversation still in progress is not a chapter yet, and a batch can mix two
|
|
1759
|
-
* unrelated topics into one conclusion.
|
|
1760
|
-
*
|
|
1761
|
-
* The two phases are split across steps on purpose: the verifier must answer
|
|
1762
|
-
* without the draft's reasoning in view (Chain-of-Verification), and a second
|
|
1763
|
-
* call in the same step would run past the window where the host model is
|
|
1764
|
-
* callable anyway.
|
|
1765
|
-
*
|
|
1766
|
-
* Returns true when a model call was made, so the caller can skip other work.
|
|
1767
|
-
*/
|
|
1768
|
-
async function runConsolidationStep() {
|
|
1769
|
-
const config = readConfig();
|
|
1770
|
-
if (config.extract === 'off' || !injectedLlm) return false;
|
|
1771
|
-
const soul = ensureSoul();
|
|
1772
|
-
const state = readConsolidationState();
|
|
1773
|
-
|
|
1774
|
-
if (state.pending?.phase === 'verify') {
|
|
1775
|
-
const { draft, batch, threadId } = state.pending;
|
|
1776
|
-
let raw;
|
|
1777
|
-
try {
|
|
1778
|
-
raw = await injectedLlm(renderVerificationPrompt({ conclusion: draft.statement, facts: batch }));
|
|
1779
|
-
} catch (error) {
|
|
1780
|
-
// The draft is still pending; the next step retries the check — but a provider
|
|
1781
|
-
// that keeps failing must not hold the queue head forever.
|
|
1782
|
-
const attempts = (state.pending.attempts ?? 0) + 1;
|
|
1783
|
-
if (attempts >= 3) {
|
|
1784
|
-
log(`consolidate verify gave up after ${attempts} failed calls — "${draft.statement}"`);
|
|
1785
|
-
trace({ event: 'consolidate', verdict: 'verify-abandoned', attempts, thread: threadId, reason: 'call-failed' });
|
|
1786
|
-
writeConsolidationState({ pending: null });
|
|
1787
|
-
return true;
|
|
1788
|
-
}
|
|
1789
|
-
writeConsolidationState({ pending: { ...state.pending, attempts } });
|
|
1790
|
-
log(`consolidate verify failed (${error instanceof Error ? error.message : String(error)}) — retry ${attempts}/3`);
|
|
1791
|
-
return true;
|
|
1792
|
-
}
|
|
1793
|
-
const checked = readVerification(raw);
|
|
1794
|
-
if (checked.kind === 'retry') {
|
|
1795
|
-
// An empty or unreadable reply is not evidence against the conclusion. But an
|
|
1796
|
-
// unbounded retry parks the thread at the head of the queue forever, so the
|
|
1797
|
-
// third unreadable reply drops the draft with a trace instead.
|
|
1798
|
-
const attempts = (state.pending.attempts ?? 0) + 1;
|
|
1799
|
-
if (attempts >= 3) {
|
|
1800
|
-
log(`consolidate verify abandoned after ${attempts} unreadable replies — "${draft.statement}"`);
|
|
1801
|
-
trace({ event: 'consolidate', verdict: 'verify-abandoned', attempts, thread: threadId });
|
|
1802
|
-
writeConsolidationState({ pending: null });
|
|
1803
|
-
return true;
|
|
1804
|
-
}
|
|
1805
|
-
writeConsolidationState({ pending: { ...state.pending, attempts } });
|
|
1806
|
-
log(`consolidate verify retry ${attempts}/3: ${checked.reason} — "${draft.statement}"`);
|
|
1807
|
-
trace({ event: 'consolidate', verdict: 'verify-retry', reason: checked.reason, thread: threadId, attempts });
|
|
1808
|
-
return true;
|
|
1809
|
-
}
|
|
1810
|
-
const verdict = checked.verdict;
|
|
1811
|
-
if (verdict.verdict === 'supported') {
|
|
1812
|
-
// Supported, but not yet written: first ask whether we already hold it.
|
|
1813
|
-
// The check runs in its own step so this step keeps to one model call.
|
|
1814
|
-
const candidates = soul.conclusionCandidates(draft.evidence, 8);
|
|
1815
|
-
if (candidates.length > 0) {
|
|
1816
|
-
writeConsolidationState({ pending: { phase: 'dedupe', draft, batch, threadId, candidates } });
|
|
1817
|
-
log(`consolidate verify ok: thread=${threadId ?? '-'} — checking ${candidates.length} held conclusions`);
|
|
1818
|
-
return true;
|
|
1819
|
-
}
|
|
1820
|
-
finishConclusion(soul, draft, { threadId, batch, relation: { kind: 'unrelated' } });
|
|
1821
|
-
} else {
|
|
1822
|
-
// Partial support is dropped, not downgraded: a half-true conclusion is a
|
|
1823
|
-
// wrong one, and the sample is what teaches the prompt next time.
|
|
1824
|
-
log(`consolidate dropped: ${verdict.verdict} — "${draft.statement}" (${verdict.reason ?? '-'})`);
|
|
1825
|
-
trace({
|
|
1826
|
-
event: 'consolidate',
|
|
1827
|
-
verdict: verdict.verdict,
|
|
1828
|
-
dropped: true,
|
|
1829
|
-
reason: verdict.reason,
|
|
1830
|
-
statement: draft.statement,
|
|
1831
|
-
thread: threadId,
|
|
1832
|
-
});
|
|
1833
|
-
}
|
|
1834
|
-
writeConsolidationState({ pending: null });
|
|
1835
|
-
return true;
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
if (state.pending?.phase === 'dedupe') {
|
|
1839
|
-
const { draft, batch, threadId, candidates } = state.pending;
|
|
1840
|
-
// The one check a rule can make safely: the same text. A hit cannot be wrong,
|
|
1841
|
-
// and it costs nothing.
|
|
1842
|
-
const exact = candidates.find(
|
|
1843
|
-
(candidate) => normalizeStatement(candidate.statement) === normalizeStatement(draft.statement),
|
|
1844
|
-
);
|
|
1845
|
-
if (exact) {
|
|
1846
|
-
log(`dedupe: exact duplicate of ${exact.id} — merging evidence, not writing`);
|
|
1847
|
-
finishConclusion(soul, draft, { threadId, batch, relation: { kind: 'duplicate', target: exact.id } });
|
|
1848
|
-
return true;
|
|
1849
|
-
}
|
|
1850
|
-
const raw = await injectedLlm(renderRelationPrompt({ conclusion: draft.statement, existing: candidates }));
|
|
1851
|
-
const parsed = parseRelationDraft(raw);
|
|
1852
|
-
// An id we did not offer is a hallucination; treating it as unrelated only
|
|
1853
|
-
// ever writes the new conclusion, never deletes one.
|
|
1854
|
-
const known = parsed?.target && candidates.some((candidate) => candidate.id === parsed.target);
|
|
1855
|
-
const relation =
|
|
1856
|
-
parsed && (parsed.kind === 'unrelated' || known) ? parsed : { kind: 'unrelated', reason: 'unknown target' };
|
|
1857
|
-
log(`dedupe: ${relation.kind}${relation.target ? ` → ${relation.target}` : ''} "${draft.statement}"`);
|
|
1858
|
-
trace({ event: 'dedupe', relation: relation.kind, target: relation.target ?? null, reason: relation.reason });
|
|
1859
|
-
finishConclusion(soul, draft, { threadId, batch, relation });
|
|
1860
|
-
return true;
|
|
1861
|
-
}
|
|
1862
|
-
|
|
1863
|
-
const threadId = shiftConsolidationQueue();
|
|
1864
|
-
if (!threadId) return false;
|
|
1865
|
-
const context = soul.threadContext(threadId);
|
|
1866
|
-
if (!context) {
|
|
1867
|
-
// Missing is permanent: the thread row is gone, not waiting for facts.
|
|
1868
|
-
clearConsolidationAttempt(threadId);
|
|
1869
|
-
log(`consolidate: thread ${threadId} missing, dropped`);
|
|
1870
|
-
trace({ event: 'consolidate', verdict: 'missing-thread', thread: threadId });
|
|
1871
|
-
return false;
|
|
1872
|
-
}
|
|
1873
|
-
// A thread can be closed while some of its turns are still waiting in the
|
|
1874
|
-
// extraction queue, so "few facts now" does not mean "few facts ever". It was
|
|
1875
|
-
// discarded here before; now it goes back to the tail with an attempt counter.
|
|
1876
|
-
if (context.facts.length < 2) {
|
|
1877
|
-
log(`consolidate deferred: thread ${threadId} has ${context.facts.length} fact(s)`);
|
|
1878
|
-
trace({ event: 'consolidate', verdict: 'too-few-facts', thread: threadId, facts: context.facts.length });
|
|
1879
|
-
deferConsolidationThread(threadId, 'too-few-facts');
|
|
1880
|
-
return false;
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
const batch = context.facts.slice(0, CONSOLIDATION_MAX_FACTS).map((fact) => ({
|
|
1884
|
-
id: fact.id,
|
|
1885
|
-
statement: fact.statement,
|
|
1886
|
-
atText: fact.atText,
|
|
1887
|
-
}));
|
|
1888
|
-
let raw;
|
|
1889
|
-
try {
|
|
1890
|
-
raw = await injectedLlm(
|
|
1891
|
-
renderConsolidationPrompt({
|
|
1892
|
-
facts: batch,
|
|
1893
|
-
existing: soul.conclusions(5).map((fact) => ({ id: fact.id, statement: fact.statement })),
|
|
1894
|
-
thread: {
|
|
1895
|
-
topic: context.thread.topic,
|
|
1896
|
-
// Whole turns: consolidation reads the chapter, and a 160/120-character
|
|
1897
|
-
// prefix cuts exactly where the decision usually is (the last clause).
|
|
1898
|
-
turns: context.turns.map((turn) => {
|
|
1899
|
-
const answer = turn.assistantText;
|
|
1900
|
-
return `用户:${turn.userText}${answer ? ` / 我:${answer}` : ''}`;
|
|
1901
|
-
}),
|
|
1902
|
-
},
|
|
1903
|
-
}),
|
|
1904
|
-
);
|
|
1905
|
-
} catch (error) {
|
|
1906
|
-
log(`consolidate draft failed: ${error instanceof Error ? error.message : String(error)} — deferring thread`);
|
|
1907
|
-
deferConsolidationThread(threadId, 'draft-call-failed');
|
|
1908
|
-
return true;
|
|
1909
|
-
}
|
|
1910
|
-
const reply = readConsolidationReply(raw);
|
|
1911
|
-
if (reply.kind !== 'draft') {
|
|
1912
|
-
if (reply.kind === 'none') {
|
|
1913
|
-
log(`consolidate: nothing to conclude from thread ${threadId} (${batch.length} facts)`);
|
|
1914
|
-
trace({ event: 'consolidate', verdict: 'none', thread: threadId, batch: batch.length });
|
|
1915
|
-
writeConsolidationState({ pending: null });
|
|
1916
|
-
return true;
|
|
1917
|
-
}
|
|
1918
|
-
// Empty or unreadable is a failed call, not a decision. Put the thread back with
|
|
1919
|
-
// an attempt counter instead of discarding its conclusion.
|
|
1920
|
-
log(`consolidate: unreadable draft (${reply.kind}) — deferring thread ${threadId}`);
|
|
1921
|
-
trace({ event: 'consolidate', verdict: 'draft-unreadable', thread: threadId, reason: reply.kind });
|
|
1922
|
-
deferConsolidationThread(threadId, `draft-${reply.kind}`);
|
|
1923
|
-
return true;
|
|
1924
|
-
}
|
|
1925
|
-
const draft = reply.draft;
|
|
1926
|
-
log(`consolidate draft: thread=${threadId} "${draft.statement}" evidence=${draft.evidence.join(',')} — verifying next step`);
|
|
1927
|
-
trace({ event: 'consolidate', verdict: 'draft', statement: draft.statement, evidence: draft.evidence, thread: threadId });
|
|
1928
|
-
writeConsolidationState({ pending: { phase: 'verify', draft, batch, threadId, attempts: 0 } });
|
|
1929
|
-
return true;
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
/**
|
|
1933
|
-
* One-shot backfill: enqueue the last few completed turns so they go through the
|
|
1934
|
-
* same path as new ones. Marker file keeps a restart from re-billing old turns.
|
|
1935
|
-
*/
|
|
1936
|
-
async function backfillExtraction() {
|
|
1937
|
-
const marker = join(HOME, '.extract-backfill-done');
|
|
1938
|
-
if (existsSync(marker)) return;
|
|
1939
|
-
const config = readConfig();
|
|
1940
|
-
if (config.extract === 'off') return;
|
|
1941
|
-
try {
|
|
1942
|
-
const recent = ensureSoul().recallTurnPairs('', 5);
|
|
1943
|
-
log(`extract backfill: queued ${recent.length} turns`);
|
|
1944
|
-
for (const turn of recent) {
|
|
1945
|
-
enqueueExtraction(turn.id, turn.turn);
|
|
1946
|
-
}
|
|
1947
|
-
writeFileSync(marker, new Date().toISOString());
|
|
1948
|
-
} catch (error) {
|
|
1949
|
-
log(`extract backfill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
async function observeText(text) {
|
|
1954
|
-
const started = Date.now();
|
|
1955
|
-
try {
|
|
1956
|
-
const result = await ensureSoul().observe({ role: 'user', content: text });
|
|
1957
|
-
const affect = result?.affect ?? {};
|
|
1958
|
-
const state = readState();
|
|
1959
|
-
writeState({
|
|
1960
|
-
observes: (state.observes ?? 0) + 1,
|
|
1961
|
-
lastObserveAt: started,
|
|
1962
|
-
lastObservedChars: text.length,
|
|
1963
|
-
lastWritten: result?.written ?? 0,
|
|
1964
|
-
lastAffect: affect.dominant ?? null,
|
|
1965
|
-
});
|
|
1966
|
-
log(
|
|
1967
|
-
`observe ok: chars=${text.length} written=${result?.written ?? '?'} committed=${result?.committed ?? '?'} by=${result?.extractedBy ?? '?'} llm=${result?.llmOutcome ?? '?'}` +
|
|
1968
|
-
` affect=${affect.dominant ?? 'none'} confidence=${affect.confidence ?? '?'} ms=${Date.now() - started}`,
|
|
1969
|
-
);
|
|
1970
|
-
// A new observation may change the anchors inside the next pre-step block.
|
|
1971
|
-
} catch (error) {
|
|
1972
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1973
|
-
const state = readState();
|
|
1974
|
-
writeState({ observeFails: (state.observeFails ?? 0) + 1, lastError: `observe failed: ${message}` });
|
|
1975
|
-
log(`observe failed: ${message}`);
|
|
1976
|
-
}
|
|
1977
|
-
}
|
|
1978
|
-
|
|
1979
|
-
/** Tool output must match the declared schema; text blocks keep it readable. */
|
|
1980
|
-
function textRender(_args, value) {
|
|
1981
|
-
return [{ type: 'text', text: JSON.stringify(value) }];
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
function recallTool() {
|
|
1985
|
-
return {
|
|
1986
|
-
name: 'soul_recall',
|
|
1987
|
-
description:
|
|
1988
|
-
'按关键词从长期记忆里召回相关条目。当用户提到过去说过的话、偏好、约定,' +
|
|
1989
|
-
'或你不确定某事是否记过时使用。返回按相关性排序的条目。',
|
|
1990
|
-
parameters: {
|
|
1991
|
-
type: 'object',
|
|
1992
|
-
properties: {
|
|
1993
|
-
query: { type: 'string', description: '检索关键词,通常是用户当前问题里的主题词' },
|
|
1994
|
-
limit: { type: 'number', description: '返回条数,默认 5,上限 20' },
|
|
1995
|
-
},
|
|
1996
|
-
required: ['query'],
|
|
1997
|
-
},
|
|
1998
|
-
output: {
|
|
1999
|
-
schema: {
|
|
2000
|
-
type: 'object',
|
|
2001
|
-
additionalProperties: false,
|
|
2002
|
-
properties: {
|
|
2003
|
-
ok: { type: 'boolean' },
|
|
2004
|
-
count: { type: 'number' },
|
|
2005
|
-
memories: {
|
|
2006
|
-
type: 'array',
|
|
2007
|
-
items: {
|
|
2008
|
-
type: 'object',
|
|
2009
|
-
additionalProperties: false,
|
|
2010
|
-
properties: {
|
|
2011
|
-
id: { type: 'string' },
|
|
2012
|
-
type: { type: 'string' },
|
|
2013
|
-
content: { type: 'string' },
|
|
2014
|
-
score: { type: 'number' },
|
|
2015
|
-
},
|
|
2016
|
-
required: ['id', 'type', 'content', 'score'],
|
|
2017
|
-
},
|
|
2018
|
-
},
|
|
2019
|
-
reason: { type: 'string' },
|
|
2020
|
-
},
|
|
2021
|
-
required: ['ok', 'count', 'memories'],
|
|
2022
|
-
},
|
|
2023
|
-
render: textRender,
|
|
2024
|
-
},
|
|
2025
|
-
async execute(args) {
|
|
2026
|
-
try {
|
|
2027
|
-
const query = String(args?.query ?? '').trim();
|
|
2028
|
-
if (query.length === 0) return { ok: false, count: 0, memories: [], reason: 'empty query' };
|
|
2029
|
-
const limit = Math.min(20, Math.max(1, Number(args?.limit ?? 5)));
|
|
2030
|
-
const hits = ensureSoul().recall(query, limit);
|
|
2031
|
-
log(`tool soul_recall: chars=${query.length} hits=${hits.length}`);
|
|
2032
|
-
return {
|
|
2033
|
-
ok: true,
|
|
2034
|
-
count: hits.length,
|
|
2035
|
-
memories: hits.map((hit) => ({
|
|
2036
|
-
id: hit.memory.id,
|
|
2037
|
-
type: hit.memory.type,
|
|
2038
|
-
content: hit.memory.content,
|
|
2039
|
-
score: Number(hit.score.toFixed(3)),
|
|
2040
|
-
})),
|
|
2041
|
-
};
|
|
2042
|
-
} catch (error) {
|
|
2043
|
-
return { ok: false, count: 0, memories: [], reason: error instanceof Error ? error.message : String(error) };
|
|
2044
|
-
}
|
|
2045
|
-
},
|
|
2046
|
-
};
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
|
-
function rememberTool() {
|
|
2050
|
-
return {
|
|
2051
|
-
name: 'soul_remember',
|
|
2052
|
-
description:
|
|
2053
|
-
'把一件值得长期记住的事写进记忆。只在用户明确要求记住、或确认这是一条长期约定/偏好时使用;' +
|
|
2054
|
-
'不要用它记录一次性的临时信息。content 必须写用户的原话(可精简,不可改写含义),' +
|
|
2055
|
-
'不要写"这是用户要求记住的一条内容"这类元描述;系统已经自动记录过的内容不要去重写。',
|
|
2056
|
-
parameters: {
|
|
2057
|
-
type: 'object',
|
|
2058
|
-
properties: {
|
|
2059
|
-
content: { type: 'string', description: '用户的原话(可精简),不超过 200 字' },
|
|
2060
|
-
type: {
|
|
2061
|
-
type: 'string',
|
|
2062
|
-
enum: ['strategy', 'fact', 'trait', 'cognition'],
|
|
2063
|
-
description: 'strategy=规则/做法,fact=事实,trait=偏好,cognition=原则',
|
|
2064
|
-
},
|
|
2065
|
-
importance: { type: 'number', description: '1-10,默认 7' },
|
|
2066
|
-
},
|
|
2067
|
-
required: ['content'],
|
|
2068
|
-
},
|
|
2069
|
-
output: {
|
|
2070
|
-
schema: {
|
|
2071
|
-
type: 'object',
|
|
2072
|
-
additionalProperties: false,
|
|
2073
|
-
properties: {
|
|
2074
|
-
ok: { type: 'boolean' },
|
|
2075
|
-
id: { type: 'string' },
|
|
2076
|
-
duplicate: { type: 'boolean' },
|
|
2077
|
-
reason: { type: 'string' },
|
|
2078
|
-
},
|
|
2079
|
-
required: ['ok'],
|
|
2080
|
-
},
|
|
2081
|
-
render: textRender,
|
|
2082
|
-
},
|
|
2083
|
-
async execute(args) {
|
|
2084
|
-
try {
|
|
2085
|
-
const content = String(args?.content ?? '').trim();
|
|
2086
|
-
if (!content) return { ok: false, reason: 'empty content' };
|
|
2087
|
-
const type = typeof args?.type === 'string' ? args.type : 'strategy';
|
|
2088
|
-
const importance = args?.importance === undefined ? 7 : Number(args.importance);
|
|
2089
|
-
// Explicit memories are lessons in the two-layer model (ADR 0032): a fact
|
|
2090
|
-
// held by the soul itself, not a row in the retired memories table.
|
|
2091
|
-
const mapped = type === 'cognition' ? '原则' : type === 'trait' ? '性格' : type === 'fact' ? '事实' : '教训';
|
|
2092
|
-
const soul = ensureSoul();
|
|
2093
|
-
const duplicate = soul.recentFacts(500).find((fact) => fact.statement === content && fact.status !== 'superseded');
|
|
2094
|
-
if (duplicate) return { ok: true, id: duplicate.id, duplicate: true };
|
|
2095
|
-
const fact = soul.recordFact({
|
|
2096
|
-
statement: content,
|
|
2097
|
-
typeName: mapped,
|
|
2098
|
-
holder: 'self',
|
|
2099
|
-
source: 'told',
|
|
2100
|
-
confidence: Math.min(1, Math.max(0, (Number.isFinite(importance) ? importance : 7) / 10)),
|
|
2101
|
-
});
|
|
2102
|
-
log(`tool soul_remember: ok=true type=${mapped} id=${fact.id}`);
|
|
2103
|
-
return { ok: true, id: fact.id };
|
|
2104
|
-
} catch (error) {
|
|
2105
|
-
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2106
|
-
}
|
|
2107
|
-
},
|
|
2108
|
-
};
|
|
2109
|
-
}
|
|
2110
|
-
|
|
2111
|
-
function detailTool() {
|
|
2112
|
-
return {
|
|
2113
|
-
name: 'soul_detail',
|
|
2114
|
-
description:
|
|
2115
|
-
'按事实 id 取回当时的完整对话。注入里每条记忆是形如 [fc_xxx] 的一句话;' +
|
|
2116
|
-
'只有需要原话、细节或上下文时才调用它——先看一句话就能回答的问题,不要调用。',
|
|
2117
|
-
parameters: {
|
|
2118
|
-
type: 'object',
|
|
2119
|
-
properties: {
|
|
2120
|
-
id: { type: 'string', description: '事实 id,例如 fc_ab12' },
|
|
2121
|
-
},
|
|
2122
|
-
required: ['id'],
|
|
2123
|
-
},
|
|
2124
|
-
output: {
|
|
2125
|
-
schema: {
|
|
2126
|
-
type: 'object',
|
|
2127
|
-
additionalProperties: false,
|
|
2128
|
-
properties: {
|
|
2129
|
-
ok: { type: 'boolean' },
|
|
2130
|
-
statement: { type: 'string' },
|
|
2131
|
-
at: { type: 'number' },
|
|
2132
|
-
atText: { type: 'string' },
|
|
2133
|
-
userText: { type: 'string' },
|
|
2134
|
-
assistantText: { type: 'string' },
|
|
2135
|
-
reason: { type: 'string' },
|
|
2136
|
-
},
|
|
2137
|
-
required: ['ok'],
|
|
2138
|
-
},
|
|
2139
|
-
render: textRender,
|
|
2140
|
-
},
|
|
2141
|
-
async execute(args) {
|
|
2142
|
-
try {
|
|
2143
|
-
const id = String(args?.id ?? '').trim();
|
|
2144
|
-
const detail = ensureSoul().factDetail(id);
|
|
2145
|
-
if (!detail) {
|
|
2146
|
-
log(`tool soul_detail: miss ${id}`);
|
|
2147
|
-
return { ok: false, reason: `no fact ${id}` };
|
|
2148
|
-
}
|
|
2149
|
-
log(`tool soul_detail: ${id} turn=${detail.turn ? 'yes' : 'no'}`);
|
|
2150
|
-
return {
|
|
2151
|
-
ok: true,
|
|
2152
|
-
statement: detail.fact.statement,
|
|
2153
|
-
at: detail.fact.at,
|
|
2154
|
-
atText: detail.fact.atText,
|
|
2155
|
-
userText: detail.turn?.userText,
|
|
2156
|
-
assistantText: detail.turn?.assistantText,
|
|
2157
|
-
};
|
|
2158
|
-
} catch (error) {
|
|
2159
|
-
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2160
|
-
}
|
|
2161
|
-
},
|
|
2162
|
-
};
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
/**
|
|
2166
|
-
* Is this plugin on the profile's bundle list?
|
|
2167
|
-
*
|
|
2168
|
-
* A plugin is loaded only because its package is listed in `dsh.profile.bundles`.
|
|
2169
|
-
* Desktop's fatal-error recovery (sanitizeProfile) renames cordis.patch.yml and
|
|
2170
|
-
* restores a caller-given bundle list, which silently dropped this bundle once:
|
|
2171
|
-
* the app stayed up, the plugin simply was not there, and the logs just stopped.
|
|
2172
|
-
*
|
|
2173
|
-
* The profile directory is derived from this module's own location
|
|
2174
|
-
* (<profile>/node_modules/dsh-plugin-soulbridge/index.js), so it cannot drift
|
|
2175
|
-
* from where the running copy actually is.
|
|
2176
|
-
*/
|
|
2177
|
-
export function checkBundle(manifest, name = 'dsh-plugin-soulbridge') {
|
|
2178
|
-
const bundles = manifest?.dsh?.profile?.bundles;
|
|
2179
|
-
if (!Array.isArray(bundles)) return { ok: false, reason: 'no dsh.profile.bundles list', bundles: [] };
|
|
2180
|
-
return { ok: bundles.includes(name), reason: bundles.includes(name) ? 'present' : 'missing', bundles };
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
function selfCheck() {
|
|
2184
|
-
try {
|
|
2185
|
-
const pluginDir = fileURLToPath(new URL('.', import.meta.url));
|
|
2186
|
-
const profileDir = resolve(pluginDir, '..', '..');
|
|
2187
|
-
const manifestFile = join(profileDir, 'package.json');
|
|
2188
|
-
if (!existsSync(manifestFile)) {
|
|
2189
|
-
log(`bundle check: no profile manifest at ${manifestFile}`);
|
|
2190
|
-
return;
|
|
2191
|
-
}
|
|
2192
|
-
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
2193
|
-
const result = checkBundle(manifest);
|
|
2194
|
-
if (result.ok) {
|
|
2195
|
-
log(`bundle check: present (profile=${profileDir})`);
|
|
2196
|
-
return;
|
|
2197
|
-
}
|
|
2198
|
-
log(
|
|
2199
|
-
`bundle check: MISSING from dsh.profile.bundles (profile=${profileDir}) — ` +
|
|
2200
|
-
`add "${name}" to that list or this plugin will not load after a restart`,
|
|
2201
|
-
);
|
|
2202
|
-
trace({ event: 'bundle-missing', profile: profileDir, bundles: result.bundles });
|
|
2203
|
-
writeState({ bundleMissing: true, bundleCheckedAt: Date.now(), profile: profileDir });
|
|
2204
|
-
} catch (error) {
|
|
2205
|
-
log(`bundle check: failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2206
|
-
}
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
export function apply(ctx) {
|
|
2210
|
-
log(`apply() called (v${VERSION})`);
|
|
2211
|
-
writeState({ appliedAt: Date.now(), version: VERSION });
|
|
2212
|
-
selfCheck();
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
const config = readConfig();
|
|
2216
|
-
injectedLlm = makeLlm(ctx, config);
|
|
2217
|
-
injectedMaxLlmChars = config.maxLlmChars;
|
|
2218
|
-
log(`extract=${config.extract}${injectedLlm ? ' via host model' : ' (no model: rules only)'}`);
|
|
2219
|
-
// Backfill is triggered by the first user message, not here: at apply() time
|
|
2220
|
-
// the host model service is not mounted yet and every call fails.
|
|
2221
|
-
|
|
2222
|
-
try {
|
|
2223
|
-
ctx.effect(
|
|
2224
|
-
() => {
|
|
2225
|
-
log('effect registered');
|
|
2226
|
-
return () => {
|
|
2227
|
-
try {
|
|
2228
|
-
soul?.store?.close();
|
|
2229
|
-
soul = null;
|
|
2230
|
-
lastSectionHash = '';
|
|
2231
|
-
log('effect disposed (store closed)');
|
|
2232
|
-
} catch (error) {
|
|
2233
|
-
log(`dispose failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2234
|
-
}
|
|
2235
|
-
};
|
|
2236
|
-
},
|
|
2237
|
-
'soulbridge.lifecycle',
|
|
2238
|
-
);
|
|
2239
|
-
} catch (error) {
|
|
2240
|
-
log(`effect registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2241
|
-
}
|
|
2242
|
-
|
|
2243
|
-
try {
|
|
2244
|
-
ctx.systemPrompt.section({
|
|
2245
|
-
name: 'soulbridge',
|
|
2246
|
-
order: 10,
|
|
2247
|
-
text: () => buildInjection(),
|
|
2248
|
-
});
|
|
2249
|
-
log('section registered (name=soulbridge order=10)');
|
|
2250
|
-
} catch (error) {
|
|
2251
|
-
log(`section registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2252
|
-
}
|
|
2253
|
-
|
|
2254
|
-
// RFC 0006: relevant memories are injected here, not in the system prompt —
|
|
2255
|
-
// this is the only point that runs after the user message and before the
|
|
2256
|
-
// request, and its waterfall can change the request.
|
|
2257
|
-
try {
|
|
2258
|
-
if (ctx.on) {
|
|
2259
|
-
ctx.on('agent/pre-step', createPreStepHandler({ getContext: buildRelatedContext, archivedSessions: () => readArchivedSessions(ctx) }));
|
|
2260
|
-
log('pre-step listener attached');
|
|
2261
|
-
}
|
|
2262
|
-
} catch (error) {
|
|
2263
|
-
log(`pre-step listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
// Tools (RFC 0005): registered only when the host exposes the service, and
|
|
2267
|
-
// disposed with the plugin so a reload cannot register twice.
|
|
2268
|
-
try {
|
|
2269
|
-
if (typeof ctx.inject === 'function') {
|
|
2270
|
-
ctx.inject(['tools'], (toolsCtx) => {
|
|
2271
|
-
const tools = toolsCtx.get('tools');
|
|
2272
|
-
if (!tools) {
|
|
2273
|
-
log('tools: service unavailable; disabled');
|
|
2274
|
-
return;
|
|
2275
|
-
}
|
|
2276
|
-
const disposers = [tools.register(recallTool()), tools.register(rememberTool()), tools.register(detailTool())];
|
|
2277
|
-
log('tools registered (soul_recall, soul_remember, soul_detail)');
|
|
2278
|
-
ctx.effect(() => () => {
|
|
2279
|
-
for (const dispose of disposers) {
|
|
2280
|
-
try {
|
|
2281
|
-
dispose?.();
|
|
2282
|
-
} catch {
|
|
2283
|
-
// A failed disposer must not block the rest of unload.
|
|
2284
|
-
}
|
|
2285
|
-
}
|
|
2286
|
-
log('tools disposed');
|
|
2287
|
-
});
|
|
2288
|
-
});
|
|
2289
|
-
} else {
|
|
2290
|
-
log('tools: ctx.inject unavailable; disabled');
|
|
2291
|
-
}
|
|
2292
|
-
} catch (error) {
|
|
2293
|
-
log(`tools registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2294
|
-
}
|
|
2295
|
-
|
|
2296
|
-
try {
|
|
2297
|
-
ctx.on('session/event', (session, event) => {
|
|
2298
|
-
const type = event?.type ?? '?';
|
|
2299
|
-
log(
|
|
2300
|
-
`session/event type=${type}` +
|
|
2301
|
-
` eventKeys=[${safeKeys(event)}]` +
|
|
2302
|
-
` dataKeys=[${safeKeys(event?.data)}]` +
|
|
2303
|
-
` sessionKeys=[${safeKeys(session)}]`,
|
|
2304
|
-
);
|
|
2305
|
-
|
|
2306
|
-
const sessionId = session?.id ?? 'default';
|
|
2307
|
-
|
|
2308
|
-
// Keep the last reply: a correction only makes sense next to what it targets.
|
|
2309
|
-
if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
|
|
2310
|
-
if (typeof event.data?.step === 'number') currentStep = event.data.step;
|
|
2311
|
-
|
|
2312
|
-
if (type === 'assistant/message') {
|
|
2313
|
-
const content = event.data?.message?.content;
|
|
2314
|
-
const reply = textOf(content);
|
|
2315
|
-
if (reply.length > 0) {
|
|
2316
|
-
lastAssistantText = reply;
|
|
2317
|
-
noteAssistantPart(sessionId, reply);
|
|
2318
|
-
}
|
|
2319
|
-
// Reasoning is stored, never injected: it is a draft, but "why did you
|
|
2320
|
-
// say that" and consolidation both need it.
|
|
2321
|
-
const reasoning = reasoningOf(content);
|
|
2322
|
-
if (reasoning.length > 0) noteAssistantStep(sessionId, 'reasoning', reasoning);
|
|
2323
|
-
if (reply.length > 0) noteAssistantStep(sessionId, 'text', reply);
|
|
2324
|
-
// Context size per request: the only number that can show whether a
|
|
2325
|
-
// long session is running away (no user text, usage only).
|
|
2326
|
-
trace({
|
|
2327
|
-
event: 'message',
|
|
2328
|
-
session: sessionId,
|
|
2329
|
-
turn: event.data?.turn,
|
|
2330
|
-
step: event.data?.step,
|
|
2331
|
-
replyChars: reply.length,
|
|
2332
|
-
// What the model actually returned, block by block: the turn is 5
|
|
2333
|
-
// steps on average and 70% of turns call tools, so "one question one
|
|
2334
|
-
// answer" is a product abstraction, not the model's shape. Types and
|
|
2335
|
-
// sizes only — never the text.
|
|
2336
|
-
blocks: summarizeBlocks(event.data?.message?.content),
|
|
2337
|
-
usage: event.data?.usage ?? null,
|
|
2338
|
-
});
|
|
2339
|
-
return;
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
// A turn is over: write the pair. This is the primary flush point; the
|
|
2343
|
-
// silence timer and the next user turn are fallbacks only.
|
|
2344
|
-
if (type === 'turn/end') {
|
|
2345
|
-
flushTurnPair(sessionId);
|
|
2346
|
-
return;
|
|
2347
|
-
}
|
|
2348
|
-
|
|
2349
|
-
if (type !== 'user/message') return;
|
|
2350
|
-
|
|
2351
|
-
const data = event.data ?? {};
|
|
2352
|
-
const kind = data.source?.kind;
|
|
2353
|
-
const text = textOf(data.content);
|
|
2354
|
-
log(`user/message source=${kind ?? '?'} chars=${text.length}`);
|
|
2355
|
-
// The model service is only mounted once the session is live, so the
|
|
2356
|
-
// one-shot backfill waits for the first real message instead of running
|
|
2357
|
-
// inside apply() where the llm lookup fails.
|
|
2358
|
-
if (kind === 'user') {
|
|
2359
|
-
void backfillExtraction();
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
// Only the human's own prose. runtime-context / time-context / injected
|
|
2363
|
-
// messages arrive with their own source kinds and must not be observed.
|
|
2364
|
-
if (kind !== 'user') return;
|
|
2365
|
-
if (text.length === 0) return;
|
|
2366
|
-
|
|
2367
|
-
// A new user turn means the previous one never saw turn/end (interrupt,
|
|
2368
|
-
// crash). Drop it rather than write a half pair.
|
|
2369
|
-
flushTurnPair(sessionId);
|
|
2370
|
-
noteUserTurn(sessionId, event, text);
|
|
2371
|
-
|
|
2372
|
-
// Evidence, not memory (RFC 0003 phase A), recorded with the reply it targeted.
|
|
2373
|
-
// Two layers: explicit markers (L1) first, then structure against that reply (L2).
|
|
2374
|
-
const verdict = detectCorrection(text, lastAssistantText);
|
|
2375
|
-
if (verdict.isCorrection) {
|
|
2376
|
-
try {
|
|
2377
|
-
const id = ensureSoul().recordCorrection({
|
|
2378
|
-
userText: text,
|
|
2379
|
-
assistantText: lastAssistantText,
|
|
2380
|
-
rule: verdict.marker ? `${verdict.layer}:${verdict.marker}` : verdict.layer,
|
|
2381
|
-
});
|
|
2382
|
-
log(`correction recorded: ${id} layer=${verdict.layer} marker=${verdict.marker ?? '-'}`);
|
|
2383
|
-
} catch (error) {
|
|
2384
|
-
log(`correction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2385
|
-
}
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
void observeText(text);
|
|
2389
|
-
});
|
|
2390
|
-
log('session/event listener attached');
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
// NOTE: reflect() is intentionally not wired. observe() already distils each
|
|
2394
|
-
// turn (distillStrategy lives in ruleExtract), so a per-turn reflect would
|
|
2395
|
-
// only return memories=0; session/disposed never fires on the desktop host.
|
|
2396
|
-
// Session-level summarisation waits for a reliable trigger and LLM distillation.
|
|
2397
|
-
} catch (error) {
|
|
2398
|
-
log(`session/event listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2399
|
-
}
|
|
2400
|
-
}
|
|
2401
|
-
|
|
2402
|
-
export { textOf, VERSION };
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-soulbridge — DSH adapter for the soulbridge personality layer.
|
|
3
|
+
*
|
|
4
|
+
* Division of labour (ADR 0024): core owns state and wording; this plugin owns
|
|
5
|
+
* timing. It does three things and nothing else:
|
|
6
|
+
* 1. puts core's context text into the system prompt (verb 1)
|
|
7
|
+
* 2. hands every human message to observe() (verb 2)
|
|
8
|
+
* 3. records a heartbeat so "is it working?" is answerable without log spelunking
|
|
9
|
+
*
|
|
10
|
+
* Every failure degrades: the host must never break because this plugin is
|
|
11
|
+
* unhappy. What was injected, observed, or skipped is written to
|
|
12
|
+
* ~/.soulbridge/plugin-state.json.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { basename, join, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import {
|
|
19
|
+
openSqlite,
|
|
20
|
+
ensureConfig,
|
|
21
|
+
resolveDbPath,
|
|
22
|
+
Soul,
|
|
23
|
+
detectCorrection,
|
|
24
|
+
renderConsolidationPrompt,
|
|
25
|
+
renderRelationPrompt,
|
|
26
|
+
renderRuleClassifyPrompt,
|
|
27
|
+
renderProfileMergePrompt,
|
|
28
|
+
parseProfileMerge,
|
|
29
|
+
groupProfileCandidates,
|
|
30
|
+
parseRuleClassifications,
|
|
31
|
+
selectRules,
|
|
32
|
+
renderVerificationPrompt,
|
|
33
|
+
parseRelationDraft,
|
|
34
|
+
normalizeStatement,
|
|
35
|
+
parseConclusionDraft,
|
|
36
|
+
parseVerificationDraft,
|
|
37
|
+
readVerification,
|
|
38
|
+
escapeAttr,
|
|
39
|
+
escapeBlockText,
|
|
40
|
+
} from 'soulbridge';
|
|
41
|
+
|
|
42
|
+
export const name = 'dsh-plugin-soulbridge';
|
|
43
|
+
export const inject = ['systemPrompt'];
|
|
44
|
+
|
|
45
|
+
const HOME = process.env.SOULBRIDGE_HOME ?? join(homedir(), '.soulbridge');
|
|
46
|
+
const VERSION = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
47
|
+
const STATE_FILE = join(HOME, 'plugin-state.json');
|
|
48
|
+
/**
|
|
49
|
+
* The model's raw replies, complete. Written as JSON strings so newlines stay
|
|
50
|
+
* one line per entry; nothing is cut, because a truncated failure message is
|
|
51
|
+
* what made an earlier diagnosis impossible.
|
|
52
|
+
*/
|
|
53
|
+
const LLM_RAW_FILE = join(HOME, 'llm-raw.log');
|
|
54
|
+
|
|
55
|
+
function logLlmRaw(raw) {
|
|
56
|
+
try {
|
|
57
|
+
mkdirSync(HOME, { recursive: true });
|
|
58
|
+
appendFileSync(LLM_RAW_FILE, `${new Date().toISOString()} ${JSON.stringify(String(raw))}\n`);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
log(`llm raw write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Structured trace (brief 0005): one jsonl line per decision, for reconciliation
|
|
66
|
+
* rather than reading. Never contains user text — ids, sizes and usage only.
|
|
67
|
+
*
|
|
68
|
+
* The first consumer is "is the context running away": a 134-turn session
|
|
69
|
+
* degenerated into repeated tokens and the database had no trace of it.
|
|
70
|
+
*/
|
|
71
|
+
const TRACE_FILE = join(HOME, 'trace.jsonl');
|
|
72
|
+
const TRACE_MAX_BYTES = 10 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
function trace(record) {
|
|
75
|
+
if (readConfig().traceEnabled === false) return;
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(HOME, { recursive: true });
|
|
78
|
+
try {
|
|
79
|
+
if (statSync(TRACE_FILE).size > TRACE_MAX_BYTES) renameSync(TRACE_FILE, `${TRACE_FILE}.1`);
|
|
80
|
+
} catch {
|
|
81
|
+
// No file yet: nothing to rotate.
|
|
82
|
+
}
|
|
83
|
+
appendFileSync(TRACE_FILE, `${JSON.stringify({ ts: new Date().toISOString(), ...record })}\n`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
log(`trace write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Prompt assembly can run several times per step; do not hit SQLite every time. */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Optional plugin config: ~/.soulbridge/plugin-config.json
|
|
93
|
+
* { "extract": "off" | "rules" | "llm", "provider": "...", "model": "...", "maxLlmChars": 2000 }
|
|
94
|
+
*
|
|
95
|
+
* One switch decides whether the host model exists for this plugin. It used to be
|
|
96
|
+
* two: `distill` created the model while the four stages checked `extract`, so the
|
|
97
|
+
* default (distill:'rules') left every stage believing a model was configured and
|
|
98
|
+
* silently returning false. `distill` is retired (docs/retirements.json).
|
|
99
|
+
*/
|
|
100
|
+
const CONFIG_FILE = join(HOME, 'plugin-config.json');
|
|
101
|
+
const CONFIG_DEFAULTS = {
|
|
102
|
+
provider: 'deepseek-account',
|
|
103
|
+
model: 'deepseek-flash',
|
|
104
|
+
maxLlmChars: 2000,
|
|
105
|
+
/** off | rules | llm — who writes entities/facts from a finished turn (ADR 0029). */
|
|
106
|
+
extract: 'llm',
|
|
107
|
+
/** Turns of silence that end a topic thread. Lowered temporarily for tests. */
|
|
108
|
+
threadIdleTurns: 6,
|
|
109
|
+
/** Minutes of silence after which another session counts as finished. */
|
|
110
|
+
threadIdleMinutes: 1440,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The extraction mode: `off` writes nothing, `rules` uses the deterministic path. */
|
|
114
|
+
export function llmMode(config = {}) {
|
|
115
|
+
const mode = config.extract ?? CONFIG_DEFAULTS.extract;
|
|
116
|
+
return mode === 'off' || mode === 'rules' ? mode : 'llm';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Is the host model available at all? Only `extract: 'llm'` provides it. */
|
|
120
|
+
export function usesModel(config = {}) {
|
|
121
|
+
return llmMode(config) === 'llm';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readConfig() {
|
|
125
|
+
try {
|
|
126
|
+
return { ...CONFIG_DEFAULTS, ...JSON.parse(readFileSync(CONFIG_FILE, 'utf8')) };
|
|
127
|
+
} catch {
|
|
128
|
+
return { ...CONFIG_DEFAULTS };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Adapter side of the core LlmFn port: the host model, called inside a turn. */
|
|
133
|
+
let currentTurn;
|
|
134
|
+
let currentStep;
|
|
135
|
+
|
|
136
|
+
// Measured, not guessed: 279 calls, p50=1.7s p90=6.8s p99=46.7s, max=142.9s.
|
|
137
|
+
// 90s is ~2x p99 and caps a hung call without cutting the ordinary tail.
|
|
138
|
+
// Re-measure with scripts/latency.mjs before changing it.
|
|
139
|
+
const LLM_TIMEOUT_MS = 95_000;
|
|
140
|
+
/** Three times one call, so a slow stage cannot hold the chain past a retry cycle. */
|
|
141
|
+
const CHAIN_TIMEOUT_MS = 285_000;
|
|
142
|
+
/** A hung model call must fail, not hold the whole stage chain forever. */
|
|
143
|
+
function withTimeout(promise, ms, label) {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
146
|
+
promise.then(
|
|
147
|
+
(value) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
resolve(value);
|
|
150
|
+
},
|
|
151
|
+
(error) => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
reject(error);
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function makeLlm(ctx, config) {
|
|
160
|
+
if (!usesModel(config)) return undefined;
|
|
161
|
+
// Resolve the service on first use: at apply() time the llm plugin may not be
|
|
162
|
+
// registered yet, and the probe showed the call only works inside a turn.
|
|
163
|
+
return async (prompt) => {
|
|
164
|
+
const service = ctx.get('llm');
|
|
165
|
+
if (!service) throw new Error('llm service unavailable');
|
|
166
|
+
// Probe: the same call succeeds and fails minutes apart, and we only ever
|
|
167
|
+
// recorded the outcome. Start/end plus the live turn/step show which window
|
|
168
|
+
// a call actually lands in.
|
|
169
|
+
const started = Date.now();
|
|
170
|
+
log(`llm probe: start turn=${currentTurn ?? '?'} step=${currentStep ?? '?'} chars=${prompt.length}`);
|
|
171
|
+
try {
|
|
172
|
+
const text = await withTimeout(
|
|
173
|
+
collectText(
|
|
174
|
+
service.stream({
|
|
175
|
+
provider: config.provider,
|
|
176
|
+
model: config.model,
|
|
177
|
+
messages: [
|
|
178
|
+
{
|
|
179
|
+
id: `distill_${Date.now()}`,
|
|
180
|
+
role: 'user',
|
|
181
|
+
content: [{ type: 'text', text: prompt }],
|
|
182
|
+
source: { kind: 'user' },
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
}),
|
|
186
|
+
),
|
|
187
|
+
LLM_TIMEOUT_MS,
|
|
188
|
+
'llm call',
|
|
189
|
+
);
|
|
190
|
+
log(
|
|
191
|
+
`llm probe: end ok ms=${Date.now() - started} out=${typeof text === 'string' ? text.length : -1}` +
|
|
192
|
+
` step=${currentStep ?? '?'}`,
|
|
193
|
+
);
|
|
194
|
+
return text;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
log(
|
|
197
|
+
`llm probe: end fail ms=${Date.now() - started} step=${currentStep ?? '?'}` +
|
|
198
|
+
` err=${error instanceof Error ? error.message : String(error)}`,
|
|
199
|
+
);
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Collect the stream's text exactly once.
|
|
207
|
+
*
|
|
208
|
+
* The host emits `text-delta` chunks and then a `block-end` carrying the whole
|
|
209
|
+
* block. Concatenating both doubled every reply, which turned valid JSON into
|
|
210
|
+
* two concatenated objects and made every distillation look like `failed`.
|
|
211
|
+
*
|
|
212
|
+
* A failed call is not an empty answer: the host ends the stream with
|
|
213
|
+
* `finish{reason:{kind:'error'|'aborted'}}` instead of throwing, so without this
|
|
214
|
+
* check the failure came back as `''` and the caller read it as "the model said
|
|
215
|
+
* nothing". `collectText` throws unless some text was produced before the failure.
|
|
216
|
+
*/
|
|
217
|
+
export async function collectText(stream) {
|
|
218
|
+
let text = '';
|
|
219
|
+
let sawDelta = false;
|
|
220
|
+
let failure;
|
|
221
|
+
for await (const chunk of stream) {
|
|
222
|
+
if (chunk.type === 'text-delta') {
|
|
223
|
+
text += chunk.text;
|
|
224
|
+
sawDelta = true;
|
|
225
|
+
} else if (!sawDelta && chunk.type === 'block-end' && chunk.block?.type === 'text') {
|
|
226
|
+
text += chunk.block.text ?? '';
|
|
227
|
+
} else if (chunk.type === 'finish' && (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted')) {
|
|
228
|
+
failure = chunk.reason;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (failure && text.length === 0) {
|
|
232
|
+
const detail = failure.message ? `: ${failure.message}` : '';
|
|
233
|
+
throw new Error(`model call ended without output (${failure.kind}${detail})`);
|
|
234
|
+
}
|
|
235
|
+
return text;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* What the consolidation call actually returned.
|
|
240
|
+
*
|
|
241
|
+
* `parseConclusionDraft` returns null for both "nothing to conclude" and "could not
|
|
242
|
+
* read the reply". Treating them the same dropped the thread's conclusion whenever
|
|
243
|
+
* the call failed — the failure looked exactly like a decision.
|
|
244
|
+
*/
|
|
245
|
+
export function readConsolidationReply(raw) {
|
|
246
|
+
const text = String(raw ?? '').trim();
|
|
247
|
+
if (text === '') return { kind: 'empty' };
|
|
248
|
+
const draft = parseConclusionDraft(text);
|
|
249
|
+
if (draft) return { kind: 'draft', draft };
|
|
250
|
+
// An explicit "无" is an answer; anything else that does not parse is a failure.
|
|
251
|
+
if (/^(无|没有|none)/m.test(text)) return { kind: 'none' };
|
|
252
|
+
return { kind: 'unparseable' };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function log(line) {
|
|
256
|
+
try {
|
|
257
|
+
mkdirSync(HOME, { recursive: true });
|
|
258
|
+
appendFileSync(join(HOME, 'plugin.log'), `${new Date().toISOString()} ${line}\n`);
|
|
259
|
+
} catch {
|
|
260
|
+
// Logging must never break the host.
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readState() {
|
|
265
|
+
try {
|
|
266
|
+
return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
|
267
|
+
} catch {
|
|
268
|
+
return {};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function writeState(patch) {
|
|
273
|
+
try {
|
|
274
|
+
mkdirSync(HOME, { recursive: true });
|
|
275
|
+
const next = { ...readState(), ...patch, version: VERSION, updatedAt: Date.now() };
|
|
276
|
+
writeFileSync(STATE_FILE, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
277
|
+
} catch (error) {
|
|
278
|
+
log(`state write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let soul = null;
|
|
283
|
+
let soulError = null;
|
|
284
|
+
/** Last reply text, so a correction can be stored with what it targets. */
|
|
285
|
+
let lastAssistantText;
|
|
286
|
+
/** Injected by apply(); the Soul reads them once at construction. */
|
|
287
|
+
let injectedLlm;
|
|
288
|
+
let injectedMaxLlmChars = 2000;
|
|
289
|
+
|
|
290
|
+
/** Open the soul once. Throws only the first time; callers degrade to no injection. */
|
|
291
|
+
function ensureSoul() {
|
|
292
|
+
if (soul) return soul;
|
|
293
|
+
if (soulError) throw soulError;
|
|
294
|
+
try {
|
|
295
|
+
const config = ensureConfig();
|
|
296
|
+
soul = new Soul({
|
|
297
|
+
store: openSqlite(resolveDbPath()),
|
|
298
|
+
soulId: config.soulId,
|
|
299
|
+
userId: config.userId,
|
|
300
|
+
name: config.name,
|
|
301
|
+
deviceId: config.deviceId,
|
|
302
|
+
llm: injectedLlm,
|
|
303
|
+
llmMaxChars: injectedMaxLlmChars,
|
|
304
|
+
onLlmRaw: logLlmRaw,
|
|
305
|
+
});
|
|
306
|
+
log(`soul ready: db=${resolveDbPath()} soul=${config.soulId} user=${config.userId}`);
|
|
307
|
+
writeState({ appliedAt: readState().appliedAt ?? Date.now(), soulId: config.soulId, userId: config.userId });
|
|
308
|
+
return soul;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
soulError = error instanceof Error ? error : new Error(String(error));
|
|
311
|
+
log(`soul init failed: ${soulError.message}`);
|
|
312
|
+
writeState({ lastError: `soul init failed: ${soulError.message}` });
|
|
313
|
+
throw soulError;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Last system-prompt text hash, so a changed section is visible in the trace. */
|
|
318
|
+
let lastSectionHash = '';
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The first whole sentence, used when the model gave no topic for a new thread.
|
|
322
|
+
*
|
|
323
|
+
* A character prefix cuts mid-clause ("这件事我们应该先" + cut), while a sentence is a
|
|
324
|
+
* complete unit — the same unit-level rule the rest of the prompt evidence follows.
|
|
325
|
+
*/
|
|
326
|
+
export function firstSentence(text) {
|
|
327
|
+
const trimmed = String(text ?? '').trim();
|
|
328
|
+
if (!trimmed) return '';
|
|
329
|
+
const match = /^[\s\S]*?[。!?!?\n]/.exec(trimmed);
|
|
330
|
+
return (match ? match[0] : trimmed).trim();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** FNV-1a: short, dependency-free, good enough to detect change. */
|
|
334
|
+
function hashText(text) {
|
|
335
|
+
let hash = 0x811c9dc5;
|
|
336
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
337
|
+
hash ^= text.charCodeAt(i);
|
|
338
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
339
|
+
}
|
|
340
|
+
return hash.toString(16);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Anchors: the few rules that should always be visible. Kept in the system
|
|
345
|
+
* prompt because that section is evaluated before the message arrives, so it
|
|
346
|
+
* cannot rank anything.
|
|
347
|
+
*/
|
|
348
|
+
const ANCHOR = { types: ['strategy', 'fact', 'trait'], minImportance: 8, maxItems: 3 };
|
|
349
|
+
/**
|
|
350
|
+
* Related injections come from `turn_pairs` (RFC 0007): every exchange scored
|
|
351
|
+
* against the current message, with no length or count cap.
|
|
352
|
+
*
|
|
353
|
+
* No cap on purpose. A long emotional exchange is exactly the thing worth
|
|
354
|
+
* remembering, so a character budget would systematically drop the important
|
|
355
|
+
* ones. Context limits are the host's problem (it compacts); ranking quality is
|
|
356
|
+
* ours. If injection grows too large later, sharpen the ranking or distill the
|
|
357
|
+
* pairs — do not go back to cutting length.
|
|
358
|
+
*/
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The system-prompt section: identity and style only.
|
|
362
|
+
*
|
|
363
|
+
* DSH declares `systemPromptUpdate: "in-history"` for this provider, and the
|
|
364
|
+
* loop appends every *changed* prompt as a new system node that keeps counting
|
|
365
|
+
* until compaction. Anchor memories drift with recency, so putting them here
|
|
366
|
+
* would grow the prompt version by version over a long session. Anything that
|
|
367
|
+
* changes per turn goes through the pre-step message instead.
|
|
368
|
+
*
|
|
369
|
+
* Synchronous by necessity: PromptSection.text cannot return a promise.
|
|
370
|
+
*/
|
|
371
|
+
function buildInjection() {
|
|
372
|
+
try {
|
|
373
|
+
// includeAffect:false — the affect sample changes almost every turn, and this
|
|
374
|
+
// section is `in-history`: DSH appends a new prompt version whenever the text
|
|
375
|
+
// changes. Injected affect therefore goes through the pre-step message (where
|
|
376
|
+
// the anchors call puts it), not here.
|
|
377
|
+
const result = ensureSoul().context({ track: false, includeAnchors: false, includeAffect: false });
|
|
378
|
+
const text = result.text;
|
|
379
|
+
const hash = hashText(text);
|
|
380
|
+
if (hash !== lastSectionHash) {
|
|
381
|
+
const changed = lastSectionHash !== '';
|
|
382
|
+
lastSectionHash = hash;
|
|
383
|
+
log(`section: ${changed ? 'changed' : 'first'} hash=${hash} chars=${text.length}`);
|
|
384
|
+
trace({ event: 'section', hash, changed, chars: text.length });
|
|
385
|
+
}
|
|
386
|
+
return text;
|
|
387
|
+
} catch (error) {
|
|
388
|
+
log(`section: failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
389
|
+
return '';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function safeKeys(value) {
|
|
394
|
+
if (!value || typeof value !== 'object') return '';
|
|
395
|
+
try {
|
|
396
|
+
return Object.keys(value).slice(0, 12).join(',');
|
|
397
|
+
} catch {
|
|
398
|
+
return '';
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** UserMessage.content is a ContentBlock[]; only text blocks are user prose. */
|
|
403
|
+
function reasoningOf(content) {
|
|
404
|
+
if (!Array.isArray(content)) return '';
|
|
405
|
+
return content
|
|
406
|
+
.filter((block) => block && block.type === 'reasoning' && typeof block.text === 'string')
|
|
407
|
+
.map((block) => block.text)
|
|
408
|
+
.join('\n')
|
|
409
|
+
.trim();
|
|
410
|
+
}
|
|
411
|
+
function textOf(content) {
|
|
412
|
+
if (typeof content === 'string') return content;
|
|
413
|
+
if (!Array.isArray(content)) return '';
|
|
414
|
+
return content
|
|
415
|
+
.filter((block) => block && block.type === 'text' && typeof block.text === 'string')
|
|
416
|
+
.map((block) => block.text)
|
|
417
|
+
.join('\n')
|
|
418
|
+
.trim();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Block inventory of one assistant message, for measuring what a reply really
|
|
423
|
+
* contains. Types, sizes and tool names only — never the text itself.
|
|
424
|
+
*/
|
|
425
|
+
export function summarizeBlocks(content) {
|
|
426
|
+
if (!Array.isArray(content)) return [];
|
|
427
|
+
return content.map((block) => {
|
|
428
|
+
const type = typeof block?.type === 'string' ? block.type : '?';
|
|
429
|
+
const entry = { type };
|
|
430
|
+
if (typeof block?.text === 'string') entry.chars = block.text.length;
|
|
431
|
+
if (typeof block?.reasoning === 'string') entry.chars = block.reasoning.length;
|
|
432
|
+
const name = block?.name ?? block?.toolName ?? block?.tool?.name;
|
|
433
|
+
if (typeof name === 'string') entry.name = name;
|
|
434
|
+
if (block?.arguments !== undefined) {
|
|
435
|
+
const raw = typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments);
|
|
436
|
+
entry.argsChars = raw.length;
|
|
437
|
+
}
|
|
438
|
+
return entry;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Exchanges ranked against the current message, for the pre-step injection.
|
|
444
|
+
*
|
|
445
|
+
* Source is `turn_pairs`, not `memories`: one question and one answer is the
|
|
446
|
+
* unit that can answer "what did we talk about last time" (RFC 0007). The
|
|
447
|
+
* system prompt keeps `memories` for now — anchors become the picture in MVP2.
|
|
448
|
+
*
|
|
449
|
+
* `used` carries the pair ids so an injection can be reviewed afterwards.
|
|
450
|
+
* Every related pair is injected, whatever its length.
|
|
451
|
+
*/
|
|
452
|
+
/**
|
|
453
|
+
* The cheapest possible memory: one line per fact, with the id needed to expand.
|
|
454
|
+
*
|
|
455
|
+
* Injection should not replay whole conversations — that decides for the model
|
|
456
|
+
* what is worth reading. It should say "this happened", and leave the detail
|
|
457
|
+
* behind `soul_detail`. When the current message names someone we know, we send
|
|
458
|
+
* that person's timeline ends: the earliest fact (how it started) and the most
|
|
459
|
+
* recent ones (where it stands).
|
|
460
|
+
*/
|
|
461
|
+
const LATIN_NAME = /[a-z0-9]/i;
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Does this message name the entity?
|
|
465
|
+
*
|
|
466
|
+
* A plain `includes` is wrong for short Latin names: an entity called "a" would
|
|
467
|
+
* match "chat", "data" and every English word. Latin names need boundaries; CJK
|
|
468
|
+
* names of two or more characters are matched as substrings, and a one-character
|
|
469
|
+
* CJK name is skipped rather than guessed.
|
|
470
|
+
*/
|
|
471
|
+
export function mentions(text, entity) {
|
|
472
|
+
const names = [entity?.name, ...(Array.isArray(entity?.aliases) ? entity.aliases : [])].filter(Boolean);
|
|
473
|
+
for (const name of names) {
|
|
474
|
+
if (LATIN_NAME.test(name)) {
|
|
475
|
+
const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
476
|
+
if (new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, 'i').test(text)) return true;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (name.length >= 2 && text.includes(name)) return true;
|
|
480
|
+
}
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function buildFactHooks(text, options = {}) {
|
|
485
|
+
const parts = [];
|
|
486
|
+
const used = [];
|
|
487
|
+
try {
|
|
488
|
+
const known = ensureSoul().findEntities('', 50);
|
|
489
|
+
// The entity timeline already covers the subject of an entity question;
|
|
490
|
+
// injecting both produced two copies of the same facts in one prompt.
|
|
491
|
+
const skip = new Set(options.skip ?? []);
|
|
492
|
+
const mentioned = known.filter((entity) => mentions(text, entity) && !skip.has(entity.name));
|
|
493
|
+
for (const entity of mentioned.slice(0, 3)) {
|
|
494
|
+
const earliest = ensureSoul().factsAbout(entity.id, { order: 'asc', limit: 1 });
|
|
495
|
+
const recent = ensureSoul().factsAbout(entity.id, { limit: 4 });
|
|
496
|
+
const seen = new Set();
|
|
497
|
+
const lines = [];
|
|
498
|
+
for (const fact of [...earliest, ...recent]) {
|
|
499
|
+
if (seen.has(fact.id)) continue;
|
|
500
|
+
seen.add(fact.id);
|
|
501
|
+
const when = fact.atText ?? (fact.at ? new Date(fact.at).toISOString().slice(0, 10) : '');
|
|
502
|
+
lines.push(`- [${fact.id}] ${escapeBlockText(fact.statement)}${when ? `(${escapeBlockText(when)})` : ''}`);
|
|
503
|
+
used.push(fact.id);
|
|
504
|
+
}
|
|
505
|
+
if (lines.length > 0) {
|
|
506
|
+
parts.push(`<about name="${escapeAttr(entity.name)}">\n${lines.join('\n')}\n</about>`);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
log(`prestep: hooks failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
511
|
+
}
|
|
512
|
+
if (parts.length === 0) return '';
|
|
513
|
+
return { text: parts.join('\n\n'), used };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Which injection blocks a plan allows.
|
|
518
|
+
*
|
|
519
|
+
* When the question names a subject, the entity timeline is the answer and the
|
|
520
|
+
* related-pairs search is the contamination: it is how "上次和 a 吃饭" ends up
|
|
521
|
+
* answered with something about b. So the plan chooses one or the other.
|
|
522
|
+
*/
|
|
523
|
+
export function planBlocks(plan) {
|
|
524
|
+
if (!plan) return { related: true, timeline: false };
|
|
525
|
+
if (plan.primitive === 'entity') return { related: false, timeline: true };
|
|
526
|
+
return { related: true, timeline: false };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const PROFILE_CORE_DIMENSION = '价值观';
|
|
530
|
+
/** A facet is injected only when the message is about that dimension. */
|
|
531
|
+
const FACET_TRIGGERS = {
|
|
532
|
+
工作方式: /代码|项目|实现|方案|架构|接口|测试|bug|重构/i,
|
|
533
|
+
沟通偏好: /聊天|回答|回复|表达|说话|解释/,
|
|
534
|
+
情绪: /累|烦|难受|焦虑|压力|开心|情绪|睡|难受/,
|
|
535
|
+
人际关系: /她|他|朋友|家里|关系|联系|见面|同事/,
|
|
536
|
+
生活兴趣: /吃|玩|旅行|电影|书|音乐|运动|兴趣|周末/,
|
|
537
|
+
边界: /不要|别|讨厌|雷区|底线|受不了/,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
function profileDimension(fact) {
|
|
541
|
+
const slot = fact.slots?.find((item) => item.slot === '维度' && item.value?.kind === 'text');
|
|
542
|
+
return slot?.value?.value ?? '';
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Core lines are always on; facets appear only when the message is about them.
|
|
547
|
+
*
|
|
548
|
+
* A whole person does not fit in a prompt, and a longer list of always-on lines is
|
|
549
|
+
* how a profile becomes wallpaper. Unlabelled lines (written before dimensions) stay
|
|
550
|
+
* in the core so nothing silently disappears.
|
|
551
|
+
*/
|
|
552
|
+
export function selectProfileFacts(facts, text, limit = 8) {
|
|
553
|
+
const picked = [];
|
|
554
|
+
for (const fact of facts) {
|
|
555
|
+
const dimension = profileDimension(fact);
|
|
556
|
+
if (!dimension || dimension === PROFILE_CORE_DIMENSION) {
|
|
557
|
+
picked.push(fact);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const trigger = FACET_TRIGGERS[dimension];
|
|
561
|
+
if (trigger && trigger.test(String(text ?? ''))) picked.push(fact);
|
|
562
|
+
}
|
|
563
|
+
return picked.slice(0, limit);
|
|
564
|
+
}
|
|
565
|
+
const RULE_LIMIT = 5;
|
|
566
|
+
|
|
567
|
+
/** Session counters a rule can key on; unknown flags are simply false. */
|
|
568
|
+
export function buildRuleState(state = {}) {
|
|
569
|
+
const turn = Number(state.turn ?? 0);
|
|
570
|
+
return { 'long-session': Number.isFinite(turn) && turn > 100 };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Render the fired rules as a requirement block, closest to the reply. */
|
|
574
|
+
export function renderRulesBlock(rules) {
|
|
575
|
+
if (!Array.isArray(rules) || rules.length === 0) return '';
|
|
576
|
+
const ids = rules.map((rule) => rule.id).join(',');
|
|
577
|
+
// Rule statements derive from lessons, which come from user text: escape them like
|
|
578
|
+
// any other content, or a lesson can close the block and forge a new requirement.
|
|
579
|
+
const lines = rules.map((rule) => `- ${escapeBlockText(rule.statement)}`).join('\n');
|
|
580
|
+
return `<rules fired="${ids}">\n${lines}\n</rules>`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function buildRelatedContext(text, state = {}) {
|
|
584
|
+
const parts = [];
|
|
585
|
+
const used = [];
|
|
586
|
+
// The profile is the merged description of the person; it is the only block that
|
|
587
|
+
// is always on, so it goes first and stays short (ADR 0033).
|
|
588
|
+
try {
|
|
589
|
+
const profile = selectProfileFacts(ensureSoul().profile(30), text, 8);
|
|
590
|
+
if (profile.length > 0) {
|
|
591
|
+
const lines = profile.map((fact) => `- ${escapeBlockText(fact.statement)}`).join('\n');
|
|
592
|
+
parts.push(`<profile>\n${lines}\n</profile>`);
|
|
593
|
+
used.push(...profile.map((fact) => fact.id));
|
|
594
|
+
}
|
|
595
|
+
} catch (error) {
|
|
596
|
+
log(`profile block failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
597
|
+
}
|
|
598
|
+
// The plan is computed first because it decides which blocks are allowed.
|
|
599
|
+
let plan = null;
|
|
600
|
+
try {
|
|
601
|
+
plan = ensureSoul().planRetrieval(text);
|
|
602
|
+
log(`router: ${plan.primitive}${plan.subject ? `(${plan.subject})` : ''} — ${plan.reason}`);
|
|
603
|
+
trace({ event: 'router', primitive: plan.primitive, subject: plan.subject ?? null, reason: plan.reason });
|
|
604
|
+
} catch (error) {
|
|
605
|
+
log(`router failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
606
|
+
}
|
|
607
|
+
const policy = planBlocks(plan);
|
|
608
|
+
try {
|
|
609
|
+
// Anchors moved here from the system prompt: a message is counted once and
|
|
610
|
+
// compacted normally, while a changed prompt becomes a new prompt version.
|
|
611
|
+
const anchors = ensureSoul().context({
|
|
612
|
+
track: false,
|
|
613
|
+
includeSoul: false,
|
|
614
|
+
includeStyle: false,
|
|
615
|
+
...ANCHOR,
|
|
616
|
+
});
|
|
617
|
+
if (anchors.text.trim()) {
|
|
618
|
+
parts.push(anchors.text.trim());
|
|
619
|
+
used.push(...anchors.used);
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
log(`prestep: anchors failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
623
|
+
}
|
|
624
|
+
const hooks = buildFactHooks(text, { skip: policy.timeline && plan?.subject ? [plan.subject] : [] });
|
|
625
|
+
if (hooks && hooks.text) {
|
|
626
|
+
parts.push(hooks.text);
|
|
627
|
+
used.push(...hooks.used);
|
|
628
|
+
}
|
|
629
|
+
if (policy.timeline && plan?.subject) {
|
|
630
|
+
try {
|
|
631
|
+
// The question, not just the subject: an entity with many facts must return
|
|
632
|
+
// the ones this question is about, not its three most recent.
|
|
633
|
+
const timeline = ensureSoul().latestAbout(plan.subject, { query: text, limit: 3 });
|
|
634
|
+
if (timeline.length > 0) {
|
|
635
|
+
const body = timeline
|
|
636
|
+
.map(
|
|
637
|
+
(fact) =>
|
|
638
|
+
`- [${fact.id}] ${fact.atText ? `${escapeBlockText(fact.atText)} ` : ''}${escapeBlockText(fact.statement)}`,
|
|
639
|
+
)
|
|
640
|
+
.join('\n');
|
|
641
|
+
parts.push(`<about-now name="${escapeAttr(plan.subject)}">\n${body}\n</about-now>`);
|
|
642
|
+
used.push(...timeline.map((fact) => fact.id));
|
|
643
|
+
} else {
|
|
644
|
+
// Saying "nothing" explicitly is better than letting a relevance search
|
|
645
|
+
// fill the gap with somebody else's memories.
|
|
646
|
+
parts.push(`<about-now name="${escapeAttr(plan.subject)}">(没有任何关于它的记忆)</about-now>`);
|
|
647
|
+
}
|
|
648
|
+
} catch (error) {
|
|
649
|
+
log(`timeline failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (policy.related) {
|
|
653
|
+
const pairs = ensureSoul().recallTurnPairs(text);
|
|
654
|
+
if (pairs.length > 0) {
|
|
655
|
+
const body = pairs
|
|
656
|
+
.map((pair) => `- [Q] ${escapeBlockText(pair.userText)}\n [A] ${escapeBlockText(pair.assistantText)}`)
|
|
657
|
+
.join('\n');
|
|
658
|
+
parts.push(`<related>\n${body}\n</related>`);
|
|
659
|
+
used.push(...pairs.map((pair) => pair.id));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// Rules last, and only the ones whose situation applies: the block sits closest
|
|
663
|
+
// to the reply and reads as a requirement, not as background material.
|
|
664
|
+
try {
|
|
665
|
+
const fired = selectRules(ensureSoul().rules(), text, buildRuleState(state), RULE_LIMIT);
|
|
666
|
+
if (fired.length > 0) {
|
|
667
|
+
parts.push(renderRulesBlock(fired));
|
|
668
|
+
log(`rules fired: ${fired.map((rule) => rule.id).join(',')}`);
|
|
669
|
+
trace({ event: 'rule_fired', rules: fired.map((rule) => rule.id), turn: state.turn ?? null });
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
log(`rules failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
673
|
+
}
|
|
674
|
+
if (parts.length === 0) return '';
|
|
675
|
+
// Retrieval practice: a fact that keeps being surfaced decays more slowly.
|
|
676
|
+
try {
|
|
677
|
+
ensureSoul().recordRecalls([...new Set(used)].filter((id) => String(id).startsWith('fc_')));
|
|
678
|
+
} catch (error) {
|
|
679
|
+
log(`recordRecalls failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
+
}
|
|
681
|
+
return { text: parts.join('\n\n'), used };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Build the agent/pre-step middleware (RFC 0006).
|
|
686
|
+
*
|
|
687
|
+
* Idempotence is by construction, not cleanup: one injection per (session,
|
|
688
|
+
* turn), and the injected message carries a source kind the observer ignores —
|
|
689
|
+
* the probe showed that using `user` there fed injected text back into memory.
|
|
690
|
+
*
|
|
691
|
+
* @param deps.getContext - returns the memory block for one message, or ''.
|
|
692
|
+
*/
|
|
693
|
+
/**
|
|
694
|
+
* The "where were we" block for a new session, or '' when there is nothing to
|
|
695
|
+
* hand over. Pure: the caller decides when, this decides what.
|
|
696
|
+
*/
|
|
697
|
+
export function renderHandoff(last, sessionId) {
|
|
698
|
+
if (!last || last.thread.sessionId === sessionId) return '';
|
|
699
|
+
const when = new Date(last.thread.closedAt ?? Date.now()).toISOString().slice(0, 10);
|
|
700
|
+
// Topic and turn text are user words: escape them, or a pasted `</handoff>` ends
|
|
701
|
+
// the block and everything after it reads as ordinary conversation.
|
|
702
|
+
// Whole lines, never prefixes: the handoff exists so the model knows what was said,
|
|
703
|
+
// and a cut line can drop the clause that made it matter. Size is bounded by how
|
|
704
|
+
// many turns the caller carries, not by cutting each one.
|
|
705
|
+
const lines = [`<handoff topic="${escapeAttr(last.thread.topic)}" closed="${when}">`];
|
|
706
|
+
if (last.conclusion) lines.push(` 上次的结论:${escapeBlockText(last.conclusion.statement)}`);
|
|
707
|
+
// Both sides (ADR 0035): "where were we" includes where I was in the conversation,
|
|
708
|
+
// not only what the user asked. The reply is usually where the decision landed.
|
|
709
|
+
for (const turn of last.turns) {
|
|
710
|
+
lines.push(` ${turn.turnNo}. 用户:${escapeBlockText(turn.userText)}`);
|
|
711
|
+
if (turn.assistantText) lines.push(` ${turn.turnNo}. 我:${escapeBlockText(turn.assistantText)}`);
|
|
712
|
+
}
|
|
713
|
+
lines.push('</handoff>');
|
|
714
|
+
return lines.join('\n');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* The sessions the user has archived in the workspace.
|
|
719
|
+
*
|
|
720
|
+
* `workspaceRegistry` is a Cordis service on the host context; archive is its
|
|
721
|
+
* durable set, not an event, so it is read at each turn. Absent service means an
|
|
722
|
+
* older or different host — the caller then falls back to the inactivity rule.
|
|
723
|
+
*/
|
|
724
|
+
function readArchivedSessions(ctx) {
|
|
725
|
+
try {
|
|
726
|
+
const registry = ctx.get?.('workspaceRegistry') ?? ctx.workspaceRegistry;
|
|
727
|
+
const ids = registry?.archivedSessionIds;
|
|
728
|
+
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
|
|
729
|
+
} catch (error) {
|
|
730
|
+
log(`archive list unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
731
|
+
return [];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Run the per-step stages in priority order, stopping at the first that did work.
|
|
736
|
+
*
|
|
737
|
+
* A stage's boolean means "a model call was made", not "I was invoked": a stage
|
|
738
|
+
* that skips its work must return false so the next stage gets the one call this
|
|
739
|
+
* step is allowed. This function exists because that contract was implicit in an
|
|
740
|
+
* `if`, and a skip returning true starved the classifier for an hour.
|
|
741
|
+
*/
|
|
742
|
+
export async function runStageChain(stages) {
|
|
743
|
+
for (const stage of stages) {
|
|
744
|
+
if (await stage()) return true;
|
|
745
|
+
}
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function createPreStepHandler(deps) {
|
|
750
|
+
/** sessionId -> last turn that received an injection */
|
|
751
|
+
const injectedTurn = new Map();
|
|
752
|
+
/** Sessions that already got the handoff block once. */
|
|
753
|
+
const handedOff = new Set();
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* The "where were we" block: the last topic that finished.
|
|
757
|
+
*
|
|
758
|
+
* Deliberately once per session and only when that topic belongs to a
|
|
759
|
+
* different session — repeating it every turn would make it wallpaper, and
|
|
760
|
+
* re-reading the previous session's own topic is not a handoff.
|
|
761
|
+
*/
|
|
762
|
+
function takeHandoff(sessionId) {
|
|
763
|
+
if (handedOff.has(sessionId)) return '';
|
|
764
|
+
try {
|
|
765
|
+
const last = ensureSoul().lastClosedThread();
|
|
766
|
+
const block = renderHandoff(last, sessionId);
|
|
767
|
+
// Only mark it once the read succeeded: a transient failure on the first turn
|
|
768
|
+
// used to exclude the session from handoff forever.
|
|
769
|
+
handedOff.add(sessionId);
|
|
770
|
+
if (block) {
|
|
771
|
+
log(`handoff: "${last.thread.topic}" summary=${last.conclusion ? 'yes' : 'no'}`);
|
|
772
|
+
trace({
|
|
773
|
+
event: 'handoff',
|
|
774
|
+
thread: last.thread.id,
|
|
775
|
+
theme: last.thread.topic,
|
|
776
|
+
hasSummary: Boolean(last.conclusion),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return block;
|
|
780
|
+
} catch (error) {
|
|
781
|
+
log(`handoff failed (will retry next turn): ${error instanceof Error ? error.message : String(error)}`);
|
|
782
|
+
return '';
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
return async (payload, next) => {
|
|
787
|
+
const decision = await next();
|
|
788
|
+
try {
|
|
789
|
+
if (decision?.kind !== 'enter') return decision;
|
|
790
|
+
// We are inside a live turn now, which is the only place the host model is
|
|
791
|
+
// callable. Stages run in priority order and a stage that skipped its work
|
|
792
|
+
// must not claim the call — that is how the classifier starved for an hour.
|
|
793
|
+
//
|
|
794
|
+
// Single flight: the handler returns before the chain finishes, so without
|
|
795
|
+
// this guard a slow stage lets the next pre-step start a second chain and two
|
|
796
|
+
// model calls overlap in one step (live log showed two verify calls in flight).
|
|
797
|
+
if (stageChainBusy) {
|
|
798
|
+
log('background: chain still running, skipping this step');
|
|
799
|
+
} else {
|
|
800
|
+
stageChainBusy = true;
|
|
801
|
+
void (async () => {
|
|
802
|
+
try {
|
|
803
|
+
// Fairness: a long consolidation backlog used to consume every step, so
|
|
804
|
+
// the profile merge never got a turn. The profile alternates with the
|
|
805
|
+
// rest of the chain instead of always queueing behind it.
|
|
806
|
+
// "Pending" means a dimension group has at least two judgments in it: a
|
|
807
|
+
// queue of singletons has nothing to collapse and must not hold a slot.
|
|
808
|
+
const profilePending = hasMergeableGroup(peekProfileQueue());
|
|
809
|
+
const stages = [];
|
|
810
|
+
const extractQueue = readExtractQueue();
|
|
811
|
+
const consolidationPending = readConsolidationQueue().length > 0;
|
|
812
|
+
// Live conversation before everything; understanding before the backfill.
|
|
813
|
+
// Fixed priority (extract always first) plus a long backfill is a starvation
|
|
814
|
+
// bug, not a preference: 29 closed threads waited while the backfill drained.
|
|
815
|
+
if (extractRunsFirst(extractQueue, consolidationPending)) stages.push(drainExtractionQueue);
|
|
816
|
+
stages.push(runConsolidationStep);
|
|
817
|
+
stages.push(drainExtractionQueue);
|
|
818
|
+
if (profilePending && stageTick % 2 === 0) stages.push(runProfileStep);
|
|
819
|
+
stages.push(runClassifyStep);
|
|
820
|
+
if (profilePending && stageTick % 2 === 1) stages.push(runProfileStep);
|
|
821
|
+
stageTick += 1;
|
|
822
|
+
// The timeout has to be cancelled when the chain wins the race. Left
|
|
823
|
+
// alone it stays a live timer for the full 285s — a leak per step, and
|
|
824
|
+
// the reason `node --test` printed every result and then never exited.
|
|
825
|
+
let chainTimer;
|
|
826
|
+
const chainDeadline = new Promise((_, reject) => {
|
|
827
|
+
chainTimer = setTimeout(() => reject(new Error('stage chain timed out')), CHAIN_TIMEOUT_MS);
|
|
828
|
+
});
|
|
829
|
+
try {
|
|
830
|
+
await Promise.race([runStageChain(stages), chainDeadline]);
|
|
831
|
+
} finally {
|
|
832
|
+
clearTimeout(chainTimer);
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
log(`background: ${error instanceof Error ? error.message : String(error)}`);
|
|
836
|
+
} finally {
|
|
837
|
+
stageChainBusy = false;
|
|
838
|
+
}
|
|
839
|
+
})();
|
|
840
|
+
}
|
|
841
|
+
// Control switch for the A/B test: plugin-config.json { "injectEnabled": false }
|
|
842
|
+
if (readConfig().injectEnabled === false) {
|
|
843
|
+
log('prestep: inject disabled by config');
|
|
844
|
+
return decision;
|
|
845
|
+
}
|
|
846
|
+
// Topics the conversation has moved past, closed once per turn. Counting
|
|
847
|
+
// actual turns — the host's turn numbers jump by 10–30 per exchange, so a
|
|
848
|
+
// numeric difference closed a topic one message after it opened.
|
|
849
|
+
const sessionId = payload?.agent?.id ?? payload?.agent?.session?.id ?? 'default';
|
|
850
|
+
try {
|
|
851
|
+
const closeConfig = readConfig();
|
|
852
|
+
const closed = ensureSoul().closeIdleThreads(sessionId, {
|
|
853
|
+
idleTurns: closeConfig.threadIdleTurns ?? 6,
|
|
854
|
+
idleMs: (closeConfig.threadIdleMinutes ?? 30) * 60_000,
|
|
855
|
+
// The host's own archive list: a session the user put away is finished
|
|
856
|
+
// by declaration, so its topics close immediately.
|
|
857
|
+
archivedSessionIds: deps.archivedSessions?.() ?? [],
|
|
858
|
+
at: Date.now(),
|
|
859
|
+
});
|
|
860
|
+
if (closed.length > 0) {
|
|
861
|
+
// The reason is logged because a threshold that fires too early must be
|
|
862
|
+
// visible: a topic closed by a guess is a partial conclusion written to
|
|
863
|
+
// durable memory.
|
|
864
|
+
log(`threads closed: ${closed.map((thread) => `${thread.id}(${thread.reason})`).join(',')}`);
|
|
865
|
+
trace({ event: 'threads-closed', closed });
|
|
866
|
+
// A closed thread is the unit consolidation runs on — never a batch of
|
|
867
|
+
// facts, which can mix two topics into one conclusion.
|
|
868
|
+
// Without a model there is nothing to consolidate, and enqueuing anyway
|
|
869
|
+
// only fills the queue until the cap silently drops the newest threads.
|
|
870
|
+
if (usesModel(closeConfig)) {
|
|
871
|
+
enqueueConsolidation(closed.map((thread) => thread.id));
|
|
872
|
+
} else if (closed.length > 0) {
|
|
873
|
+
log(`consolidate: ${closed.length} closed thread(s) not queued (extract=${closeConfig.extract})`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
} catch (error) {
|
|
877
|
+
log(`threads close failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
878
|
+
}
|
|
879
|
+
const messages = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
880
|
+
const text = textOf(messages[messages.length - 1]?.content);
|
|
881
|
+
if (text.length === 0) return decision;
|
|
882
|
+
|
|
883
|
+
const turn = payload?.turn;
|
|
884
|
+
// A non-finite turn cannot identify a step; injecting once per undefined turn
|
|
885
|
+
// would compare undefined === undefined and skip every pre-step silently.
|
|
886
|
+
if (turn !== undefined && injectedTurn.get(sessionId) === turn) return decision;
|
|
887
|
+
|
|
888
|
+
const ctx = deps.getContext(text, { turn });
|
|
889
|
+
const related = typeof ctx === 'string' ? ctx : (ctx?.text ?? '');
|
|
890
|
+
const used = typeof ctx === 'string' ? [] : (ctx?.used ?? []);
|
|
891
|
+
// "Where were we" is answered by the last topic that finished, not by the
|
|
892
|
+
// previous session: one session can hold several topics, and a topic can
|
|
893
|
+
// span sessions. Injected once per session, when it is a different one.
|
|
894
|
+
const handoff = takeHandoff(sessionId);
|
|
895
|
+
const block = [handoff, related].filter(Boolean).join('\n\n');
|
|
896
|
+
injectedTurn.set(sessionId, turn);
|
|
897
|
+
if (injectedTurn.size > 16) {
|
|
898
|
+
// Keep the map bounded: drop the oldest entry, not the newest.
|
|
899
|
+
const oldest = injectedTurn.keys().next().value;
|
|
900
|
+
if (oldest !== undefined) injectedTurn.delete(oldest);
|
|
901
|
+
}
|
|
902
|
+
if (!block) {
|
|
903
|
+
log(`prestep: no related memory (turn=${turn})`);
|
|
904
|
+
return decision;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const note = {
|
|
908
|
+
id: `memctx_${turn}`,
|
|
909
|
+
role: 'user',
|
|
910
|
+
content: [{ type: 'text', text: block }],
|
|
911
|
+
source: { kind: 'memory-context' },
|
|
912
|
+
};
|
|
913
|
+
const idPart = used.length > 0 ? ` ids=[${used.join(',')}]` : '';
|
|
914
|
+
log(`prestep: inject turn=${turn} chars=${block.length}${idPart} handoff=${handoff ? 'yes' : 'no'}`);
|
|
915
|
+
trace({ event: 'inject', session: sessionId, turn, chars: block.length, ids: used, handoff: Boolean(handoff) });
|
|
916
|
+
return { ...decision, messages: [...decision.messages, note] };
|
|
917
|
+
} catch (error) {
|
|
918
|
+
log(`prestep: skipped after error: ${error instanceof Error ? error.message : String(error)}`);
|
|
919
|
+
return decision;
|
|
920
|
+
}
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/* ------------------------------------------------------------------ *
|
|
925
|
+
* Turn pairs (RFC 0007): one exchange is the unit of memory.
|
|
926
|
+
* ------------------------------------------------------------------ */
|
|
927
|
+
|
|
928
|
+
/** Exchanges awaiting their reply to finish: sessionId -> { turn, userText, at, assistantParts }. */
|
|
929
|
+
const pendingTurns = new Map();
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* The turn boundary is an event, not a silence.
|
|
933
|
+
*
|
|
934
|
+
* This used to write a turn after two seconds without a new assistant message.
|
|
935
|
+
* That looked harmless until the data said otherwise: a tool call often runs
|
|
936
|
+
* longer than two seconds, so the timer fired mid-turn, wrote a truncated pair
|
|
937
|
+
* and dropped everything after it. One recorded write landed 1m47s before the
|
|
938
|
+
* same turn's `turn/end`.
|
|
939
|
+
*
|
|
940
|
+
* Now the only triggers are `turn/end` and the next user message. If the app is
|
|
941
|
+
* killed mid-turn, that turn is lost — a missing turn is better than a turn cut
|
|
942
|
+
* in half, and the loss is visible (the next turn's text starts mid-story).
|
|
943
|
+
*/
|
|
944
|
+
function noteUserTurn(sessionId, event, text) {
|
|
945
|
+
pendingTurns.set(sessionId, {
|
|
946
|
+
turn: Number(event?.data?.turn ?? event?.seq ?? 0),
|
|
947
|
+
userText: text,
|
|
948
|
+
at: Number(event?.time ?? Date.now()),
|
|
949
|
+
assistantParts: [],
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function noteAssistantPart(sessionId, text) {
|
|
954
|
+
const pending = pendingTurns.get(sessionId);
|
|
955
|
+
if (!pending) return;
|
|
956
|
+
pending.assistantParts.push(text);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** One step of the reply, kept in order: reasoning, text, tool. */
|
|
960
|
+
function noteAssistantStep(sessionId, kind, text) {
|
|
961
|
+
const pending = pendingTurns.get(sessionId);
|
|
962
|
+
if (!pending) return;
|
|
963
|
+
if (!pending.steps) pending.steps = [];
|
|
964
|
+
pending.steps.push({ idx: pending.steps.length, kind, text, at: Date.now() });
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Write the pending exchange. Called on `turn/end` and, as a fallback, when the
|
|
969
|
+
* next user message arrives. No assistant text means the turn never completed —
|
|
970
|
+
* drop it rather than write a pair with an empty side (RFC 0007 §2.2).
|
|
971
|
+
*/
|
|
972
|
+
function flushTurnPair(sessionId) {
|
|
973
|
+
const pending = pendingTurns.get(sessionId);
|
|
974
|
+
if (!pending) return;
|
|
975
|
+
pendingTurns.delete(sessionId);
|
|
976
|
+
if (pending.assistantParts.length === 0) {
|
|
977
|
+
log(`turn pair dropped (no reply) session=${sessionId} turn=${pending.turn}`);
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
try {
|
|
981
|
+
const turnId = ensureSoul().recordTurnPair({
|
|
982
|
+
sessionId,
|
|
983
|
+
turn: pending.turn,
|
|
984
|
+
userText: pending.userText,
|
|
985
|
+
assistantText: pending.assistantParts.join('\n'),
|
|
986
|
+
at: pending.at,
|
|
987
|
+
steps: pending.steps ?? [],
|
|
988
|
+
});
|
|
989
|
+
const chars = pending.userText.length + pending.assistantParts.join('').length;
|
|
990
|
+
log(`turn pair recorded session=${sessionId} turn=${pending.turn} chars=${chars}`);
|
|
991
|
+
// Extraction is queued, not run here: outside a live turn the host model is
|
|
992
|
+
// not callable (every attempt returned an empty stream today). The queue is
|
|
993
|
+
// drained at the start of the next turn, which is inside one.
|
|
994
|
+
enqueueExtraction(turnId, pending.turn);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
log(`turn pair failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/* ------------------------------------------------------------------ *
|
|
1001
|
+
* Extraction queue: written at turn end, drained inside the next turn
|
|
1002
|
+
* ------------------------------------------------------------------ */
|
|
1003
|
+
|
|
1004
|
+
const EXTRACT_QUEUE_FILE = join(HOME, 'pending-extract.json');
|
|
1005
|
+
const EXTRACT_MAX_ATTEMPTS = 3;
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Read a queue file.
|
|
1009
|
+
*
|
|
1010
|
+
* The four stages keep different items and different caps, but the file handling is
|
|
1011
|
+
* the same shape, and it used to be written out four times. An unreadable file is
|
|
1012
|
+
* not an empty one: treating it as empty silently erases the backlog on the next
|
|
1013
|
+
* write, so it is moved aside and logged.
|
|
1014
|
+
*/
|
|
1015
|
+
function readQueueFile(file) {
|
|
1016
|
+
try {
|
|
1017
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
1018
|
+
return Array.isArray(raw) ? raw : [];
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
if (existsSync(file)) {
|
|
1021
|
+
log(`queue unreadable (${file}), setting it aside: ${error instanceof Error ? error.message : String(error)}`);
|
|
1022
|
+
try {
|
|
1023
|
+
renameSync(file, `${file}.corrupt-${Date.now()}`);
|
|
1024
|
+
} catch {
|
|
1025
|
+
/* the log above is the record */
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return [];
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* Write a queue file, keeping either the newest or the oldest items over the cap.
|
|
1034
|
+
*
|
|
1035
|
+
* Extraction keeps the newest (the old code kept the oldest and dropped the turns
|
|
1036
|
+
* that had just been added); the rest keep the oldest, because they are FIFO work
|
|
1037
|
+
* lists where the front is the most urgent.
|
|
1038
|
+
*/
|
|
1039
|
+
function writeQueueFile(file, queue, { cap, keep = 'oldest' } = {}) {
|
|
1040
|
+
try {
|
|
1041
|
+
const kept = !cap || queue.length <= cap ? queue : keep === 'newest' ? queue.slice(queue.length - cap) : queue.slice(0, cap);
|
|
1042
|
+
const dropped = queue.length - kept.length;
|
|
1043
|
+
// A cap drop is data loss. It used to be logged only when it happened, which is
|
|
1044
|
+
// exactly when nobody was reading; a backlog also logs its size so a batch that
|
|
1045
|
+
// never drains is visible instead of being inferred from an empty file later.
|
|
1046
|
+
if (dropped > 0) log(`queue over cap (${basename(file)}): wrote ${kept.length}, dropped ${dropped}`);
|
|
1047
|
+
else if (queue.length >= 10) log(`queue backlog (${basename(file)}): ${queue.length}`);
|
|
1048
|
+
writeFileSync(file, JSON.stringify(kept, null, 2), 'utf8');
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
log(`queue write failed (${file}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const readExtractQueue = () => readQueueFile(EXTRACT_QUEUE_FILE);
|
|
1055
|
+
const writeExtractQueue = (queue) => writeQueueFile(EXTRACT_QUEUE_FILE, queue, { cap: 50, keep: 'newest' });
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Remove one item by identity, computed from a fresh read.
|
|
1059
|
+
*
|
|
1060
|
+
* The drain used to write a `rest` array captured before its model call, so a turn
|
|
1061
|
+
* enqueued during that call (turn/end arrives while extraction is still running) was
|
|
1062
|
+
* overwritten and lost.
|
|
1063
|
+
*/
|
|
1064
|
+
function removeExtractItem(turnId) {
|
|
1065
|
+
writeExtractQueue(readExtractQueue().filter((item) => item.turnId !== turnId));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function bumpExtractAttempt(turnId) {
|
|
1069
|
+
writeExtractQueue(
|
|
1070
|
+
readExtractQueue().map((item) =>
|
|
1071
|
+
item.turnId === turnId ? { ...item, attempts: (item.attempts ?? 0) + 1 } : item,
|
|
1072
|
+
),
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Add one turn to the extraction queue.
|
|
1078
|
+
*
|
|
1079
|
+
* Exported so a script can batch-requeue history through the same writer: the last
|
|
1080
|
+
* attempt wrote the file from a separate script and the batch vanished unobserved
|
|
1081
|
+
* (capped at 50 by the writer it bypassed, with no line saying so). One writer, one
|
|
1082
|
+
* cap, one log line.
|
|
1083
|
+
*/
|
|
1084
|
+
export function enqueueExtraction(turnId, turnNo, origin = 'live') {
|
|
1085
|
+
const queue = readExtractQueue();
|
|
1086
|
+
if (queue.some((item) => item.turnId === turnId)) return false;
|
|
1087
|
+
// `origin` separates live turns from a historical backfill. Without it the attribution
|
|
1088
|
+
// experiment counts re-extracted old turns as fresh evidence, and a verdict about the
|
|
1089
|
+
// new default-continue rule would be read off history that never saw that rule.
|
|
1090
|
+
queue.push({ turnId, turnNo, attempts: 0, origin });
|
|
1091
|
+
writeExtractQueue(queue);
|
|
1092
|
+
log(`extract queued turn=${turnNo} origin=${origin} pending=${queue.length} file=${EXTRACT_QUEUE_FILE}`);
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Does this message carry anything worth extracting?
|
|
1098
|
+
*
|
|
1099
|
+
* Length alone is the wrong test: "做" and "重来" are one and two characters and
|
|
1100
|
+
* are real decisions. Only pure acknowledgements are skipped, and a skip is
|
|
1101
|
+
* always logged — a silent skip is how the pipeline looked dead for an hour.
|
|
1102
|
+
*/
|
|
1103
|
+
function hasSubstance(text) {
|
|
1104
|
+
const trimmed = text.trim();
|
|
1105
|
+
if (trimmed.length === 0) return false;
|
|
1106
|
+
return !/^(好的?|嗯+|哦+|可以|行|ok|okay|收到|明白|继续|好)[。!!.\s]*$/i.test(trimmed);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
async function runExtraction(text, turnId, turnNo, origin = 'live') {
|
|
1110
|
+
const config = readConfig();
|
|
1111
|
+
if (config.extract === 'off') return 'skipped-off';
|
|
1112
|
+
if (typeof text !== 'string' || !hasSubstance(text)) {
|
|
1113
|
+
log(`extract skipped: no substance (chars=${typeof text === 'string' ? text.length : -1}) turn=${turnNo}`);
|
|
1114
|
+
return 'skipped-empty';
|
|
1115
|
+
}
|
|
1116
|
+
const started = Date.now();
|
|
1117
|
+
try {
|
|
1118
|
+
const useLlm = config.extract === 'llm' && Boolean(injectedLlm);
|
|
1119
|
+
const soul = ensureSoul();
|
|
1120
|
+
const turnRow = soul.turn(turnId);
|
|
1121
|
+
// The context carries both halves of continuity (ADR 0034): the candidates' last
|
|
1122
|
+
// lines, and the previous user line — extraction is per turn, so without it the
|
|
1123
|
+
// model cannot tell "继续" from a new subject.
|
|
1124
|
+
const context = soul.extractionContext(20, { turnNo, sessionId: turnRow?.sessionId });
|
|
1125
|
+
// "The previous turn belonged to this thread" is the cheapest continuity hint
|
|
1126
|
+
// there is: in most cases the answer is simply "the same one again".
|
|
1127
|
+
if (turnRow) {
|
|
1128
|
+
const previousThreadId = soul.threadBefore(turnRow.sessionId, turnNo);
|
|
1129
|
+
if (previousThreadId) context.previousThreadId = previousThreadId;
|
|
1130
|
+
}
|
|
1131
|
+
const draft = await soul.extract(text, {
|
|
1132
|
+
llm: useLlm ? injectedLlm : undefined,
|
|
1133
|
+
mode: useLlm ? 'auto' : 'rules',
|
|
1134
|
+
// The extractor needs the open threads so it can say which one this turn
|
|
1135
|
+
// continues, and how long ago each was last seen.
|
|
1136
|
+
context,
|
|
1137
|
+
// The exchange, not the message (ADR 0035): my reply is where promises and
|
|
1138
|
+
// decisions live, and it is the only way "我的承诺" can ever be extracted.
|
|
1139
|
+
assistantText: turnRow?.assistantText ?? undefined,
|
|
1140
|
+
});
|
|
1141
|
+
const result = soul.commitDrafts(draft, { turnId });
|
|
1142
|
+
// Attribution is written even when nothing was extracted: a turn that says
|
|
1143
|
+
// "无" (nothing worth remembering) can still be part of a thread.
|
|
1144
|
+
const attribution = soul.applyThread(draft.thread, {
|
|
1145
|
+
sessionId: turnRow?.sessionId ?? 'unknown',
|
|
1146
|
+
turnNo,
|
|
1147
|
+
turnId,
|
|
1148
|
+
at: Date.now(),
|
|
1149
|
+
fallbackTopic: firstSentence(text),
|
|
1150
|
+
previousThreadId: context.previousThreadId,
|
|
1151
|
+
});
|
|
1152
|
+
const ms = Date.now() - started;
|
|
1153
|
+
// Provenance is observable (ADR 0035): the fact count alone cannot tell whether
|
|
1154
|
+
// anything came from my side of the exchange.
|
|
1155
|
+
const inferred = result.facts.filter((fact) => fact.source === 'inferred').length;
|
|
1156
|
+
log(
|
|
1157
|
+
`extract ok: by=${draft.by} reason=${draft.reason ?? '-'} entities=${result.entities.length} facts=${result.facts.length}` +
|
|
1158
|
+
` gaps=${result.gaps.length} skipped=${result.skipped.length}` +
|
|
1159
|
+
` slots->text=${result.droppedSlots.length} holder->world=${result.holderFallbacks.length}` +
|
|
1160
|
+
` inferred=${inferred}` +
|
|
1161
|
+
` ms=${ms} turn=${turnNo} origin=${origin}` +
|
|
1162
|
+
` thread=${attribution.threadId}${attribution.created ? '(new)' : ''}` +
|
|
1163
|
+
` join=${attribution.joinReason} threadLine=${draft.threadLine ?? 'rules'}` +
|
|
1164
|
+
`${attribution.topicUpdated ? ' topic=updated' : ''}`,
|
|
1165
|
+
);
|
|
1166
|
+
for (const skipped of result.skipped.slice(0, 3)) {
|
|
1167
|
+
log(` extract skipped: ${skipped.what} (${skipped.reason})`);
|
|
1168
|
+
}
|
|
1169
|
+
for (const slot of result.droppedSlots.slice(0, 3)) {
|
|
1170
|
+
log(` extract slot kept as text: ${slot.slot}=${slot.value} (undeclared)`);
|
|
1171
|
+
}
|
|
1172
|
+
trace({
|
|
1173
|
+
event: 'extract',
|
|
1174
|
+
turn: turnNo,
|
|
1175
|
+
by: draft.by,
|
|
1176
|
+
reason: draft.reason,
|
|
1177
|
+
entities: result.entities.length,
|
|
1178
|
+
facts: result.facts.length,
|
|
1179
|
+
gaps: result.gaps.length,
|
|
1180
|
+
skipped: result.skipped,
|
|
1181
|
+
droppedSlots: result.droppedSlots,
|
|
1182
|
+
holderFallbacks: result.holderFallbacks,
|
|
1183
|
+
thread: attribution.threadId,
|
|
1184
|
+
threadCreated: attribution.created,
|
|
1185
|
+
threadTopic: attribution.topic,
|
|
1186
|
+
threadLine: draft.threadLine ?? null,
|
|
1187
|
+
topicUpdated: attribution.topicUpdated,
|
|
1188
|
+
origin,
|
|
1189
|
+
joinReason: attribution.joinReason,
|
|
1190
|
+
ms,
|
|
1191
|
+
});
|
|
1192
|
+
// Rules mode is a deliberate configuration, not a failed model call.
|
|
1193
|
+
if (!useLlm) return 'rules-mode';
|
|
1194
|
+
return draft.by === 'llm' ? 'ok' : `rules:${draft.reason ?? 'unknown'}`;
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
log(`extract failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1197
|
+
return `failed:${error instanceof Error ? error.message : String(error)}`;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
let draining = false;
|
|
1202
|
+
/** True while a stage chain is running, so two chains never overlap. */
|
|
1203
|
+
let stageChainBusy = false;
|
|
1204
|
+
/** Alternates the profile merge with the rest of the chain so it cannot be starved. */
|
|
1205
|
+
let stageTick = 0;
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Which queued turn to extract next.
|
|
1209
|
+
*
|
|
1210
|
+
* Live turns first, then history: FIFO alone would put 20 backfill items ahead of the
|
|
1211
|
+
* conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1212
|
+
*/
|
|
1213
|
+
export function pickExtractionItem(queue) {
|
|
1214
|
+
// Live turns first, then history. FIFO alone would put 20 backfill items ahead of the
|
|
1215
|
+
// conversation happening right now, so a live turn waits dozens of steps to be read.
|
|
1216
|
+
return queue.find((item) => (item.origin ?? 'live') !== 'backfill') ?? queue[0] ?? null;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/**
|
|
1220
|
+
* Should extraction take this step before consolidation?
|
|
1221
|
+
*
|
|
1222
|
+
* A backfill backlog used to keep extraction busy every step, and because a stage that
|
|
1223
|
+
* did work ends the chain, consolidation never got a turn: measured 29 threads queued,
|
|
1224
|
+
* 0 consolidated while the backfill drained. Live conversation still goes first;
|
|
1225
|
+
* when only history is waiting, understanding goes first and the backfill waits.
|
|
1226
|
+
*/
|
|
1227
|
+
export function extractRunsFirst(extractQueue, consolidationPending) {
|
|
1228
|
+
const hasLive = extractQueue.some((item) => (item.origin ?? 'live') !== 'backfill');
|
|
1229
|
+
return hasLive || !consolidationPending;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Drain ONE queued turn per step.
|
|
1234
|
+
*
|
|
1235
|
+
* The host model is only callable inside the current step: of three serial
|
|
1236
|
+
* calls in one drain, the middle one succeeded and the other two failed with the
|
|
1237
|
+
* stream gone. One item per step keeps every attempt inside that window; the
|
|
1238
|
+
* queue is a backlog, not a batch.
|
|
1239
|
+
*
|
|
1240
|
+
* A model failure keeps the item, because the rules pass is only a floor: if it
|
|
1241
|
+
* counted as done, that turn would never get a model extraction. Three failures
|
|
1242
|
+
* drop it with the reason.
|
|
1243
|
+
*/
|
|
1244
|
+
export async function drainExtractionQueue() {
|
|
1245
|
+
if (draining) return false;
|
|
1246
|
+
const config = readConfig();
|
|
1247
|
+
if (config.extract === 'off') return false;
|
|
1248
|
+
const queue = readExtractQueue();
|
|
1249
|
+
if (queue.length === 0) return false;
|
|
1250
|
+
draining = true;
|
|
1251
|
+
try {
|
|
1252
|
+
const item = pickExtractionItem(queue);
|
|
1253
|
+
let text = '';
|
|
1254
|
+
try {
|
|
1255
|
+
const turn = ensureSoul().turn(item.turnId);
|
|
1256
|
+
text = turn?.userText ?? '';
|
|
1257
|
+
} catch {
|
|
1258
|
+
text = '';
|
|
1259
|
+
}
|
|
1260
|
+
if (!text) {
|
|
1261
|
+
log(`extract queue: turn ${item.turnId} missing, dropped`);
|
|
1262
|
+
removeExtractItem(item.turnId);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const outcome = await runExtraction(text, item.turnId, item.turnNo, item.origin ?? 'live');
|
|
1267
|
+
const ok =
|
|
1268
|
+
outcome === 'ok' || outcome === 'skipped-off' || outcome === 'skipped-empty' || outcome === 'rules-mode';
|
|
1269
|
+
if (ok) {
|
|
1270
|
+
removeExtractItem(item.turnId);
|
|
1271
|
+
// Promotion is decided after the facts exist: a corrected turn's lessons enter
|
|
1272
|
+
// the classification queue here, which is the only live producer it has.
|
|
1273
|
+
feedClassifyFromTurn(item.turnId);
|
|
1274
|
+
trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome });
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// A rules result caused by a broken model call is not a completed extraction.
|
|
1279
|
+
const attempts = (item.attempts ?? 0) + 1;
|
|
1280
|
+
if (attempts >= EXTRACT_MAX_ATTEMPTS) {
|
|
1281
|
+
log(`extract queue: giving up on turn=${item.turnNo} after ${attempts}: ${outcome}`);
|
|
1282
|
+
removeExtractItem(item.turnId);
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
log(`extract queue: retry turn=${item.turnNo} attempt=${attempts} (${outcome})`);
|
|
1286
|
+
// Bump in place, computed from a fresh read: an item enqueued during the model
|
|
1287
|
+
// call must not be overwritten by a stale queue snapshot.
|
|
1288
|
+
bumpExtractAttempt(item.turnId);
|
|
1289
|
+
trace({ event: 'extract-queue', drained: 1, remaining: readExtractQueue().length, outcome, retry: true });
|
|
1290
|
+
return true;
|
|
1291
|
+
} finally {
|
|
1292
|
+
draining = false;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/* ------------------------------------------------------------------ *
|
|
1297
|
+
* Consolidation: observations → one conclusion, checked before it lands
|
|
1298
|
+
* ------------------------------------------------------------------ */
|
|
1299
|
+
|
|
1300
|
+
const CONSOLIDATION_FILE = join(HOME, 'consolidation.json');
|
|
1301
|
+
/** Closed threads waiting to be consolidated, oldest first. */
|
|
1302
|
+
const CONSOLIDATION_QUEUE_FILE = join(HOME, 'pending-threads.json');
|
|
1303
|
+
const CONSOLIDATION_MAX_FACTS = 8;
|
|
1304
|
+
|
|
1305
|
+
function readConsolidationState() {
|
|
1306
|
+
try {
|
|
1307
|
+
const raw = JSON.parse(readFileSync(CONSOLIDATION_FILE, 'utf8'));
|
|
1308
|
+
return { pending: raw?.pending ?? null };
|
|
1309
|
+
} catch {
|
|
1310
|
+
return { pending: null };
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
const readConsolidationQueue = () => readQueueFile(CONSOLIDATION_QUEUE_FILE);
|
|
1315
|
+
const writeConsolidationQueue = (queue) => writeQueueFile(CONSOLIDATION_QUEUE_FILE, queue, { cap: 50 });
|
|
1316
|
+
|
|
1317
|
+
function enqueueConsolidation(threadIds) {
|
|
1318
|
+
if (!Array.isArray(threadIds) || threadIds.length === 0) return;
|
|
1319
|
+
const queue = readConsolidationQueue();
|
|
1320
|
+
for (const id of threadIds) if (!queue.includes(id)) queue.push(id);
|
|
1321
|
+
writeConsolidationQueue(queue);
|
|
1322
|
+
log(`consolidate queued: ${threadIds.join(',')} pending=${queue.length}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function shiftConsolidationQueue() {
|
|
1326
|
+
const queue = readConsolidationQueue();
|
|
1327
|
+
if (queue.length === 0) return null;
|
|
1328
|
+
const [first, ...rest] = queue;
|
|
1329
|
+
writeConsolidationQueue(rest);
|
|
1330
|
+
return first;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* How many times a thread has been put back without producing a result.
|
|
1335
|
+
*
|
|
1336
|
+
* A thread whose facts have not arrived yet must be retried, not discarded; the
|
|
1337
|
+
* counter is what stops a permanently unsuitable thread from cycling forever.
|
|
1338
|
+
*/
|
|
1339
|
+
const CONSOLIDATION_ATTEMPTS_FILE = join(HOME, 'consolidation-attempts.json');
|
|
1340
|
+
|
|
1341
|
+
function readConsolidationAttempts() {
|
|
1342
|
+
try {
|
|
1343
|
+
const raw = JSON.parse(readFileSync(CONSOLIDATION_ATTEMPTS_FILE, 'utf8'));
|
|
1344
|
+
return raw && typeof raw === 'object' ? raw : {};
|
|
1345
|
+
} catch {
|
|
1346
|
+
return {};
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function bumpConsolidationAttempt(threadId) {
|
|
1351
|
+
const attempts = readConsolidationAttempts();
|
|
1352
|
+
const next = Number(attempts[threadId] ?? 0) + 1;
|
|
1353
|
+
attempts[threadId] = next;
|
|
1354
|
+
try {
|
|
1355
|
+
writeFileSync(CONSOLIDATION_ATTEMPTS_FILE, JSON.stringify(attempts, null, 2), 'utf8');
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
log(`consolidate attempts write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1358
|
+
}
|
|
1359
|
+
return next;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function clearConsolidationAttempt(threadId) {
|
|
1363
|
+
const attempts = readConsolidationAttempts();
|
|
1364
|
+
if (!(threadId in attempts)) return;
|
|
1365
|
+
delete attempts[threadId];
|
|
1366
|
+
try {
|
|
1367
|
+
writeFileSync(CONSOLIDATION_ATTEMPTS_FILE, JSON.stringify(attempts, null, 2), 'utf8');
|
|
1368
|
+
} catch {
|
|
1369
|
+
/* the counter is an optimisation, not a record */
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** Put a thread back at the tail, or give up loudly once it has had enough tries. */
|
|
1374
|
+
function deferConsolidationThread(threadId, why) {
|
|
1375
|
+
const attempts = bumpConsolidationAttempt(threadId);
|
|
1376
|
+
if (attempts >= 3) {
|
|
1377
|
+
clearConsolidationAttempt(threadId);
|
|
1378
|
+
log(`consolidate: giving up on thread ${threadId} after ${attempts} attempts (${why})`);
|
|
1379
|
+
trace({ event: 'consolidate', verdict: 'abandoned', thread: threadId, reason: why, attempts });
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
enqueueConsolidation([threadId]);
|
|
1383
|
+
log(`consolidate: thread ${threadId} deferred after ${attempts} attempt(s) (${why})`);
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function writeConsolidationState(state) {
|
|
1387
|
+
try {
|
|
1388
|
+
writeFileSync(CONSOLIDATION_FILE, JSON.stringify(state, null, 2), 'utf8');
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
log(`consolidate: state write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Write (or merge) the conclusion and close the thread with it.
|
|
1396
|
+
*
|
|
1397
|
+
* Merging never rewrites the held statement — the evidence is what grows, so the
|
|
1398
|
+
* merge stays auditable. Refine and conflict still write a new row and supersede
|
|
1399
|
+
* the old one, which keeps the chain of revisions.
|
|
1400
|
+
*/
|
|
1401
|
+
function finishConclusion(soul, draft, { threadId, batch, relation }) {
|
|
1402
|
+
const allowed = batch.map((fact) => fact.id);
|
|
1403
|
+
let result;
|
|
1404
|
+
if (relation.kind === 'duplicate' && relation.target) {
|
|
1405
|
+
result = soul.recordConclusion(draft, { allowed, mergeInto: relation.target });
|
|
1406
|
+
} else {
|
|
1407
|
+
const effective =
|
|
1408
|
+
(relation.kind === 'refine' || relation.kind === 'conflict') && relation.target
|
|
1409
|
+
? { ...draft, supersedes: relation.target }
|
|
1410
|
+
: draft;
|
|
1411
|
+
result = soul.recordConclusion(effective, { allowed });
|
|
1412
|
+
}
|
|
1413
|
+
const summaryId = result.merged ?? result.fact?.id;
|
|
1414
|
+
if (!result.ok) {
|
|
1415
|
+
// The gate refused it (too few citations, evidence outside the batch, length,
|
|
1416
|
+
// over-generalisation). Before this it logged "consolidate ok written=false",
|
|
1417
|
+
// cleared pending, and left the thread closed with no summary and no retry.
|
|
1418
|
+
log(`consolidate rejected: ${result.reason ?? 'gate'} — "${draft.statement}"`);
|
|
1419
|
+
trace({
|
|
1420
|
+
event: 'consolidate',
|
|
1421
|
+
verdict: 'rejected',
|
|
1422
|
+
ok: false,
|
|
1423
|
+
reason: result.reason,
|
|
1424
|
+
relation: relation.kind,
|
|
1425
|
+
target: relation.target ?? null,
|
|
1426
|
+
statement: draft.statement,
|
|
1427
|
+
thread: threadId,
|
|
1428
|
+
});
|
|
1429
|
+
if (threadId) deferConsolidationThread(threadId, `gate:${result.reason ?? 'unknown'}`);
|
|
1430
|
+
writeConsolidationState({ pending: null });
|
|
1431
|
+
return;
|
|
1432
|
+
}
|
|
1433
|
+
if (threadId) clearConsolidationAttempt(threadId);
|
|
1434
|
+
// The conclusion becomes the thread's summary; a closed thread with no summary
|
|
1435
|
+
// is still closed, it just has nothing to remember it by.
|
|
1436
|
+
if (threadId && summaryId) soul.store.closeThread(threadId, Date.now(), summaryId);
|
|
1437
|
+
log(
|
|
1438
|
+
`consolidate ok: thread=${threadId ?? '-'} relation=${relation.kind} "${draft.statement}" ` +
|
|
1439
|
+
`evidence=${draft.evidence.length} written=${Boolean(result.fact)} merged=${result.merged ?? '-'} ` +
|
|
1440
|
+
`superseded=${result.superseded ?? '-'}`,
|
|
1441
|
+
);
|
|
1442
|
+
trace({
|
|
1443
|
+
event: 'consolidate',
|
|
1444
|
+
verdict: 'supported',
|
|
1445
|
+
ok: result.ok,
|
|
1446
|
+
reason: result.reason,
|
|
1447
|
+
relation: relation.kind,
|
|
1448
|
+
target: relation.target ?? null,
|
|
1449
|
+
statement: draft.statement,
|
|
1450
|
+
thread: threadId,
|
|
1451
|
+
});
|
|
1452
|
+
writeConsolidationState({ pending: null });
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
/* ------------------------------------------------------------------ *
|
|
1456
|
+
* Profile merging: many judgments about the person → a few (ADR 0033)
|
|
1457
|
+
* ------------------------------------------------------------------ */
|
|
1458
|
+
|
|
1459
|
+
const PROFILE_FILE = join(HOME, 'pending-profile.json');
|
|
1460
|
+
|
|
1461
|
+
const readProfileQueue = () => readQueueFile(PROFILE_FILE);
|
|
1462
|
+
const writeProfileQueue = (queue) => writeQueueFile(PROFILE_FILE, queue, { cap: 200 });
|
|
1463
|
+
|
|
1464
|
+
export function peekProfileQueue() {
|
|
1465
|
+
return readProfileQueue();
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Add one judged-about-the-person item to the merge queue.
|
|
1470
|
+
*
|
|
1471
|
+
* Read and write the file in one go: a snapshot taken before a model call once
|
|
1472
|
+
* overwrote items enqueued while the call was in flight, silently losing turns.
|
|
1473
|
+
* The dimension comes along because the merge step cuts its batches along it.
|
|
1474
|
+
*/
|
|
1475
|
+
export function enqueueProfileCandidate(item) {
|
|
1476
|
+
if (!item?.id) return false;
|
|
1477
|
+
const queue = readProfileQueue();
|
|
1478
|
+
if (queue.some((entry) => entry.id === item.id)) return false;
|
|
1479
|
+
writeProfileQueue([...queue, { id: item.id, statement: item.statement ?? '', dimension: item.dimension ?? '' }]);
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
export function commitProfileMerge(ids) {
|
|
1484
|
+
const done = new Set(ids);
|
|
1485
|
+
writeProfileQueue(readProfileQueue().filter((item) => !done.has(item.id)));
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
export function retryProfileMerge(batch) {
|
|
1489
|
+
const retry = new Map(batch.map((item) => [item.id, item]));
|
|
1490
|
+
const out = [];
|
|
1491
|
+
for (const item of readProfileQueue()) {
|
|
1492
|
+
if (!retry.has(item.id)) {
|
|
1493
|
+
out.push(item);
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const attempts = (item.attempts ?? 0) + 1;
|
|
1497
|
+
if (attempts >= 3) {
|
|
1498
|
+
log(`profile: giving up on ${item.id} after ${attempts} attempts`);
|
|
1499
|
+
trace({ event: 'profile-abandoned', id: item.id, attempts });
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
out.push({ ...item, attempts });
|
|
1503
|
+
}
|
|
1504
|
+
writeProfileQueue(out);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* One merge per quiet step, always inside a single dimension.
|
|
1509
|
+
*
|
|
1510
|
+
* Sending every queued judgment in one call welded unrelated ones into one sentence,
|
|
1511
|
+
* which is why the batches are now cut by dimension before the model sees them. The
|
|
1512
|
+
* biggest group goes first: it holds the most repetition, so it is where a call buys
|
|
1513
|
+
* the most. A group of one has nothing to merge and waits for company.
|
|
1514
|
+
*/
|
|
1515
|
+
async function runProfileStep() {
|
|
1516
|
+
const config = readConfig();
|
|
1517
|
+
if (config.extract === 'off' || !injectedLlm) {
|
|
1518
|
+
log('profile: skipped — model unavailable');
|
|
1519
|
+
return false;
|
|
1520
|
+
}
|
|
1521
|
+
const groups = groupProfileCandidates(peekProfileQueue());
|
|
1522
|
+
const [group] = mergeableGroups(groups);
|
|
1523
|
+
if (!group) {
|
|
1524
|
+
if (groups.length > 0) log(`profile: nothing to merge yet (${describeGroups(groups)})`);
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
const batch = group.items;
|
|
1528
|
+
const dimension = group.dimension;
|
|
1529
|
+
log(`profile: merging ${describeGroups(groups)} — picking ${dimension} x${batch.length}`);
|
|
1530
|
+
|
|
1531
|
+
let raw;
|
|
1532
|
+
try {
|
|
1533
|
+
raw = await injectedLlm(renderProfileMergePrompt({ items: batch, dimension }));
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
log(`profile: call failed (${error instanceof Error ? error.message : String(error)}) — retrying`);
|
|
1536
|
+
retryProfileMerge(batch);
|
|
1537
|
+
return true;
|
|
1538
|
+
}
|
|
1539
|
+
const allowed = new Set(batch.map((item) => item.id));
|
|
1540
|
+
const lines = parseProfileMerge(raw, allowed, dimension);
|
|
1541
|
+
if (lines.length === 0) {
|
|
1542
|
+
log('profile: no usable merge in the reply — retrying');
|
|
1543
|
+
retryProfileMerge(batch);
|
|
1544
|
+
return true;
|
|
1545
|
+
}
|
|
1546
|
+
const soul = ensureSoul();
|
|
1547
|
+
const written = soul.applyProfileMerge(lines);
|
|
1548
|
+
// A merge is supposed to cover its whole input. Lines the model did not cite used
|
|
1549
|
+
// to stay active forever, so the profile grew a stale tail nobody saw.
|
|
1550
|
+
const covered = new Set(lines.flatMap((line) => line.sources));
|
|
1551
|
+
const leftovers = batch.map((item) => item.id).filter((id) => !covered.has(id));
|
|
1552
|
+
const successor = written[0]?.id;
|
|
1553
|
+
for (const id of leftovers) {
|
|
1554
|
+
const fact = soul.store.fact(id);
|
|
1555
|
+
if (fact && fact.status !== 'superseded' && successor) soul.store.supersedeFact(id, successor);
|
|
1556
|
+
}
|
|
1557
|
+
commitProfileMerge(batch.map((item) => item.id));
|
|
1558
|
+
log(
|
|
1559
|
+
`profile merged: dimension=${dimension} sources=${batch.length} lines=${written.length}` +
|
|
1560
|
+
` covered=${covered.size} retired=${leftovers.length}`,
|
|
1561
|
+
);
|
|
1562
|
+
trace({
|
|
1563
|
+
event: 'profile',
|
|
1564
|
+
dimension,
|
|
1565
|
+
sources: batch.length,
|
|
1566
|
+
lines: written.length,
|
|
1567
|
+
retired: leftovers.length,
|
|
1568
|
+
});
|
|
1569
|
+
return true;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
/** "工作方式:5,沟通偏好:2,未标注:1" — the queue in one readable line. */
|
|
1573
|
+
export function describeGroups(groups) {
|
|
1574
|
+
return groups
|
|
1575
|
+
.map((group) => `${group.dimension || '未标注'}:${group.items.length}`)
|
|
1576
|
+
.join(',');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* The groups a merge may run on: one dimension, at least two judgments.
|
|
1581
|
+
*
|
|
1582
|
+
* Unlabelled judgments are deliberately excluded. Merging them would put several
|
|
1583
|
+
* lenses back into one call — the welding the split exists to stop — and assigning a
|
|
1584
|
+
* dimension just to fill a batch would be invention. They are reported instead, and
|
|
1585
|
+
* labelled by hand (scripts/label-profile.mjs) before they can be merged.
|
|
1586
|
+
*/
|
|
1587
|
+
export function mergeableGroups(groups) {
|
|
1588
|
+
return groups.filter((group) => group.dimension && group.items.length >= 2);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/** Is there any group with at least two judgments to collapse? */
|
|
1592
|
+
export function hasMergeableGroup(queue) {
|
|
1593
|
+
return mergeableGroups(groupProfileCandidates(queue)).length > 0;
|
|
1594
|
+
}
|
|
1595
|
+
/* ------------------------------------------------------------------ *
|
|
1596
|
+
* Classifying old lessons: rule (with trigger) or profile (ADR 0033)
|
|
1597
|
+
* ------------------------------------------------------------------ */
|
|
1598
|
+
|
|
1599
|
+
const CLASSIFY_FILE = join(HOME, 'pending-classify.json');
|
|
1600
|
+
const CLASSIFY_BATCH = 10;
|
|
1601
|
+
|
|
1602
|
+
const readClassifyQueue = () => readQueueFile(CLASSIFY_FILE);
|
|
1603
|
+
const writeClassifyQueue = (queue) => writeQueueFile(CLASSIFY_FILE, queue, { cap: 400 });
|
|
1604
|
+
|
|
1605
|
+
export function peekClassifyBatch() {
|
|
1606
|
+
return readClassifyQueue().slice(0, CLASSIFY_BATCH);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/** Remove items only after their result has been applied. */
|
|
1610
|
+
export function commitClassifyBatch(ids) {
|
|
1611
|
+
const done = new Set(ids);
|
|
1612
|
+
writeClassifyQueue(readClassifyQueue().filter((item) => !done.has(item.id)));
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Put a failed batch back with one more attempt.
|
|
1617
|
+
*
|
|
1618
|
+
* A batch used to be removed before the model call, so a throw or an empty reply
|
|
1619
|
+
* lost up to ten lessons with only a generic background error to show for it.
|
|
1620
|
+
*/
|
|
1621
|
+
export function retryClassifyBatch(batch) {
|
|
1622
|
+
const retry = new Map(batch.map((item) => [item.id, item]));
|
|
1623
|
+
const out = [];
|
|
1624
|
+
for (const item of readClassifyQueue()) {
|
|
1625
|
+
if (!retry.has(item.id)) {
|
|
1626
|
+
out.push(item);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const attempts = (item.attempts ?? 0) + 1;
|
|
1630
|
+
if (attempts >= 3) {
|
|
1631
|
+
log(`classify: giving up ${item.id} after ${attempts} attempts`);
|
|
1632
|
+
trace({ event: 'classify-abandoned', id: item.id, attempts });
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
out.push({ ...item, attempts });
|
|
1636
|
+
}
|
|
1637
|
+
writeClassifyQueue(out);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/**
|
|
1641
|
+
* Put one lesson in the classification queue.
|
|
1642
|
+
*
|
|
1643
|
+
* The reason travels with it so the queue is explainable: "why was this promoted"
|
|
1644
|
+
* must be answerable from the queue file, not reconstructed from the log.
|
|
1645
|
+
*/
|
|
1646
|
+
export function enqueueClassifyCandidate(item) {
|
|
1647
|
+
if (!item?.id) return false;
|
|
1648
|
+
const queue = readClassifyQueue();
|
|
1649
|
+
if (queue.some((entry) => entry.id === item.id)) return false;
|
|
1650
|
+
writeClassifyQueue([
|
|
1651
|
+
...queue,
|
|
1652
|
+
{ id: item.id, statement: item.statement ?? '', reason: item.reason ?? 'unknown' },
|
|
1653
|
+
]);
|
|
1654
|
+
return true;
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* T1 (brief 0023): a corrected turn's lessons are promoted, and nothing else is.
|
|
1659
|
+
*
|
|
1660
|
+
* Being told you were wrong is the consequence an experience comes with, which is
|
|
1661
|
+
* the one promotion signal we can trust without a threshold. Everything else waits.
|
|
1662
|
+
*/
|
|
1663
|
+
export function feedClassifyFromTurn(turnId) {
|
|
1664
|
+
try {
|
|
1665
|
+
// Classification is a model stage. Without a model the candidate would sit in
|
|
1666
|
+
// the queue until the cap drops it, so it is not queued at all.
|
|
1667
|
+
if (!usesModel(readConfig())) return 0;
|
|
1668
|
+
const soul = ensureSoul();
|
|
1669
|
+
if (!soul.wasCorrectedTurn(turnId)) return 0;
|
|
1670
|
+
let added = 0;
|
|
1671
|
+
for (const fact of soul.promotionCandidates(turnId)) {
|
|
1672
|
+
if (enqueueClassifyCandidate({ id: fact.id, statement: fact.statement, reason: 'T1-修正' })) {
|
|
1673
|
+
added += 1;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
if (added > 0) log(`promote: T1 corrected turn → ${added} candidate(s) queued for classification`);
|
|
1677
|
+
trace({ event: 'promote', rule: 'T1', turn: turnId, queued: added });
|
|
1678
|
+
return added;
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
log(`promote: failed (${error instanceof Error ? error.message : String(error)})`);
|
|
1681
|
+
return 0;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* One classification batch per quiet step.
|
|
1687
|
+
*
|
|
1688
|
+
* Returns true when a model call was made, mirroring the extraction contract so the
|
|
1689
|
+
* caller can keep to one call per step.
|
|
1690
|
+
*/
|
|
1691
|
+
async function runClassifyStep() {
|
|
1692
|
+
const config = readConfig();
|
|
1693
|
+
if (config.extract === 'off' || !injectedLlm) return false;
|
|
1694
|
+
const batch = peekClassifyBatch();
|
|
1695
|
+
if (batch.length === 0) return false;
|
|
1696
|
+
|
|
1697
|
+
let raw;
|
|
1698
|
+
try {
|
|
1699
|
+
raw = await injectedLlm(renderRuleClassifyPrompt({ items: batch }));
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
log(`classify: call failed (${error instanceof Error ? error.message : String(error)}) — retrying batch`);
|
|
1702
|
+
retryClassifyBatch(batch);
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
1705
|
+
const classified = parseRuleClassifications(raw);
|
|
1706
|
+
if (classified.length === 0) {
|
|
1707
|
+
// An empty or unreadable reply is not a classification: keep the lessons.
|
|
1708
|
+
log('classify: empty or unparseable reply — retrying batch');
|
|
1709
|
+
retryClassifyBatch(batch);
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const soul = ensureSoul();
|
|
1714
|
+
let rules = 0;
|
|
1715
|
+
let profiles = 0;
|
|
1716
|
+
let pending = 0;
|
|
1717
|
+
const known = new Map(batch.map((item) => [item.id, item]));
|
|
1718
|
+
for (const item of classified) {
|
|
1719
|
+
if (!known.has(item.id)) continue;
|
|
1720
|
+
if (item.kind === 'rule' && item.trigger) {
|
|
1721
|
+
soul.markRule(item.id, { trigger: item.trigger, action: item.action });
|
|
1722
|
+
rules += 1;
|
|
1723
|
+
} else if (item.kind === 'profile') {
|
|
1724
|
+
soul.markProfile(item.id, { dimension: item.dimension });
|
|
1725
|
+
// The classification is the only moment the dimension is known, so the merge
|
|
1726
|
+
// queue is fed here. Without this the merge stage had no live input at all and
|
|
1727
|
+
// only ever ran from a hand-seeded queue.
|
|
1728
|
+
enqueueProfileCandidate({
|
|
1729
|
+
id: item.id,
|
|
1730
|
+
statement: known.get(item.id).statement,
|
|
1731
|
+
dimension: item.dimension ?? '',
|
|
1732
|
+
});
|
|
1733
|
+
profiles += 1;
|
|
1734
|
+
} else {
|
|
1735
|
+
// Neither bucket is safe for an unclassifiable lesson: a rule would fire
|
|
1736
|
+
// every turn, a profile line would put an imperative into a person.
|
|
1737
|
+
soul.markUnclassified(item.id);
|
|
1738
|
+
pending += 1;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
commitClassifyBatch(batch.map((item) => item.id));
|
|
1742
|
+
// "Promoted because of what" is a number, not a memory: the brief's acceptance
|
|
1743
|
+
// metric is the reason split, and it can only be counted if it is logged.
|
|
1744
|
+
const reasons = {};
|
|
1745
|
+
for (const item of batch) {
|
|
1746
|
+
const reason = item.reason ?? 'seed';
|
|
1747
|
+
reasons[reason] = (reasons[reason] ?? 0) + 1;
|
|
1748
|
+
}
|
|
1749
|
+
const reasonText = Object.entries(reasons).map(([reason, n]) => `${reason}:${n}`).join(',');
|
|
1750
|
+
log(`classify: batch=${batch.length} rules=${rules} profiles=${profiles} pending=${pending} reasons=${reasonText}`);
|
|
1751
|
+
trace({ event: 'classify', batch: batch.length, rules, profiles, pending, reasons });
|
|
1752
|
+
return true;
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* One consolidation step per call, never two model calls in one step.
|
|
1756
|
+
*
|
|
1757
|
+
* Consolidation runs on a **closed thread**, not on a batch of facts: a
|
|
1758
|
+
* conversation still in progress is not a chapter yet, and a batch can mix two
|
|
1759
|
+
* unrelated topics into one conclusion.
|
|
1760
|
+
*
|
|
1761
|
+
* The two phases are split across steps on purpose: the verifier must answer
|
|
1762
|
+
* without the draft's reasoning in view (Chain-of-Verification), and a second
|
|
1763
|
+
* call in the same step would run past the window where the host model is
|
|
1764
|
+
* callable anyway.
|
|
1765
|
+
*
|
|
1766
|
+
* Returns true when a model call was made, so the caller can skip other work.
|
|
1767
|
+
*/
|
|
1768
|
+
async function runConsolidationStep() {
|
|
1769
|
+
const config = readConfig();
|
|
1770
|
+
if (config.extract === 'off' || !injectedLlm) return false;
|
|
1771
|
+
const soul = ensureSoul();
|
|
1772
|
+
const state = readConsolidationState();
|
|
1773
|
+
|
|
1774
|
+
if (state.pending?.phase === 'verify') {
|
|
1775
|
+
const { draft, batch, threadId } = state.pending;
|
|
1776
|
+
let raw;
|
|
1777
|
+
try {
|
|
1778
|
+
raw = await injectedLlm(renderVerificationPrompt({ conclusion: draft.statement, facts: batch }));
|
|
1779
|
+
} catch (error) {
|
|
1780
|
+
// The draft is still pending; the next step retries the check — but a provider
|
|
1781
|
+
// that keeps failing must not hold the queue head forever.
|
|
1782
|
+
const attempts = (state.pending.attempts ?? 0) + 1;
|
|
1783
|
+
if (attempts >= 3) {
|
|
1784
|
+
log(`consolidate verify gave up after ${attempts} failed calls — "${draft.statement}"`);
|
|
1785
|
+
trace({ event: 'consolidate', verdict: 'verify-abandoned', attempts, thread: threadId, reason: 'call-failed' });
|
|
1786
|
+
writeConsolidationState({ pending: null });
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
writeConsolidationState({ pending: { ...state.pending, attempts } });
|
|
1790
|
+
log(`consolidate verify failed (${error instanceof Error ? error.message : String(error)}) — retry ${attempts}/3`);
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
const checked = readVerification(raw);
|
|
1794
|
+
if (checked.kind === 'retry') {
|
|
1795
|
+
// An empty or unreadable reply is not evidence against the conclusion. But an
|
|
1796
|
+
// unbounded retry parks the thread at the head of the queue forever, so the
|
|
1797
|
+
// third unreadable reply drops the draft with a trace instead.
|
|
1798
|
+
const attempts = (state.pending.attempts ?? 0) + 1;
|
|
1799
|
+
if (attempts >= 3) {
|
|
1800
|
+
log(`consolidate verify abandoned after ${attempts} unreadable replies — "${draft.statement}"`);
|
|
1801
|
+
trace({ event: 'consolidate', verdict: 'verify-abandoned', attempts, thread: threadId });
|
|
1802
|
+
writeConsolidationState({ pending: null });
|
|
1803
|
+
return true;
|
|
1804
|
+
}
|
|
1805
|
+
writeConsolidationState({ pending: { ...state.pending, attempts } });
|
|
1806
|
+
log(`consolidate verify retry ${attempts}/3: ${checked.reason} — "${draft.statement}"`);
|
|
1807
|
+
trace({ event: 'consolidate', verdict: 'verify-retry', reason: checked.reason, thread: threadId, attempts });
|
|
1808
|
+
return true;
|
|
1809
|
+
}
|
|
1810
|
+
const verdict = checked.verdict;
|
|
1811
|
+
if (verdict.verdict === 'supported') {
|
|
1812
|
+
// Supported, but not yet written: first ask whether we already hold it.
|
|
1813
|
+
// The check runs in its own step so this step keeps to one model call.
|
|
1814
|
+
const candidates = soul.conclusionCandidates(draft.evidence, 8);
|
|
1815
|
+
if (candidates.length > 0) {
|
|
1816
|
+
writeConsolidationState({ pending: { phase: 'dedupe', draft, batch, threadId, candidates } });
|
|
1817
|
+
log(`consolidate verify ok: thread=${threadId ?? '-'} — checking ${candidates.length} held conclusions`);
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
finishConclusion(soul, draft, { threadId, batch, relation: { kind: 'unrelated' } });
|
|
1821
|
+
} else {
|
|
1822
|
+
// Partial support is dropped, not downgraded: a half-true conclusion is a
|
|
1823
|
+
// wrong one, and the sample is what teaches the prompt next time.
|
|
1824
|
+
log(`consolidate dropped: ${verdict.verdict} — "${draft.statement}" (${verdict.reason ?? '-'})`);
|
|
1825
|
+
trace({
|
|
1826
|
+
event: 'consolidate',
|
|
1827
|
+
verdict: verdict.verdict,
|
|
1828
|
+
dropped: true,
|
|
1829
|
+
reason: verdict.reason,
|
|
1830
|
+
statement: draft.statement,
|
|
1831
|
+
thread: threadId,
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
writeConsolidationState({ pending: null });
|
|
1835
|
+
return true;
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
if (state.pending?.phase === 'dedupe') {
|
|
1839
|
+
const { draft, batch, threadId, candidates } = state.pending;
|
|
1840
|
+
// The one check a rule can make safely: the same text. A hit cannot be wrong,
|
|
1841
|
+
// and it costs nothing.
|
|
1842
|
+
const exact = candidates.find(
|
|
1843
|
+
(candidate) => normalizeStatement(candidate.statement) === normalizeStatement(draft.statement),
|
|
1844
|
+
);
|
|
1845
|
+
if (exact) {
|
|
1846
|
+
log(`dedupe: exact duplicate of ${exact.id} — merging evidence, not writing`);
|
|
1847
|
+
finishConclusion(soul, draft, { threadId, batch, relation: { kind: 'duplicate', target: exact.id } });
|
|
1848
|
+
return true;
|
|
1849
|
+
}
|
|
1850
|
+
const raw = await injectedLlm(renderRelationPrompt({ conclusion: draft.statement, existing: candidates }));
|
|
1851
|
+
const parsed = parseRelationDraft(raw);
|
|
1852
|
+
// An id we did not offer is a hallucination; treating it as unrelated only
|
|
1853
|
+
// ever writes the new conclusion, never deletes one.
|
|
1854
|
+
const known = parsed?.target && candidates.some((candidate) => candidate.id === parsed.target);
|
|
1855
|
+
const relation =
|
|
1856
|
+
parsed && (parsed.kind === 'unrelated' || known) ? parsed : { kind: 'unrelated', reason: 'unknown target' };
|
|
1857
|
+
log(`dedupe: ${relation.kind}${relation.target ? ` → ${relation.target}` : ''} "${draft.statement}"`);
|
|
1858
|
+
trace({ event: 'dedupe', relation: relation.kind, target: relation.target ?? null, reason: relation.reason });
|
|
1859
|
+
finishConclusion(soul, draft, { threadId, batch, relation });
|
|
1860
|
+
return true;
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
const threadId = shiftConsolidationQueue();
|
|
1864
|
+
if (!threadId) return false;
|
|
1865
|
+
const context = soul.threadContext(threadId);
|
|
1866
|
+
if (!context) {
|
|
1867
|
+
// Missing is permanent: the thread row is gone, not waiting for facts.
|
|
1868
|
+
clearConsolidationAttempt(threadId);
|
|
1869
|
+
log(`consolidate: thread ${threadId} missing, dropped`);
|
|
1870
|
+
trace({ event: 'consolidate', verdict: 'missing-thread', thread: threadId });
|
|
1871
|
+
return false;
|
|
1872
|
+
}
|
|
1873
|
+
// A thread can be closed while some of its turns are still waiting in the
|
|
1874
|
+
// extraction queue, so "few facts now" does not mean "few facts ever". It was
|
|
1875
|
+
// discarded here before; now it goes back to the tail with an attempt counter.
|
|
1876
|
+
if (context.facts.length < 2) {
|
|
1877
|
+
log(`consolidate deferred: thread ${threadId} has ${context.facts.length} fact(s)`);
|
|
1878
|
+
trace({ event: 'consolidate', verdict: 'too-few-facts', thread: threadId, facts: context.facts.length });
|
|
1879
|
+
deferConsolidationThread(threadId, 'too-few-facts');
|
|
1880
|
+
return false;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
const batch = context.facts.slice(0, CONSOLIDATION_MAX_FACTS).map((fact) => ({
|
|
1884
|
+
id: fact.id,
|
|
1885
|
+
statement: fact.statement,
|
|
1886
|
+
atText: fact.atText,
|
|
1887
|
+
}));
|
|
1888
|
+
let raw;
|
|
1889
|
+
try {
|
|
1890
|
+
raw = await injectedLlm(
|
|
1891
|
+
renderConsolidationPrompt({
|
|
1892
|
+
facts: batch,
|
|
1893
|
+
existing: soul.conclusions(5).map((fact) => ({ id: fact.id, statement: fact.statement })),
|
|
1894
|
+
thread: {
|
|
1895
|
+
topic: context.thread.topic,
|
|
1896
|
+
// Whole turns: consolidation reads the chapter, and a 160/120-character
|
|
1897
|
+
// prefix cuts exactly where the decision usually is (the last clause).
|
|
1898
|
+
turns: context.turns.map((turn) => {
|
|
1899
|
+
const answer = turn.assistantText;
|
|
1900
|
+
return `用户:${turn.userText}${answer ? ` / 我:${answer}` : ''}`;
|
|
1901
|
+
}),
|
|
1902
|
+
},
|
|
1903
|
+
}),
|
|
1904
|
+
);
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
log(`consolidate draft failed: ${error instanceof Error ? error.message : String(error)} — deferring thread`);
|
|
1907
|
+
deferConsolidationThread(threadId, 'draft-call-failed');
|
|
1908
|
+
return true;
|
|
1909
|
+
}
|
|
1910
|
+
const reply = readConsolidationReply(raw);
|
|
1911
|
+
if (reply.kind !== 'draft') {
|
|
1912
|
+
if (reply.kind === 'none') {
|
|
1913
|
+
log(`consolidate: nothing to conclude from thread ${threadId} (${batch.length} facts)`);
|
|
1914
|
+
trace({ event: 'consolidate', verdict: 'none', thread: threadId, batch: batch.length });
|
|
1915
|
+
writeConsolidationState({ pending: null });
|
|
1916
|
+
return true;
|
|
1917
|
+
}
|
|
1918
|
+
// Empty or unreadable is a failed call, not a decision. Put the thread back with
|
|
1919
|
+
// an attempt counter instead of discarding its conclusion.
|
|
1920
|
+
log(`consolidate: unreadable draft (${reply.kind}) — deferring thread ${threadId}`);
|
|
1921
|
+
trace({ event: 'consolidate', verdict: 'draft-unreadable', thread: threadId, reason: reply.kind });
|
|
1922
|
+
deferConsolidationThread(threadId, `draft-${reply.kind}`);
|
|
1923
|
+
return true;
|
|
1924
|
+
}
|
|
1925
|
+
const draft = reply.draft;
|
|
1926
|
+
log(`consolidate draft: thread=${threadId} "${draft.statement}" evidence=${draft.evidence.join(',')} — verifying next step`);
|
|
1927
|
+
trace({ event: 'consolidate', verdict: 'draft', statement: draft.statement, evidence: draft.evidence, thread: threadId });
|
|
1928
|
+
writeConsolidationState({ pending: { phase: 'verify', draft, batch, threadId, attempts: 0 } });
|
|
1929
|
+
return true;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* One-shot backfill: enqueue the last few completed turns so they go through the
|
|
1934
|
+
* same path as new ones. Marker file keeps a restart from re-billing old turns.
|
|
1935
|
+
*/
|
|
1936
|
+
async function backfillExtraction() {
|
|
1937
|
+
const marker = join(HOME, '.extract-backfill-done');
|
|
1938
|
+
if (existsSync(marker)) return;
|
|
1939
|
+
const config = readConfig();
|
|
1940
|
+
if (config.extract === 'off') return;
|
|
1941
|
+
try {
|
|
1942
|
+
const recent = ensureSoul().recallTurnPairs('', 5);
|
|
1943
|
+
log(`extract backfill: queued ${recent.length} turns`);
|
|
1944
|
+
for (const turn of recent) {
|
|
1945
|
+
enqueueExtraction(turn.id, turn.turn);
|
|
1946
|
+
}
|
|
1947
|
+
writeFileSync(marker, new Date().toISOString());
|
|
1948
|
+
} catch (error) {
|
|
1949
|
+
log(`extract backfill failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
async function observeText(text) {
|
|
1954
|
+
const started = Date.now();
|
|
1955
|
+
try {
|
|
1956
|
+
const result = await ensureSoul().observe({ role: 'user', content: text });
|
|
1957
|
+
const affect = result?.affect ?? {};
|
|
1958
|
+
const state = readState();
|
|
1959
|
+
writeState({
|
|
1960
|
+
observes: (state.observes ?? 0) + 1,
|
|
1961
|
+
lastObserveAt: started,
|
|
1962
|
+
lastObservedChars: text.length,
|
|
1963
|
+
lastWritten: result?.written ?? 0,
|
|
1964
|
+
lastAffect: affect.dominant ?? null,
|
|
1965
|
+
});
|
|
1966
|
+
log(
|
|
1967
|
+
`observe ok: chars=${text.length} written=${result?.written ?? '?'} committed=${result?.committed ?? '?'} by=${result?.extractedBy ?? '?'} llm=${result?.llmOutcome ?? '?'}` +
|
|
1968
|
+
` affect=${affect.dominant ?? 'none'} confidence=${affect.confidence ?? '?'} ms=${Date.now() - started}`,
|
|
1969
|
+
);
|
|
1970
|
+
// A new observation may change the anchors inside the next pre-step block.
|
|
1971
|
+
} catch (error) {
|
|
1972
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1973
|
+
const state = readState();
|
|
1974
|
+
writeState({ observeFails: (state.observeFails ?? 0) + 1, lastError: `observe failed: ${message}` });
|
|
1975
|
+
log(`observe failed: ${message}`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
/** Tool output must match the declared schema; text blocks keep it readable. */
|
|
1980
|
+
function textRender(_args, value) {
|
|
1981
|
+
return [{ type: 'text', text: JSON.stringify(value) }];
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function recallTool() {
|
|
1985
|
+
return {
|
|
1986
|
+
name: 'soul_recall',
|
|
1987
|
+
description:
|
|
1988
|
+
'按关键词从长期记忆里召回相关条目。当用户提到过去说过的话、偏好、约定,' +
|
|
1989
|
+
'或你不确定某事是否记过时使用。返回按相关性排序的条目。',
|
|
1990
|
+
parameters: {
|
|
1991
|
+
type: 'object',
|
|
1992
|
+
properties: {
|
|
1993
|
+
query: { type: 'string', description: '检索关键词,通常是用户当前问题里的主题词' },
|
|
1994
|
+
limit: { type: 'number', description: '返回条数,默认 5,上限 20' },
|
|
1995
|
+
},
|
|
1996
|
+
required: ['query'],
|
|
1997
|
+
},
|
|
1998
|
+
output: {
|
|
1999
|
+
schema: {
|
|
2000
|
+
type: 'object',
|
|
2001
|
+
additionalProperties: false,
|
|
2002
|
+
properties: {
|
|
2003
|
+
ok: { type: 'boolean' },
|
|
2004
|
+
count: { type: 'number' },
|
|
2005
|
+
memories: {
|
|
2006
|
+
type: 'array',
|
|
2007
|
+
items: {
|
|
2008
|
+
type: 'object',
|
|
2009
|
+
additionalProperties: false,
|
|
2010
|
+
properties: {
|
|
2011
|
+
id: { type: 'string' },
|
|
2012
|
+
type: { type: 'string' },
|
|
2013
|
+
content: { type: 'string' },
|
|
2014
|
+
score: { type: 'number' },
|
|
2015
|
+
},
|
|
2016
|
+
required: ['id', 'type', 'content', 'score'],
|
|
2017
|
+
},
|
|
2018
|
+
},
|
|
2019
|
+
reason: { type: 'string' },
|
|
2020
|
+
},
|
|
2021
|
+
required: ['ok', 'count', 'memories'],
|
|
2022
|
+
},
|
|
2023
|
+
render: textRender,
|
|
2024
|
+
},
|
|
2025
|
+
async execute(args) {
|
|
2026
|
+
try {
|
|
2027
|
+
const query = String(args?.query ?? '').trim();
|
|
2028
|
+
if (query.length === 0) return { ok: false, count: 0, memories: [], reason: 'empty query' };
|
|
2029
|
+
const limit = Math.min(20, Math.max(1, Number(args?.limit ?? 5)));
|
|
2030
|
+
const hits = ensureSoul().recall(query, limit);
|
|
2031
|
+
log(`tool soul_recall: chars=${query.length} hits=${hits.length}`);
|
|
2032
|
+
return {
|
|
2033
|
+
ok: true,
|
|
2034
|
+
count: hits.length,
|
|
2035
|
+
memories: hits.map((hit) => ({
|
|
2036
|
+
id: hit.memory.id,
|
|
2037
|
+
type: hit.memory.type,
|
|
2038
|
+
content: hit.memory.content,
|
|
2039
|
+
score: Number(hit.score.toFixed(3)),
|
|
2040
|
+
})),
|
|
2041
|
+
};
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
return { ok: false, count: 0, memories: [], reason: error instanceof Error ? error.message : String(error) };
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
function rememberTool() {
|
|
2050
|
+
return {
|
|
2051
|
+
name: 'soul_remember',
|
|
2052
|
+
description:
|
|
2053
|
+
'把一件值得长期记住的事写进记忆。只在用户明确要求记住、或确认这是一条长期约定/偏好时使用;' +
|
|
2054
|
+
'不要用它记录一次性的临时信息。content 必须写用户的原话(可精简,不可改写含义),' +
|
|
2055
|
+
'不要写"这是用户要求记住的一条内容"这类元描述;系统已经自动记录过的内容不要去重写。',
|
|
2056
|
+
parameters: {
|
|
2057
|
+
type: 'object',
|
|
2058
|
+
properties: {
|
|
2059
|
+
content: { type: 'string', description: '用户的原话(可精简),不超过 200 字' },
|
|
2060
|
+
type: {
|
|
2061
|
+
type: 'string',
|
|
2062
|
+
enum: ['strategy', 'fact', 'trait', 'cognition'],
|
|
2063
|
+
description: 'strategy=规则/做法,fact=事实,trait=偏好,cognition=原则',
|
|
2064
|
+
},
|
|
2065
|
+
importance: { type: 'number', description: '1-10,默认 7' },
|
|
2066
|
+
},
|
|
2067
|
+
required: ['content'],
|
|
2068
|
+
},
|
|
2069
|
+
output: {
|
|
2070
|
+
schema: {
|
|
2071
|
+
type: 'object',
|
|
2072
|
+
additionalProperties: false,
|
|
2073
|
+
properties: {
|
|
2074
|
+
ok: { type: 'boolean' },
|
|
2075
|
+
id: { type: 'string' },
|
|
2076
|
+
duplicate: { type: 'boolean' },
|
|
2077
|
+
reason: { type: 'string' },
|
|
2078
|
+
},
|
|
2079
|
+
required: ['ok'],
|
|
2080
|
+
},
|
|
2081
|
+
render: textRender,
|
|
2082
|
+
},
|
|
2083
|
+
async execute(args) {
|
|
2084
|
+
try {
|
|
2085
|
+
const content = String(args?.content ?? '').trim();
|
|
2086
|
+
if (!content) return { ok: false, reason: 'empty content' };
|
|
2087
|
+
const type = typeof args?.type === 'string' ? args.type : 'strategy';
|
|
2088
|
+
const importance = args?.importance === undefined ? 7 : Number(args.importance);
|
|
2089
|
+
// Explicit memories are lessons in the two-layer model (ADR 0032): a fact
|
|
2090
|
+
// held by the soul itself, not a row in the retired memories table.
|
|
2091
|
+
const mapped = type === 'cognition' ? '原则' : type === 'trait' ? '性格' : type === 'fact' ? '事实' : '教训';
|
|
2092
|
+
const soul = ensureSoul();
|
|
2093
|
+
const duplicate = soul.recentFacts(500).find((fact) => fact.statement === content && fact.status !== 'superseded');
|
|
2094
|
+
if (duplicate) return { ok: true, id: duplicate.id, duplicate: true };
|
|
2095
|
+
const fact = soul.recordFact({
|
|
2096
|
+
statement: content,
|
|
2097
|
+
typeName: mapped,
|
|
2098
|
+
holder: 'self',
|
|
2099
|
+
source: 'told',
|
|
2100
|
+
confidence: Math.min(1, Math.max(0, (Number.isFinite(importance) ? importance : 7) / 10)),
|
|
2101
|
+
});
|
|
2102
|
+
log(`tool soul_remember: ok=true type=${mapped} id=${fact.id}`);
|
|
2103
|
+
return { ok: true, id: fact.id };
|
|
2104
|
+
} catch (error) {
|
|
2105
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2106
|
+
}
|
|
2107
|
+
},
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
function detailTool() {
|
|
2112
|
+
return {
|
|
2113
|
+
name: 'soul_detail',
|
|
2114
|
+
description:
|
|
2115
|
+
'按事实 id 取回当时的完整对话。注入里每条记忆是形如 [fc_xxx] 的一句话;' +
|
|
2116
|
+
'只有需要原话、细节或上下文时才调用它——先看一句话就能回答的问题,不要调用。',
|
|
2117
|
+
parameters: {
|
|
2118
|
+
type: 'object',
|
|
2119
|
+
properties: {
|
|
2120
|
+
id: { type: 'string', description: '事实 id,例如 fc_ab12' },
|
|
2121
|
+
},
|
|
2122
|
+
required: ['id'],
|
|
2123
|
+
},
|
|
2124
|
+
output: {
|
|
2125
|
+
schema: {
|
|
2126
|
+
type: 'object',
|
|
2127
|
+
additionalProperties: false,
|
|
2128
|
+
properties: {
|
|
2129
|
+
ok: { type: 'boolean' },
|
|
2130
|
+
statement: { type: 'string' },
|
|
2131
|
+
at: { type: 'number' },
|
|
2132
|
+
atText: { type: 'string' },
|
|
2133
|
+
userText: { type: 'string' },
|
|
2134
|
+
assistantText: { type: 'string' },
|
|
2135
|
+
reason: { type: 'string' },
|
|
2136
|
+
},
|
|
2137
|
+
required: ['ok'],
|
|
2138
|
+
},
|
|
2139
|
+
render: textRender,
|
|
2140
|
+
},
|
|
2141
|
+
async execute(args) {
|
|
2142
|
+
try {
|
|
2143
|
+
const id = String(args?.id ?? '').trim();
|
|
2144
|
+
const detail = ensureSoul().factDetail(id);
|
|
2145
|
+
if (!detail) {
|
|
2146
|
+
log(`tool soul_detail: miss ${id}`);
|
|
2147
|
+
return { ok: false, reason: `no fact ${id}` };
|
|
2148
|
+
}
|
|
2149
|
+
log(`tool soul_detail: ${id} turn=${detail.turn ? 'yes' : 'no'}`);
|
|
2150
|
+
return {
|
|
2151
|
+
ok: true,
|
|
2152
|
+
statement: detail.fact.statement,
|
|
2153
|
+
at: detail.fact.at,
|
|
2154
|
+
atText: detail.fact.atText,
|
|
2155
|
+
userText: detail.turn?.userText,
|
|
2156
|
+
assistantText: detail.turn?.assistantText,
|
|
2157
|
+
};
|
|
2158
|
+
} catch (error) {
|
|
2159
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2160
|
+
}
|
|
2161
|
+
},
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Is this plugin on the profile's bundle list?
|
|
2167
|
+
*
|
|
2168
|
+
* A plugin is loaded only because its package is listed in `dsh.profile.bundles`.
|
|
2169
|
+
* Desktop's fatal-error recovery (sanitizeProfile) renames cordis.patch.yml and
|
|
2170
|
+
* restores a caller-given bundle list, which silently dropped this bundle once:
|
|
2171
|
+
* the app stayed up, the plugin simply was not there, and the logs just stopped.
|
|
2172
|
+
*
|
|
2173
|
+
* The profile directory is derived from this module's own location
|
|
2174
|
+
* (<profile>/node_modules/dsh-plugin-soulbridge/index.js), so it cannot drift
|
|
2175
|
+
* from where the running copy actually is.
|
|
2176
|
+
*/
|
|
2177
|
+
export function checkBundle(manifest, name = 'dsh-plugin-soulbridge') {
|
|
2178
|
+
const bundles = manifest?.dsh?.profile?.bundles;
|
|
2179
|
+
if (!Array.isArray(bundles)) return { ok: false, reason: 'no dsh.profile.bundles list', bundles: [] };
|
|
2180
|
+
return { ok: bundles.includes(name), reason: bundles.includes(name) ? 'present' : 'missing', bundles };
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function selfCheck() {
|
|
2184
|
+
try {
|
|
2185
|
+
const pluginDir = fileURLToPath(new URL('.', import.meta.url));
|
|
2186
|
+
const profileDir = resolve(pluginDir, '..', '..');
|
|
2187
|
+
const manifestFile = join(profileDir, 'package.json');
|
|
2188
|
+
if (!existsSync(manifestFile)) {
|
|
2189
|
+
log(`bundle check: no profile manifest at ${manifestFile}`);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
|
|
2193
|
+
const result = checkBundle(manifest);
|
|
2194
|
+
if (result.ok) {
|
|
2195
|
+
log(`bundle check: present (profile=${profileDir})`);
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
log(
|
|
2199
|
+
`bundle check: MISSING from dsh.profile.bundles (profile=${profileDir}) — ` +
|
|
2200
|
+
`add "${name}" to that list or this plugin will not load after a restart`,
|
|
2201
|
+
);
|
|
2202
|
+
trace({ event: 'bundle-missing', profile: profileDir, bundles: result.bundles });
|
|
2203
|
+
writeState({ bundleMissing: true, bundleCheckedAt: Date.now(), profile: profileDir });
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
log(`bundle check: failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
export function apply(ctx) {
|
|
2210
|
+
log(`apply() called (v${VERSION})`);
|
|
2211
|
+
writeState({ appliedAt: Date.now(), version: VERSION });
|
|
2212
|
+
selfCheck();
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
const config = readConfig();
|
|
2216
|
+
injectedLlm = makeLlm(ctx, config);
|
|
2217
|
+
injectedMaxLlmChars = config.maxLlmChars;
|
|
2218
|
+
log(`extract=${config.extract}${injectedLlm ? ' via host model' : ' (no model: rules only)'}`);
|
|
2219
|
+
// Backfill is triggered by the first user message, not here: at apply() time
|
|
2220
|
+
// the host model service is not mounted yet and every call fails.
|
|
2221
|
+
|
|
2222
|
+
try {
|
|
2223
|
+
ctx.effect(
|
|
2224
|
+
() => {
|
|
2225
|
+
log('effect registered');
|
|
2226
|
+
return () => {
|
|
2227
|
+
try {
|
|
2228
|
+
soul?.store?.close();
|
|
2229
|
+
soul = null;
|
|
2230
|
+
lastSectionHash = '';
|
|
2231
|
+
log('effect disposed (store closed)');
|
|
2232
|
+
} catch (error) {
|
|
2233
|
+
log(`dispose failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2234
|
+
}
|
|
2235
|
+
};
|
|
2236
|
+
},
|
|
2237
|
+
'soulbridge.lifecycle',
|
|
2238
|
+
);
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
log(`effect registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
try {
|
|
2244
|
+
ctx.systemPrompt.section({
|
|
2245
|
+
name: 'soulbridge',
|
|
2246
|
+
order: 10,
|
|
2247
|
+
text: () => buildInjection(),
|
|
2248
|
+
});
|
|
2249
|
+
log('section registered (name=soulbridge order=10)');
|
|
2250
|
+
} catch (error) {
|
|
2251
|
+
log(`section registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// RFC 0006: relevant memories are injected here, not in the system prompt —
|
|
2255
|
+
// this is the only point that runs after the user message and before the
|
|
2256
|
+
// request, and its waterfall can change the request.
|
|
2257
|
+
try {
|
|
2258
|
+
if (ctx.on) {
|
|
2259
|
+
ctx.on('agent/pre-step', createPreStepHandler({ getContext: buildRelatedContext, archivedSessions: () => readArchivedSessions(ctx) }));
|
|
2260
|
+
log('pre-step listener attached');
|
|
2261
|
+
}
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
log(`pre-step listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
// Tools (RFC 0005): registered only when the host exposes the service, and
|
|
2267
|
+
// disposed with the plugin so a reload cannot register twice.
|
|
2268
|
+
try {
|
|
2269
|
+
if (typeof ctx.inject === 'function') {
|
|
2270
|
+
ctx.inject(['tools'], (toolsCtx) => {
|
|
2271
|
+
const tools = toolsCtx.get('tools');
|
|
2272
|
+
if (!tools) {
|
|
2273
|
+
log('tools: service unavailable; disabled');
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
const disposers = [tools.register(recallTool()), tools.register(rememberTool()), tools.register(detailTool())];
|
|
2277
|
+
log('tools registered (soul_recall, soul_remember, soul_detail)');
|
|
2278
|
+
ctx.effect(() => () => {
|
|
2279
|
+
for (const dispose of disposers) {
|
|
2280
|
+
try {
|
|
2281
|
+
dispose?.();
|
|
2282
|
+
} catch {
|
|
2283
|
+
// A failed disposer must not block the rest of unload.
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
log('tools disposed');
|
|
2287
|
+
});
|
|
2288
|
+
});
|
|
2289
|
+
} else {
|
|
2290
|
+
log('tools: ctx.inject unavailable; disabled');
|
|
2291
|
+
}
|
|
2292
|
+
} catch (error) {
|
|
2293
|
+
log(`tools registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
try {
|
|
2297
|
+
ctx.on('session/event', (session, event) => {
|
|
2298
|
+
const type = event?.type ?? '?';
|
|
2299
|
+
log(
|
|
2300
|
+
`session/event type=${type}` +
|
|
2301
|
+
` eventKeys=[${safeKeys(event)}]` +
|
|
2302
|
+
` dataKeys=[${safeKeys(event?.data)}]` +
|
|
2303
|
+
` sessionKeys=[${safeKeys(session)}]`,
|
|
2304
|
+
);
|
|
2305
|
+
|
|
2306
|
+
const sessionId = session?.id ?? 'default';
|
|
2307
|
+
|
|
2308
|
+
// Keep the last reply: a correction only makes sense next to what it targets.
|
|
2309
|
+
if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
|
|
2310
|
+
if (typeof event.data?.step === 'number') currentStep = event.data.step;
|
|
2311
|
+
|
|
2312
|
+
if (type === 'assistant/message') {
|
|
2313
|
+
const content = event.data?.message?.content;
|
|
2314
|
+
const reply = textOf(content);
|
|
2315
|
+
if (reply.length > 0) {
|
|
2316
|
+
lastAssistantText = reply;
|
|
2317
|
+
noteAssistantPart(sessionId, reply);
|
|
2318
|
+
}
|
|
2319
|
+
// Reasoning is stored, never injected: it is a draft, but "why did you
|
|
2320
|
+
// say that" and consolidation both need it.
|
|
2321
|
+
const reasoning = reasoningOf(content);
|
|
2322
|
+
if (reasoning.length > 0) noteAssistantStep(sessionId, 'reasoning', reasoning);
|
|
2323
|
+
if (reply.length > 0) noteAssistantStep(sessionId, 'text', reply);
|
|
2324
|
+
// Context size per request: the only number that can show whether a
|
|
2325
|
+
// long session is running away (no user text, usage only).
|
|
2326
|
+
trace({
|
|
2327
|
+
event: 'message',
|
|
2328
|
+
session: sessionId,
|
|
2329
|
+
turn: event.data?.turn,
|
|
2330
|
+
step: event.data?.step,
|
|
2331
|
+
replyChars: reply.length,
|
|
2332
|
+
// What the model actually returned, block by block: the turn is 5
|
|
2333
|
+
// steps on average and 70% of turns call tools, so "one question one
|
|
2334
|
+
// answer" is a product abstraction, not the model's shape. Types and
|
|
2335
|
+
// sizes only — never the text.
|
|
2336
|
+
blocks: summarizeBlocks(event.data?.message?.content),
|
|
2337
|
+
usage: event.data?.usage ?? null,
|
|
2338
|
+
});
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// A turn is over: write the pair. This is the primary flush point; the
|
|
2343
|
+
// silence timer and the next user turn are fallbacks only.
|
|
2344
|
+
if (type === 'turn/end') {
|
|
2345
|
+
flushTurnPair(sessionId);
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
if (type !== 'user/message') return;
|
|
2350
|
+
|
|
2351
|
+
const data = event.data ?? {};
|
|
2352
|
+
const kind = data.source?.kind;
|
|
2353
|
+
const text = textOf(data.content);
|
|
2354
|
+
log(`user/message source=${kind ?? '?'} chars=${text.length}`);
|
|
2355
|
+
// The model service is only mounted once the session is live, so the
|
|
2356
|
+
// one-shot backfill waits for the first real message instead of running
|
|
2357
|
+
// inside apply() where the llm lookup fails.
|
|
2358
|
+
if (kind === 'user') {
|
|
2359
|
+
void backfillExtraction();
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
// Only the human's own prose. runtime-context / time-context / injected
|
|
2363
|
+
// messages arrive with their own source kinds and must not be observed.
|
|
2364
|
+
if (kind !== 'user') return;
|
|
2365
|
+
if (text.length === 0) return;
|
|
2366
|
+
|
|
2367
|
+
// A new user turn means the previous one never saw turn/end (interrupt,
|
|
2368
|
+
// crash). Drop it rather than write a half pair.
|
|
2369
|
+
flushTurnPair(sessionId);
|
|
2370
|
+
noteUserTurn(sessionId, event, text);
|
|
2371
|
+
|
|
2372
|
+
// Evidence, not memory (RFC 0003 phase A), recorded with the reply it targeted.
|
|
2373
|
+
// Two layers: explicit markers (L1) first, then structure against that reply (L2).
|
|
2374
|
+
const verdict = detectCorrection(text, lastAssistantText);
|
|
2375
|
+
if (verdict.isCorrection) {
|
|
2376
|
+
try {
|
|
2377
|
+
const id = ensureSoul().recordCorrection({
|
|
2378
|
+
userText: text,
|
|
2379
|
+
assistantText: lastAssistantText,
|
|
2380
|
+
rule: verdict.marker ? `${verdict.layer}:${verdict.marker}` : verdict.layer,
|
|
2381
|
+
});
|
|
2382
|
+
log(`correction recorded: ${id} layer=${verdict.layer} marker=${verdict.marker ?? '-'}`);
|
|
2383
|
+
} catch (error) {
|
|
2384
|
+
log(`correction failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
void observeText(text);
|
|
2389
|
+
});
|
|
2390
|
+
log('session/event listener attached');
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
// NOTE: reflect() is intentionally not wired. observe() already distils each
|
|
2394
|
+
// turn (distillStrategy lives in ruleExtract), so a per-turn reflect would
|
|
2395
|
+
// only return memories=0; session/disposed never fires on the desktop host.
|
|
2396
|
+
// Session-level summarisation waits for a reliable trigger and LLM distillation.
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
log(`session/event listener failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
export { textOf, VERSION };
|