@pugi/cli 0.1.0-alpha.16 → 0.1.0-alpha.18
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 +40 -16
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/repl/session.js +276 -31
- package/dist/core/repl/slash-commands.js +25 -29
- package/dist/runtime/cli.js +1 -1
- package/dist/tui/repl-render.js +72 -0
- package/dist/tui/repl-splash-mascot.js +20 -8
- package/package.json +6 -3
|
@@ -42,14 +42,33 @@ const MAX_TOOL_CALLS = 200;
|
|
|
42
42
|
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
43
43
|
const RECONNECT_BASE_MS = 250;
|
|
44
44
|
const RECONNECT_MAX_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* α6.5 filewatch throttle: minimum gap between two file-change
|
|
47
|
+
* system lines surfaced in the conversation pane. Per the sprint
|
|
48
|
+
* spec, a noisy save burst should not flood the transcript - we
|
|
49
|
+
* coalesce all chokidar batches that arrive inside the window into
|
|
50
|
+
* a single "file changed: ..." line.
|
|
51
|
+
*/
|
|
52
|
+
const FILEWATCH_SYSTEM_LINE_GAP_MS = 5_000;
|
|
53
|
+
/**
|
|
54
|
+
* Hard cap on the size of `pendingFilewatchBatches`. The throttle window
|
|
55
|
+
* is 5s, but a `tsc --watch` style tool can fire dozens of `change`
|
|
56
|
+
* events per second for hours on end if the operator leaves the REPL
|
|
57
|
+
* up. Without a cap, every batch arriving inside the throttle window
|
|
58
|
+
* would accumulate forever, holding refs to thousands of FilewatchBatch
|
|
59
|
+
* objects (each carrying its own events array). On overflow we drop
|
|
60
|
+
* the OLDEST batch and surface a one-shot system warning so the
|
|
61
|
+
* operator knows the buffer is shedding. triple-review P1 (PR #380).
|
|
62
|
+
*/
|
|
63
|
+
const PENDING_FILEWATCH_BATCH_CAP = 100;
|
|
45
64
|
/**
|
|
46
65
|
* Cap on silent session-recreate attempts on HTTP 404 from the SSE
|
|
47
66
|
* stream. When admin-api restarts it drops its in-memory session
|
|
48
67
|
* store, so the saved sessionId returns 404 on every subscribe. The
|
|
49
68
|
* CLI mints a fresh server session, swaps the consumer over, and
|
|
50
|
-
* keeps running
|
|
69
|
+
* keeps running - but we cap the recovery to 3 attempts inside 60s
|
|
51
70
|
* so a truly down admin-api fails loud instead of spinning forever.
|
|
52
|
-
* (α6.14.2 wave 5
|
|
71
|
+
* (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
53
72
|
*/
|
|
54
73
|
const MAX_SESSION_RECREATE_ATTEMPTS = 3;
|
|
55
74
|
const SESSION_RECREATE_WINDOW_MS = 60_000;
|
|
@@ -84,19 +103,19 @@ export class ReplSession {
|
|
|
84
103
|
* `detail` field carries the cumulative model output. `agent.completed`
|
|
85
104
|
* arrives last and previously overwrote the visible detail to the
|
|
86
105
|
* literal string `'shipped'` while the transcript line said only
|
|
87
|
-
* `shipped.`
|
|
106
|
+
* `shipped.` - the actual reply text was lost. By caching the last
|
|
88
107
|
* non-trivial detail here, we can flush it into the transcript when
|
|
89
108
|
* the agent completes so the operator sees what the persona actually
|
|
90
109
|
* said. CEO wave-2 fix 2026-05-25.
|
|
91
110
|
*/
|
|
92
111
|
lastStepDetail = new Map();
|
|
93
112
|
/**
|
|
94
|
-
* Optional local SessionStore
|
|
113
|
+
* Optional local SessionStore - α6.4. When non-null, every
|
|
95
114
|
* appendRow() call mirrors the row into the JSONL log so the
|
|
96
115
|
* conversation can be restored via `/resume`. Errors from the store
|
|
97
116
|
* are swallowed to a single system line (degradation, not crash).
|
|
98
117
|
* The store is opened by the CLI bootstrap and closed via
|
|
99
|
-
* `ReplSession.close()`. The store ownership is shared
|
|
118
|
+
* `ReplSession.close()`. The store ownership is shared - the
|
|
100
119
|
* SqliteSessionStore is process-wide singleton-ish under the
|
|
101
120
|
* lockfile, so close-on-quit is safe.
|
|
102
121
|
*/
|
|
@@ -111,15 +130,69 @@ export class ReplSession {
|
|
|
111
130
|
localSessionId;
|
|
112
131
|
/**
|
|
113
132
|
* One-shot guard so a store error only emits ONE system line per
|
|
114
|
-
* session
|
|
133
|
+
* session - without this, a stuck filesystem would spam the operator
|
|
115
134
|
* with `[store]` errors on every keystroke.
|
|
116
135
|
*/
|
|
117
136
|
storeErrorEmitted = false;
|
|
137
|
+
/**
|
|
138
|
+
* α6.5 Tier 0 / Tier 1 / chokidar wiring. The bootstrap builds the
|
|
139
|
+
* skeleton + working set + watcher once and hands them to the
|
|
140
|
+
* session. The session uses them to:
|
|
141
|
+
*
|
|
142
|
+
* - render `/context` (count + cap + total bytes + skeleton size).
|
|
143
|
+
* - emit throttled "file changed" system lines on watcher batches.
|
|
144
|
+
* - forget removed files from the working set on `unlink`.
|
|
145
|
+
*
|
|
146
|
+
* All three are optional - tests and minimal callers pass null /
|
|
147
|
+
* undefined and the session degrades to "no three-tier integration"
|
|
148
|
+
* silently. The watcher's own lifecycle is owned by the bootstrap
|
|
149
|
+
* (we do NOT close it in `close()`).
|
|
150
|
+
*/
|
|
151
|
+
repoSkeleton;
|
|
152
|
+
workingSet;
|
|
153
|
+
watcher;
|
|
154
|
+
/**
|
|
155
|
+
* Epoch ms of the last filewatch system line. Initialised to 0 so
|
|
156
|
+
* the FIRST batch always emits; subsequent batches inside the gap
|
|
157
|
+
* are coalesced into the next emit window.
|
|
158
|
+
*/
|
|
159
|
+
lastFilewatchLineAtEpochMs = 0;
|
|
160
|
+
/**
|
|
161
|
+
* Buffer of batches whose emission was throttled. Drained on the
|
|
162
|
+
* next within-window batch by overwriting the throttled line with
|
|
163
|
+
* a summary that mentions how many additional files were touched.
|
|
164
|
+
* Capped at PENDING_FILEWATCH_BATCH_CAP to bound memory growth
|
|
165
|
+
* under long-running noisy filewatch sources (tsc --watch on a
|
|
166
|
+
* 200-file project hammering for hours). triple-review P1 (PR #380).
|
|
167
|
+
*/
|
|
168
|
+
pendingFilewatchBatches = [];
|
|
169
|
+
/**
|
|
170
|
+
* One-shot guard so the overflow-warning system line emits only once
|
|
171
|
+
* per session rather than spamming the operator with `[filewatch]
|
|
172
|
+
* shedding` on every dropped batch.
|
|
173
|
+
*/
|
|
174
|
+
pendingFilewatchOverflowWarned = false;
|
|
175
|
+
/**
|
|
176
|
+
* Bound subscriber refs so close() can detach the listeners from the
|
|
177
|
+
* shared watcher. The bootstrap owns the watcher lifecycle (it calls
|
|
178
|
+
* watcher.close() on REPL teardown), but the session MUST detach its
|
|
179
|
+
* own listeners on close() so any chokidar event landing between
|
|
180
|
+
* session.close() and watcher.close() does not run handlers on a
|
|
181
|
+
* dead session. Without detachment, recordFilewatchBatch would
|
|
182
|
+
* touch this.workingSet / this.transcript on a closed session.
|
|
183
|
+
* triple-review P1 (PR #380).
|
|
184
|
+
*/
|
|
185
|
+
filewatchBatchHandler = (batch) => {
|
|
186
|
+
this.recordFilewatchBatch(batch);
|
|
187
|
+
};
|
|
188
|
+
filewatchCapHandler = (info) => {
|
|
189
|
+
this.recordFilewatchCapExceeded(info);
|
|
190
|
+
};
|
|
118
191
|
/**
|
|
119
192
|
* Rolling dedupe set for `<pugi-ask>` and `<pugi-plan-review>`
|
|
120
193
|
* signatures. The persona may emit the same envelope twice on network
|
|
121
194
|
* retry; we suppress the duplicate so the operator does not see two
|
|
122
|
-
* stacked modals. Capped at 32 entries
|
|
195
|
+
* stacked modals. Capped at 32 entries - generous for a real session,
|
|
123
196
|
* defensive against a hostile flood. (α6.3.)
|
|
124
197
|
*/
|
|
125
198
|
seenTagSignatures = [];
|
|
@@ -144,6 +217,16 @@ export class ReplSession {
|
|
|
144
217
|
this.options = options;
|
|
145
218
|
this.store = options.store ?? null;
|
|
146
219
|
this.localSessionId = options.localSessionId;
|
|
220
|
+
this.repoSkeleton = options.repoSkeleton ?? null;
|
|
221
|
+
this.workingSet = options.workingSet ?? null;
|
|
222
|
+
this.watcher = options.watcher ?? null;
|
|
223
|
+
// Subscribe to the chokidar watcher when present. Late-binding
|
|
224
|
+
// happens here so the bootstrap can construct the session and
|
|
225
|
+
// attach the watcher in one pass without re-validating shape.
|
|
226
|
+
if (this.watcher) {
|
|
227
|
+
this.watcher.on('batch', this.filewatchBatchHandler);
|
|
228
|
+
this.watcher.on('capExceeded', this.filewatchCapHandler);
|
|
229
|
+
}
|
|
147
230
|
this.state = {
|
|
148
231
|
sessionId: undefined,
|
|
149
232
|
workspaceLabel: options.workspaceLabel,
|
|
@@ -207,6 +290,16 @@ export class ReplSession {
|
|
|
207
290
|
clearTimeout(this.reconnectTimer);
|
|
208
291
|
this.reconnectTimer = undefined;
|
|
209
292
|
}
|
|
293
|
+
// Detach watcher listeners so any chokidar event landing between
|
|
294
|
+
// session.close() and the bootstrap-owned watcher.close() does NOT
|
|
295
|
+
// run a handler on a dead session. The handlers themselves also
|
|
296
|
+
// hard-guard on `this.closed`, but detaching is the load-bearing
|
|
297
|
+
// fix - it severs the strong reference the watcher held on the
|
|
298
|
+
// session callback, which otherwise blocks GC. triple-review P1 (PR #380).
|
|
299
|
+
if (this.watcher) {
|
|
300
|
+
this.watcher.off('batch', this.filewatchBatchHandler);
|
|
301
|
+
this.watcher.off('capExceeded', this.filewatchCapHandler);
|
|
302
|
+
}
|
|
210
303
|
}
|
|
211
304
|
/* ------------- input handling -------------- */
|
|
212
305
|
/**
|
|
@@ -284,6 +377,10 @@ export class ReplSession {
|
|
|
284
377
|
await this.dispatchResume();
|
|
285
378
|
return verdict;
|
|
286
379
|
}
|
|
380
|
+
case 'context': {
|
|
381
|
+
this.dispatchContext();
|
|
382
|
+
return verdict;
|
|
383
|
+
}
|
|
287
384
|
case 'ask': {
|
|
288
385
|
// α6.3: synthesise a local yes/no `<pugi-ask>` modal so the
|
|
289
386
|
// operator can exercise the question UI without a persona-side
|
|
@@ -306,7 +403,7 @@ export class ReplSession {
|
|
|
306
403
|
}
|
|
307
404
|
}
|
|
308
405
|
/**
|
|
309
|
-
* In-REPL `/resume`
|
|
406
|
+
* In-REPL `/resume` - α6.4. Lists the 10 most recent sessions from
|
|
310
407
|
* the local SessionStore and prints them as a numbered system menu.
|
|
311
408
|
* The Ink-side picker UI is deferred to the next sprint; today the
|
|
312
409
|
* operator gets a deterministic list + the exact command to relaunch
|
|
@@ -352,12 +449,12 @@ export class ReplSession {
|
|
|
352
449
|
/* ------------- α6.3 office-hours surface -------------- */
|
|
353
450
|
/**
|
|
354
451
|
* Surface an `<pugi-ask>` modal manually. Returned promise resolves
|
|
355
|
-
* with the operator's verdict
|
|
452
|
+
* with the operator's verdict - used by the `pugi ask "<q>"` shell
|
|
356
453
|
* command and by the `/ask` slash. The resolver is wired into the
|
|
357
454
|
* session state via `pendingAsk` so the REPL UI can render the modal
|
|
358
455
|
* and forward `onResolve` back through `resolveAsk()`.
|
|
359
456
|
*
|
|
360
|
-
* NOTE: idempotent on a duplicate signature
|
|
457
|
+
* NOTE: idempotent on a duplicate signature - a second presentAsk
|
|
361
458
|
* with the same question + option values returns the first
|
|
362
459
|
* outstanding promise rather than stacking two modals.
|
|
363
460
|
*/
|
|
@@ -370,7 +467,7 @@ export class ReplSession {
|
|
|
370
467
|
return this.outstandingAskPromise;
|
|
371
468
|
}
|
|
372
469
|
// If a DIFFERENT ask is open, reject the new one with a clear
|
|
373
|
-
// error rather than silently queueing
|
|
470
|
+
// error rather than silently queueing - the persona should never
|
|
374
471
|
// emit two concurrent asks, and surfacing the bug fails loud.
|
|
375
472
|
if (this.outstandingAskPromise) {
|
|
376
473
|
return Promise.reject(new Error('presentAsk: another ask is already pending. Resolve it first.'));
|
|
@@ -584,6 +681,129 @@ export class ReplSession {
|
|
|
584
681
|
this.appendSystemLine(`Workspace: ${this.state.workspaceLabel}.`);
|
|
585
682
|
this.appendSystemLine(`CLI: pugi ${this.state.cliVersion}.`);
|
|
586
683
|
}
|
|
684
|
+
/**
|
|
685
|
+
* α6.5 `/context` slash handler. Surfaces the three-tier context
|
|
686
|
+
* summary as a stack of system lines. Sections (in order):
|
|
687
|
+
*
|
|
688
|
+
* 1. Tier 0 (repo skeleton) - size in bytes, branch, package
|
|
689
|
+
* manager, languages. Skipped when no skeleton was injected
|
|
690
|
+
* (REPL launched outside a workspace or with --no-context).
|
|
691
|
+
*
|
|
692
|
+
* 2. Tier 1 (working set) - `count / capacity` plus the total
|
|
693
|
+
* size in bytes plus the oldest entry's age in seconds.
|
|
694
|
+
* Always emits even when empty so the operator can confirm
|
|
695
|
+
* the tier is wired.
|
|
696
|
+
*
|
|
697
|
+
* 3. Tier 2 (RAG) - one-line heads-up that the Anvil-side
|
|
698
|
+
* workspace lands in α6.5b.
|
|
699
|
+
*
|
|
700
|
+
* The renderer never mutates state.
|
|
701
|
+
*/
|
|
702
|
+
dispatchContext() {
|
|
703
|
+
if (this.repoSkeleton) {
|
|
704
|
+
const parts = [`Tier 0 skeleton: ${this.repoSkeleton.totalSize} bytes`];
|
|
705
|
+
if (this.repoSkeleton.branch)
|
|
706
|
+
parts.push(`branch ${this.repoSkeleton.branch}`);
|
|
707
|
+
if (this.repoSkeleton.packageManager)
|
|
708
|
+
parts.push(this.repoSkeleton.packageManager);
|
|
709
|
+
if (this.repoSkeleton.primaryLanguages.length > 0) {
|
|
710
|
+
parts.push(`langs ${this.repoSkeleton.primaryLanguages.slice(0, 3).join('/')}`);
|
|
711
|
+
}
|
|
712
|
+
this.appendSystemLine(parts.join(' - '));
|
|
713
|
+
}
|
|
714
|
+
else {
|
|
715
|
+
this.appendSystemLine('Tier 0 skeleton: not loaded (run pugi init or launch in a workspace).');
|
|
716
|
+
}
|
|
717
|
+
if (this.workingSet) {
|
|
718
|
+
const summary = this.workingSet.summary();
|
|
719
|
+
const ageLine = summary.oldestTouchedAtEpochMs !== null
|
|
720
|
+
? ` - oldest touch ${formatAgeSeconds(this.now() - summary.oldestTouchedAtEpochMs)} ago`
|
|
721
|
+
: '';
|
|
722
|
+
this.appendSystemLine(`Tier 1 working set: ${summary.count}/${summary.capacity} files, ${summary.totalSizeBytes} bytes${ageLine}.`);
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
this.appendSystemLine('Tier 1 working set: not wired.');
|
|
726
|
+
}
|
|
727
|
+
this.appendSystemLine('Tier 2 RAG: deferred to α6.5b (Anvil-side per-tenant workspace).');
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* α6.5 chokidar batch handler. Forwards each event to the working
|
|
731
|
+
* set tracker (so `unlink` evicts and `add`/`change` bump the
|
|
732
|
+
* recency) and emits at most one throttled system line per
|
|
733
|
+
* `FILEWATCH_SYSTEM_LINE_GAP_MS` window.
|
|
734
|
+
*
|
|
735
|
+
* The transcript surface intentionally shows ONE filename + the
|
|
736
|
+
* count of additional changes (`file changed: src/foo.ts (+3 more)`).
|
|
737
|
+
* The full event list is preserved in the buffer for future
|
|
738
|
+
* `/context --files` deep-dive (not in α6.5 Phase 1).
|
|
739
|
+
*/
|
|
740
|
+
recordFilewatchBatch(batch) {
|
|
741
|
+
// Hard-guard against post-close invocation. close() detaches the
|
|
742
|
+
// watcher listeners, but the EventEmitter contract allows the
|
|
743
|
+
// currently-dispatching emit() call to finish delivering to every
|
|
744
|
+
// listener captured at the start of emit(). If the session closes
|
|
745
|
+
// mid-emit, the handler can still fire on a dead session. Returning
|
|
746
|
+
// early keeps the working set + transcript untouched.
|
|
747
|
+
// triple-review P1 (PR #380).
|
|
748
|
+
if (this.closed)
|
|
749
|
+
return;
|
|
750
|
+
if (this.workingSet) {
|
|
751
|
+
for (const event of batch.events) {
|
|
752
|
+
if (event.kind === 'unlink') {
|
|
753
|
+
this.workingSet.forget(event.absPath);
|
|
754
|
+
}
|
|
755
|
+
// Note: we do NOT auto-track add/change here. The working set
|
|
756
|
+
// reflects files the AGENT touched - filewatch is informational.
|
|
757
|
+
// Future wiring: bash/read/edit tools will call track() directly.
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const nowMs = this.now();
|
|
761
|
+
const sinceLast = nowMs - this.lastFilewatchLineAtEpochMs;
|
|
762
|
+
if (sinceLast < FILEWATCH_SYSTEM_LINE_GAP_MS && this.lastFilewatchLineAtEpochMs !== 0) {
|
|
763
|
+
// Inside the throttle window - buffer for future deep-dive but
|
|
764
|
+
// do not emit a system line. Cap the buffer at
|
|
765
|
+
// PENDING_FILEWATCH_BATCH_CAP and drop the oldest on overflow so
|
|
766
|
+
// a noisy filewatch source cannot drive unbounded memory growth
|
|
767
|
+
// across a long REPL session. triple-review P1 (PR #380).
|
|
768
|
+
if (this.pendingFilewatchBatches.length >= PENDING_FILEWATCH_BATCH_CAP) {
|
|
769
|
+
this.pendingFilewatchBatches.shift();
|
|
770
|
+
if (!this.pendingFilewatchOverflowWarned) {
|
|
771
|
+
this.pendingFilewatchOverflowWarned = true;
|
|
772
|
+
this.appendSystemLine(`Filewatch buffer at cap (${PENDING_FILEWATCH_BATCH_CAP} batches) - shedding oldest. Source may be a build watcher in a tight loop.`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
this.pendingFilewatchBatches.push(batch);
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
const totalEvents = this.pendingFilewatchBatches.reduce((acc, b) => acc + b.events.length, 0) + batch.events.length;
|
|
779
|
+
const head = batch.events[0];
|
|
780
|
+
if (!head) {
|
|
781
|
+
// Empty batch - should not happen given the watcher guards,
|
|
782
|
+
// but defensive.
|
|
783
|
+
this.pendingFilewatchBatches = [];
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const wsLine = this.workingSet
|
|
787
|
+
? ` (working set: ${this.workingSet.size()}/${this.workingSet.capacityLimit()})`
|
|
788
|
+
: '';
|
|
789
|
+
const tail = totalEvents > 1 ? ` (+${totalEvents - 1} more)` : '';
|
|
790
|
+
this.appendSystemLine(`file ${head.kind}: ${head.path}${tail}${wsLine}`);
|
|
791
|
+
this.lastFilewatchLineAtEpochMs = nowMs;
|
|
792
|
+
this.pendingFilewatchBatches = [];
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* α6.5 chokidar cap-exceeded handler. The watcher closes itself
|
|
796
|
+
* when it crosses the watched-paths cap; the session surfaces a
|
|
797
|
+
* single system line so the operator knows live updates are off.
|
|
798
|
+
* The conversation stays usable - we just lose the file-changed
|
|
799
|
+
* badge for the rest of the session.
|
|
800
|
+
*/
|
|
801
|
+
recordFilewatchCapExceeded(info) {
|
|
802
|
+
// Same post-close guard as recordFilewatchBatch. triple-review P1 (PR #380).
|
|
803
|
+
if (this.closed)
|
|
804
|
+
return;
|
|
805
|
+
this.appendSystemLine(`Filewatch off: ${info.watchedCount} watched paths exceeded cap (${info.cap}). Falling back to manual stat-on-read.`);
|
|
806
|
+
}
|
|
587
807
|
/**
|
|
588
808
|
* Fetch one URL via the web_fetch tool and inject the resulting
|
|
589
809
|
* Markdown into the transcript as an operator-attributed brief. The
|
|
@@ -716,11 +936,11 @@ export class ReplSession {
|
|
|
716
936
|
// synchronously in some transports). If that second 404 arrives
|
|
717
937
|
// with recreatingSession === true, we must SHORT-CIRCUIT it too
|
|
718
938
|
// rather than fall through to the legacy "Stream interrupted"
|
|
719
|
-
// path
|
|
939
|
+
// path - otherwise the operator sees the exact 404 line the
|
|
720
940
|
// recreate is trying to suppress.
|
|
721
941
|
if (this.isSessionNotFoundError(error)) {
|
|
722
942
|
if (this.recreatingSession) {
|
|
723
|
-
// Recreate already in flight
|
|
943
|
+
// Recreate already in flight - drop the duplicate 404 on the
|
|
724
944
|
// floor. The first recreate will either succeed (new stream
|
|
725
945
|
// opens, this dead handle is gone) or fall through to the
|
|
726
946
|
// loud "keeps dropping" / "session recreate refused" paths
|
|
@@ -770,7 +990,7 @@ export class ReplSession {
|
|
|
770
990
|
this.recentRecreateAtMs.shift();
|
|
771
991
|
}
|
|
772
992
|
if (this.recentRecreateAtMs.length >= MAX_SESSION_RECREATE_ATTEMPTS) {
|
|
773
|
-
// Cap exceeded
|
|
993
|
+
// Cap exceeded - fall back to the loud "give up" path so the
|
|
774
994
|
// operator sees something is actually wrong.
|
|
775
995
|
this.appendSystemLine('Admin API session keeps dropping (HTTP 404 x3). Type /quit and `pugi resume` to retry.');
|
|
776
996
|
this.patch({ connection: 'offline' });
|
|
@@ -784,7 +1004,7 @@ export class ReplSession {
|
|
|
784
1004
|
this.streamHandle.close();
|
|
785
1005
|
this.streamHandle = undefined;
|
|
786
1006
|
}
|
|
787
|
-
// Reset reconnect attempt + lastEventId
|
|
1007
|
+
// Reset reconnect attempt + lastEventId - the new session is a
|
|
788
1008
|
// fresh stream, not a continuation of the dead one.
|
|
789
1009
|
this.reconnectAttempt = 0;
|
|
790
1010
|
this.lastEventId = undefined;
|
|
@@ -804,7 +1024,7 @@ export class ReplSession {
|
|
|
804
1024
|
this.openStream();
|
|
805
1025
|
}
|
|
806
1026
|
catch (error) {
|
|
807
|
-
// The recreate POST itself failed
|
|
1027
|
+
// The recreate POST itself failed - fall back to the existing
|
|
808
1028
|
// backoff reconnect so the operator still sees retry progress.
|
|
809
1029
|
this.appendSystemLine(`Session recreate refused (${this.errorMessage(error)}). Reconnecting.`);
|
|
810
1030
|
this.scheduleReconnect();
|
|
@@ -964,7 +1184,7 @@ export class ReplSession {
|
|
|
964
1184
|
// splits per line so word-wrap stays correct.
|
|
965
1185
|
//
|
|
966
1186
|
// Claude triple-review P1 (PR #369): the prior `includes('```')`
|
|
967
|
-
// gate only caught fences
|
|
1187
|
+
// gate only caught fences - multi-line bullets fragmented
|
|
968
1188
|
// per row showed as `▸ Mira • read PUGI.md / ▸ Mira • patched
|
|
969
1189
|
// bug / ...` instead of a single grouped bullet block.
|
|
970
1190
|
if (looksLikeMarkdown(finalDetail)) {
|
|
@@ -1076,7 +1296,7 @@ export class ReplSession {
|
|
|
1076
1296
|
// colon / whitespace) so the prefix the pane adds is the only one
|
|
1077
1297
|
// visible. We also drop any leaked `<workspace-context-NONCE>`
|
|
1078
1298
|
// wrapper the model sometimes echoes back at the head of its
|
|
1079
|
-
// first turn
|
|
1299
|
+
// first turn - that envelope is for prompt scaffolding, not for
|
|
1080
1300
|
// the operator's eyes.
|
|
1081
1301
|
const stripped = stripPersonaPrefixEcho(personaSlug, text);
|
|
1082
1302
|
this.appendRow({ source: 'persona', text: stripped, personaSlug });
|
|
@@ -1106,7 +1326,7 @@ export class ReplSession {
|
|
|
1106
1326
|
* Best-effort write of one transcript row into the local
|
|
1107
1327
|
* SessionStore. Swallows errors after emitting one system line so a
|
|
1108
1328
|
* broken store never blocks the conversation. Public callers go
|
|
1109
|
-
* through `appendRow`
|
|
1329
|
+
* through `appendRow` - this method is private on purpose.
|
|
1110
1330
|
*/
|
|
1111
1331
|
persistRow(row) {
|
|
1112
1332
|
if (!this.store)
|
|
@@ -1138,14 +1358,14 @@ export class ReplSession {
|
|
|
1138
1358
|
});
|
|
1139
1359
|
}
|
|
1140
1360
|
/**
|
|
1141
|
-
* Restore a transcript from a stored event log
|
|
1361
|
+
* Restore a transcript from a stored event log - α6.4. Called by
|
|
1142
1362
|
* the CLI bootstrap when the operator runs `pugi resume <id>` or
|
|
1143
1363
|
* picks an entry from the `/resume` picker. Replays each event into
|
|
1144
1364
|
* the local transcript WITHOUT writing back to the store so the
|
|
1145
1365
|
* restore is idempotent.
|
|
1146
1366
|
*
|
|
1147
1367
|
* Implementation note: we briefly disable persistence by setting
|
|
1148
|
-
* `storeErrorEmitted` BEFORE the replay and clearing it after
|
|
1368
|
+
* `storeErrorEmitted` BEFORE the replay and clearing it after - but
|
|
1149
1369
|
* the cleaner path is to bypass `appendRow` entirely and patch
|
|
1150
1370
|
* state directly. We do the latter so persistRow does not double-
|
|
1151
1371
|
* write the restored events.
|
|
@@ -1157,7 +1377,7 @@ export class ReplSession {
|
|
|
1157
1377
|
if (row)
|
|
1158
1378
|
rows.push(row);
|
|
1159
1379
|
}
|
|
1160
|
-
// Cap at MAX_TRANSCRIPT_ROWS
|
|
1380
|
+
// Cap at MAX_TRANSCRIPT_ROWS - the same cap appendRow uses so the
|
|
1161
1381
|
// window math stays consistent post-restore.
|
|
1162
1382
|
const capped = rows.slice(-MAX_TRANSCRIPT_ROWS);
|
|
1163
1383
|
this.patch({ transcript: capped });
|
|
@@ -1193,7 +1413,7 @@ export class ReplSession {
|
|
|
1193
1413
|
// a fresh `agent.step` carries the full body up to the current
|
|
1194
1414
|
// token (matches the wave-2 caching contract above). We pass the
|
|
1195
1415
|
// raw detail through the extractors directly rather than keeping a
|
|
1196
|
-
// separate buffer
|
|
1416
|
+
// separate buffer - but we still record the pre-extraction body so
|
|
1197
1417
|
// a partial open tag is preserved when the next chunk arrives.
|
|
1198
1418
|
this.askBuffer.set(taskId, detail);
|
|
1199
1419
|
const askResult = extractAskTags(detail);
|
|
@@ -1202,7 +1422,7 @@ export class ReplSession {
|
|
|
1202
1422
|
if (this.seenTagSignatures.includes(tag.signature))
|
|
1203
1423
|
continue;
|
|
1204
1424
|
this.recordSeenTag(tag.signature);
|
|
1205
|
-
// Only one pending ask at a time
|
|
1425
|
+
// Only one pending ask at a time - drop additional tags in the
|
|
1206
1426
|
// same step into the cleaned body as a system warning. The
|
|
1207
1427
|
// persona's prompt forbids concurrent asks, so this branch is a
|
|
1208
1428
|
// defensive guard against a misbehaving model.
|
|
@@ -1287,7 +1507,7 @@ export class ReplSession {
|
|
|
1287
1507
|
/**
|
|
1288
1508
|
* Map a stored SessionEvent back into a TranscriptRow for `/resume`
|
|
1289
1509
|
* replay. Returns null when the event has no operator-visible body
|
|
1290
|
-
* (e.g. tool.start without a text payload
|
|
1510
|
+
* (e.g. tool.start without a text payload - those land back as
|
|
1291
1511
|
* tool stream rows, not transcript rows). The shape mirrors the
|
|
1292
1512
|
* `persistRow` mapping in reverse:
|
|
1293
1513
|
*
|
|
@@ -1345,7 +1565,7 @@ function eventToTranscriptRow(event) {
|
|
|
1345
1565
|
/**
|
|
1346
1566
|
* Heuristic: does this text contain Markdown structures that benefit
|
|
1347
1567
|
* from atomic grouping? Code fences, bullet lists, numbered lists,
|
|
1348
|
-
* headings
|
|
1568
|
+
* headings - anything where per-line splitting would fragment visual
|
|
1349
1569
|
* grouping (Claude triple-review P1 PR #369).
|
|
1350
1570
|
*/
|
|
1351
1571
|
function looksLikeMarkdown(text) {
|
|
@@ -1375,6 +1595,31 @@ function safePersonaName(role) {
|
|
|
1375
1595
|
return role;
|
|
1376
1596
|
}
|
|
1377
1597
|
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Render a millisecond delta as a compact human-readable age. Used by
|
|
1600
|
+
* `/context` to surface the oldest working-set entry's age:
|
|
1601
|
+
*
|
|
1602
|
+
* < 60s -> `45s`
|
|
1603
|
+
* < 1h -> `4m`
|
|
1604
|
+
* < 24h -> `2h`
|
|
1605
|
+
* >= 24h -> `3d`
|
|
1606
|
+
*
|
|
1607
|
+
* Negative deltas (clock skew) clamp to `0s`.
|
|
1608
|
+
*/
|
|
1609
|
+
function formatAgeSeconds(deltaMs) {
|
|
1610
|
+
const ms = Math.max(0, deltaMs);
|
|
1611
|
+
const seconds = Math.floor(ms / 1000);
|
|
1612
|
+
if (seconds < 60)
|
|
1613
|
+
return `${seconds}s`;
|
|
1614
|
+
const minutes = Math.floor(seconds / 60);
|
|
1615
|
+
if (minutes < 60)
|
|
1616
|
+
return `${minutes}m`;
|
|
1617
|
+
const hours = Math.floor(minutes / 60);
|
|
1618
|
+
if (hours < 24)
|
|
1619
|
+
return `${hours}h`;
|
|
1620
|
+
const days = Math.floor(hours / 24);
|
|
1621
|
+
return `${days}d`;
|
|
1622
|
+
}
|
|
1378
1623
|
/**
|
|
1379
1624
|
* Convenience: list the legal role slugs the operator can target with
|
|
1380
1625
|
* `/stop`. Surfaced in the slash command help overlay and in the
|
|
@@ -1474,7 +1719,7 @@ function parseStatusFromTail(tail) {
|
|
|
1474
1719
|
/* */
|
|
1475
1720
|
/* Mirrors `tui/ask-modal.tsx#encodeAskVerdict` so the session can */
|
|
1476
1721
|
/* synthesise the operator-side echo without dragging an Ink module */
|
|
1477
|
-
/* into the test surface. The two encoders MUST agree byte-for-byte
|
|
1722
|
+
/* into the test surface. The two encoders MUST agree byte-for-byte - */
|
|
1478
1723
|
/* a divergence would silently mis-prefix the persona's follow-up. */
|
|
1479
1724
|
/* ------------------------------------------------------------------ */
|
|
1480
1725
|
function encodeAskVerdictLocal(verdict) {
|
|
@@ -1629,10 +1874,10 @@ export function synthesiseLocalAskTag(question) {
|
|
|
1629
1874
|
* "<workspace-context-abc>Pugi, привет" -> "привет"
|
|
1630
1875
|
* "обычный ответ без префикса" -> "обычный ответ без префикса"
|
|
1631
1876
|
*
|
|
1632
|
-
* The strip is conservative
|
|
1877
|
+
* The strip is conservative - we only remove the display name when it
|
|
1633
1878
|
* is followed by a separator (comma, colon, dash, space) so a sentence
|
|
1634
1879
|
* that legitimately contains the name mid-text ("спроси у Pugi") is
|
|
1635
|
-
* not mangled. (α6.14.2 wave 5
|
|
1880
|
+
* not mangled. (α6.14.2 wave 5 - CEO dogfood fix.)
|
|
1636
1881
|
*/
|
|
1637
1882
|
export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
1638
1883
|
let working = text.trimStart();
|
|
@@ -1652,7 +1897,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
1652
1897
|
}
|
|
1653
1898
|
// Resolve the display name from the canonical roster. Unknown slugs
|
|
1654
1899
|
// (forward-compat with future personas streamed by a newer server)
|
|
1655
|
-
// skip the strip
|
|
1900
|
+
// skip the strip - better to leave the text alone than to mis-strip.
|
|
1656
1901
|
const persona = getPersona(personaSlug);
|
|
1657
1902
|
if (!persona)
|
|
1658
1903
|
return working;
|
|
@@ -1671,7 +1916,7 @@ export function stripPersonaPrefixEcho(personaSlug, text) {
|
|
|
1671
1916
|
// name two or three times back-to-back when the pane prefix also
|
|
1672
1917
|
// injects "▸ Pugi"; without the loop, only the first token would be
|
|
1673
1918
|
// peeled and the operator would still see "▸ Pugi Pugi, координатор".
|
|
1674
|
-
// Cap at 3 iterations
|
|
1919
|
+
// Cap at 3 iterations - beyond that the text is either pathological
|
|
1675
1920
|
// or unrelated and we should not keep chewing it. Bail when an
|
|
1676
1921
|
// iteration makes no progress to avoid infinite loops on a regex that
|
|
1677
1922
|
// matches an empty string (defence-in-depth even though the current
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* REPL slash command registry
|
|
2
|
+
* REPL slash command registry - Sprint α5.7, expanded α6.14 wave 2.
|
|
3
3
|
*
|
|
4
4
|
* The REPL input box surfaces a palette of slash commands the operator
|
|
5
5
|
* can run from inside a persistent session. The wave-2 expansion (CEO
|
|
@@ -15,44 +15,31 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Tiering (per CEO wave-2 spec):
|
|
17
17
|
*
|
|
18
|
-
* Tier 1
|
|
18
|
+
* Tier 1 - wired against real state (3 + existing 6 = 9 wired):
|
|
19
19
|
* brief, agents, stop, help, quit, web, clear, version, jobs.
|
|
20
20
|
*
|
|
21
|
-
* Tier 2
|
|
21
|
+
* Tier 2 - best-effort wiring against existing surfaces (3):
|
|
22
22
|
* diff, cost, status.
|
|
23
23
|
*
|
|
24
|
-
* Tier 3
|
|
24
|
+
* Tier 3 - deterministic stubs ("coming in αX.Y") (8):
|
|
25
25
|
* compact, resume, memory, config, privacy, budget, mcp, undo.
|
|
26
26
|
*
|
|
27
27
|
* Brand voice (brandbook §08): power words `brief / dispatch / stop /
|
|
28
28
|
* agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
|
|
29
|
-
* `/quit` confirmation and `/help` footer
|
|
29
|
+
* `/quit` confirmation and `/help` footer - never inline.
|
|
30
30
|
*/
|
|
31
31
|
import { listRoles } from '../agents/registry.js';
|
|
32
32
|
/**
|
|
33
33
|
* Deterministic stub copy returned by the Tier 3 commands. Spec'd
|
|
34
34
|
* inline so the unit test can pin the exact text without poking at
|
|
35
35
|
* the help overlay. The version tag at the end maps to the sprint we
|
|
36
|
-
* intend to land the real wiring in.
|
|
36
|
+
* intend to land the real wiring in. Keyed by StubSlashCommandName
|
|
37
|
+
* (not the full SlashCommandName union) so wired commands cannot
|
|
38
|
+
* silently appear here with empty placeholders.
|
|
37
39
|
*/
|
|
38
40
|
export const SLASH_STUB_MESSAGES = Object.freeze({
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
stop: '',
|
|
42
|
-
help: '',
|
|
43
|
-
quit: '',
|
|
44
|
-
web: '',
|
|
45
|
-
clear: '',
|
|
46
|
-
version: '',
|
|
47
|
-
jobs: '',
|
|
48
|
-
ask: '',
|
|
49
|
-
diff: '',
|
|
50
|
-
cost: '',
|
|
51
|
-
status: '',
|
|
52
|
-
consensus: '',
|
|
53
|
-
compact: 'Manual context compaction lands in α6.5.',
|
|
54
|
-
resume: '',
|
|
55
|
-
memory: 'Session memory editor lands in α6.5.',
|
|
41
|
+
compact: 'Manual context compaction lands in α6.5b.',
|
|
42
|
+
memory: 'Session memory editor lands in α6.5b.',
|
|
56
43
|
config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
|
|
57
44
|
privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
|
|
58
45
|
budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
|
|
@@ -69,8 +56,9 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
69
56
|
// Session
|
|
70
57
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
71
58
|
{ name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
|
|
72
|
-
{ name: '
|
|
73
|
-
{ name: '
|
|
59
|
+
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
60
|
+
{ name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
|
|
61
|
+
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
74
62
|
// Pugi tools
|
|
75
63
|
{ name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
|
|
76
64
|
{ name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
|
|
@@ -105,7 +93,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
|
105
93
|
* - Empty / whitespace-only input returns `noop` with the original
|
|
106
94
|
* text so the REPL can ignore it without printing anything.
|
|
107
95
|
* - Input that does not start with `/` is treated as an implicit
|
|
108
|
-
* `/brief <text>`
|
|
96
|
+
* `/brief <text>` - the most-common operator action.
|
|
109
97
|
* - `/<name> [args]` resolves the name against the registry; unknown
|
|
110
98
|
* names return `error` so the REPL can render a one-line tip
|
|
111
99
|
* instead of silently dropping the input.
|
|
@@ -113,7 +101,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
|
|
|
113
101
|
* can render the deterministic "coming in αX.Y" copy uniformly.
|
|
114
102
|
*
|
|
115
103
|
* The function never throws. Bad input maps to a structured result the
|
|
116
|
-
* REPL can render
|
|
104
|
+
* REPL can render - the alternative (throwing from a keystroke handler)
|
|
117
105
|
* would unmount Ink mid-frame.
|
|
118
106
|
*/
|
|
119
107
|
export function parseSlashCommand(input) {
|
|
@@ -210,6 +198,13 @@ export function parseSlashCommand(input) {
|
|
|
210
198
|
// so the slash-command layer stays UI-agnostic.
|
|
211
199
|
return { kind: 'resume' };
|
|
212
200
|
}
|
|
201
|
+
case 'context':
|
|
202
|
+
case 'ctx': {
|
|
203
|
+
// α6.5: surface Tier 0 + Tier 1 status. The session module
|
|
204
|
+
// renders the summary as system lines so the operator can see
|
|
205
|
+
// skeleton size + working-set utilisation at a glance.
|
|
206
|
+
return { kind: 'context' };
|
|
207
|
+
}
|
|
213
208
|
case 'compact':
|
|
214
209
|
case 'memory':
|
|
215
210
|
case 'config':
|
|
@@ -217,10 +212,11 @@ export function parseSlashCommand(input) {
|
|
|
217
212
|
case 'budget':
|
|
218
213
|
case 'mcp':
|
|
219
214
|
case 'undo': {
|
|
215
|
+
const stubName = name;
|
|
220
216
|
return {
|
|
221
217
|
kind: 'stub',
|
|
222
|
-
name:
|
|
223
|
-
message: SLASH_STUB_MESSAGES[
|
|
218
|
+
name: stubName,
|
|
219
|
+
message: SLASH_STUB_MESSAGES[stubName],
|
|
224
220
|
};
|
|
225
221
|
}
|
|
226
222
|
default: {
|
package/dist/runtime/cli.js
CHANGED
|
@@ -42,7 +42,7 @@ import { slugForCwd } from '../core/repl/history.js';
|
|
|
42
42
|
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
43
43
|
* three are in lockstep.
|
|
44
44
|
*/
|
|
45
|
-
const PUGI_CLI_VERSION = "0.1.0-alpha.
|
|
45
|
+
const PUGI_CLI_VERSION = "0.1.0-alpha.18";
|
|
46
46
|
const handlers = {
|
|
47
47
|
accounts,
|
|
48
48
|
agents: dispatchAgents,
|