dsh-advisor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +7 -0
  3. package/README.md +303 -0
  4. package/README.zh.md +166 -0
  5. package/cordis.patch.yml +6 -0
  6. package/lib/advisor-runtime.d.ts +242 -0
  7. package/lib/advisor-runtime.js +662 -0
  8. package/lib/advisor-runtime.js.map +1 -0
  9. package/lib/client/advisor-card.d.ts +90 -0
  10. package/lib/client/advisor-store.d.ts +310 -0
  11. package/lib/client/index.d.ts +39 -0
  12. package/lib/client/locales.d.ts +40 -0
  13. package/lib/client.d.ts +1 -0
  14. package/lib/client.js +840 -0
  15. package/lib/commands.d.ts +136 -0
  16. package/lib/commands.js +185 -0
  17. package/lib/commands.js.map +1 -0
  18. package/lib/config.d.ts +74 -0
  19. package/lib/config.js +93 -0
  20. package/lib/config.js.map +1 -0
  21. package/lib/delivery.d.ts +129 -0
  22. package/lib/delivery.js +169 -0
  23. package/lib/delivery.js.map +1 -0
  24. package/lib/emission-guard.d.ts +99 -0
  25. package/lib/emission-guard.js +155 -0
  26. package/lib/emission-guard.js.map +1 -0
  27. package/lib/gateway.d.ts +116 -0
  28. package/lib/gateway.js +214 -0
  29. package/lib/gateway.js.map +1 -0
  30. package/lib/index.d.ts +48 -0
  31. package/lib/index.js +485 -0
  32. package/lib/index.js.map +1 -0
  33. package/lib/kinds.d.ts +38 -0
  34. package/lib/kinds.js +24 -0
  35. package/lib/kinds.js.map +1 -0
  36. package/lib/prompts.d.ts +22 -0
  37. package/lib/prompts.js +38 -0
  38. package/lib/prompts.js.map +1 -0
  39. package/lib/settings.d.ts +96 -0
  40. package/lib/settings.js +141 -0
  41. package/lib/settings.js.map +1 -0
  42. package/lib/transcript.d.ts +257 -0
  43. package/lib/transcript.js +530 -0
  44. package/lib/transcript.js.map +1 -0
  45. package/package.json +90 -0
  46. package/scripts/build-client.mjs +268 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Per-session advisor runtime (spec §2 S2, §4 mapping rows, §6, §8.2 KD-2,
3
+ * §8.5 KD-5) — the "advisor model call + note extraction + drain/backlog"
4
+ * core.
5
+ *
6
+ * One {@link AdvisorRuntime} exists per session (created on `agent/created` or
7
+ * lazily on the first stepped `turn/end`, disposed on `agent/disposed` /
8
+ * `session/disposed` — wired in `index.ts`). It owns:
9
+ *
10
+ * - a FIFO queue of pending transcript deltas (bounded — spec §6 "bounded
11
+ * backlog"; drop-newest when full);
12
+ * - a serialized async drain loop: one `llm.stream` call per delta with
13
+ * `{ provider, model, system, messages: [user delta], maxTokens: 5120 }` and
14
+ * `purpose` left UNSET (KD-5 — an advisor call is an ordinary conversation
15
+ * request);
16
+ * - a call-level deadline on every `llm.stream` call (dsh-timeout `deadline`,
17
+ * fused with the dispose signal and raced per chunk): a hung provider stream
18
+ * times out instead of wedging the drain, and a timeout is a transient
19
+ * failure (KD-5 retry → drop);
20
+ * - KD-2 JSON-frame extraction: the first balanced `{…}` in the reply is
21
+ * parsed (tolerant of prose/fences), `note` must be non-empty (else
22
+ * drop+log), `severity` missing/invalid defaults to `nit`, no parse retry;
23
+ * - the KD-5 failure policy: transient → 1 retry with a short backoff → drop;
24
+ * 3 consecutive dropped deltas → flush the pending backlog (never stall);
25
+ * permanent errors (`invalid_request_error`, model-not-found, "is not
26
+ * supported when") → halt the session's advisor; quota/rate-limit → pause
27
+ * (`quota_exhausted`), batch retained, no auto-resume timer; the in-flight
28
+ * call is aborted on dispose via the `signal`. `halted` is terminal in
29
+ * place — the command layer rebuilds the runtime (dispose + recreate); a
30
+ * `quota_exhausted` runtime resumes via {@link AdvisorRuntime.resume}.
31
+ *
32
+ * The runtime never parks the primary loop: everything is fire-and-forget
33
+ * async and a failing advisor can only drop its own backlog.
34
+ *
35
+ * @module dsh-advisor/advisor-runtime
36
+ */
37
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
38
+ if (value !== null && value !== void 0) {
39
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
40
+ var dispose, inner;
41
+ if (async) {
42
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
43
+ dispose = value[Symbol.asyncDispose];
44
+ }
45
+ if (dispose === void 0) {
46
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
47
+ dispose = value[Symbol.dispose];
48
+ if (async) inner = dispose;
49
+ }
50
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
51
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
52
+ env.stack.push({ value: value, dispose: dispose, async: async });
53
+ }
54
+ else if (async) {
55
+ env.stack.push({ async: true });
56
+ }
57
+ return value;
58
+ };
59
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
60
+ return function (env) {
61
+ function fail(e) {
62
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
63
+ env.hasError = true;
64
+ }
65
+ var r, s = 0;
66
+ function next() {
67
+ while (r = env.stack.pop()) {
68
+ try {
69
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
70
+ if (r.dispose) {
71
+ var result = r.dispose.call(r.value);
72
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
73
+ }
74
+ else s |= 1;
75
+ }
76
+ catch (e) {
77
+ fail(e);
78
+ }
79
+ }
80
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
81
+ if (env.hasError) throw env.error;
82
+ }
83
+ return next();
84
+ };
85
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
86
+ var e = new Error(message);
87
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
88
+ });
89
+ import { createUserMessage, isQuotaExceededError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
90
+ import { INVALID_CREDENTIAL_CODE, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm';
91
+ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
92
+ import { createEmissionGuard } from './emission-guard.js';
93
+ /** Pinned policy values (KD-2 / KD-5). */
94
+ const DEFAULT_MAX_TOKENS = 256;
95
+ /**
96
+ * n4 user direction: the advisor call runs with a 20x token budget
97
+ * (256 -> 5120) so even a reasoning-heavy reply cannot starve the JSON frame.
98
+ * Exported so the test suites assert the pinned value instead of a magic
99
+ * literal.
100
+ *
101
+ * Supersession note (qc2 S-2 / qc1 S-2 / qc3 F-2): the frozen spec §8.2
102
+ * (KD-2) pins `maxTokens: 256` ("so a runaway reply cannot blow the budget").
103
+ * This 5120 value is the USER-DIRECTED supersession of that pin — a 20x
104
+ * worst-case per-call ceiling, adopted together with `reasoningEffort: 'off'`
105
+ * (capability-gated, see `resolveReasoningEffort`) so the raised budget goes
106
+ * to the JSON frame rather than reasoning output. The looser runaway-reply
107
+ * guard is re-bounded downstream: `extractAdviceNote` caps the note at
108
+ * `ADVISOR_NOTE_MAX_CHARS` and `buildAdvisorMessage` bounds the notice
109
+ * summary via `boundContextSummary`.
110
+ */
111
+ export const ADVISOR_MAX_TOKENS = 5_120;
112
+ /**
113
+ * One extracted note's length cap (qc3 F-2 / qc2 S-1): a verbose/rogue advisor
114
+ * reply with the 20x token budget must not inject an unbounded user-role
115
+ * message into the primary session. Truncated with a '…' marker.
116
+ */
117
+ export const ADVISOR_NOTE_MAX_CHARS = 1_000;
118
+ const DEFAULT_RETRY_BACKOFF_MS = 1_000;
119
+ const DEFAULT_MAX_QUEUED = 32;
120
+ /** Whole-call deadline for one `llm.stream` (qc2 W-4 / qc3 W-1); see `callTimeoutMs`. */
121
+ const DEFAULT_CALL_TIMEOUT_MS = 60_000;
122
+ /** Capability-owned code stamped onto the deadline's TimeoutReason. */
123
+ const ADVISOR_CALL_TIMEOUT = 'ADVISOR_CALL_TIMEOUT';
124
+ /** Transient retries after the first attempt (KD-5: retry(1) → drop). */
125
+ const MAX_TRANSIENT_ATTEMPTS = 1;
126
+ /** Consecutive dropped deltas after which the pending backlog is flushed (KD-5). */
127
+ const MAX_CONSECUTIVE_DROPS = 3;
128
+ // ---------------------------------------------------------------------------
129
+ // KD-2 — JSON-frame note extraction
130
+ // ---------------------------------------------------------------------------
131
+ /**
132
+ * Yield every top-level balanced `{…}` region of `text`, skipping braces
133
+ * inside string literals so a quoted `{`/`}` never corrupts the balance.
134
+ */
135
+ function* balancedObjects(text) {
136
+ let start = -1;
137
+ let depth = 0;
138
+ let inString = false;
139
+ let escaped = false;
140
+ for (let index = 0; index < text.length; index++) {
141
+ const char = text[index];
142
+ if (inString) {
143
+ if (escaped)
144
+ escaped = false;
145
+ else if (char === '\\')
146
+ escaped = true;
147
+ else if (char === '"')
148
+ inString = false;
149
+ continue;
150
+ }
151
+ if (char === '"') {
152
+ inString = true;
153
+ }
154
+ else if (char === '{') {
155
+ if (start < 0) {
156
+ start = index;
157
+ depth = 1;
158
+ }
159
+ else {
160
+ depth++;
161
+ }
162
+ }
163
+ else if (char === '}') {
164
+ if (start >= 0 && --depth === 0) {
165
+ yield text.slice(start, index + 1);
166
+ start = -1;
167
+ }
168
+ }
169
+ }
170
+ }
171
+ /**
172
+ * Extract one {@link AdviceNote} from the advisor's reply (KD-2).
173
+ *
174
+ * Locates the first balanced `{…}` object (tolerant of surrounding prose and
175
+ * markdown fences), parses it, and validates: `note` must be a non-empty
176
+ * string after trim (else drop), `severity` missing/invalid defaults to
177
+ * `nit`. A reply with no parseable frame returns `undefined` — the caller
178
+ * drops + logs, and there is NO retry for parse failures (the retry budget is
179
+ * reserved for transport errors, KD-2). The note is capped at
180
+ * {@link ADVISOR_NOTE_MAX_CHARS} with a '…' marker (qc3 F-2 — the 20x token
181
+ * budget must not translate into an unbounded injection into the primary
182
+ * session). Never throws.
183
+ */
184
+ export function extractAdviceNote(reply) {
185
+ for (const frame of balancedObjects(reply)) {
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(frame);
189
+ }
190
+ catch {
191
+ continue; // not JSON — try the next balanced region
192
+ }
193
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
194
+ continue;
195
+ const record = parsed;
196
+ const note = record.note;
197
+ if (typeof note !== 'string' || note.trim().length === 0)
198
+ continue; // KD-2: drop empty note
199
+ const severity = record.severity;
200
+ const trimmed = note.trim();
201
+ return {
202
+ note: trimmed.length > ADVISOR_NOTE_MAX_CHARS
203
+ ? `${trimmed.slice(0, ADVISOR_NOTE_MAX_CHARS - 1)}…`
204
+ : trimmed,
205
+ severity: severity === 'concern' || severity === 'blocker' ? severity : 'nit',
206
+ };
207
+ }
208
+ return undefined;
209
+ }
210
+ /**
211
+ * omp's permanent-rejection wording (see the advisor runtime it ports): a
212
+ * request the provider refuses outright for this advisor configuration — no
213
+ * retry can fix it. Halting avoids re-attempting on every new delta forever.
214
+ */
215
+ const PERMANENT_FAILURE_PATTERN = /invalid_request_error|model[_ ]not[_ ]found|is not supported when|does not exist/i;
216
+ /** Classify one provider failure per KD-5 (quota/rate-limit → pause, permanent → halt). */
217
+ function classifyFailure(failure) {
218
+ if (failure.code === QUOTA_EXCEEDED_CODE
219
+ || failure.code === 'RATE_LIMIT'
220
+ || isQuotaExceededError(failure.message)) {
221
+ return 'quota';
222
+ }
223
+ if (failure.code === INVALID_CREDENTIAL_CODE
224
+ || failure.code === 'NO_ADAPTER'
225
+ || PERMANENT_FAILURE_PATTERN.test(failure.message)) {
226
+ return 'permanent';
227
+ }
228
+ return 'transient';
229
+ }
230
+ /** Minimal provider-neutral failure snapshot from a thrown value. */
231
+ function normalizeFailure(value) {
232
+ if (value instanceof Error) {
233
+ const code = value.code;
234
+ return {
235
+ message: value.message,
236
+ code: typeof code === 'string' && code.length > 0 ? code : 'UNKNOWN',
237
+ };
238
+ }
239
+ return { message: String(value), code: 'UNKNOWN' };
240
+ }
241
+ function sleep(ms) {
242
+ return new Promise((resolve) => setTimeout(resolve, ms));
243
+ }
244
+ /**
245
+ * Race one async-iterator demand against a deadline signal: resolve with the
246
+ * iterator's next result, or `'aborted'` when the signal aborts first. A
247
+ * provider error rejects through the race (the caller classifies it). This is
248
+ * what makes a hung stream (no chunk, no end, no error) terminable even when
249
+ * the provider ignores the abort signal — the runtime never depends on the
250
+ * provider honoring `signal` (qc2 W-4 / qc3 W-1).
251
+ */
252
+ function raceIteratorNext(iterator, signal) {
253
+ if (signal.aborted)
254
+ return Promise.resolve('aborted');
255
+ return new Promise((resolve, reject) => {
256
+ const onAbort = () => resolve('aborted');
257
+ signal.addEventListener('abort', onAbort, { once: true });
258
+ Promise.resolve()
259
+ .then(() => iterator.next())
260
+ .then((result) => {
261
+ signal.removeEventListener('abort', onAbort);
262
+ resolve(result);
263
+ }, (error) => {
264
+ signal.removeEventListener('abort', onAbort);
265
+ reject(error);
266
+ });
267
+ });
268
+ }
269
+ /**
270
+ * Per-session advisor runtime: queue deltas, async drain, `llm.stream` call,
271
+ * JSON-frame note extraction, and the KD-5 failure policy. All async work is
272
+ * fire-and-forget — the primary loop is never parked.
273
+ */
274
+ export class AdvisorRuntime {
275
+ provider;
276
+ model;
277
+ systemPrompt;
278
+ maxTokens;
279
+ retryBackoffMs;
280
+ callTimeoutMs;
281
+ maxQueued;
282
+ llm;
283
+ guard;
284
+ onNote;
285
+ logger;
286
+ controller = new AbortController();
287
+ /**
288
+ * Per-(provider, model) reasoning-effort capability cache (qc2 W-1 / qc1
289
+ * W-1 / qc3 F-3): resolving the model's declared efforts on EVERY advisor
290
+ * call would add an adapter round-trip per delta; one resolution per
291
+ * (provider, model) per runtime suffices — the route is pinned for the
292
+ * runtime's lifetime.
293
+ */
294
+ reasoningEffortCache = new Map();
295
+ queue = [];
296
+ state = 'running';
297
+ draining = false;
298
+ disposed = false;
299
+ consecutiveDrops = 0;
300
+ drainPromise;
301
+ /** Epoch-ms of the last note accepted by the emission guard (T7 status). */
302
+ lastActivityAt;
303
+ constructor(options) {
304
+ this.provider = options.provider;
305
+ this.model = options.model;
306
+ this.systemPrompt = options.systemPrompt;
307
+ this.maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
308
+ this.retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
309
+ this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
310
+ this.maxQueued = options.maxQueued ?? DEFAULT_MAX_QUEUED;
311
+ this.llm = options.llm;
312
+ this.guard = options.guard ?? createEmissionGuard();
313
+ this.onNote = options.onNote;
314
+ this.logger = options.logger ?? console;
315
+ }
316
+ /** Current per-session status (T7 `/advisor status` surface). */
317
+ status() {
318
+ return this.state;
319
+ }
320
+ /** Number of deltas waiting to be drained (bounded by `maxQueued`). */
321
+ get pendingCount() {
322
+ return this.queue.length;
323
+ }
324
+ /**
325
+ * T7 `/advisor status` surface — epoch-ms of the last note accepted by the
326
+ * emission guard, or `undefined` before the first accepted note.
327
+ */
328
+ get lastActivity() {
329
+ return this.lastActivityAt;
330
+ }
331
+ /**
332
+ * Queue one rendered transcript delta (from the T3 observer's `onDelta`).
333
+ * While `quota_exhausted`, deltas queue up (bounded) but the drain is never
334
+ * auto-restarted (KD-5 — no auto-resume timer); while `halted`/disposed they
335
+ * are dropped with a log. Never throws, never parks the caller.
336
+ */
337
+ enqueue(delta) {
338
+ if (this.disposed) {
339
+ this.logger.debug('advisor: enqueue ignored — runtime disposed');
340
+ return;
341
+ }
342
+ if (this.state === 'halted') {
343
+ this.logger.debug('advisor: enqueue ignored — advisor halted');
344
+ return;
345
+ }
346
+ if (this.queue.length >= this.maxQueued) {
347
+ // simplify: FIFO queue with drop-newest when full. A full queue means the
348
+ // advisor is far behind; the newest delta is the most redundant. A
349
+ // coalescing batch (omp-style) would be the upgrade path.
350
+ this.logger.debug('advisor: enqueue dropped — backlog full', { maxQueued: this.maxQueued });
351
+ return;
352
+ }
353
+ this.queue.push(delta);
354
+ if (this.state === 'quota_exhausted')
355
+ return; // paused: retain, never auto-resume
356
+ this.kickDrain();
357
+ }
358
+ /** Abort the in-flight call and stop the drain (wiring: session/agent disposed). */
359
+ dispose() {
360
+ if (this.disposed)
361
+ return;
362
+ this.disposed = true;
363
+ this.controller.abort('advisor disposed');
364
+ this.queue.length = 0;
365
+ this.consecutiveDrops = 0;
366
+ }
367
+ /**
368
+ * Manual resume after a quota pause (T7 `/advisor on`); no-op when halted/
369
+ * disposed — a halted runtime is terminal in place and is recovered by the
370
+ * command layer via dispose-and-recreate (qc1/qc2/qc3 W-1/I-4), never
371
+ * resumed here.
372
+ */
373
+ resume() {
374
+ if (this.disposed || this.state === 'halted')
375
+ return;
376
+ this.state = 'running';
377
+ this.kickDrain();
378
+ }
379
+ /**
380
+ * KD-5 reset trigger: a compaction / surface rewrite clears the emission
381
+ * guard's dedupe history and per-update latch — the session state is being
382
+ * rewritten, so the old note history no longer applies (a note already
383
+ * advised before the rewrite may legitimately be advised again). The wiring
384
+ * (`index.ts` onRewrite) calls this alongside the delivery cooldown reset.
385
+ */
386
+ resetGuard() {
387
+ this.guard.reset();
388
+ }
389
+ /** Resolve once the current drain run settles (test/integration hook). */
390
+ async waitForDrain() {
391
+ await this.drainPromise;
392
+ }
393
+ kickDrain() {
394
+ if (this.draining || this.disposed)
395
+ return;
396
+ if (this.state === 'quota_exhausted' || this.state === 'halted')
397
+ return;
398
+ this.drainPromise = this.drain().catch((error) => {
399
+ // Defense in depth: the drain is fire-and-forget, so a rejection here
400
+ // would be an unhandled promise rejection (process crash under Node
401
+ // ≥22/24 defaults). A failing advisor may only drop its own backlog —
402
+ // per-batch errors stay contained inside callModel.
403
+ this.logger.warn('advisor: drain loop failed — contained', { error });
404
+ });
405
+ }
406
+ /** Serialized drain loop: process the queue one delta at a time. */
407
+ async drain() {
408
+ this.draining = true;
409
+ try {
410
+ while (!this.disposed
411
+ && this.queue.length > 0
412
+ && this.state !== 'quota_exhausted'
413
+ && this.state !== 'halted') {
414
+ const delta = this.queue.shift();
415
+ switch ((await this.processDelta(delta)).kind) {
416
+ case 'note':
417
+ case 'no-note':
418
+ // A completed model call breaks the failure streak — extraction
419
+ // failures (KD-2 drops) are output-quality issues, not transport
420
+ // failures, and do not count toward the 3-drop flush.
421
+ this.consecutiveDrops = 0;
422
+ break;
423
+ case 'drop':
424
+ this.consecutiveDrops++;
425
+ if (this.consecutiveDrops >= MAX_CONSECUTIVE_DROPS)
426
+ this.flushBacklog();
427
+ break;
428
+ case 'requeue':
429
+ this.queue.unshift(delta); // quota pause: batch retained for a later resume
430
+ return;
431
+ case 'halt':
432
+ this.flushBacklog(); // halted: drop any still-pending backlog
433
+ return;
434
+ case 'aborted':
435
+ return;
436
+ }
437
+ }
438
+ }
439
+ finally {
440
+ this.draining = false;
441
+ }
442
+ }
443
+ /** KD-5: clear the pending backlog after consecutive failures (never stall). */
444
+ flushBacklog() {
445
+ this.consecutiveDrops = 0;
446
+ if (this.queue.length === 0)
447
+ return;
448
+ const flushed = this.queue.length;
449
+ this.queue.length = 0;
450
+ this.logger.warn('advisor: flushed pending backlog after consecutive failures', { flushed });
451
+ }
452
+ /** Process one delta: attempt + single retry (transient), classify terminal failures. */
453
+ async processDelta(delta) {
454
+ // One advisor model cycle per delta: the emission guard's one-note-per-
455
+ // update latch resets here, so each processed delta may deliver one note
456
+ // again (spec §6; the reset point is the drain's per-delta boundary).
457
+ this.guard.beginUpdate();
458
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_ATTEMPTS; attempt++) {
459
+ if (attempt > 0) {
460
+ if (this.disposed)
461
+ return { kind: 'aborted' };
462
+ await sleep(this.retryBackoffMs);
463
+ if (this.disposed)
464
+ return { kind: 'aborted' };
465
+ }
466
+ const result = await this.callModel(delta);
467
+ if (result.kind === 'note' || result.kind === 'no-note' || result.kind === 'aborted')
468
+ return result;
469
+ switch (classifyFailure(result.failure)) {
470
+ case 'quota':
471
+ this.state = 'quota_exhausted';
472
+ this.logger.warn('advisor: quota/rate-limit reached — paused, batch retained', { failure: result.failure });
473
+ return { kind: 'requeue' };
474
+ case 'permanent':
475
+ this.state = 'halted';
476
+ this.logger.warn('advisor: permanent model error — halted', { failure: result.failure });
477
+ return { kind: 'halt' };
478
+ case 'transient':
479
+ break; // retry once (the loop's next iteration)
480
+ }
481
+ }
482
+ this.logger.warn('advisor: dropping delta after transient failures', { attempts: MAX_TRANSIENT_ATTEMPTS + 1 });
483
+ return { kind: 'drop' };
484
+ }
485
+ /** One model call: build options, stream text, extract the note (KD-2). */
486
+ async callModel(delta) {
487
+ const env_1 = { stack: [], error: void 0, hasError: false };
488
+ try {
489
+ let text = '';
490
+ let finish;
491
+ // Call-level deadline (dsh-timeout): fuses the runtime's dispose signal
492
+ // with a whole-call timer. The fused signal is passed to the provider
493
+ // (AbortSignal honored) AND raced per chunk below, so a hung stream cannot
494
+ // wedge the drain even when the provider ignores the signal. A timeout is
495
+ // a transient failure — KD-5 retry(1) → drop (qc2 W-4 / qc3 W-1).
496
+ const deadlineHandle = __addDisposableResource(env_1, deadline(this.controller.signal, this.callTimeoutMs, ADVISOR_CALL_TIMEOUT), false);
497
+ const deadlineSignal = deadlineHandle.signal;
498
+ try {
499
+ // Capability-gate `reasoningEffort` (qc2 W-1 / qc1 W-1 / qc3 F-3):
500
+ // resolve the model's declared efforts ONCE per (provider, model) and
501
+ // pass 'off' only when supported; never fail the call on a resolution
502
+ // error (the option is simply omitted). The resolution is bounded by the
503
+ // call deadline (n4 QC N-5): the fused signal is threaded through, so a
504
+ // hung adapter capability lookup that honors cancellation aborts with
505
+ // the call instead of wedging the drain ahead of the deadline-guarded
506
+ // stream loop.
507
+ const reasoningEffort = await this.resolveReasoningEffort(deadlineSignal);
508
+ const stream = this.llm.stream(this.buildOptions(delta, deadlineSignal, reasoningEffort));
509
+ const iterator = stream[Symbol.asyncIterator]();
510
+ for (;;) {
511
+ const next = await raceIteratorNext(iterator, deadlineSignal);
512
+ if (next === 'aborted') {
513
+ // Best-effort teardown of a provider that may still be mid-flight;
514
+ // never await a hung teardown (it may never settle).
515
+ iterator.return?.().catch(() => { });
516
+ if (timeoutOf(deadlineSignal, ADVISOR_CALL_TIMEOUT) !== undefined) {
517
+ return {
518
+ kind: 'failure',
519
+ failure: { message: `advisor call timed out after ${this.callTimeoutMs}ms`, code: 'TIMEOUT' },
520
+ };
521
+ }
522
+ return { kind: 'aborted' }; // dispose aborted the in-flight call
523
+ }
524
+ if (next.done)
525
+ break;
526
+ const chunk = next.value;
527
+ if (chunk.type === 'text-delta')
528
+ text += chunk.text;
529
+ else if (chunk.type === 'finish')
530
+ finish = chunk.reason;
531
+ }
532
+ }
533
+ catch (error) {
534
+ return { kind: 'failure', failure: normalizeFailure(error) };
535
+ }
536
+ if (finish === undefined) {
537
+ return { kind: 'failure', failure: { message: 'advisor stream ended without a finish chunk', code: 'UNKNOWN' } };
538
+ }
539
+ if (finish.kind === 'error')
540
+ return { kind: 'failure', failure: finish.failure };
541
+ if (finish.kind === 'aborted') {
542
+ // Our own dispose aborts the in-flight call; a provider-side abort is a
543
+ // terminal failure like any other. A deadline timeout surfaced as a
544
+ // provider abort (rather than through the per-chunk race) is still the
545
+ // transient timeout case — KD-5 retry once → drop.
546
+ if (timeoutOf(deadlineSignal, ADVISOR_CALL_TIMEOUT) !== undefined) {
547
+ return {
548
+ kind: 'failure',
549
+ failure: { message: `advisor call timed out after ${this.callTimeoutMs}ms`, code: 'TIMEOUT' },
550
+ };
551
+ }
552
+ if (this.disposed || this.controller.signal.aborted)
553
+ return { kind: 'aborted' };
554
+ return { kind: 'failure', failure: finish.failure };
555
+ }
556
+ // stop | max-tokens | tool-calls: extract from the collected text (KD-2;
557
+ // no retry on parse failures — the frame must simply be absent/valid).
558
+ const note = extractAdviceNote(text);
559
+ if (note === undefined) {
560
+ this.logger.debug('advisor: reply yielded no note — dropped (KD-2)');
561
+ return { kind: 'no-note' };
562
+ }
563
+ try {
564
+ // The T5 emission guard sits between extraction and delivery: only
565
+ // accepted notes reach the delivery callback (T6). Suppression is
566
+ // silent — the caller cannot tell an accepted from a suppressed note,
567
+ // and a guard failure must never crash the drain (T4 F1 containment).
568
+ if (this.guard.accept(note)) {
569
+ // T7: timestamp the moment a note is accepted for delivery (before
570
+ // onNote, so a throwing delivery seam cannot lose the activity
571
+ // record) — surfaced by `/advisor status` as "last activity".
572
+ this.lastActivityAt = Date.now();
573
+ this.onNote(note);
574
+ }
575
+ else {
576
+ this.logger.debug('advisor: note suppressed by emission guard', {
577
+ note: note.note,
578
+ severity: note.severity,
579
+ });
580
+ }
581
+ }
582
+ catch (error) {
583
+ // The emission guard / delivery seam must never crash the drain: log
584
+ // and continue. The model call itself succeeded — this is not a
585
+ // transport failure, so retry/drop/quota/halt semantics are untouched
586
+ // and the note still counts as extracted.
587
+ this.logger.warn('advisor: emission guard or delivery callback threw — contained', { error });
588
+ }
589
+ return { kind: 'note', note };
590
+ }
591
+ catch (e_1) {
592
+ env_1.error = e_1;
593
+ env_1.hasError = true;
594
+ }
595
+ finally {
596
+ __disposeResources(env_1);
597
+ }
598
+ }
599
+ buildOptions(delta, signal, reasoningEffort) {
600
+ return {
601
+ provider: this.provider,
602
+ model: this.model,
603
+ system: this.systemPrompt,
604
+ messages: [createUserMessage({
605
+ content: [{ type: 'text', text: delta.markdown }],
606
+ source: { kind: 'user' },
607
+ })],
608
+ // n4 root-cause (host-observed "reply yielded no note"): deepseek-v4-flash
609
+ // is a reasoning model — with the default 256-token budget the reasoning
610
+ // stream consumed the whole budget, the text output came back EMPTY
611
+ // (finish: max-tokens), and KD-2 dropped every reply. Turn reasoning OFF
612
+ // so the budget goes to the JSON frame, and raise it for headroom
613
+ // (ADVISOR_MAX_TOKENS). The advisor's job is a short structured note —
614
+ // reasoning is not needed. Capability-gated (qc2 W-1 / qc1 W-1 / qc3
615
+ // F-3): `resolveReasoningEffort` passes the branded 'off' ONLY when the
616
+ // resolved model declares it; otherwise the option is omitted entirely
617
+ // (the dsh LlmRuntime would reject an explicit effort for a model whose
618
+ // adapter lacks reasoning metadata with UNSUPPORTED_REASONING_EFFORT,
619
+ // silently killing the advisor for non-deepseek models — pre-n4 these
620
+ // worked because no effort was sent).
621
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
622
+ // n4 user direction: amplify the token budget 20x (256 -> 5120) so even a
623
+ // reasoning-heavy reply cannot starve the JSON frame.
624
+ maxTokens: ADVISOR_MAX_TOKENS,
625
+ signal,
626
+ // KD-5: `purpose` is a closed union ('compaction' | 'session-title'); an
627
+ // advisor call is an ordinary conversation request and leaves it unset.
628
+ };
629
+ }
630
+ /**
631
+ * Capability-gate the `reasoningEffort: 'off'` option (qc2 W-1 / qc1 W-1 /
632
+ * qc3 F-3): pass the branded 'off' effort only when the resolved model's
633
+ * `reasoning.efforts` includes it, else omit the option (`resolveCallFor`
634
+ * materializes the adapter default). Cached per (provider, model) — one
635
+ * resolution per runtime, never per call. A resolution failure (unknown
636
+ * route, adapter throw, or a deadline abort — n4 QC N-5) is advisory: the
637
+ * call proceeds WITHOUT the option, matching the pre-n4 behavior for every
638
+ * model that does not declare 'off'. The optional `signal` (the call's
639
+ * deadline signal) is threaded into `resolveModelInfo`, whose contract
640
+ * allows adapter-owned asynchronous lookup with cancellation — a hung
641
+ * lookup that honors the signal aborts with the call deadline instead of
642
+ * wedging the drain.
643
+ */
644
+ async resolveReasoningEffort(signal) {
645
+ const key = `${this.provider}\u0000${this.model}`;
646
+ if (this.reasoningEffortCache.has(key))
647
+ return this.reasoningEffortCache.get(key);
648
+ let effort;
649
+ try {
650
+ const info = await this.llm.resolveModelInfo?.(this.provider, this.model, signal);
651
+ effort = info?.reasoning?.efforts.some((entry) => entry.id === ReasoningEffortId('off'))
652
+ ? ReasoningEffortId('off')
653
+ : undefined;
654
+ }
655
+ catch {
656
+ effort = undefined;
657
+ }
658
+ this.reasoningEffortCache.set(key, effort);
659
+ return effort;
660
+ }
661
+ }
662
+ //# sourceMappingURL=advisor-runtime.js.map