@pugi/cli 0.1.0-alpha.10 → 0.1.0-alpha.16
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/assets/pugi-mascot.ansi +17 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1080 -11
- package/dist/core/repl/slash-commands.js +25 -3
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/runtime/cli.js +504 -10
- package/dist/runtime/commands/config.js +202 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +85 -0
- package/dist/tui/repl-splash-mascot.js +118 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +59 -10
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +5 -4
|
@@ -27,18 +27,32 @@
|
|
|
27
27
|
* verbatim - the brand gate on those happens at the controller.
|
|
28
28
|
*/
|
|
29
29
|
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { getPersona } from '@pugi/personas';
|
|
30
31
|
import { listRoles, getPersonaForRole } from '../agents/registry.js';
|
|
31
32
|
import { evaluateCap, describeVerdict } from './cap-warning.js';
|
|
32
33
|
import { parseSlashCommand } from './slash-commands.js';
|
|
33
34
|
import { webFetchTool } from '../../tools/web-fetch.js';
|
|
34
35
|
import { loadSettings } from '../settings.js';
|
|
35
36
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
37
|
+
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
36
38
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
37
39
|
import { resolve as resolvePath } from 'node:path';
|
|
38
40
|
const MAX_TRANSCRIPT_ROWS = 500;
|
|
41
|
+
const MAX_TOOL_CALLS = 200;
|
|
39
42
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
40
43
|
const RECONNECT_BASE_MS = 250;
|
|
41
44
|
const RECONNECT_MAX_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* Cap on silent session-recreate attempts on HTTP 404 from the SSE
|
|
47
|
+
* stream. When admin-api restarts it drops its in-memory session
|
|
48
|
+
* store, so the saved sessionId returns 404 on every subscribe. The
|
|
49
|
+
* CLI mints a fresh server session, swaps the consumer over, and
|
|
50
|
+
* keeps running — but we cap the recovery to 3 attempts inside 60s
|
|
51
|
+
* so a truly down admin-api fails loud instead of spinning forever.
|
|
52
|
+
* (α6.14.2 wave 5 — CEO dogfood fix.)
|
|
53
|
+
*/
|
|
54
|
+
const MAX_SESSION_RECREATE_ATTEMPTS = 3;
|
|
55
|
+
const SESSION_RECREATE_WINDOW_MS = 60_000;
|
|
42
56
|
export class ReplSession {
|
|
43
57
|
options;
|
|
44
58
|
subscribers = new Set();
|
|
@@ -48,6 +62,22 @@ export class ReplSession {
|
|
|
48
62
|
reconnectAttempt = 0;
|
|
49
63
|
reconnectTimer;
|
|
50
64
|
closed = false;
|
|
65
|
+
/**
|
|
66
|
+
* Rolling window of recent silent-recreate timestamps (epoch ms).
|
|
67
|
+
* The SSE stream returns HTTP 404 when admin-api has restarted and
|
|
68
|
+
* lost its in-memory session store; rather than spam the operator
|
|
69
|
+
* with "Stream interrupted (HTTP 404)" loops, we mint a fresh
|
|
70
|
+
* session and swap the consumer. Capped at MAX_SESSION_RECREATE_*
|
|
71
|
+
* inside SESSION_RECREATE_WINDOW_MS so a permanently down admin-api
|
|
72
|
+
* fails loud instead of looping silently. (α6.14.2 wave 5.)
|
|
73
|
+
*/
|
|
74
|
+
recentRecreateAtMs = [];
|
|
75
|
+
/**
|
|
76
|
+
* True while a session-recreate POST is in flight. Guards against
|
|
77
|
+
* the SSE stream firing multiple `onError(404)` callbacks racing
|
|
78
|
+
* the in-flight createSession promise. (α6.14.2 wave 5.)
|
|
79
|
+
*/
|
|
80
|
+
recreatingSession = false;
|
|
51
81
|
/**
|
|
52
82
|
* Last non-trivial step.detail recorded per taskId. The server streams
|
|
53
83
|
* the persona reply incrementally via `agent.step` events whose
|
|
@@ -60,17 +90,74 @@ export class ReplSession {
|
|
|
60
90
|
* said. CEO wave-2 fix 2026-05-25.
|
|
61
91
|
*/
|
|
62
92
|
lastStepDetail = new Map();
|
|
93
|
+
/**
|
|
94
|
+
* Optional local SessionStore — α6.4. When non-null, every
|
|
95
|
+
* appendRow() call mirrors the row into the JSONL log so the
|
|
96
|
+
* conversation can be restored via `/resume`. Errors from the store
|
|
97
|
+
* are swallowed to a single system line (degradation, not crash).
|
|
98
|
+
* The store is opened by the CLI bootstrap and closed via
|
|
99
|
+
* `ReplSession.close()`. The store ownership is shared — the
|
|
100
|
+
* SqliteSessionStore is process-wide singleton-ish under the
|
|
101
|
+
* lockfile, so close-on-quit is safe.
|
|
102
|
+
*/
|
|
103
|
+
store;
|
|
104
|
+
/**
|
|
105
|
+
* Local session id used as the persistence key. Distinct from the
|
|
106
|
+
* server-side sessionId issued by admin-api in state.sessionId.
|
|
107
|
+
* When the operator runs `pugi resume <id>`, the CLI passes the id
|
|
108
|
+
* via `localSessionId` so the JSONL log keeps growing under the
|
|
109
|
+
* original id rather than fragmenting into a new one.
|
|
110
|
+
*/
|
|
111
|
+
localSessionId;
|
|
112
|
+
/**
|
|
113
|
+
* One-shot guard so a store error only emits ONE system line per
|
|
114
|
+
* session — without this, a stuck filesystem would spam the operator
|
|
115
|
+
* with `[store]` errors on every keystroke.
|
|
116
|
+
*/
|
|
117
|
+
storeErrorEmitted = false;
|
|
118
|
+
/**
|
|
119
|
+
* Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
|
|
120
|
+
* signatures. The persona may emit the same envelope twice on network
|
|
121
|
+
* retry; we suppress the duplicate so the operator does not see two
|
|
122
|
+
* stacked modals. Capped at 32 entries — generous for a real session,
|
|
123
|
+
* defensive against a hostile flood. (α6.3.)
|
|
124
|
+
*/
|
|
125
|
+
seenTagSignatures = [];
|
|
126
|
+
/**
|
|
127
|
+
* Per-task buffer for streaming tag detection. The persona's
|
|
128
|
+
* `<pugi-ask>` open and close tags may arrive in separate
|
|
129
|
+
* `agent.step` events when the upstream LLM token-streams output
|
|
130
|
+
* char-by-char. We accumulate the running detail per taskId until a
|
|
131
|
+
* complete envelope lands OR the turn ends. (α6.3.)
|
|
132
|
+
*/
|
|
133
|
+
askBuffer = new Map();
|
|
134
|
+
/**
|
|
135
|
+
* Tracks taskIds that had an `<pugi-ask>` or `<pugi-plan-review>`
|
|
136
|
+
* envelope mid-stream the last time the parser ran on the buffer. If
|
|
137
|
+
* the turn ends with this flag still set, we emit a system-line
|
|
138
|
+
* warning that the persona produced an incomplete tag - the partial
|
|
139
|
+
* XML is silently dropped (the parser already withheld it from the
|
|
140
|
+
* cleaned body). Codex triple-review P2 (PR #375).
|
|
141
|
+
*/
|
|
142
|
+
askBufferPending = new Set();
|
|
63
143
|
constructor(options) {
|
|
64
144
|
this.options = options;
|
|
145
|
+
this.store = options.store ?? null;
|
|
146
|
+
this.localSessionId = options.localSessionId;
|
|
65
147
|
this.state = {
|
|
66
148
|
sessionId: undefined,
|
|
67
149
|
workspaceLabel: options.workspaceLabel,
|
|
68
150
|
cliVersion: options.cliVersion,
|
|
69
151
|
connection: 'connecting',
|
|
70
152
|
agents: [],
|
|
153
|
+
toolCalls: [],
|
|
71
154
|
transcript: [],
|
|
72
155
|
tokensDownstreamTotal: 0,
|
|
73
156
|
briefStartedAtEpochMs: undefined,
|
|
157
|
+
pendingAsk: null,
|
|
158
|
+
pendingAskSource: null,
|
|
159
|
+
pendingPlanReview: null,
|
|
160
|
+
pendingPlanReviewSource: null,
|
|
74
161
|
};
|
|
75
162
|
}
|
|
76
163
|
/* ------------- subscribe / state -------------- */
|
|
@@ -183,12 +270,77 @@ export class ReplSession {
|
|
|
183
270
|
this.dispatchStatus();
|
|
184
271
|
return verdict;
|
|
185
272
|
}
|
|
273
|
+
case 'consensus': {
|
|
274
|
+
// alpha 6.7: surface a deterministic deep-link so the operator
|
|
275
|
+
// knows the command exists in the REPL palette even though the
|
|
276
|
+
// full SSE renderer ships outside the Ink frame. Running the
|
|
277
|
+
// gate live inside the REPL needs the non-TTY emit path; for
|
|
278
|
+
// M1 we point the operator at the shell command.
|
|
279
|
+
const tail = verdict.ref ? ` ${verdict.ref}` : '';
|
|
280
|
+
this.appendSystemLine(`Run \`pugi review --consensus${tail}\` from a fresh shell to dispatch the 3-model gate.`);
|
|
281
|
+
return verdict;
|
|
282
|
+
}
|
|
283
|
+
case 'resume': {
|
|
284
|
+
await this.dispatchResume();
|
|
285
|
+
return verdict;
|
|
286
|
+
}
|
|
287
|
+
case 'ask': {
|
|
288
|
+
// α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
|
|
289
|
+
// operator can exercise the question UI without a persona-side
|
|
290
|
+
// round trip. The REPL UI mounts the modal from the resulting
|
|
291
|
+
// `pendingAsk` state; on resolution the encoded verdict lands
|
|
292
|
+
// in the transcript as a system line (no admin-api dispatch
|
|
293
|
+
// because the question is local).
|
|
294
|
+
const askTag = synthesiseLocalAskTag(verdict.question);
|
|
295
|
+
if (!askTag) {
|
|
296
|
+
this.appendSystemLine('Could not synthesise local ask (question too long?). Cap is 80 chars.');
|
|
297
|
+
return verdict;
|
|
298
|
+
}
|
|
299
|
+
this.patch({ pendingAsk: askTag, pendingAskSource: 'local' });
|
|
300
|
+
return verdict;
|
|
301
|
+
}
|
|
186
302
|
case 'stub': {
|
|
187
303
|
this.appendSystemLine(verdict.message);
|
|
188
304
|
return verdict;
|
|
189
305
|
}
|
|
190
306
|
}
|
|
191
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* In-REPL `/resume` — α6.4. Lists the 10 most recent sessions from
|
|
310
|
+
* the local SessionStore and prints them as a numbered system menu.
|
|
311
|
+
* The Ink-side picker UI is deferred to the next sprint; today the
|
|
312
|
+
* operator gets a deterministic list + the exact command to relaunch
|
|
313
|
+
* with: `pugi resume <id>`. Keeping the picker out of the REPL
|
|
314
|
+
* frame avoids re-architecting the conversation pane mid-sprint and
|
|
315
|
+
* keeps `/resume` testable without an Ink runtime.
|
|
316
|
+
*/
|
|
317
|
+
async dispatchResume() {
|
|
318
|
+
if (!this.store) {
|
|
319
|
+
this.appendSystemLine('Local session store is disabled - /resume is unavailable.');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
let rows;
|
|
323
|
+
try {
|
|
324
|
+
rows = await this.store.listSessions({ limit: 10 });
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
this.appendSystemLine(`Could not list sessions: ${this.errorMessage(error)}`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (rows.length === 0) {
|
|
331
|
+
this.appendSystemLine('No stored sessions yet - keep dispatching to build history.');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.appendSystemLine(`Recent sessions (${rows.length}):`);
|
|
335
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
336
|
+
const row = rows[i];
|
|
337
|
+
const title = (row.title ?? '(untitled)').slice(0, 64);
|
|
338
|
+
const idShort = row.id.slice(0, 13);
|
|
339
|
+
const branch = row.branch ?? 'no-branch';
|
|
340
|
+
this.appendSystemLine(` ${(i + 1).toString().padStart(2)}. ${idShort} ${branch.padEnd(16)} ${title}`);
|
|
341
|
+
}
|
|
342
|
+
this.appendSystemLine('Pick one with: pugi resume <id> (paste the 13-char id from above).');
|
|
343
|
+
}
|
|
192
344
|
/**
|
|
193
345
|
* Reset the conversation transcript. The agent registry stays intact
|
|
194
346
|
* so the operator can `/clear` to declutter the chat pane without
|
|
@@ -197,6 +349,173 @@ export class ReplSession {
|
|
|
197
349
|
clearTranscript() {
|
|
198
350
|
this.patch({ transcript: [] });
|
|
199
351
|
}
|
|
352
|
+
/* ------------- α6.3 office-hours surface -------------- */
|
|
353
|
+
/**
|
|
354
|
+
* Surface an `<pugi-ask>` modal manually. Returned promise resolves
|
|
355
|
+
* with the operator's verdict — used by the `pugi ask "<q>"` shell
|
|
356
|
+
* command and by the `/ask` slash. The resolver is wired into the
|
|
357
|
+
* session state via `pendingAsk` so the REPL UI can render the modal
|
|
358
|
+
* and forward `onResolve` back through `resolveAsk()`.
|
|
359
|
+
*
|
|
360
|
+
* NOTE: idempotent on a duplicate signature — a second presentAsk
|
|
361
|
+
* with the same question + option values returns the first
|
|
362
|
+
* outstanding promise rather than stacking two modals.
|
|
363
|
+
*/
|
|
364
|
+
presentAsk(tag) {
|
|
365
|
+
if (this.outstandingAskPromise
|
|
366
|
+
&& this.state.pendingAsk?.signature === tag.signature) {
|
|
367
|
+
// The operator is already looking at this exact ask; reuse the
|
|
368
|
+
// outstanding promise so the second caller sees the same answer
|
|
369
|
+
// when it eventually arrives.
|
|
370
|
+
return this.outstandingAskPromise;
|
|
371
|
+
}
|
|
372
|
+
// If a DIFFERENT ask is open, reject the new one with a clear
|
|
373
|
+
// error rather than silently queueing — the persona should never
|
|
374
|
+
// emit two concurrent asks, and surfacing the bug fails loud.
|
|
375
|
+
if (this.outstandingAskPromise) {
|
|
376
|
+
return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
|
|
377
|
+
}
|
|
378
|
+
this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
|
|
379
|
+
const promise = new Promise((resolve) => {
|
|
380
|
+
this.outstandingAskResolver = resolve;
|
|
381
|
+
});
|
|
382
|
+
this.outstandingAskPromise = promise;
|
|
383
|
+
return promise;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Resolve the currently pending `<pugi-ask>` modal. Called by the
|
|
387
|
+
* REPL UI when the operator submits the modal. Appends an operator
|
|
388
|
+
* line to the transcript carrying the verdict, dispatches the
|
|
389
|
+
* verdict-encoded brief to admin-api as the next user turn (when the
|
|
390
|
+
* modal originated from a persona stream), and clears `pendingAsk`.
|
|
391
|
+
* Idempotent: a second call without a fresh `pendingAsk` is a no-op.
|
|
392
|
+
*
|
|
393
|
+
* The verdict is also forwarded to the resolver returned by
|
|
394
|
+
* `presentAsk()` if there is one outstanding, so the CLI's `pugi ask`
|
|
395
|
+
* command can await the answer.
|
|
396
|
+
*
|
|
397
|
+
* Cancellation contract (Claude triple-review P1): when the modal
|
|
398
|
+
* came from a persona stream, cancel ALSO dispatches a literal
|
|
399
|
+
* `[ASK-RESPONSE:cancelled]` to admin-api so the persona observes the
|
|
400
|
+
* cancellation rather than hanging indefinitely on the missing
|
|
401
|
+
* follow-up. The matching documentation in the Mira system prompt
|
|
402
|
+
* teaches the persona to acknowledge cancellation and offer a
|
|
403
|
+
* different path. Local-origin modals (synthesised via `/ask`) skip
|
|
404
|
+
* the dispatch entirely - the persona never saw the question.
|
|
405
|
+
*
|
|
406
|
+
* Free-text sanitisation (Claude triple-review P1): the operator's
|
|
407
|
+
* customInput is stripped of any leading `[ASK-RESPONSE:...]` /
|
|
408
|
+
* `[PLAN-VERDICT:...]` prefix before being encoded, so a malicious
|
|
409
|
+
* (or accidental) operator string cannot forge a verdict header that
|
|
410
|
+
* a prefix-greedy persona would misinterpret as a different choice.
|
|
411
|
+
*/
|
|
412
|
+
async resolveAsk(verdict) {
|
|
413
|
+
if (!this.state.pendingAsk)
|
|
414
|
+
return;
|
|
415
|
+
const tag = this.state.pendingAsk;
|
|
416
|
+
const source = this.state.pendingAskSource;
|
|
417
|
+
const sanitisedVerdict = sanitiseAskVerdict(verdict);
|
|
418
|
+
this.patch({ pendingAsk: null, pendingAskSource: null });
|
|
419
|
+
const encoded = encodeAskVerdictLocal(sanitisedVerdict);
|
|
420
|
+
// Tell the outstanding presentAsk caller, if any. The sanitised
|
|
421
|
+
// verdict is forwarded so downstream consumers cannot be tricked
|
|
422
|
+
// by a forged verdict header either.
|
|
423
|
+
if (this.outstandingAskResolver) {
|
|
424
|
+
const resolver = this.outstandingAskResolver;
|
|
425
|
+
this.outstandingAskResolver = undefined;
|
|
426
|
+
this.outstandingAskPromise = undefined;
|
|
427
|
+
resolver(sanitisedVerdict);
|
|
428
|
+
}
|
|
429
|
+
// Surface the operator's choice as a transcript row so the
|
|
430
|
+
// conversation reads linearly. The label of the chosen option
|
|
431
|
+
// (or the literal custom input) is more readable than the bare
|
|
432
|
+
// value - Codex CLI's "you chose: Vercel" pattern.
|
|
433
|
+
const humanLabel = humanLabelForVerdict(tag, sanitisedVerdict);
|
|
434
|
+
this.appendOperatorLine(humanLabel);
|
|
435
|
+
// Local-origin modals (operator typed `/ask`) never need an
|
|
436
|
+
// admin-api round trip - the persona never observed the question.
|
|
437
|
+
// Codex triple-review P2.
|
|
438
|
+
if (source !== 'persona')
|
|
439
|
+
return;
|
|
440
|
+
// Persona-origin modals always dispatch, including on cancel:
|
|
441
|
+
// without the cancellation echo the persona's last turn stays open
|
|
442
|
+
// and the agent hangs. Claude triple-review P1.
|
|
443
|
+
await this.dispatchAskFollowup(encoded);
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Same shape as `presentAsk` for the plan-review modal.
|
|
447
|
+
*/
|
|
448
|
+
presentPlanReview(tag) {
|
|
449
|
+
if (this.outstandingPlanReviewPromise
|
|
450
|
+
&& this.state.pendingPlanReview?.signature === tag.signature) {
|
|
451
|
+
return this.outstandingPlanReviewPromise;
|
|
452
|
+
}
|
|
453
|
+
if (this.outstandingPlanReviewPromise) {
|
|
454
|
+
return Promise.reject(new Error('presentPlanReview: another plan review is already pending.'));
|
|
455
|
+
}
|
|
456
|
+
this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
|
|
457
|
+
const promise = new Promise((resolve) => {
|
|
458
|
+
this.outstandingPlanReviewResolver = resolve;
|
|
459
|
+
});
|
|
460
|
+
this.outstandingPlanReviewPromise = promise;
|
|
461
|
+
return promise;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Resolve the currently pending `<pugi-plan-review>` modal. Same
|
|
465
|
+
* mechanics as `resolveAsk` - including the cancel-dispatch contract
|
|
466
|
+
* and modifyText sanitisation. The persona always sees a
|
|
467
|
+
* `[PLAN-VERDICT:...]` echo (even on cancel) so it never hangs
|
|
468
|
+
* waiting for the verdict that the operator declined to send.
|
|
469
|
+
*/
|
|
470
|
+
async resolvePlanReview(result) {
|
|
471
|
+
if (!this.state.pendingPlanReview)
|
|
472
|
+
return;
|
|
473
|
+
const source = this.state.pendingPlanReviewSource;
|
|
474
|
+
const sanitisedResult = sanitisePlanReviewResult(result);
|
|
475
|
+
this.patch({ pendingPlanReview: null, pendingPlanReviewSource: null });
|
|
476
|
+
const encoded = encodePlanReviewVerdictLocal(sanitisedResult);
|
|
477
|
+
if (this.outstandingPlanReviewResolver) {
|
|
478
|
+
const resolver = this.outstandingPlanReviewResolver;
|
|
479
|
+
this.outstandingPlanReviewResolver = undefined;
|
|
480
|
+
this.outstandingPlanReviewPromise = undefined;
|
|
481
|
+
resolver(sanitisedResult);
|
|
482
|
+
}
|
|
483
|
+
this.appendOperatorLine(humanLabelForPlanReviewVerdict(sanitisedResult));
|
|
484
|
+
// Local-origin plan reviews skip the dispatch (Codex P2). Persona
|
|
485
|
+
// origin always dispatches, including on cancel (Claude P1).
|
|
486
|
+
if (source !== 'persona')
|
|
487
|
+
return;
|
|
488
|
+
await this.dispatchAskFollowup(encoded);
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Internal: post the verdict-encoded brief WITHOUT going through the
|
|
492
|
+
* cap-warning gate. The follow-up is the natural continuation of the
|
|
493
|
+
* same conversation the persona started, so blocking it on capacity
|
|
494
|
+
* would strand the operator with no way to answer.
|
|
495
|
+
*/
|
|
496
|
+
async dispatchAskFollowup(encodedBrief) {
|
|
497
|
+
const sessionId = this.state.sessionId;
|
|
498
|
+
if (!sessionId) {
|
|
499
|
+
this.appendSystemLine('No server session - response queued locally.');
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
await this.options.transport.postBrief({
|
|
504
|
+
apiUrl: this.options.apiUrl,
|
|
505
|
+
apiKey: this.options.apiKey,
|
|
506
|
+
sessionId,
|
|
507
|
+
brief: encodedBrief,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
catch (error) {
|
|
511
|
+
this.appendSystemLine(`Could not forward response: ${this.errorMessage(error)}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// ----- Outstanding resolver bookkeeping for presentAsk / presentPlanReview -----
|
|
515
|
+
outstandingAskResolver;
|
|
516
|
+
outstandingAskPromise;
|
|
517
|
+
outstandingPlanReviewResolver;
|
|
518
|
+
outstandingPlanReviewPromise;
|
|
200
519
|
/* ------------- Tier 1 / Tier 2 wired handlers -------------- */
|
|
201
520
|
async dispatchJobs() {
|
|
202
521
|
try {
|
|
@@ -382,12 +701,118 @@ export class ReplSession {
|
|
|
382
701
|
onError: (error) => {
|
|
383
702
|
if (this.closed)
|
|
384
703
|
return;
|
|
704
|
+
// α6.14.2 wave 5: when admin-api restarts it drops the in-memory
|
|
705
|
+
// session store, so subscribe returns HTTP 404 forever on the
|
|
706
|
+
// saved sessionId. Detect that case and mint a fresh server
|
|
707
|
+
// session silently rather than spamming the operator with
|
|
708
|
+
// "Stream interrupted (HTTP 404)" reconnect lines. Capped to
|
|
709
|
+
// MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS
|
|
710
|
+
// so a permanently down admin-api fails loud.
|
|
711
|
+
//
|
|
712
|
+
// Race guard (triple-review P2 follow-up): the SSE transport can
|
|
713
|
+
// fire onError synchronously a second time while we are tearing
|
|
714
|
+
// down the dead stream inside recreateSessionSilently (the
|
|
715
|
+
// streamHandle.close() call there can flush a pending error
|
|
716
|
+
// synchronously in some transports). If that second 404 arrives
|
|
717
|
+
// with recreatingSession === true, we must SHORT-CIRCUIT it too
|
|
718
|
+
// rather than fall through to the legacy "Stream interrupted"
|
|
719
|
+
// path — otherwise the operator sees the exact 404 line the
|
|
720
|
+
// recreate is trying to suppress.
|
|
721
|
+
if (this.isSessionNotFoundError(error)) {
|
|
722
|
+
if (this.recreatingSession) {
|
|
723
|
+
// Recreate already in flight — drop the duplicate 404 on the
|
|
724
|
+
// floor. The first recreate will either succeed (new stream
|
|
725
|
+
// opens, this dead handle is gone) or fall through to the
|
|
726
|
+
// loud "keeps dropping" / "session recreate refused" paths
|
|
727
|
+
// already defined in recreateSessionSilently.
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
this.patch({ connection: 'reconnecting' });
|
|
731
|
+
void this.recreateSessionSilently();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
385
734
|
this.patch({ connection: 'reconnecting' });
|
|
386
735
|
this.appendSystemLine(`Stream interrupted (${this.errorMessage(error)}). Reconnecting.`);
|
|
387
736
|
this.scheduleReconnect();
|
|
388
737
|
},
|
|
389
738
|
});
|
|
390
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* Detect "session not found" from the SSE transport. The production
|
|
742
|
+
* transport in `repl-render.tsx` wraps non-2xx responses as
|
|
743
|
+
* `Error("HTTP 404 on SSE stream")`. We pattern-match on the status
|
|
744
|
+
* 404 so a different transport (e.g. a test fake or a future polling
|
|
745
|
+
* fallback) can surface the same intent with the same shape.
|
|
746
|
+
* (α6.14.2 wave 5.)
|
|
747
|
+
*/
|
|
748
|
+
isSessionNotFoundError(error) {
|
|
749
|
+
const msg = this.errorMessage(error);
|
|
750
|
+
return /\b404\b/.test(msg);
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Mint a fresh server-side session, swap the consumer to the new
|
|
754
|
+
* stream URL, keep the conversation flowing. Caps at
|
|
755
|
+
* MAX_SESSION_RECREATE_ATTEMPTS inside SESSION_RECREATE_WINDOW_MS so
|
|
756
|
+
* a permanently down admin-api fails loud after a few seconds of
|
|
757
|
+
* trying. Logged once per attempt at debug level (we surface a
|
|
758
|
+
* single visible line on first auto-recreate so the operator knows
|
|
759
|
+
* what happened, then stay quiet). (α6.14.2 wave 5.)
|
|
760
|
+
*/
|
|
761
|
+
async recreateSessionSilently() {
|
|
762
|
+
if (this.closed)
|
|
763
|
+
return;
|
|
764
|
+
if (this.recreatingSession)
|
|
765
|
+
return;
|
|
766
|
+
const nowMs = this.now();
|
|
767
|
+
// Drop stale window entries so the cap is rolling, not cumulative.
|
|
768
|
+
while (this.recentRecreateAtMs.length > 0
|
|
769
|
+
&& nowMs - (this.recentRecreateAtMs[0] ?? 0) > SESSION_RECREATE_WINDOW_MS) {
|
|
770
|
+
this.recentRecreateAtMs.shift();
|
|
771
|
+
}
|
|
772
|
+
if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
|
|
773
|
+
// Cap exceeded — fall back to the loud "give up" path so the
|
|
774
|
+
// operator sees something is actually wrong.
|
|
775
|
+
this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
|
|
776
|
+
this.patch({ connection: 'offline' });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
this.recreatingSession = true;
|
|
780
|
+
this.recentRecreateAtMs.push(nowMs);
|
|
781
|
+
// Tear down the dead SSE handle so the next openStream() does not
|
|
782
|
+
// close-over the stale sessionId.
|
|
783
|
+
if (this.streamHandle) {
|
|
784
|
+
this.streamHandle.close();
|
|
785
|
+
this.streamHandle = undefined;
|
|
786
|
+
}
|
|
787
|
+
// Reset reconnect attempt + lastEventId — the new session is a
|
|
788
|
+
// fresh stream, not a continuation of the dead one.
|
|
789
|
+
this.reconnectAttempt = 0;
|
|
790
|
+
this.lastEventId = undefined;
|
|
791
|
+
// Single visible line on the FIRST auto-recreate of the window so
|
|
792
|
+
// the operator knows the CLI is recovering; later recreates in
|
|
793
|
+
// the same window stay silent.
|
|
794
|
+
if (this.recentRecreateAtMs.length === 1) {
|
|
795
|
+
this.appendSystemLine('Admin API restarted - minting a fresh session.');
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
const { sessionId } = await this.options.transport.createSession({
|
|
799
|
+
apiUrl: this.options.apiUrl,
|
|
800
|
+
apiKey: this.options.apiKey,
|
|
801
|
+
workspace: this.options.workspace,
|
|
802
|
+
});
|
|
803
|
+
this.patch({ sessionId, connection: 'connecting' });
|
|
804
|
+
this.openStream();
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
// The recreate POST itself failed — fall back to the existing
|
|
808
|
+
// backoff reconnect so the operator still sees retry progress.
|
|
809
|
+
this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
|
|
810
|
+
this.scheduleReconnect();
|
|
811
|
+
}
|
|
812
|
+
finally {
|
|
813
|
+
this.recreatingSession = false;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
391
816
|
scheduleReconnect() {
|
|
392
817
|
if (this.closed)
|
|
393
818
|
return;
|
|
@@ -411,7 +836,7 @@ export class ReplSession {
|
|
|
411
836
|
// Wave 4 fix 2026-05-25: the roster collapses to one row per
|
|
412
837
|
// persona slug. The α5.7 reducer pushed a fresh row on every
|
|
413
838
|
// spawn, so after three turns the bottom panel stacked
|
|
414
|
-
// "
|
|
839
|
+
// "Pugi orchestrator shipped" three times. The new contract:
|
|
415
840
|
// - If a row already exists for this personaSlug, REUSE it.
|
|
416
841
|
// Replace its taskId, reset status to 'queued', clear the
|
|
417
842
|
// detail line, restart the duration clock, zero the token
|
|
@@ -447,20 +872,46 @@ export class ReplSession {
|
|
|
447
872
|
// double-print. `void persona` keeps the resolved name in scope
|
|
448
873
|
// for the agent tree node above without leaking it into the
|
|
449
874
|
// transcript body.
|
|
875
|
+
// α6.14.3 CEO dogfood 2026-05-25: drop the "dispatched (X)"
|
|
876
|
+
// transcript echo. The agent tree pane already shows the
|
|
877
|
+
// spawned state; printing it as a persona row is pure noise
|
|
878
|
+
// between the operator's brief and the persona's real reply.
|
|
450
879
|
void persona;
|
|
451
|
-
this.appendPersonaLine(event.personaSlug, `dispatched (${event.role}).`);
|
|
452
880
|
return;
|
|
453
881
|
}
|
|
454
882
|
case 'agent.step': {
|
|
883
|
+
// α6.3 office-hours: scan the running buffer for `<pugi-ask>` /
|
|
884
|
+
// `<pugi-plan-review>` envelopes BEFORE we cache the detail.
|
|
885
|
+
// The parser returns the cleaned remainder with the raw XML
|
|
886
|
+
// stripped, so the operator never sees the envelope as prose.
|
|
887
|
+
// Streaming partial tags (open seen, close not yet streamed)
|
|
888
|
+
// are kept in the buffer; the next step event extends it.
|
|
889
|
+
const sanitised = this.consumeAskAndPlanReviewTags(event.taskId, event.detail);
|
|
455
890
|
// Cache the running detail per task so we can surface the
|
|
456
891
|
// model's actual reply when agent.completed lands (otherwise
|
|
457
892
|
// the reply disappears under the literal 'shipped' patch).
|
|
458
|
-
if (
|
|
459
|
-
this.lastStepDetail.set(event.taskId,
|
|
893
|
+
if (sanitised && sanitised.trim().length > 0) {
|
|
894
|
+
this.lastStepDetail.set(event.taskId, sanitised);
|
|
895
|
+
}
|
|
896
|
+
// α6.12: synthesise a tool call entry when the step detail
|
|
897
|
+
// matches a tool-invocation grammar. The pattern is generous
|
|
898
|
+
// (Read(path) / Edit(path:lines) / Bash(cmd) / Grep(pat) /
|
|
899
|
+
// Glob(pat) / WebFetch(url)) so the pane has rows to render
|
|
900
|
+
// before the admin-api side ships the proper tool.* SSE events.
|
|
901
|
+
// Use the sanitised detail (post-tag-strip) so a `<pugi-ask>`
|
|
902
|
+
// envelope never produces a phantom tool row.
|
|
903
|
+
const synthesised = synthesiseToolCall({
|
|
904
|
+
taskId: event.taskId,
|
|
905
|
+
detail: sanitised,
|
|
906
|
+
agent: this.personaSlugForTask(event.taskId),
|
|
907
|
+
now: this.now(),
|
|
908
|
+
});
|
|
909
|
+
if (synthesised) {
|
|
910
|
+
this.appendToolCall(synthesised);
|
|
460
911
|
}
|
|
461
912
|
this.patch({
|
|
462
913
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
463
|
-
? { ...a, status: 'thinking', detail: event.detail }
|
|
914
|
+
? { ...a, status: 'thinking', detail: sanitised || event.detail }
|
|
464
915
|
: a),
|
|
465
916
|
});
|
|
466
917
|
return;
|
|
@@ -483,6 +934,11 @@ export class ReplSession {
|
|
|
483
934
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
484
935
|
const finalDetail = this.lastStepDetail.get(event.taskId);
|
|
485
936
|
this.lastStepDetail.delete(event.taskId);
|
|
937
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
938
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
939
|
+
}
|
|
940
|
+
this.askBuffer.delete(event.taskId);
|
|
941
|
+
this.askBufferPending.delete(event.taskId);
|
|
486
942
|
this.patch({
|
|
487
943
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
488
944
|
? { ...a, status: 'shipped', detail: 'shipped' }
|
|
@@ -500,15 +956,34 @@ export class ReplSession {
|
|
|
500
956
|
if (finalDetail
|
|
501
957
|
&& finalDetail !== 'queued for dispatch'
|
|
502
958
|
&& finalDetail.trim().length > 4) {
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
959
|
+
// α6.12: ship the WHOLE body as one transcript row when the
|
|
960
|
+
// reply contains ANY Markdown structure (code fence, bullet
|
|
961
|
+
// list, numbered list, headings). The conversation pane
|
|
962
|
+
// routes it through Markdown renderer в one pass, preserving
|
|
963
|
+
// grouped bullets + heading hierarchy. Plain prose still
|
|
964
|
+
// splits per line so word-wrap stays correct.
|
|
965
|
+
//
|
|
966
|
+
// Claude triple-review P1 (PR #369): the prior `includes('```')`
|
|
967
|
+
// gate only caught fences — multi-line bullets fragmented
|
|
968
|
+
// per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
|
|
969
|
+
// bug / ...` instead of a single grouped bullet block.
|
|
970
|
+
if (looksLikeMarkdown(finalDetail)) {
|
|
971
|
+
this.appendPersonaLine(target.personaSlug, finalDetail);
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
for (const line of finalDetail.split('\n')) {
|
|
975
|
+
const trimmed = line.trim();
|
|
976
|
+
if (trimmed.length > 0) {
|
|
977
|
+
this.appendPersonaLine(target.personaSlug, trimmed);
|
|
978
|
+
}
|
|
507
979
|
}
|
|
508
980
|
}
|
|
509
981
|
}
|
|
510
982
|
else {
|
|
511
|
-
|
|
983
|
+
// α6.14.3 CEO dogfood 2026-05-25: drop the literal
|
|
984
|
+
// "shipped." fallback row. If we have no cached detail to
|
|
985
|
+
// surface, stay silent. The agent tree pane already shows
|
|
986
|
+
// the green check + duration.
|
|
512
987
|
}
|
|
513
988
|
}
|
|
514
989
|
return;
|
|
@@ -516,6 +991,11 @@ export class ReplSession {
|
|
|
516
991
|
case 'agent.blocked': {
|
|
517
992
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
518
993
|
this.lastStepDetail.delete(event.taskId);
|
|
994
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
995
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
996
|
+
}
|
|
997
|
+
this.askBuffer.delete(event.taskId);
|
|
998
|
+
this.askBufferPending.delete(event.taskId);
|
|
519
999
|
this.patch({
|
|
520
1000
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
521
1001
|
? { ...a, status: 'blocked', detail: event.detail }
|
|
@@ -529,6 +1009,11 @@ export class ReplSession {
|
|
|
529
1009
|
case 'agent.failed': {
|
|
530
1010
|
const target = this.state.agents.find((a) => a.taskId === event.taskId);
|
|
531
1011
|
this.lastStepDetail.delete(event.taskId);
|
|
1012
|
+
if (this.askBufferPending.has(event.taskId)) {
|
|
1013
|
+
this.appendSystemLine('Persona emitted incomplete <pugi-ask> or <pugi-plan-review> tag; dropped.');
|
|
1014
|
+
}
|
|
1015
|
+
this.askBuffer.delete(event.taskId);
|
|
1016
|
+
this.askBufferPending.delete(event.taskId);
|
|
532
1017
|
this.patch({
|
|
533
1018
|
agents: this.state.agents.map((a) => a.taskId === event.taskId
|
|
534
1019
|
? { ...a, status: 'failed', detail: event.error }
|
|
@@ -542,6 +1027,39 @@ export class ReplSession {
|
|
|
542
1027
|
}
|
|
543
1028
|
}
|
|
544
1029
|
/* ------------- transcript helpers -------------- */
|
|
1030
|
+
/**
|
|
1031
|
+
* Look up the persona slug for a running task. Used by the tool call
|
|
1032
|
+
* synthesiser to colour the tool stream rows correctly. Falls back to
|
|
1033
|
+
* the literal `unknown` slug when the task is not in the agent tree
|
|
1034
|
+
* (the SSE wire can emit a step before the matching spawn under heavy
|
|
1035
|
+
* load - rare in practice but the pane stays robust).
|
|
1036
|
+
*/
|
|
1037
|
+
personaSlugForTask(taskId) {
|
|
1038
|
+
const agent = this.state.agents.find((a) => a.taskId === taskId);
|
|
1039
|
+
return agent?.personaSlug ?? 'unknown';
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* Fold a tool call entry into the rolling list. If the entry id
|
|
1043
|
+
* already exists, replace it in-place (so a synthesised `running` →
|
|
1044
|
+
* `ok` transition reuses the same row). Otherwise append. The list
|
|
1045
|
+
* is capped at `MAX_TOOL_CALLS` so a long-running session does not
|
|
1046
|
+
* leak memory.
|
|
1047
|
+
*/
|
|
1048
|
+
appendToolCall(entry) {
|
|
1049
|
+
const existingIndex = this.state.toolCalls.findIndex((c) => c.id === entry.id);
|
|
1050
|
+
let next;
|
|
1051
|
+
if (existingIndex >= 0) {
|
|
1052
|
+
next = this.state.toolCalls.slice();
|
|
1053
|
+
next[existingIndex] = entry;
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
next = this.state.toolCalls.concat(entry);
|
|
1057
|
+
}
|
|
1058
|
+
if (next.length > MAX_TOOL_CALLS) {
|
|
1059
|
+
next = next.slice(-MAX_TOOL_CALLS);
|
|
1060
|
+
}
|
|
1061
|
+
this.patch({ toolCalls: next });
|
|
1062
|
+
}
|
|
545
1063
|
appendOperatorLine(text) {
|
|
546
1064
|
this.appendRow({ source: 'operator', text });
|
|
547
1065
|
}
|
|
@@ -549,7 +1067,19 @@ export class ReplSession {
|
|
|
549
1067
|
this.appendRow({ source: 'system', text });
|
|
550
1068
|
}
|
|
551
1069
|
appendPersonaLine(personaSlug, text) {
|
|
552
|
-
|
|
1070
|
+
// α6.14.2 wave 5: dedup the persona display-name prefix. The
|
|
1071
|
+
// conversation pane already renders `▸ <DisplayName> <text>` from
|
|
1072
|
+
// the slug → name map; when the model's own reply begins with
|
|
1073
|
+
// the same display name (CEO 2026-05-25 screenshot: "Pugi Pugi,
|
|
1074
|
+
// координатор Pugi"), the operator sees the name twice. Strip
|
|
1075
|
+
// the leading display-name token (with optional trailing comma /
|
|
1076
|
+
// colon / whitespace) so the prefix the pane adds is the only one
|
|
1077
|
+
// visible. We also drop any leaked `<workspace-context-NONCE>`
|
|
1078
|
+
// wrapper the model sometimes echoes back at the head of its
|
|
1079
|
+
// first turn — that envelope is for prompt scaffolding, not for
|
|
1080
|
+
// the operator's eyes.
|
|
1081
|
+
const stripped = stripPersonaPrefixEcho(personaSlug, text);
|
|
1082
|
+
this.appendRow({ source: 'persona', text: stripped, personaSlug });
|
|
553
1083
|
}
|
|
554
1084
|
appendRow(input) {
|
|
555
1085
|
if (input.text.length === 0)
|
|
@@ -563,6 +1093,161 @@ export class ReplSession {
|
|
|
563
1093
|
};
|
|
564
1094
|
const next = this.state.transcript.concat(row).slice(-MAX_TRANSCRIPT_ROWS);
|
|
565
1095
|
this.patch({ transcript: next });
|
|
1096
|
+
// Mirror into the local SessionStore so `/resume` can replay.
|
|
1097
|
+
// Persistence is fail-safe: a single error becomes one system
|
|
1098
|
+
// line, subsequent errors are silent so a stuck disk does not
|
|
1099
|
+
// flood the operator. The mapping from row.source -> store kind:
|
|
1100
|
+
// operator -> 'user' (drives turn_count + title)
|
|
1101
|
+
// persona -> 'persona'
|
|
1102
|
+
// system -> 'system'
|
|
1103
|
+
this.persistRow(row);
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Best-effort write of one transcript row into the local
|
|
1107
|
+
* SessionStore. Swallows errors after emitting one system line so a
|
|
1108
|
+
* broken store never blocks the conversation. Public callers go
|
|
1109
|
+
* through `appendRow` — this method is private on purpose.
|
|
1110
|
+
*/
|
|
1111
|
+
persistRow(row) {
|
|
1112
|
+
if (!this.store)
|
|
1113
|
+
return;
|
|
1114
|
+
const kind = row.source === 'operator' ? 'user'
|
|
1115
|
+
: row.source === 'persona' ? 'persona'
|
|
1116
|
+
: 'system';
|
|
1117
|
+
const payload = row.source === 'persona'
|
|
1118
|
+
? { text: row.text, personaSlug: row.personaSlug }
|
|
1119
|
+
: row.source === 'operator'
|
|
1120
|
+
? { brief: row.text }
|
|
1121
|
+
: { text: row.text };
|
|
1122
|
+
const event = { t: row.timestampEpochMs, kind, payload };
|
|
1123
|
+
void this.store.appendEvent(event).catch((error) => {
|
|
1124
|
+
if (this.storeErrorEmitted)
|
|
1125
|
+
return;
|
|
1126
|
+
this.storeErrorEmitted = true;
|
|
1127
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1128
|
+
// Use appendRow directly via state patch so we don't recurse
|
|
1129
|
+
// into persistRow (which would loop on a stuck store).
|
|
1130
|
+
const errRow = {
|
|
1131
|
+
id: randomUUID(),
|
|
1132
|
+
source: 'system',
|
|
1133
|
+
text: `Local session persistence failed: ${msg}. Conversation continues in-memory only.`,
|
|
1134
|
+
timestampEpochMs: this.now(),
|
|
1135
|
+
};
|
|
1136
|
+
const next = this.state.transcript.concat(errRow).slice(-MAX_TRANSCRIPT_ROWS);
|
|
1137
|
+
this.patch({ transcript: next });
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Restore a transcript from a stored event log — α6.4. Called by
|
|
1142
|
+
* the CLI bootstrap when the operator runs `pugi resume <id>` or
|
|
1143
|
+
* picks an entry from the `/resume` picker. Replays each event into
|
|
1144
|
+
* the local transcript WITHOUT writing back to the store so the
|
|
1145
|
+
* restore is idempotent.
|
|
1146
|
+
*
|
|
1147
|
+
* Implementation note: we briefly disable persistence by setting
|
|
1148
|
+
* `storeErrorEmitted` BEFORE the replay and clearing it after — but
|
|
1149
|
+
* the cleaner path is to bypass `appendRow` entirely and patch
|
|
1150
|
+
* state directly. We do the latter so persistRow does not double-
|
|
1151
|
+
* write the restored events.
|
|
1152
|
+
*/
|
|
1153
|
+
restoreTranscript(events) {
|
|
1154
|
+
const rows = [];
|
|
1155
|
+
for (const event of events) {
|
|
1156
|
+
const row = eventToTranscriptRow(event);
|
|
1157
|
+
if (row)
|
|
1158
|
+
rows.push(row);
|
|
1159
|
+
}
|
|
1160
|
+
// Cap at MAX_TRANSCRIPT_ROWS — the same cap appendRow uses so the
|
|
1161
|
+
// window math stays consistent post-restore.
|
|
1162
|
+
const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
|
|
1163
|
+
this.patch({ transcript: capped });
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Local session id used as the persistence key. Surfaced to the
|
|
1167
|
+
* CLI bootstrap so `pugi resume` listings can pin the id without
|
|
1168
|
+
* pulling it out of internal state.
|
|
1169
|
+
*/
|
|
1170
|
+
getLocalSessionId() {
|
|
1171
|
+
return this.localSessionId;
|
|
1172
|
+
}
|
|
1173
|
+
/* ------------- α6.3 buffered tag detection -------------- */
|
|
1174
|
+
/**
|
|
1175
|
+
* Scan the running `agent.step.detail` buffer for `<pugi-ask>` /
|
|
1176
|
+
* `<pugi-plan-review>` envelopes. If a complete envelope is found,
|
|
1177
|
+
* the parser strips it from the visible body and sets the matching
|
|
1178
|
+
* `pendingAsk` / `pendingPlanReview` state so the REPL can render
|
|
1179
|
+
* the modal. Streaming partial tags (open observed, close not yet
|
|
1180
|
+
* arrived) are kept in the buffer so the next step event can extend
|
|
1181
|
+
* them.
|
|
1182
|
+
*
|
|
1183
|
+
* Returns the sanitised body the caller should treat as the
|
|
1184
|
+
* persona's prose. May be empty when the entire body was tag XML;
|
|
1185
|
+
* the caller then leaves `lastStepDetail` untouched and the
|
|
1186
|
+
* `agent.completed` fallback ("shipped.") fires.
|
|
1187
|
+
*/
|
|
1188
|
+
consumeAskAndPlanReviewTags(taskId, detail) {
|
|
1189
|
+
if (!detail || detail.length === 0) {
|
|
1190
|
+
return this.askBuffer.get(taskId) ?? '';
|
|
1191
|
+
}
|
|
1192
|
+
// The persona emits the running detail as a cumulative string, so
|
|
1193
|
+
// a fresh `agent.step` carries the full body up to the current
|
|
1194
|
+
// token (matches the wave-2 caching contract above). We pass the
|
|
1195
|
+
// raw detail through the extractors directly rather than keeping a
|
|
1196
|
+
// separate buffer — but we still record the pre-extraction body so
|
|
1197
|
+
// a partial open tag is preserved when the next chunk arrives.
|
|
1198
|
+
this.askBuffer.set(taskId, detail);
|
|
1199
|
+
const askResult = extractAskTags(detail);
|
|
1200
|
+
let working = askResult.cleaned;
|
|
1201
|
+
for (const tag of askResult.tags) {
|
|
1202
|
+
if (this.seenTagSignatures.includes(tag.signature))
|
|
1203
|
+
continue;
|
|
1204
|
+
this.recordSeenTag(tag.signature);
|
|
1205
|
+
// Only one pending ask at a time — drop additional tags in the
|
|
1206
|
+
// same step into the cleaned body as a system warning. The
|
|
1207
|
+
// persona's prompt forbids concurrent asks, so this branch is a
|
|
1208
|
+
// defensive guard against a misbehaving model.
|
|
1209
|
+
if (this.state.pendingAsk) {
|
|
1210
|
+
this.appendSystemLine('Persona emitted a second <pugi-ask> while one was already open. Dropped.');
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
this.patch({ pendingAsk: tag, pendingAskSource: 'persona' });
|
|
1214
|
+
}
|
|
1215
|
+
if (askResult.hadMalformedTag) {
|
|
1216
|
+
this.appendSystemLine('Malformed <pugi-ask> dropped (parser refusal).');
|
|
1217
|
+
}
|
|
1218
|
+
const planResult = extractPlanReviewTags(working);
|
|
1219
|
+
working = planResult.cleaned;
|
|
1220
|
+
for (const tag of planResult.tags) {
|
|
1221
|
+
if (this.seenTagSignatures.includes(tag.signature))
|
|
1222
|
+
continue;
|
|
1223
|
+
this.recordSeenTag(tag.signature);
|
|
1224
|
+
if (this.state.pendingPlanReview) {
|
|
1225
|
+
this.appendSystemLine('Persona emitted a second <pugi-plan-review> while one was already open. Dropped.');
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
this.patch({ pendingPlanReview: tag, pendingPlanReviewSource: 'persona' });
|
|
1229
|
+
}
|
|
1230
|
+
if (planResult.hadMalformedTag) {
|
|
1231
|
+
this.appendSystemLine('Malformed <pugi-plan-review> dropped (parser refusal).');
|
|
1232
|
+
}
|
|
1233
|
+
// Record / clear the "pending open tag" flag so agent.completed can
|
|
1234
|
+
// emit a warning if the persona ends the turn with an unfinished
|
|
1235
|
+
// envelope. The flag flips OFF when both parsers report no
|
|
1236
|
+
// outstanding open tag - if either is still pending, we keep it on
|
|
1237
|
+
// so the warning fires once at turn end.
|
|
1238
|
+
if (askResult.pendingOpenTag || planResult.pendingOpenTag) {
|
|
1239
|
+
this.askBufferPending.add(taskId);
|
|
1240
|
+
}
|
|
1241
|
+
else {
|
|
1242
|
+
this.askBufferPending.delete(taskId);
|
|
1243
|
+
}
|
|
1244
|
+
return working;
|
|
1245
|
+
}
|
|
1246
|
+
recordSeenTag(signature) {
|
|
1247
|
+
this.seenTagSignatures.push(signature);
|
|
1248
|
+
while (this.seenTagSignatures.length > 32) {
|
|
1249
|
+
this.seenTagSignatures.shift();
|
|
1250
|
+
}
|
|
566
1251
|
}
|
|
567
1252
|
/* ------------- agent count + clock -------------- */
|
|
568
1253
|
activeAgentCount() {
|
|
@@ -599,6 +1284,89 @@ export class ReplSession {
|
|
|
599
1284
|
* does not recognise, the REPL still renders something usable rather
|
|
600
1285
|
* than crashing mid-frame.
|
|
601
1286
|
*/
|
|
1287
|
+
/**
|
|
1288
|
+
* Map a stored SessionEvent back into a TranscriptRow for `/resume`
|
|
1289
|
+
* replay. Returns null when the event has no operator-visible body
|
|
1290
|
+
* (e.g. tool.start without a text payload — those land back as
|
|
1291
|
+
* tool stream rows, not transcript rows). The shape mirrors the
|
|
1292
|
+
* `persistRow` mapping in reverse:
|
|
1293
|
+
*
|
|
1294
|
+
* 'user' -> operator (brief)
|
|
1295
|
+
* 'persona' -> persona (text + personaSlug)
|
|
1296
|
+
* 'system' -> system (text)
|
|
1297
|
+
*
|
|
1298
|
+
* Exported indirectly via `restoreTranscript`.
|
|
1299
|
+
*/
|
|
1300
|
+
function eventToTranscriptRow(event) {
|
|
1301
|
+
const payload = (event.payload ?? null);
|
|
1302
|
+
if (event.kind === 'user') {
|
|
1303
|
+
const text = typeof payload?.brief === 'string'
|
|
1304
|
+
? payload.brief
|
|
1305
|
+
: typeof payload?.text === 'string'
|
|
1306
|
+
? payload.text
|
|
1307
|
+
: '';
|
|
1308
|
+
if (text.length === 0)
|
|
1309
|
+
return null;
|
|
1310
|
+
return {
|
|
1311
|
+
id: randomUUID(),
|
|
1312
|
+
source: 'operator',
|
|
1313
|
+
text,
|
|
1314
|
+
timestampEpochMs: event.t,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
if (event.kind === 'persona') {
|
|
1318
|
+
const text = typeof payload?.text === 'string' ? payload.text : '';
|
|
1319
|
+
if (text.length === 0)
|
|
1320
|
+
return null;
|
|
1321
|
+
const personaSlug = typeof payload?.personaSlug === 'string'
|
|
1322
|
+
? payload.personaSlug
|
|
1323
|
+
: undefined;
|
|
1324
|
+
return {
|
|
1325
|
+
id: randomUUID(),
|
|
1326
|
+
source: 'persona',
|
|
1327
|
+
text,
|
|
1328
|
+
personaSlug,
|
|
1329
|
+
timestampEpochMs: event.t,
|
|
1330
|
+
};
|
|
1331
|
+
}
|
|
1332
|
+
if (event.kind === 'system') {
|
|
1333
|
+
const text = typeof payload?.text === 'string' ? payload.text : '';
|
|
1334
|
+
if (text.length === 0)
|
|
1335
|
+
return null;
|
|
1336
|
+
return {
|
|
1337
|
+
id: randomUUID(),
|
|
1338
|
+
source: 'system',
|
|
1339
|
+
text,
|
|
1340
|
+
timestampEpochMs: event.t,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Heuristic: does this text contain Markdown structures that benefit
|
|
1347
|
+
* from atomic grouping? Code fences, bullet lists, numbered lists,
|
|
1348
|
+
* headings — anything where per-line splitting would fragment visual
|
|
1349
|
+
* grouping (Claude triple-review P1 PR #369).
|
|
1350
|
+
*/
|
|
1351
|
+
function looksLikeMarkdown(text) {
|
|
1352
|
+
if (text.includes('```'))
|
|
1353
|
+
return true;
|
|
1354
|
+
const lines = text.split('\n');
|
|
1355
|
+
let bulletCount = 0;
|
|
1356
|
+
let numberedCount = 0;
|
|
1357
|
+
let headingCount = 0;
|
|
1358
|
+
for (const raw of lines) {
|
|
1359
|
+
const line = raw.trim();
|
|
1360
|
+
if (/^[-*+]\s+\S/.test(line))
|
|
1361
|
+
bulletCount += 1;
|
|
1362
|
+
if (/^\d+\.\s+\S/.test(line))
|
|
1363
|
+
numberedCount += 1;
|
|
1364
|
+
if (/^#{1,6}\s+\S/.test(line))
|
|
1365
|
+
headingCount += 1;
|
|
1366
|
+
}
|
|
1367
|
+
// 2+ bullets OR 2+ numbered OR any heading = group atomically.
|
|
1368
|
+
return bulletCount >= 2 || numberedCount >= 2 || headingCount >= 1;
|
|
1369
|
+
}
|
|
602
1370
|
function safePersonaName(role) {
|
|
603
1371
|
try {
|
|
604
1372
|
return getPersonaForRole(role).name;
|
|
@@ -615,4 +1383,305 @@ function safePersonaName(role) {
|
|
|
615
1383
|
export function knownRoles() {
|
|
616
1384
|
return listRoles();
|
|
617
1385
|
}
|
|
1386
|
+
/* ------------------------------------------------------------------ */
|
|
1387
|
+
/* Tool call synthesiser - α6.12 */
|
|
1388
|
+
/* ------------------------------------------------------------------ */
|
|
1389
|
+
/**
|
|
1390
|
+
* Match canonical tool invocation grammar in an `agent.step.detail`
|
|
1391
|
+
* string and emit a synthesised `ToolCallEntry`. Returns null when no
|
|
1392
|
+
* known tool pattern matches.
|
|
1393
|
+
*
|
|
1394
|
+
* The grammar mirrors the way Claude Code, Codex CLI, and Gemini CLI
|
|
1395
|
+
* display tool calls in their tool stream panes:
|
|
1396
|
+
*
|
|
1397
|
+
* Read(path)
|
|
1398
|
+
* Edit(path[:lines])
|
|
1399
|
+
* Bash(command)
|
|
1400
|
+
* Grep("pattern" [in path])
|
|
1401
|
+
* Glob(pattern)
|
|
1402
|
+
* WebFetch(url)
|
|
1403
|
+
*
|
|
1404
|
+
* The matcher is case-insensitive on the tool name so a persona that
|
|
1405
|
+
* spells the tool as `READ(...)` or `web_fetch(...)` still lands in
|
|
1406
|
+
* the pane. Args are capped at 80 characters; the pane will further
|
|
1407
|
+
* truncate to 60 on render so the row stays single-line on a narrow
|
|
1408
|
+
* terminal.
|
|
1409
|
+
*
|
|
1410
|
+
* Exported for unit testing - production code path is internal.
|
|
1411
|
+
*/
|
|
1412
|
+
export function synthesiseToolCall(input) {
|
|
1413
|
+
const detail = input.detail.trim();
|
|
1414
|
+
if (detail.length === 0)
|
|
1415
|
+
return null;
|
|
1416
|
+
// Pattern: ToolName(args) optionally suffixed with a result hint.
|
|
1417
|
+
// We allow the canonical Claude Code casing AND the snake_case
|
|
1418
|
+
// alias `web_fetch` so the synthesiser matches what personas write.
|
|
1419
|
+
const match = /^(Read|Edit|Bash|Grep|Glob|WebFetch|web_fetch)\s*\(\s*([^)]*)\s*\)\s*(.*)$/i
|
|
1420
|
+
.exec(detail);
|
|
1421
|
+
if (!match)
|
|
1422
|
+
return null;
|
|
1423
|
+
const toolName = normaliseToolName(match[1]);
|
|
1424
|
+
const args = (match[2] ?? '').trim().slice(0, 80);
|
|
1425
|
+
const tail = (match[3] ?? '').trim();
|
|
1426
|
+
const status = parseStatusFromTail(tail);
|
|
1427
|
+
return {
|
|
1428
|
+
id: `${input.taskId}:${toolName}:${args}`,
|
|
1429
|
+
agent: input.agent,
|
|
1430
|
+
tool: toolName,
|
|
1431
|
+
args,
|
|
1432
|
+
status: status.status,
|
|
1433
|
+
detail: status.detail,
|
|
1434
|
+
startedAtEpochMs: input.now,
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
function normaliseToolName(raw) {
|
|
1438
|
+
const lower = raw.toLowerCase();
|
|
1439
|
+
if (lower === 'webfetch' || lower === 'web_fetch')
|
|
1440
|
+
return 'web_fetch';
|
|
1441
|
+
if (lower === 'read')
|
|
1442
|
+
return 'read';
|
|
1443
|
+
if (lower === 'edit')
|
|
1444
|
+
return 'edit';
|
|
1445
|
+
if (lower === 'bash')
|
|
1446
|
+
return 'bash';
|
|
1447
|
+
if (lower === 'grep')
|
|
1448
|
+
return 'grep';
|
|
1449
|
+
if (lower === 'glob')
|
|
1450
|
+
return 'glob';
|
|
1451
|
+
// Unreachable - regex constrains the input. Fallback keeps types happy.
|
|
1452
|
+
return 'read';
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Cheap status inference from the tail string. We honour explicit
|
|
1456
|
+
* `OK` / `error` / `running` prefixes the dispatcher may write, plus
|
|
1457
|
+
* a `+/-` diff hint (treated as `ok`) and a `no match` (treated as
|
|
1458
|
+
* `ok` because grep with no result is not an error condition).
|
|
1459
|
+
*/
|
|
1460
|
+
function parseStatusFromTail(tail) {
|
|
1461
|
+
if (tail.length === 0)
|
|
1462
|
+
return { status: 'running' };
|
|
1463
|
+
const lower = tail.toLowerCase();
|
|
1464
|
+
if (lower.startsWith('error') || lower.startsWith('failed')) {
|
|
1465
|
+
return { status: 'error', detail: tail };
|
|
1466
|
+
}
|
|
1467
|
+
if (lower.startsWith('running')) {
|
|
1468
|
+
return { status: 'running', detail: tail };
|
|
1469
|
+
}
|
|
1470
|
+
return { status: 'ok', detail: tail };
|
|
1471
|
+
}
|
|
1472
|
+
/* ------------------------------------------------------------------ */
|
|
1473
|
+
/* α6.3 office-hours encoders */
|
|
1474
|
+
/* */
|
|
1475
|
+
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
|
|
1476
|
+
/* synthesise the operator-side echo without dragging an Ink module */
|
|
1477
|
+
/* into the test surface. The two encoders MUST agree byte-for-byte — */
|
|
1478
|
+
/* a divergence would silently mis-prefix the persona's follow-up. */
|
|
1479
|
+
/* ------------------------------------------------------------------ */
|
|
1480
|
+
function encodeAskVerdictLocal(verdict) {
|
|
1481
|
+
if (verdict.cancelled)
|
|
1482
|
+
return '[ASK-RESPONSE:cancelled]';
|
|
1483
|
+
if (verdict.value.length > 0)
|
|
1484
|
+
return `[ASK-RESPONSE:${verdict.value}]`;
|
|
1485
|
+
if (verdict.customInput && verdict.customInput.length > 0) {
|
|
1486
|
+
return `[ASK-RESPONSE:other] ${verdict.customInput}`;
|
|
1487
|
+
}
|
|
1488
|
+
return '[ASK-RESPONSE:cancelled]';
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Strip any leading `[ASK-RESPONSE:...]` or `[PLAN-VERDICT:...]`
|
|
1492
|
+
* pattern from free-text operator input so a malicious or accidental
|
|
1493
|
+
* operator string cannot forge a verdict header. Stripping iterates
|
|
1494
|
+
* because the operator could prepend several forged headers in a row;
|
|
1495
|
+
* we keep peeling until the head is clean.
|
|
1496
|
+
*
|
|
1497
|
+
* Example: operator types `[ASK-RESPONSE:vercel] my real answer` -
|
|
1498
|
+
* the leading `[ASK-RESPONSE:vercel] ` is stripped, leaving
|
|
1499
|
+
* `my real answer`, so the encoded wire string becomes
|
|
1500
|
+
* `[ASK-RESPONSE:other] my real answer` rather than
|
|
1501
|
+
* `[ASK-RESPONSE:other] [ASK-RESPONSE:vercel] my real answer` which
|
|
1502
|
+
* a prefix-greedy persona could read as "operator chose vercel".
|
|
1503
|
+
*
|
|
1504
|
+
* Claude triple-review P1 (PR #375).
|
|
1505
|
+
*/
|
|
1506
|
+
function sanitiseVerdictText(raw) {
|
|
1507
|
+
let cleaned = raw;
|
|
1508
|
+
// Bounded loop: each iteration must strip a non-empty pattern, so it
|
|
1509
|
+
// terminates in O(input length). Hard cap as defence-in-depth in case
|
|
1510
|
+
// the regex ever matches an empty span.
|
|
1511
|
+
for (let i = 0; i < raw.length + 4; i += 1) {
|
|
1512
|
+
const stripped = cleaned.replace(/^\s*\[(?:ASK-RESPONSE|PLAN-VERDICT):[^\]]*\]\s*/u, '');
|
|
1513
|
+
if (stripped === cleaned)
|
|
1514
|
+
break;
|
|
1515
|
+
cleaned = stripped;
|
|
1516
|
+
}
|
|
1517
|
+
return cleaned.trim();
|
|
1518
|
+
}
|
|
1519
|
+
function sanitiseAskVerdict(verdict) {
|
|
1520
|
+
if (verdict.customInput === undefined)
|
|
1521
|
+
return verdict;
|
|
1522
|
+
const sanitisedCustom = sanitiseVerdictText(verdict.customInput);
|
|
1523
|
+
if (sanitisedCustom === verdict.customInput)
|
|
1524
|
+
return verdict;
|
|
1525
|
+
// If sanitisation emptied the buffer, treat the verdict as a
|
|
1526
|
+
// cancellation rather than dispatching a meaningless "other" with no
|
|
1527
|
+
// body. Preserves the dispatch invariant (no empty bodies) and
|
|
1528
|
+
// matches the encoder's fallback.
|
|
1529
|
+
if (sanitisedCustom.length === 0) {
|
|
1530
|
+
return { value: '', cancelled: true };
|
|
1531
|
+
}
|
|
1532
|
+
return { ...verdict, customInput: sanitisedCustom };
|
|
1533
|
+
}
|
|
1534
|
+
function sanitisePlanReviewResult(result) {
|
|
1535
|
+
if (result.verdict !== 'modify')
|
|
1536
|
+
return result;
|
|
1537
|
+
if (result.modifyText === undefined)
|
|
1538
|
+
return result;
|
|
1539
|
+
const sanitisedText = sanitiseVerdictText(result.modifyText);
|
|
1540
|
+
if (sanitisedText === result.modifyText)
|
|
1541
|
+
return result;
|
|
1542
|
+
if (sanitisedText.length === 0) {
|
|
1543
|
+
return { verdict: 'cancel' };
|
|
1544
|
+
}
|
|
1545
|
+
return { verdict: 'modify', modifyText: sanitisedText };
|
|
1546
|
+
}
|
|
1547
|
+
function encodePlanReviewVerdictLocal(result) {
|
|
1548
|
+
switch (result.verdict) {
|
|
1549
|
+
case 'approve':
|
|
1550
|
+
return '[PLAN-VERDICT:approve]';
|
|
1551
|
+
case 'cancel':
|
|
1552
|
+
return '[PLAN-VERDICT:cancel]';
|
|
1553
|
+
case 'modify':
|
|
1554
|
+
if (result.modifyText && result.modifyText.length > 0) {
|
|
1555
|
+
return `[PLAN-VERDICT:modify] ${result.modifyText}`;
|
|
1556
|
+
}
|
|
1557
|
+
return '[PLAN-VERDICT:cancel]';
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Compose the human-readable transcript line that records the
|
|
1562
|
+
* operator's ask verdict. Mirrors Codex CLI's "you chose: <label>"
|
|
1563
|
+
* pattern so the conversation reads linearly.
|
|
1564
|
+
*/
|
|
1565
|
+
function humanLabelForVerdict(tag, verdict) {
|
|
1566
|
+
if (verdict.cancelled)
|
|
1567
|
+
return '(cancelled the question)';
|
|
1568
|
+
if (verdict.value.length > 0) {
|
|
1569
|
+
const opt = tag.options.find((o) => o.value === verdict.value);
|
|
1570
|
+
return opt ? `chose: ${opt.label}` : `chose: ${verdict.value}`;
|
|
1571
|
+
}
|
|
1572
|
+
if (verdict.customInput && verdict.customInput.length > 0) {
|
|
1573
|
+
return `chose: other - ${verdict.customInput}`;
|
|
1574
|
+
}
|
|
1575
|
+
return '(cancelled the question)';
|
|
1576
|
+
}
|
|
1577
|
+
function humanLabelForPlanReviewVerdict(result) {
|
|
1578
|
+
switch (result.verdict) {
|
|
1579
|
+
case 'approve':
|
|
1580
|
+
return 'approved the plan';
|
|
1581
|
+
case 'cancel':
|
|
1582
|
+
return 'cancelled the plan';
|
|
1583
|
+
case 'modify':
|
|
1584
|
+
if (result.modifyText && result.modifyText.length > 0) {
|
|
1585
|
+
return `modified the plan: ${result.modifyText}`;
|
|
1586
|
+
}
|
|
1587
|
+
return 'cancelled the plan';
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Synthesise a 2-option yes/no `<pugi-ask>` tag from a raw question
|
|
1592
|
+
* string. Used by the `/ask` slash command and by `pugi ask <question>`
|
|
1593
|
+
* to give the operator a manual entrypoint into the office-hours UI
|
|
1594
|
+
* without needing a persona-side emission.
|
|
1595
|
+
*
|
|
1596
|
+
* Returns null when the question fails the parser's length cap so the
|
|
1597
|
+
* caller can surface a clear error rather than crashing the modal.
|
|
1598
|
+
*/
|
|
1599
|
+
export function synthesiseLocalAskTag(question) {
|
|
1600
|
+
const trimmed = question.trim();
|
|
1601
|
+
if (trimmed.length === 0 || trimmed.length > 80)
|
|
1602
|
+
return null;
|
|
1603
|
+
const options = [
|
|
1604
|
+
{ value: 'yes', label: 'Yes' },
|
|
1605
|
+
{ value: 'no', label: 'No' },
|
|
1606
|
+
];
|
|
1607
|
+
// Use the single-source signature helper so a persona-emitted ask
|
|
1608
|
+
// with the same question + same option values does not collide with
|
|
1609
|
+
// this synthesised one under a divergent algorithm. Claude
|
|
1610
|
+
// triple-review P1 (PR #375).
|
|
1611
|
+
const signature = signatureForAsk(trimmed, options);
|
|
1612
|
+
return {
|
|
1613
|
+
question: trimmed,
|
|
1614
|
+
options,
|
|
1615
|
+
signature,
|
|
1616
|
+
start: 0,
|
|
1617
|
+
end: 0,
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* Strip the persona's own display name from the head of a streamed
|
|
1622
|
+
* reply, plus any leaked `<workspace-context-...>` envelope the model
|
|
1623
|
+
* may echo on its first turn. Exported for direct unit testing —
|
|
1624
|
+
* production callers go through `appendPersonaLine`.
|
|
1625
|
+
*
|
|
1626
|
+
* Examples (display name = "Pugi"):
|
|
1627
|
+
* "Pugi, координатор Pugi. Брифую..." -> "координатор Pugi. Брифую..."
|
|
1628
|
+
* "Pugi: вот результат" -> "вот результат"
|
|
1629
|
+
* "<workspace-context-abc>Pugi, привет" -> "привет"
|
|
1630
|
+
* "обычный ответ без префикса" -> "обычный ответ без префикса"
|
|
1631
|
+
*
|
|
1632
|
+
* The strip is conservative — we only remove the display name when it
|
|
1633
|
+
* is followed by a separator (comma, colon, dash, space) so a sentence
|
|
1634
|
+
* that legitimately contains the name mid-text ("спроси у Pugi") is
|
|
1635
|
+
* not mangled. (α6.14.2 wave 5 — CEO dogfood fix.)
|
|
1636
|
+
*/
|
|
1637
|
+
export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
1638
|
+
let working = text.trimStart();
|
|
1639
|
+
// Drop any leaked `<workspace-context-...>` / `</workspace-context-...>`
|
|
1640
|
+
// wrapper at the head. The Mira prompt v1.1 sometimes echoes the
|
|
1641
|
+
// scaffolding envelope back when the model is warm-starting the
|
|
1642
|
+
// first turn; cosmetic noise the operator never needs to see.
|
|
1643
|
+
// We strip both opening tag and any text up to (and including) the
|
|
1644
|
+
// matching closing tag if present, else just the opening tag.
|
|
1645
|
+
const openMatch = /^<workspace-context[^>]*>/i.exec(working);
|
|
1646
|
+
if (openMatch) {
|
|
1647
|
+
working = working.slice(openMatch[0].length).trimStart();
|
|
1648
|
+
const closeMatch = /^([\s\S]*?)<\/workspace-context[^>]*>/i.exec(working);
|
|
1649
|
+
if (closeMatch) {
|
|
1650
|
+
working = working.slice(closeMatch[0].length).trimStart();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
// Resolve the display name from the canonical roster. Unknown slugs
|
|
1654
|
+
// (forward-compat with future personas streamed by a newer server)
|
|
1655
|
+
// skip the strip — better to leave the text alone than to mis-strip.
|
|
1656
|
+
const persona = getPersona(personaSlug);
|
|
1657
|
+
if (!persona)
|
|
1658
|
+
return working;
|
|
1659
|
+
const display = persona.name;
|
|
1660
|
+
if (!display || display.length === 0)
|
|
1661
|
+
return working;
|
|
1662
|
+
// Match `<DisplayName>` followed by an end-of-string, or by a
|
|
1663
|
+
// separator (comma, colon, dash, period followed by space, single
|
|
1664
|
+
// space). The match is case-insensitive so "pugi" also strips.
|
|
1665
|
+
// Escape regex specials in the display name even though THE_TEN
|
|
1666
|
+
// names are alpha-only today (forward-defense).
|
|
1667
|
+
const escaped = display.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1668
|
+
const re = new RegExp(`^${escaped}(?:[\\s,:;\\-—–]+|$)`, 'i');
|
|
1669
|
+
// Loop the strip so cascading echoes ("Pugi Pugi Pugi, координатор ...")
|
|
1670
|
+
// collapse to a single name. The model occasionally emits the display
|
|
1671
|
+
// name two or three times back-to-back when the pane prefix also
|
|
1672
|
+
// injects "▸ Pugi"; without the loop, only the first token would be
|
|
1673
|
+
// peeled and the operator would still see "▸ Pugi Pugi, координатор".
|
|
1674
|
+
// Cap at 3 iterations — beyond that the text is either pathological
|
|
1675
|
+
// or unrelated and we should not keep chewing it. Bail when an
|
|
1676
|
+
// iteration makes no progress to avoid infinite loops on a regex that
|
|
1677
|
+
// matches an empty string (defence-in-depth even though the current
|
|
1678
|
+
// pattern guarantees at least one consumed char).
|
|
1679
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1680
|
+
const m = re.exec(working);
|
|
1681
|
+
if (!m || m[0].length === 0)
|
|
1682
|
+
break;
|
|
1683
|
+
working = working.slice(m[0].length).trimStart();
|
|
1684
|
+
}
|
|
1685
|
+
return working;
|
|
1686
|
+
}
|
|
618
1687
|
//# sourceMappingURL=session.js.map
|