@pugi/cli 0.1.0-beta.29 → 0.1.0-beta.30
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/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/dual-write.spec.js +297 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/repl/session.js +101 -2
- package/dist/core/repl/slash-commands.js +30 -5
- package/dist/runtime/cli.js +5 -0
- package/dist/runtime/commands/compact.js +3 -2
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tui/input-box.js +45 -1
- package/dist/tui/repl.js +13 -1
- package/package.json +2 -2
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi CLI dual-write client — Memory Phase 1 (2026-05-27, Wave 6).
|
|
3
|
+
*
|
|
4
|
+
* Promotes the per-session NDJSON event log (Memory Phase 0, PR #522)
|
|
5
|
+
* to a best-effort Prisma row store via the admin-api endpoint:
|
|
6
|
+
*
|
|
7
|
+
* POST /api/pugi/sessions/:sessionId/events
|
|
8
|
+
*
|
|
9
|
+
* Design contract:
|
|
10
|
+
*
|
|
11
|
+
* 1. Local NDJSON is the SOURCE OF TRUTH. The dual-write client is
|
|
12
|
+
* fire-and-forget — a network failure NEVER blocks the local
|
|
13
|
+
* append path, and the operator never loses data because the
|
|
14
|
+
* .pugi/sessions/<id>/events.<n>.jsonl file is always written
|
|
15
|
+
* first by the SessionStore.
|
|
16
|
+
*
|
|
17
|
+
* 2. Async + debounced. Events are buffered in memory and flushed on
|
|
18
|
+
* a timer (default 250 ms) or when the buffer crosses the batch
|
|
19
|
+
* cap (default 50). The debounce + buffer keeps the network cost
|
|
20
|
+
* proportional to operator activity, not per-event.
|
|
21
|
+
*
|
|
22
|
+
* 3. Retry with backoff. A failed flush retries up to 3 times with
|
|
23
|
+
* exponential backoff (250 ms / 750 ms / 2250 ms). Failure beyond
|
|
24
|
+
* that drops the batch; the next flush brings in the backlog from
|
|
25
|
+
* the local NDJSON via the resume path.
|
|
26
|
+
*
|
|
27
|
+
* 4. Resume marker. The client tracks `lastSyncedSeq` per session in
|
|
28
|
+
* `~/.pugi/memory-sync/<sessionId>.json`. On `flushBacklog()` it
|
|
29
|
+
* reads any events from the local NDJSON whose seq > lastSyncedSeq
|
|
30
|
+
* and posts them in order. The marker is updated only on a
|
|
31
|
+
* successful POST so a crash mid-flush retries the unsent batch.
|
|
32
|
+
*
|
|
33
|
+
* 5. Kill switch. `PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED=false`
|
|
34
|
+
* shuts the dual-write off entirely (env var, no settings file
|
|
35
|
+
* round-trip). The client is also a no-op when no credentials are
|
|
36
|
+
* available — anon CLI sessions never POST.
|
|
37
|
+
*
|
|
38
|
+
* 6. Schema contract. The kinds mirror
|
|
39
|
+
* `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
|
|
40
|
+
* PUGI_SESSION_EVENT_KINDS. The CLI's broader NDJSON kind set
|
|
41
|
+
* (`user`, `persona`, `rewind-marker`, etc.) is REMAPPED to the
|
|
42
|
+
* Phase-1 closed set inside `eventToPhase1()` — adding a new
|
|
43
|
+
* Phase-1 kind requires a coordinated CLI + server bump.
|
|
44
|
+
*
|
|
45
|
+
* The client carries no Nest / Prisma deps — pure node:fs + global
|
|
46
|
+
* fetch so it boots inside the CLI's REPL workflow without dragging in
|
|
47
|
+
* the admin-api graph.
|
|
48
|
+
*/
|
|
49
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
50
|
+
import { homedir } from 'node:os';
|
|
51
|
+
import { resolve } from 'node:path';
|
|
52
|
+
import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
|
|
53
|
+
/* ------------------------------------------------------------------ */
|
|
54
|
+
/* Constants */
|
|
55
|
+
/* ------------------------------------------------------------------ */
|
|
56
|
+
export const DEFAULT_DEBOUNCE_MS = 250;
|
|
57
|
+
export const DEFAULT_MAX_BATCH_SIZE = 50;
|
|
58
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
59
|
+
/** Server-side hard ceiling (mirror of admin-api MAX_BATCH_EVENTS). */
|
|
60
|
+
const SERVER_MAX_BATCH_EVENTS = 200;
|
|
61
|
+
/** Env kill-switch. Unset / 'true' / '1' = enabled. 'false' / '0' = off. */
|
|
62
|
+
export function isDualWriteEnabledFromEnv(env = process.env) {
|
|
63
|
+
const raw = env.PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED;
|
|
64
|
+
if (raw === undefined || raw === '')
|
|
65
|
+
return true;
|
|
66
|
+
const v = raw.toLowerCase().trim();
|
|
67
|
+
return v !== 'false' && v !== '0' && v !== 'off' && v !== 'no';
|
|
68
|
+
}
|
|
69
|
+
/** Env override for debounce (ms). Falls back to DEFAULT_DEBOUNCE_MS. */
|
|
70
|
+
export function debounceMsFromEnv(env = process.env) {
|
|
71
|
+
const raw = env.PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS;
|
|
72
|
+
if (!raw)
|
|
73
|
+
return DEFAULT_DEBOUNCE_MS;
|
|
74
|
+
const n = Number.parseInt(raw, 10);
|
|
75
|
+
if (!Number.isFinite(n) || n < 0 || n > 60_000)
|
|
76
|
+
return DEFAULT_DEBOUNCE_MS;
|
|
77
|
+
return n;
|
|
78
|
+
}
|
|
79
|
+
export function defaultStateDir(home = homedir()) {
|
|
80
|
+
return resolve(home, '.pugi', 'memory-sync');
|
|
81
|
+
}
|
|
82
|
+
export function syncStatePath(sessionId, stateDir) {
|
|
83
|
+
return resolve(stateDir, `${sessionId}.json`);
|
|
84
|
+
}
|
|
85
|
+
export function readSyncState(sessionId, stateDir = defaultStateDir()) {
|
|
86
|
+
const p = syncStatePath(sessionId, stateDir);
|
|
87
|
+
if (!existsSync(p))
|
|
88
|
+
return 0;
|
|
89
|
+
try {
|
|
90
|
+
const raw = readFileSync(p, 'utf-8');
|
|
91
|
+
const parsed = JSON.parse(raw);
|
|
92
|
+
if (parsed.schema === 1 &&
|
|
93
|
+
typeof parsed.lastSyncedSeq === 'number' &&
|
|
94
|
+
Number.isInteger(parsed.lastSyncedSeq) &&
|
|
95
|
+
parsed.lastSyncedSeq >= 0) {
|
|
96
|
+
return parsed.lastSyncedSeq;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Best effort — a corrupt state file means "resync from zero".
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
export function writeSyncState(sessionId, lastSyncedSeq, stateDir = defaultStateDir()) {
|
|
106
|
+
if (!existsSync(stateDir)) {
|
|
107
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
}
|
|
109
|
+
const p = syncStatePath(sessionId, stateDir);
|
|
110
|
+
const record = {
|
|
111
|
+
schema: 1,
|
|
112
|
+
sessionId,
|
|
113
|
+
lastSyncedSeq,
|
|
114
|
+
updatedAt: new Date().toISOString(),
|
|
115
|
+
};
|
|
116
|
+
// Atomic write: temp + rename so a crash mid-write leaves either the
|
|
117
|
+
// old contents or the new — never a partial JSON document.
|
|
118
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
|
|
119
|
+
writeFileSync(tmp, JSON.stringify(record), { encoding: 'utf-8', mode: 0o600 });
|
|
120
|
+
renameSync(tmp, p);
|
|
121
|
+
}
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
/* DualWriteClient */
|
|
124
|
+
/* ------------------------------------------------------------------ */
|
|
125
|
+
/**
|
|
126
|
+
* Compute the next backoff delay (ms). Exponential growth from the
|
|
127
|
+
* debounce base — 1× / 3× / 9× — keeps the retry storm bounded.
|
|
128
|
+
*/
|
|
129
|
+
export function nextBackoffMs(attempt, base) {
|
|
130
|
+
// attempt 1 -> base, attempt 2 -> 3×base, attempt 3 -> 9×base.
|
|
131
|
+
return base * Math.pow(3, Math.max(0, attempt - 1));
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Tiny await-able sleeper. Public for tests that want a deterministic
|
|
135
|
+
* scheduler — production callers go through the internal debounce
|
|
136
|
+
* timer, not this helper.
|
|
137
|
+
*/
|
|
138
|
+
function sleep(ms, scheduler = setTimeout) {
|
|
139
|
+
return new Promise((res) => scheduler(res, Math.max(0, ms)));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Buffered, debounced, retry-capable dual-write client.
|
|
143
|
+
*
|
|
144
|
+
* One instance per active session. The CLI's SessionStore creates one
|
|
145
|
+
* at `openSession()` and disposes it at `archive()` / `process.exit()`.
|
|
146
|
+
* The instance retains its own buffer + flushing promise so concurrent
|
|
147
|
+
* `enqueue()` calls from multiple producers (REPL turn + subagent
|
|
148
|
+
* dispatcher) coalesce into the same batch.
|
|
149
|
+
*/
|
|
150
|
+
export class DualWriteClient {
|
|
151
|
+
cfg;
|
|
152
|
+
buffer = [];
|
|
153
|
+
flushTimer = null;
|
|
154
|
+
/**
|
|
155
|
+
* Active flush promise (null when idle). Re-using the in-flight
|
|
156
|
+
* promise lets `enqueue` callers tail-chain a pending flush instead
|
|
157
|
+
* of stacking N flushes when the CLI is in a hot loop.
|
|
158
|
+
*/
|
|
159
|
+
flushing = null;
|
|
160
|
+
/** Disposed clients reject further enqueues quietly. */
|
|
161
|
+
disposed = false;
|
|
162
|
+
/** Track the highest server-acknowledged seq. */
|
|
163
|
+
highestSyncedSeq;
|
|
164
|
+
constructor(config) {
|
|
165
|
+
const stateDir = config.stateDir ?? defaultStateDir();
|
|
166
|
+
this.cfg = {
|
|
167
|
+
apiUrl: config.apiUrl.replace(/\/+$/, ''),
|
|
168
|
+
apiKey: config.apiKey,
|
|
169
|
+
sessionId: config.sessionId,
|
|
170
|
+
debounceMs: config.debounceMs ?? debounceMsFromEnv(),
|
|
171
|
+
maxBatchSize: Math.min(config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE, SERVER_MAX_BATCH_EVENTS),
|
|
172
|
+
maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
|
|
173
|
+
fetchImpl: config.fetchImpl ?? globalThis.fetch,
|
|
174
|
+
stateDir,
|
|
175
|
+
};
|
|
176
|
+
this.highestSyncedSeq = readSyncState(config.sessionId, stateDir);
|
|
177
|
+
}
|
|
178
|
+
/** Currently-known synced seq (read-through for callers). */
|
|
179
|
+
get lastSyncedSeq() {
|
|
180
|
+
return this.highestSyncedSeq;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Buffer an event for the next flush. Returns immediately — the
|
|
184
|
+
* actual POST happens on the debounce timer.
|
|
185
|
+
*
|
|
186
|
+
* The producer (SessionStore) is expected to have ALREADY written
|
|
187
|
+
* the local NDJSON line before calling this. The dual-write client
|
|
188
|
+
* never touches the NDJSON itself.
|
|
189
|
+
*/
|
|
190
|
+
enqueue(event) {
|
|
191
|
+
if (this.disposed)
|
|
192
|
+
return;
|
|
193
|
+
if (!isValidPhase1Kind(event.kind)) {
|
|
194
|
+
// Quietly ignore events the Phase-1 schema cannot represent. The
|
|
195
|
+
// local NDJSON keeps them; a future Phase-1.1 may widen the kind
|
|
196
|
+
// set.
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!Number.isInteger(event.seq) || event.seq <= 0)
|
|
200
|
+
return;
|
|
201
|
+
this.buffer.push(event);
|
|
202
|
+
// Crossed the batch cap — flush eagerly to bound buffer growth.
|
|
203
|
+
if (this.buffer.length >= this.cfg.maxBatchSize) {
|
|
204
|
+
this.scheduleFlush(0);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.scheduleFlush(this.cfg.debounceMs);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Force any pending events out. Used at session archive / shutdown
|
|
211
|
+
* so the trailing batch lands before the process exits.
|
|
212
|
+
*/
|
|
213
|
+
async flush() {
|
|
214
|
+
if (this.flushTimer) {
|
|
215
|
+
clearTimeout(this.flushTimer);
|
|
216
|
+
this.flushTimer = null;
|
|
217
|
+
}
|
|
218
|
+
return this.doFlush();
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Mark the client disposed. Subsequent enqueues are no-ops. Pending
|
|
222
|
+
* flush is awaited so the caller can `await client.dispose()` to
|
|
223
|
+
* fence completion.
|
|
224
|
+
*/
|
|
225
|
+
async dispose() {
|
|
226
|
+
this.disposed = true;
|
|
227
|
+
if (this.flushTimer) {
|
|
228
|
+
clearTimeout(this.flushTimer);
|
|
229
|
+
this.flushTimer = null;
|
|
230
|
+
}
|
|
231
|
+
if (this.flushing) {
|
|
232
|
+
try {
|
|
233
|
+
await this.flushing;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// ignore
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Backlog catch-up. The caller passes the local NDJSON's events
|
|
242
|
+
* (already filtered to the session) — the client posts any whose
|
|
243
|
+
* seq > lastSyncedSeq, in order, in batches of maxBatchSize.
|
|
244
|
+
*
|
|
245
|
+
* Returns the aggregate FlushResult across all batches (sums
|
|
246
|
+
* persisted + duplicate counts, lastSeq is the max).
|
|
247
|
+
*/
|
|
248
|
+
async flushBacklog(allEvents) {
|
|
249
|
+
const pending = allEvents
|
|
250
|
+
.filter((e) => e.seq > this.highestSyncedSeq && isValidPhase1Kind(e.kind))
|
|
251
|
+
.sort((a, b) => a.seq - b.seq);
|
|
252
|
+
if (pending.length === 0) {
|
|
253
|
+
return {
|
|
254
|
+
persistedCount: 0,
|
|
255
|
+
duplicateCount: 0,
|
|
256
|
+
lastSeq: this.highestSyncedSeq,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
let totalPersisted = 0;
|
|
260
|
+
let totalDuplicates = 0;
|
|
261
|
+
let maxLastSeq = this.highestSyncedSeq;
|
|
262
|
+
for (let i = 0; i < pending.length; i += this.cfg.maxBatchSize) {
|
|
263
|
+
const batch = pending.slice(i, i + this.cfg.maxBatchSize);
|
|
264
|
+
const result = await this.postBatchWithRetry(batch);
|
|
265
|
+
if (!result)
|
|
266
|
+
break; // surrender — backlog stays for next session
|
|
267
|
+
totalPersisted += result.persistedCount;
|
|
268
|
+
totalDuplicates += result.duplicateCount;
|
|
269
|
+
maxLastSeq = Math.max(maxLastSeq, result.lastSeq);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
persistedCount: totalPersisted,
|
|
273
|
+
duplicateCount: totalDuplicates,
|
|
274
|
+
lastSeq: maxLastSeq,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/* ---------------- internal ---------------- */
|
|
278
|
+
scheduleFlush(delay) {
|
|
279
|
+
if (this.flushTimer)
|
|
280
|
+
clearTimeout(this.flushTimer);
|
|
281
|
+
this.flushTimer = setTimeout(() => {
|
|
282
|
+
this.flushTimer = null;
|
|
283
|
+
// doFlush handles its own error swallowing; we drop the floating
|
|
284
|
+
// promise on the floor on purpose so the timer callback returns
|
|
285
|
+
// synchronously (Node requires that).
|
|
286
|
+
void this.doFlush();
|
|
287
|
+
}, delay);
|
|
288
|
+
}
|
|
289
|
+
async doFlush() {
|
|
290
|
+
if (this.flushing)
|
|
291
|
+
return this.flushing;
|
|
292
|
+
if (this.buffer.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
const drained = this.buffer.splice(0, this.buffer.length);
|
|
295
|
+
this.flushing = this.postBatchWithRetry(drained);
|
|
296
|
+
try {
|
|
297
|
+
return await this.flushing;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
this.flushing = null;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async postBatchWithRetry(batch) {
|
|
304
|
+
if (batch.length === 0)
|
|
305
|
+
return null;
|
|
306
|
+
// Sort + within-batch dedup (defence in depth — the server also
|
|
307
|
+
// enforces monotonic seq + dedup, but enforcing here keeps the
|
|
308
|
+
// wire payload clean and avoids the 400 on a buggy producer).
|
|
309
|
+
const sorted = [...batch].sort((a, b) => a.seq - b.seq);
|
|
310
|
+
const deduped = [];
|
|
311
|
+
let prevSeq = -1;
|
|
312
|
+
for (const ev of sorted) {
|
|
313
|
+
if (ev.seq === prevSeq)
|
|
314
|
+
continue;
|
|
315
|
+
deduped.push(ev);
|
|
316
|
+
prevSeq = ev.seq;
|
|
317
|
+
}
|
|
318
|
+
for (let attempt = 1; attempt <= this.cfg.maxRetries; attempt++) {
|
|
319
|
+
try {
|
|
320
|
+
const result = await this.doPost(deduped);
|
|
321
|
+
// Persist the lastSyncedSeq marker so a crash before the next
|
|
322
|
+
// flush still rediscovers the high-water-mark on restart.
|
|
323
|
+
if (result.lastSeq > this.highestSyncedSeq) {
|
|
324
|
+
this.highestSyncedSeq = result.lastSeq;
|
|
325
|
+
try {
|
|
326
|
+
writeSyncState(this.cfg.sessionId, this.highestSyncedSeq, this.cfg.stateDir);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Best effort — a marker write failure does not invalidate
|
|
330
|
+
// the on-server data, only the resume hint.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (attempt === this.cfg.maxRetries) {
|
|
337
|
+
// Final surrender — drop the batch silently. Local NDJSON is
|
|
338
|
+
// authoritative; the next session resume can replay the gap.
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
await sleep(nextBackoffMs(attempt, this.cfg.debounceMs));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
async doPost(events) {
|
|
347
|
+
const url = `${this.cfg.apiUrl}/api/pugi/sessions/${encodeURIComponent(this.cfg.sessionId)}/events`;
|
|
348
|
+
const res = await this.cfg.fetchImpl(url, {
|
|
349
|
+
method: 'POST',
|
|
350
|
+
headers: {
|
|
351
|
+
authorization: `Bearer ${this.cfg.apiKey}`,
|
|
352
|
+
'content-type': 'application/json',
|
|
353
|
+
accept: 'application/json',
|
|
354
|
+
},
|
|
355
|
+
body: JSON.stringify({ events }),
|
|
356
|
+
});
|
|
357
|
+
if (!res.ok) {
|
|
358
|
+
throw new Error(`dual-write POST ${url} returned ${res.status} ${res.statusText}`);
|
|
359
|
+
}
|
|
360
|
+
const body = (await res.json());
|
|
361
|
+
return {
|
|
362
|
+
persistedCount: body.persistedCount ?? 0,
|
|
363
|
+
duplicateCount: body.duplicateCount ?? 0,
|
|
364
|
+
lastSeq: body.lastSeq ?? 0,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Validate a kind string against the Phase-1 closed set. */
|
|
369
|
+
export function isValidPhase1Kind(value) {
|
|
370
|
+
return PUGI_SESSION_EVENT_KINDS.includes(value);
|
|
371
|
+
}
|
|
372
|
+
/* ------------------------------------------------------------------ */
|
|
373
|
+
/* Kind remap (CLI broad set -> Phase-1 closed set) */
|
|
374
|
+
/* ------------------------------------------------------------------ */
|
|
375
|
+
/**
|
|
376
|
+
* Map the CLI's broader NDJSON kind set to the Phase-1 server-side set.
|
|
377
|
+
* Returns `null` when the kind has no Phase-1 representation — the
|
|
378
|
+
* caller should drop that event from the dual-write (the local NDJSON
|
|
379
|
+
* still carries it for offline analysis).
|
|
380
|
+
*
|
|
381
|
+
* The CLI's kind set lives in
|
|
382
|
+
* `apps/pugi-cli/src/core/repl/store/types.ts`:
|
|
383
|
+
*
|
|
384
|
+
* 'user' -> 'turn.user'
|
|
385
|
+
* 'persona' -> 'turn.assistant'
|
|
386
|
+
* 'system' -> 'system'
|
|
387
|
+
* 'tool.start' -> 'tool.call'
|
|
388
|
+
* 'tool.result' -> 'tool.result'
|
|
389
|
+
* 'agent.spawned' -> 'dispatch.start'
|
|
390
|
+
* 'agent.completed'-> 'dispatch.end'
|
|
391
|
+
* 'compaction' -> 'compact.boundary'
|
|
392
|
+
* 'rewind-marker' -> null (Phase-1 has no analog yet)
|
|
393
|
+
*/
|
|
394
|
+
export function cliKindToPhase1(cliKind) {
|
|
395
|
+
switch (cliKind) {
|
|
396
|
+
case 'user':
|
|
397
|
+
return 'turn.user';
|
|
398
|
+
case 'persona':
|
|
399
|
+
return 'turn.assistant';
|
|
400
|
+
case 'system':
|
|
401
|
+
return 'system';
|
|
402
|
+
case 'tool.start':
|
|
403
|
+
return 'tool.call';
|
|
404
|
+
case 'tool.result':
|
|
405
|
+
return 'tool.result';
|
|
406
|
+
case 'agent.spawned':
|
|
407
|
+
return 'dispatch.start';
|
|
408
|
+
case 'agent.completed':
|
|
409
|
+
return 'dispatch.end';
|
|
410
|
+
case 'compaction':
|
|
411
|
+
return 'compact.boundary';
|
|
412
|
+
default:
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
//# sourceMappingURL=dual-write.js.map
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi CLI dual-write client spec — Memory Phase 1 (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. happy path — single batch posts to /api/pugi/sessions/:id/events
|
|
6
|
+
* 2. debounce + batch coalesces multiple enqueues into one POST
|
|
7
|
+
* 3. retry on transient failure with exponential backoff
|
|
8
|
+
* 4. surrender after maxRetries — local NDJSON stays authoritative
|
|
9
|
+
* 5. lastSyncedSeq marker is written + persisted across instances
|
|
10
|
+
* 6. flushBacklog catches up rows with seq > lastSyncedSeq
|
|
11
|
+
* 7. env kill-switch turns dual-write off
|
|
12
|
+
* 8. cliKindToPhase1 remap covers every CLI kind
|
|
13
|
+
* 9. invalid Phase-1 kinds quietly dropped from enqueue
|
|
14
|
+
* 10. dispose awaits the pending flush
|
|
15
|
+
*/
|
|
16
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { DualWriteClient, cliKindToPhase1, debounceMsFromEnv, isDualWriteEnabledFromEnv, isValidPhase1Kind, nextBackoffMs, readSyncState, writeSyncState, } from './dual-write.js';
|
|
22
|
+
import { PUGI_SESSION_EVENT_KINDS, } from './phase1-kinds.js';
|
|
23
|
+
let tmpDir = '';
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = mkdtempSync(resolve(tmpdir(), 'pugi-dual-write-'));
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
try {
|
|
29
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
function makeStubFetch(responses) {
|
|
36
|
+
const calls = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
const fetchImpl = async (input, init) => {
|
|
39
|
+
const url = typeof input === 'string' ? input : input.toString();
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (init?.headers) {
|
|
42
|
+
const h = init.headers;
|
|
43
|
+
for (const k of Object.keys(h))
|
|
44
|
+
headers[k.toLowerCase()] = h[k];
|
|
45
|
+
}
|
|
46
|
+
const bodyRaw = typeof init?.body === 'string' ? init.body : '';
|
|
47
|
+
let body = null;
|
|
48
|
+
try {
|
|
49
|
+
body = bodyRaw ? JSON.parse(bodyRaw) : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
body = bodyRaw;
|
|
53
|
+
}
|
|
54
|
+
calls.push({ url, body, headers });
|
|
55
|
+
const spec = responses[i++] ?? responses[responses.length - 1] ?? { ok: true };
|
|
56
|
+
if (spec.throw)
|
|
57
|
+
throw spec.throw;
|
|
58
|
+
return {
|
|
59
|
+
ok: spec.ok,
|
|
60
|
+
status: spec.status ?? (spec.ok ? 200 : 500),
|
|
61
|
+
statusText: spec.ok ? 'OK' : 'Internal Server Error',
|
|
62
|
+
json: async () => spec.body ?? {
|
|
63
|
+
persistedCount: 0,
|
|
64
|
+
duplicateCount: 0,
|
|
65
|
+
lastSeq: 0,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
return { fetchImpl, calls };
|
|
70
|
+
}
|
|
71
|
+
/* --------------------------------------------------------------- */
|
|
72
|
+
/* Pure helpers */
|
|
73
|
+
/* --------------------------------------------------------------- */
|
|
74
|
+
describe('dual-write: env helpers', () => {
|
|
75
|
+
it('isDualWriteEnabledFromEnv: defaults to ON', () => {
|
|
76
|
+
assert.equal(isDualWriteEnabledFromEnv({}), true);
|
|
77
|
+
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'true' }), true);
|
|
78
|
+
});
|
|
79
|
+
it('isDualWriteEnabledFromEnv: OFF on common false-ish values', () => {
|
|
80
|
+
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'false' }), false);
|
|
81
|
+
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: '0' }), false);
|
|
82
|
+
assert.equal(isDualWriteEnabledFromEnv({ PUGI_MEMORY_PHASE1_PRISMA_PERSIST_ENABLED: 'off' }), false);
|
|
83
|
+
});
|
|
84
|
+
it('debounceMsFromEnv: parses + clamps + falls back', () => {
|
|
85
|
+
assert.equal(debounceMsFromEnv({}), 250);
|
|
86
|
+
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '500' }), 500);
|
|
87
|
+
// Bogus -> default.
|
|
88
|
+
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: 'abc' }), 250);
|
|
89
|
+
// Negative -> default.
|
|
90
|
+
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '-5' }), 250);
|
|
91
|
+
// Too large -> default.
|
|
92
|
+
assert.equal(debounceMsFromEnv({ PUGI_MEMORY_DUAL_WRITE_DEBOUNCE_MS: '120000' }), 250);
|
|
93
|
+
});
|
|
94
|
+
it('nextBackoffMs: 1x / 3x / 9x growth', () => {
|
|
95
|
+
assert.equal(nextBackoffMs(1, 100), 100);
|
|
96
|
+
assert.equal(nextBackoffMs(2, 100), 300);
|
|
97
|
+
assert.equal(nextBackoffMs(3, 100), 900);
|
|
98
|
+
});
|
|
99
|
+
it('cliKindToPhase1: covers every CLI kind we know about', () => {
|
|
100
|
+
assert.equal(cliKindToPhase1('user'), 'turn.user');
|
|
101
|
+
assert.equal(cliKindToPhase1('persona'), 'turn.assistant');
|
|
102
|
+
assert.equal(cliKindToPhase1('system'), 'system');
|
|
103
|
+
assert.equal(cliKindToPhase1('tool.start'), 'tool.call');
|
|
104
|
+
assert.equal(cliKindToPhase1('tool.result'), 'tool.result');
|
|
105
|
+
assert.equal(cliKindToPhase1('agent.spawned'), 'dispatch.start');
|
|
106
|
+
assert.equal(cliKindToPhase1('agent.completed'), 'dispatch.end');
|
|
107
|
+
assert.equal(cliKindToPhase1('compaction'), 'compact.boundary');
|
|
108
|
+
// rewind-marker and any other -> null (the CLI keeps these in NDJSON only).
|
|
109
|
+
assert.equal(cliKindToPhase1('rewind-marker'), null);
|
|
110
|
+
assert.equal(cliKindToPhase1('totally-unknown'), null);
|
|
111
|
+
});
|
|
112
|
+
it('isValidPhase1Kind matches the closed set', () => {
|
|
113
|
+
for (const k of PUGI_SESSION_EVENT_KINDS) {
|
|
114
|
+
assert.equal(isValidPhase1Kind(k), true);
|
|
115
|
+
}
|
|
116
|
+
assert.equal(isValidPhase1Kind('user'), false); // CLI kind, not Phase-1
|
|
117
|
+
assert.equal(isValidPhase1Kind('nope'), false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
/* --------------------------------------------------------------- */
|
|
121
|
+
/* lastSyncedSeq marker */
|
|
122
|
+
/* --------------------------------------------------------------- */
|
|
123
|
+
describe('dual-write: sync state marker', () => {
|
|
124
|
+
it('reads 0 when no marker on disk', () => {
|
|
125
|
+
assert.equal(readSyncState('sess-1', tmpDir), 0);
|
|
126
|
+
});
|
|
127
|
+
it('writes + reads back monotonic seq', () => {
|
|
128
|
+
writeSyncState('sess-1', 42, tmpDir);
|
|
129
|
+
assert.equal(readSyncState('sess-1', tmpDir), 42);
|
|
130
|
+
writeSyncState('sess-1', 100, tmpDir);
|
|
131
|
+
assert.equal(readSyncState('sess-1', tmpDir), 100);
|
|
132
|
+
});
|
|
133
|
+
it('isolates per-session', () => {
|
|
134
|
+
writeSyncState('sess-a', 5, tmpDir);
|
|
135
|
+
writeSyncState('sess-b', 10, tmpDir);
|
|
136
|
+
assert.equal(readSyncState('sess-a', tmpDir), 5);
|
|
137
|
+
assert.equal(readSyncState('sess-b', tmpDir), 10);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
/* --------------------------------------------------------------- */
|
|
141
|
+
/* DualWriteClient end-to-end */
|
|
142
|
+
/* --------------------------------------------------------------- */
|
|
143
|
+
describe('DualWriteClient', () => {
|
|
144
|
+
it('flushes a single batch on demand', async () => {
|
|
145
|
+
const { fetchImpl, calls } = makeStubFetch([
|
|
146
|
+
{ ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 2 } },
|
|
147
|
+
]);
|
|
148
|
+
const client = new DualWriteClient({
|
|
149
|
+
apiUrl: 'http://localhost',
|
|
150
|
+
apiKey: 'test-key',
|
|
151
|
+
sessionId: 'sess-1',
|
|
152
|
+
fetchImpl,
|
|
153
|
+
debounceMs: 5,
|
|
154
|
+
stateDir: tmpDir,
|
|
155
|
+
});
|
|
156
|
+
client.enqueue({ seq: 1, kind: 'turn.user', payload: { text: 'hi' } });
|
|
157
|
+
client.enqueue({ seq: 2, kind: 'turn.assistant', payload: { text: 'hello' } });
|
|
158
|
+
const result = await client.flush();
|
|
159
|
+
await client.dispose();
|
|
160
|
+
assert.equal(calls.length, 1);
|
|
161
|
+
assert.equal(calls[0].headers['authorization'], 'Bearer test-key');
|
|
162
|
+
const sentBody = calls[0].body;
|
|
163
|
+
assert.equal(sentBody.events.length, 2);
|
|
164
|
+
assert.equal(sentBody.events[0].seq, 1);
|
|
165
|
+
assert.equal(sentBody.events[1].kind, 'turn.assistant');
|
|
166
|
+
assert.equal(result?.persistedCount, 2);
|
|
167
|
+
assert.equal(result?.lastSeq, 2);
|
|
168
|
+
// Marker persisted.
|
|
169
|
+
assert.equal(readSyncState('sess-1', tmpDir), 2);
|
|
170
|
+
});
|
|
171
|
+
it('retries on transient failure with exponential backoff', async () => {
|
|
172
|
+
const { fetchImpl, calls } = makeStubFetch([
|
|
173
|
+
{ ok: false, status: 503, body: {} },
|
|
174
|
+
{ ok: false, status: 503, body: {} },
|
|
175
|
+
{ ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 5 } },
|
|
176
|
+
]);
|
|
177
|
+
const client = new DualWriteClient({
|
|
178
|
+
apiUrl: 'http://localhost',
|
|
179
|
+
apiKey: 'k',
|
|
180
|
+
sessionId: 'sess-r',
|
|
181
|
+
fetchImpl,
|
|
182
|
+
debounceMs: 1, // base for backoff -> 1ms / 3ms / 9ms; fast test
|
|
183
|
+
maxRetries: 3,
|
|
184
|
+
stateDir: tmpDir,
|
|
185
|
+
});
|
|
186
|
+
client.enqueue({ seq: 5, kind: 'turn.user', payload: {} });
|
|
187
|
+
const result = await client.flush();
|
|
188
|
+
await client.dispose();
|
|
189
|
+
assert.equal(calls.length, 3);
|
|
190
|
+
assert.equal(result?.lastSeq, 5);
|
|
191
|
+
assert.equal(readSyncState('sess-r', tmpDir), 5);
|
|
192
|
+
});
|
|
193
|
+
it('surrenders after maxRetries — local NDJSON stays the truth', async () => {
|
|
194
|
+
const { fetchImpl, calls } = makeStubFetch([
|
|
195
|
+
{ ok: false, status: 500, body: {} },
|
|
196
|
+
{ ok: false, status: 500, body: {} },
|
|
197
|
+
{ ok: false, status: 500, body: {} },
|
|
198
|
+
]);
|
|
199
|
+
const client = new DualWriteClient({
|
|
200
|
+
apiUrl: 'http://localhost',
|
|
201
|
+
apiKey: 'k',
|
|
202
|
+
sessionId: 'sess-fail',
|
|
203
|
+
fetchImpl,
|
|
204
|
+
debounceMs: 1,
|
|
205
|
+
maxRetries: 3,
|
|
206
|
+
stateDir: tmpDir,
|
|
207
|
+
});
|
|
208
|
+
client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
|
|
209
|
+
const result = await client.flush();
|
|
210
|
+
await client.dispose();
|
|
211
|
+
assert.equal(calls.length, 3);
|
|
212
|
+
assert.equal(result, null); // surrender
|
|
213
|
+
// Marker NOT written — next session retries from zero.
|
|
214
|
+
assert.equal(readSyncState('sess-fail', tmpDir), 0);
|
|
215
|
+
});
|
|
216
|
+
it('drops events whose kind is not in the Phase-1 set', async () => {
|
|
217
|
+
const { fetchImpl, calls } = makeStubFetch([
|
|
218
|
+
{ ok: true, body: { persistedCount: 1, duplicateCount: 0, lastSeq: 1 } },
|
|
219
|
+
]);
|
|
220
|
+
const client = new DualWriteClient({
|
|
221
|
+
apiUrl: 'http://localhost',
|
|
222
|
+
apiKey: 'k',
|
|
223
|
+
sessionId: 'sess-d',
|
|
224
|
+
fetchImpl,
|
|
225
|
+
debounceMs: 5,
|
|
226
|
+
stateDir: tmpDir,
|
|
227
|
+
});
|
|
228
|
+
// The string is broader than Phase-1 — the client filters silently.
|
|
229
|
+
client.enqueue({ seq: 1, kind: 'rewind-marker', payload: {} });
|
|
230
|
+
client.enqueue({ seq: 2, kind: 'turn.user', payload: {} });
|
|
231
|
+
await client.flush();
|
|
232
|
+
await client.dispose();
|
|
233
|
+
const sent = calls[0].body;
|
|
234
|
+
assert.equal(sent.events.length, 1);
|
|
235
|
+
assert.equal(sent.events[0].seq, 2);
|
|
236
|
+
});
|
|
237
|
+
it('flushBacklog skips events <= lastSyncedSeq and posts the rest', async () => {
|
|
238
|
+
const { fetchImpl, calls } = makeStubFetch([
|
|
239
|
+
{ ok: true, body: { persistedCount: 2, duplicateCount: 0, lastSeq: 10 } },
|
|
240
|
+
]);
|
|
241
|
+
// Seed a marker: last synced was seq=5.
|
|
242
|
+
writeSyncState('sess-bk', 5, tmpDir);
|
|
243
|
+
const client = new DualWriteClient({
|
|
244
|
+
apiUrl: 'http://localhost',
|
|
245
|
+
apiKey: 'k',
|
|
246
|
+
sessionId: 'sess-bk',
|
|
247
|
+
fetchImpl,
|
|
248
|
+
stateDir: tmpDir,
|
|
249
|
+
});
|
|
250
|
+
const allEvents = [
|
|
251
|
+
{ seq: 1, kind: 'turn.user', payload: {} }, // skip
|
|
252
|
+
{ seq: 5, kind: 'turn.assistant', payload: {} }, // skip (<=5)
|
|
253
|
+
{ seq: 7, kind: 'turn.user', payload: {} }, // POST
|
|
254
|
+
{ seq: 10, kind: 'turn.assistant', payload: {} }, // POST
|
|
255
|
+
];
|
|
256
|
+
const result = await client.flushBacklog(allEvents);
|
|
257
|
+
assert.equal(calls.length, 1);
|
|
258
|
+
const sent = calls[0].body;
|
|
259
|
+
assert.equal(sent.events.length, 2);
|
|
260
|
+
assert.equal(sent.events[0].seq, 7);
|
|
261
|
+
assert.equal(sent.events[1].seq, 10);
|
|
262
|
+
assert.equal(result.persistedCount, 2);
|
|
263
|
+
assert.equal(result.lastSeq, 10);
|
|
264
|
+
assert.equal(readSyncState('sess-bk', tmpDir), 10);
|
|
265
|
+
await client.dispose();
|
|
266
|
+
});
|
|
267
|
+
it('dispose awaits the in-flight flush', async () => {
|
|
268
|
+
// Use a fetch that resolves after a tiny delay so the dispose has to
|
|
269
|
+
// wait.
|
|
270
|
+
let resolved = false;
|
|
271
|
+
const fetchImpl = async () => {
|
|
272
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
273
|
+
resolved = true;
|
|
274
|
+
return {
|
|
275
|
+
ok: true,
|
|
276
|
+
status: 200,
|
|
277
|
+
statusText: 'OK',
|
|
278
|
+
json: async () => ({ persistedCount: 1, duplicateCount: 0, lastSeq: 1 }),
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
const client = new DualWriteClient({
|
|
282
|
+
apiUrl: 'http://localhost',
|
|
283
|
+
apiKey: 'k',
|
|
284
|
+
sessionId: 'sess-disp',
|
|
285
|
+
fetchImpl,
|
|
286
|
+
debounceMs: 1,
|
|
287
|
+
stateDir: tmpDir,
|
|
288
|
+
});
|
|
289
|
+
client.enqueue({ seq: 1, kind: 'turn.user', payload: {} });
|
|
290
|
+
// Schedule a flush + immediately dispose; dispose must wait.
|
|
291
|
+
const flushPromise = client.flush();
|
|
292
|
+
await client.dispose();
|
|
293
|
+
await flushPromise;
|
|
294
|
+
assert.equal(resolved, true);
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
//# sourceMappingURL=dual-write.spec.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-1 event kind closed set — mirror of
|
|
3
|
+
* `apps/admin-api/src/pugi-session-events/pugi-session-events.types.ts`
|
|
4
|
+
* PUGI_SESSION_EVENT_KINDS.
|
|
5
|
+
*
|
|
6
|
+
* Kept as a separate file so the CLI does not import from admin-api
|
|
7
|
+
* (would drag the whole Nest graph). The TWO lists must stay in
|
|
8
|
+
* lockstep — a new kind requires editing both files.
|
|
9
|
+
*/
|
|
10
|
+
export const PUGI_SESSION_EVENT_KINDS = [
|
|
11
|
+
'turn.user',
|
|
12
|
+
'turn.assistant',
|
|
13
|
+
'tool.call',
|
|
14
|
+
'tool.result',
|
|
15
|
+
'dispatch.start',
|
|
16
|
+
'dispatch.end',
|
|
17
|
+
'compact.boundary',
|
|
18
|
+
'system',
|
|
19
|
+
];
|
|
20
|
+
//# sourceMappingURL=phase1-kinds.js.map
|
|
@@ -580,6 +580,73 @@ export class ReplSession {
|
|
|
580
580
|
getDispatchState() {
|
|
581
581
|
return this.fsm.current;
|
|
582
582
|
}
|
|
583
|
+
/**
|
|
584
|
+
* Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Trim the last
|
|
585
|
+
* operator/persona turn pair from the in-memory transcript so the
|
|
586
|
+
* model's next call sees the conversation as if the most recent
|
|
587
|
+
* turn never happened. The local SessionStore still has the events
|
|
588
|
+
* on disk (append-only); the in-memory mask is advisory and the next
|
|
589
|
+
* `/compact` boundary will fold them naturally.
|
|
590
|
+
*
|
|
591
|
+
* Refusal modes:
|
|
592
|
+
* - `'no-turn'` - transcript has no operator/persona row to pop.
|
|
593
|
+
* - `'in-flight'` - dispatch is mid-flight; popping would race with
|
|
594
|
+
* the streaming persona row. The operator must
|
|
595
|
+
* cancel (Ctrl+C) before walking back.
|
|
596
|
+
*
|
|
597
|
+
* Success mode:
|
|
598
|
+
* - `'walked-back'` - the trailing persona row + the operator row
|
|
599
|
+
* that triggered it are gone from the transcript.
|
|
600
|
+
* A `↩ walked back 1 turn` status row is appended
|
|
601
|
+
* so the operator sees the state change without
|
|
602
|
+
* guessing.
|
|
603
|
+
*
|
|
604
|
+
* The mask is in-memory only on purpose. Disk-side rewind already has
|
|
605
|
+
* a separate first-class command (`/rewind`) with checkpoint
|
|
606
|
+
* semantics — the Esc-Esc shortcut is a one-tap "oops, undo that" for
|
|
607
|
+
* the live transcript, NOT a transactional rollback.
|
|
608
|
+
*/
|
|
609
|
+
walkbackLastTurn() {
|
|
610
|
+
// Refuse while a dispatch is running. Popping the operator row that
|
|
611
|
+
// is currently driving the model's response would leave the persona
|
|
612
|
+
// line orphaned on the next streamed chunk; the FSM also lacks a
|
|
613
|
+
// clean teardown path here. The operator gets a one-line refusal
|
|
614
|
+
// and can Ctrl+C first if they really want to walk back.
|
|
615
|
+
const current = this.fsm.current;
|
|
616
|
+
if (current !== 'idle' && current !== 'completed'
|
|
617
|
+
&& current !== 'aborted' && current !== 'failed') {
|
|
618
|
+
this.appendSystemLine('Walkback refused: dispatch in flight. Cancel with Ctrl+C, then Esc-Esc again.');
|
|
619
|
+
return 'in-flight';
|
|
620
|
+
}
|
|
621
|
+
// Find the trailing operator row. Walking backwards because the
|
|
622
|
+
// transcript is append-only and the most recent operator turn is
|
|
623
|
+
// by definition the last `source === 'operator'` row.
|
|
624
|
+
const transcript = this.state.transcript;
|
|
625
|
+
let operatorIdx = -1;
|
|
626
|
+
for (let i = transcript.length - 1; i >= 0; i -= 1) {
|
|
627
|
+
const row = transcript[i];
|
|
628
|
+
if (row.source === 'operator') {
|
|
629
|
+
operatorIdx = i;
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (operatorIdx === -1) {
|
|
634
|
+
// No operator turn to pop. Quiet refusal — surfacing a "nothing
|
|
635
|
+
// to undo" line on every accidental double-Esc would be noisy.
|
|
636
|
+
return 'no-turn';
|
|
637
|
+
}
|
|
638
|
+
// Trim everything from the operator row onward (its echo + any
|
|
639
|
+
// persona/system rows that landed in response). The slice keeps
|
|
640
|
+
// every row BEFORE the operator turn, which is the conversation
|
|
641
|
+
// exactly as it stood right before the operator pressed Enter.
|
|
642
|
+
const trimmed = transcript.slice(0, operatorIdx);
|
|
643
|
+
this.patch({ transcript: trimmed });
|
|
644
|
+
// Status row so the operator sees the state change without
|
|
645
|
+
// guessing. Brand voice: single ASCII line, return-arrow glyph
|
|
646
|
+
// (U+21A9) which renders across every modern terminal.
|
|
647
|
+
this.appendSystemLine('↩ walked back 1 turn');
|
|
648
|
+
return 'walked-back';
|
|
649
|
+
}
|
|
583
650
|
/**
|
|
584
651
|
* Current cancellation token. Returned for the tool execution path
|
|
585
652
|
* (file-tools.ts) so it can pass the token down into a ToolContext
|
|
@@ -1089,7 +1156,38 @@ export class ReplSession {
|
|
|
1089
1156
|
// single-sourced. The session module owns the in-memory
|
|
1090
1157
|
// transcript echo (system line + banner row) so the operator
|
|
1091
1158
|
// sees the marker land without a fresh REPL bootstrap.
|
|
1092
|
-
|
|
1159
|
+
//
|
|
1160
|
+
// Wave 6 BT 8 (Claude Code parity): `--force` bypasses the
|
|
1161
|
+
// noop-empty guard so the operator can compact even short
|
|
1162
|
+
// sessions (useful before a manual checkpoint).
|
|
1163
|
+
await this.dispatchCompact('manual', { force: verdict.force });
|
|
1164
|
+
return verdict;
|
|
1165
|
+
}
|
|
1166
|
+
case 'model': {
|
|
1167
|
+
// Wave 6 BT 8 (Claude Code parity): /model lists OR selects the
|
|
1168
|
+
// active model. Slash + top-level CLI share `runModelCommand`.
|
|
1169
|
+
// The session module forwards writeOutput → appendSystemLine so
|
|
1170
|
+
// the menu + the confirmation line land inline in the
|
|
1171
|
+
// transcript. Tier override is undefined at the slash surface;
|
|
1172
|
+
// the runner defaults to 'team' so unauthenticated operators
|
|
1173
|
+
// see every model. Server-side calls enforce the real tier cap.
|
|
1174
|
+
try {
|
|
1175
|
+
const { runModelCommand } = await import('../../runtime/commands/model.js');
|
|
1176
|
+
await runModelCommand({ slug: verdict.slug }, {
|
|
1177
|
+
workspaceRoot: process.cwd(),
|
|
1178
|
+
writeOutput: (line) => {
|
|
1179
|
+
const trimmed = line.replace(/\n+$/u, '');
|
|
1180
|
+
if (trimmed.length > 0)
|
|
1181
|
+
this.appendSystemLine(trimmed);
|
|
1182
|
+
else
|
|
1183
|
+
this.appendSystemLine('');
|
|
1184
|
+
},
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
catch (error) {
|
|
1188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1189
|
+
this.appendSystemLine(`/model failed: ${message}`);
|
|
1190
|
+
}
|
|
1093
1191
|
return verdict;
|
|
1094
1192
|
}
|
|
1095
1193
|
case 'rewind': {
|
|
@@ -1442,7 +1540,7 @@ export class ReplSession {
|
|
|
1442
1540
|
* `trigger='auto'` for the threshold gate. The runner records the
|
|
1443
1541
|
* trigger in the marker payload so the banner can distinguish them.
|
|
1444
1542
|
*/
|
|
1445
|
-
async dispatchCompact(trigger) {
|
|
1543
|
+
async dispatchCompact(trigger, options = {}) {
|
|
1446
1544
|
if (!this.store || !this.localSessionId) {
|
|
1447
1545
|
this.appendSystemLine('Local session store is disabled — /compact is unavailable.');
|
|
1448
1546
|
return;
|
|
@@ -1454,6 +1552,7 @@ export class ReplSession {
|
|
|
1454
1552
|
sessionId: this.localSessionId,
|
|
1455
1553
|
store: this.store,
|
|
1456
1554
|
trigger,
|
|
1555
|
+
force: options.force === true,
|
|
1457
1556
|
writeOutput: (_payload, text) => {
|
|
1458
1557
|
if (text.length > 0)
|
|
1459
1558
|
this.appendSystemLine(text);
|
|
@@ -65,7 +65,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
65
65
|
{ name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
|
|
66
66
|
{ name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
|
|
67
67
|
{ name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
|
|
68
|
-
{ name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
|
|
68
|
+
{ name: 'compact', args: '[--force]', gloss: 'Summarise older turns into a boundary marker (leak L8). --force bypasses the noop-empty guard', group: 'Session' },
|
|
69
69
|
{ name: 'rewind', args: '[N | --to <id>]', gloss: 'Roll the conversation back to a checkpoint (leak L9)', group: 'Session' },
|
|
70
70
|
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
71
71
|
{ name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
|
|
@@ -82,6 +82,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
82
82
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
83
83
|
{ name: 'permissions', args: '[mode] [--persist]', gloss: 'Show or flip permission mode (plan / ask / allow / bypass) (also: /plan)', group: 'Settings' },
|
|
84
84
|
{ name: 'plan', args: '[--back | --persist] [<prompt>]', gloss: 'Switch to plan mode (read-only). Same as /permissions plan, slicker UX.', group: 'Settings' },
|
|
85
|
+
{ name: 'model', args: '[<slug>]', gloss: 'Show or select the active model. Bare /model lists tier-gated options', group: 'Settings' },
|
|
85
86
|
{ name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
|
|
86
87
|
{ name: 'mcp', args: '[sub]', gloss: 'MCP servers — list / trust / deny / install / serve / perms', group: 'Settings' },
|
|
87
88
|
{ name: 'style', args: '[name] [--persist|--reset|--list]', gloss: 'Output-style preset (default / terse / explanatory / russian-formal / casual)', group: 'Settings' },
|
|
@@ -379,6 +380,27 @@ export function parseSlashCommand(input) {
|
|
|
379
380
|
}
|
|
380
381
|
return { kind: 'plan', back, persist, autoBack, prompt };
|
|
381
382
|
}
|
|
383
|
+
case 'model': {
|
|
384
|
+
// Wave 6 BT 8 (Claude Code parity): `/model [<slug>]`. Bare form
|
|
385
|
+
// prints the tier-gated model menu + current selection; with a
|
|
386
|
+
// slug it flips workspace selection. Slug grammar (loose): alnum
|
|
387
|
+
// + dash + dot + slash. Anything outside that range becomes an
|
|
388
|
+
// error verdict so the operator sees a clear message instead of a
|
|
389
|
+
// silent no-op. Whitespace inside the tail = multiple tokens = we
|
|
390
|
+
// take the first; the help gloss documents single-slug usage.
|
|
391
|
+
const trimmedTail = tail.trim();
|
|
392
|
+
if (trimmedTail.length === 0) {
|
|
393
|
+
return { kind: 'model', slug: undefined };
|
|
394
|
+
}
|
|
395
|
+
const firstToken = trimmedTail.split(/\s+/)[0] ?? '';
|
|
396
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._\-\/]{0,63}$/.test(firstToken)) {
|
|
397
|
+
return {
|
|
398
|
+
kind: 'error',
|
|
399
|
+
message: `/model: invalid slug '${firstToken}'. Use letters / digits / '-' / '.' / '/' only.`,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return { kind: 'model', slug: firstToken };
|
|
403
|
+
}
|
|
382
404
|
case 'mcp': {
|
|
383
405
|
// β4 Sl7: tokenize the tail. Empty tail -> `list` (matches CLI).
|
|
384
406
|
// Quoting / shell-escapes are NOT supported — the slash surface is
|
|
@@ -453,11 +475,14 @@ export function parseSlashCommand(input) {
|
|
|
453
475
|
}
|
|
454
476
|
case 'compact': {
|
|
455
477
|
// Leak L8 (2026-05-27): graduated from stub. The session module
|
|
456
|
-
// owns the summariser round-trip
|
|
457
|
-
//
|
|
478
|
+
// owns the summariser round-trip. Wave 6 BT 8: `--force` overrides
|
|
479
|
+
// the noop-empty guard. Unknown flags fall through silently per
|
|
480
|
+
// the existing tail-tolerance behaviour (operators wanting a
|
|
458
481
|
// per-session compact run `pugi compact --session <id>` from a
|
|
459
|
-
// fresh shell.
|
|
460
|
-
|
|
482
|
+
// fresh shell).
|
|
483
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
484
|
+
const force = tokens.some((t) => t === '--force' || t === '-f');
|
|
485
|
+
return { kind: 'compact', force };
|
|
461
486
|
}
|
|
462
487
|
case 'rewind': {
|
|
463
488
|
// Leak L9 (2026-05-27): `/rewind [N | --to <id>]`. Tokenize the
|
package/dist/runtime/cli.js
CHANGED
|
@@ -644,9 +644,14 @@ async function dispatchUndo(args, flags, session) {
|
|
|
644
644
|
* sourced.
|
|
645
645
|
*/
|
|
646
646
|
async function dispatchCompact(args, flags, _session) {
|
|
647
|
+
// Wave 6 BT 8 (Claude Code parity): parse `--force` / `-f` so the
|
|
648
|
+
// operator can produce a marker against a short session. Auto-trigger
|
|
649
|
+
// paths never pass this flag — only the explicit CLI / slash invocation.
|
|
650
|
+
const force = args.some((t) => t === '--force' || t === '-f');
|
|
647
651
|
const result = await runCompactCommand(args, {
|
|
648
652
|
workspaceRoot: process.cwd(),
|
|
649
653
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
654
|
+
force,
|
|
650
655
|
});
|
|
651
656
|
if (result.status === 'failed_no_session'
|
|
652
657
|
|| result.status === 'failed_transport'
|
|
@@ -88,13 +88,14 @@ export async function runCompactCommand(_args, ctx) {
|
|
|
88
88
|
reason: `Could not load events: ${errMsg(error)}`,
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
|
-
if (events.length < MIN_EVENTS_TO_COMPACT) {
|
|
91
|
+
if (events.length < MIN_EVENTS_TO_COMPACT && ctx.force !== true) {
|
|
92
92
|
return emit(ctx, {
|
|
93
93
|
command: 'compact',
|
|
94
94
|
status: 'noop_empty',
|
|
95
95
|
sessionId,
|
|
96
96
|
trigger,
|
|
97
|
-
reason: `Only ${events.length} events on disk; need at least ${MIN_EVENTS_TO_COMPACT}
|
|
97
|
+
reason: `Only ${events.length} events on disk; need at least ${MIN_EVENTS_TO_COMPACT}. `
|
|
98
|
+
+ 'Pass --force to compact anyway.',
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
101
|
// Locate the source slice: everything strictly after the latest
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/model` / `pugi model` — Wave 6 BT 8 (Claude Code parity).
|
|
3
|
+
*
|
|
4
|
+
* Lists OR selects the active model. The MVP uses a hardcoded
|
|
5
|
+
* tier-aware registry mirrored from the per-model price ladder in
|
|
6
|
+
* `core/repl/model-pricing.ts` so the operator sees the same models
|
|
7
|
+
* the cost meter understands. A future iteration can swap the static
|
|
8
|
+
* registry for an admin-api `GET /api/pugi/models` round-trip — the
|
|
9
|
+
* runner contract (`MODEL_REGISTRY` + `runModelCommand`) is the right
|
|
10
|
+
* shape for that swap because the renderer + writer paths are
|
|
11
|
+
* decoupled from the source.
|
|
12
|
+
*
|
|
13
|
+
* Persistence lands at `<workspaceRoot>/.pugi/settings.json` under a
|
|
14
|
+
* top-level `model.slug` key. We extend the file directly with a
|
|
15
|
+
* round-trip read-merge-write so we do not invalidate the existing
|
|
16
|
+
* Zod schema in `core/settings.ts` — the schema there reads only the
|
|
17
|
+
* keys it knows about, leaving `model.*` as a forward-compatible
|
|
18
|
+
* passthrough.
|
|
19
|
+
*
|
|
20
|
+
* Tier gating mirrors the four-tier pricing (Free / Founder $20 /
|
|
21
|
+
* Builder $99 / Team $199). The slugs here are the same ones the
|
|
22
|
+
* cost meter renders, so the operator can always swap to a cheaper
|
|
23
|
+
* fallback if they hit a quota wall.
|
|
24
|
+
*/
|
|
25
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
26
|
+
import { dirname, resolve } from 'node:path';
|
|
27
|
+
/**
|
|
28
|
+
* Tier rank for comparing "user tier >= model.minTier". The order is
|
|
29
|
+
* Free < Founder < Builder < Team. The cabinet's pricing module owns
|
|
30
|
+
* the canonical pricing copy; the CLI just needs the rank to filter
|
|
31
|
+
* the menu.
|
|
32
|
+
*/
|
|
33
|
+
export const TIER_RANK = Object.freeze({
|
|
34
|
+
free: 0,
|
|
35
|
+
founder: 1,
|
|
36
|
+
builder: 2,
|
|
37
|
+
team: 3,
|
|
38
|
+
});
|
|
39
|
+
/**
|
|
40
|
+
* Static model registry. Sources:
|
|
41
|
+
* - Anthropic Claude family (claude-opus, claude-sonnet, claude-haiku):
|
|
42
|
+
* 2026-05-26 list prices.
|
|
43
|
+
* - OpenAI (gpt-4o, gpt-4o-mini, o3, o3-mini): 2026-05-26 list prices.
|
|
44
|
+
* - Mistral (mistral-large, mistral-small): 2026-05-26 list prices.
|
|
45
|
+
*
|
|
46
|
+
* Family fallbacks (`claude-opus-4-7` falling back onto the
|
|
47
|
+
* `claude-opus-` family entry in `model-pricing.ts`) live in the price
|
|
48
|
+
* ladder, not here — the registry is the canonical selectable surface.
|
|
49
|
+
*/
|
|
50
|
+
export const MODEL_REGISTRY = Object.freeze([
|
|
51
|
+
// Free tier — cheap defaults so the meter never blanks on a fresh signup.
|
|
52
|
+
{
|
|
53
|
+
slug: 'claude-haiku-4-5',
|
|
54
|
+
label: 'Claude Haiku 4.5',
|
|
55
|
+
minTier: 'free',
|
|
56
|
+
inputUsdPerM: 0.8,
|
|
57
|
+
outputUsdPerM: 4.0,
|
|
58
|
+
summary: 'Fast + cheap. Default for quick dispatches and the free tier.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
slug: 'gpt-4o-mini',
|
|
62
|
+
label: 'GPT-4o mini',
|
|
63
|
+
minTier: 'free',
|
|
64
|
+
inputUsdPerM: 0.15,
|
|
65
|
+
outputUsdPerM: 0.6,
|
|
66
|
+
summary: 'Cheapest mainstream model. Good for short turns.',
|
|
67
|
+
},
|
|
68
|
+
// Founder ($20) — mid-tier mainstream models.
|
|
69
|
+
{
|
|
70
|
+
slug: 'claude-sonnet-4-6',
|
|
71
|
+
label: 'Claude Sonnet 4.6',
|
|
72
|
+
minTier: 'founder',
|
|
73
|
+
inputUsdPerM: 3.0,
|
|
74
|
+
outputUsdPerM: 15.0,
|
|
75
|
+
summary: 'Balanced for code dispatch. Strong tool use.',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
slug: 'gpt-4o',
|
|
79
|
+
label: 'GPT-4o',
|
|
80
|
+
minTier: 'founder',
|
|
81
|
+
inputUsdPerM: 2.5,
|
|
82
|
+
outputUsdPerM: 10.0,
|
|
83
|
+
summary: 'Multimodal-capable. Good general-purpose flagship.',
|
|
84
|
+
},
|
|
85
|
+
// Builder ($99) — mid-strong reasoning + Mistral options.
|
|
86
|
+
{
|
|
87
|
+
slug: 'o3-mini',
|
|
88
|
+
label: 'OpenAI o3-mini',
|
|
89
|
+
minTier: 'builder',
|
|
90
|
+
inputUsdPerM: 1.1,
|
|
91
|
+
outputUsdPerM: 4.4,
|
|
92
|
+
summary: 'Cheaper reasoning. Good for plan/review turns.',
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
slug: 'mistral-large',
|
|
96
|
+
label: 'Mistral Large',
|
|
97
|
+
minTier: 'builder',
|
|
98
|
+
inputUsdPerM: 2.0,
|
|
99
|
+
outputUsdPerM: 6.0,
|
|
100
|
+
summary: 'Strong open-weight reasoning. EU-hosted option.',
|
|
101
|
+
},
|
|
102
|
+
// Team ($199) — top-tier reasoning.
|
|
103
|
+
{
|
|
104
|
+
slug: 'claude-opus-4-7',
|
|
105
|
+
label: 'Claude Opus 4.7',
|
|
106
|
+
minTier: 'team',
|
|
107
|
+
inputUsdPerM: 15.0,
|
|
108
|
+
outputUsdPerM: 75.0,
|
|
109
|
+
summary: 'Flagship reasoning. Best for hard refactors + multi-file edits.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
slug: 'o3',
|
|
113
|
+
label: 'OpenAI o3',
|
|
114
|
+
minTier: 'team',
|
|
115
|
+
inputUsdPerM: 10.0,
|
|
116
|
+
outputUsdPerM: 40.0,
|
|
117
|
+
summary: 'Strong reasoning + deliberation. Good for plan/critique loops.',
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
120
|
+
/** Returns the registry filtered to models the operator's tier can use. */
|
|
121
|
+
export function modelsForTier(tier) {
|
|
122
|
+
const rank = TIER_RANK[tier];
|
|
123
|
+
return MODEL_REGISTRY.filter((m) => TIER_RANK[m.minTier] <= rank);
|
|
124
|
+
}
|
|
125
|
+
/** Looks up a descriptor by slug. */
|
|
126
|
+
export function lookupModel(slug) {
|
|
127
|
+
return MODEL_REGISTRY.find((m) => m.slug === slug);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Entry point for the slash + the top-level CLI. Side effects:
|
|
131
|
+
* - command.slug undefined: prints the tier-gated menu + current row.
|
|
132
|
+
* - command.slug set + unknown: prints a one-line error.
|
|
133
|
+
* - command.slug set + above tier: prints a tier-lock message.
|
|
134
|
+
* - command.slug set + valid + reachable: merges `model.slug` into
|
|
135
|
+
* <workspaceRoot>/.pugi/settings.json and prints a confirmation.
|
|
136
|
+
*
|
|
137
|
+
* The function never throws on a missing settings file — it creates
|
|
138
|
+
* `.pugi/` + writes a fresh object. Read failures (malformed JSON) are
|
|
139
|
+
* surfaced as a system line + the runner refuses to write so the
|
|
140
|
+
* operator can recover by hand.
|
|
141
|
+
*/
|
|
142
|
+
export async function runModelCommand(command, ctx) {
|
|
143
|
+
const tier = ctx.tier ?? 'team';
|
|
144
|
+
const fs = ctx.fs ?? defaultFs(ctx.workspaceRoot);
|
|
145
|
+
if (command.slug === undefined) {
|
|
146
|
+
return renderMenu(ctx, fs, tier);
|
|
147
|
+
}
|
|
148
|
+
const descriptor = lookupModel(command.slug);
|
|
149
|
+
if (!descriptor) {
|
|
150
|
+
ctx.writeOutput(`/model: unknown slug '${command.slug}'. Run /model to see the menu.`);
|
|
151
|
+
return { command: 'model', status: 'unknown_slug', reason: command.slug };
|
|
152
|
+
}
|
|
153
|
+
if (TIER_RANK[descriptor.minTier] > TIER_RANK[tier]) {
|
|
154
|
+
ctx.writeOutput(`/model: '${descriptor.slug}' requires the ${descriptor.minTier} tier. `
|
|
155
|
+
+ `You are on '${tier}'. Run /model to see available options.`);
|
|
156
|
+
return { command: 'model', status: 'tier_locked', slug: descriptor.slug };
|
|
157
|
+
}
|
|
158
|
+
// Merge + persist. Any IO error becomes a one-line warning; the
|
|
159
|
+
// session continues without crashing.
|
|
160
|
+
try {
|
|
161
|
+
const current = fs.readSettings() ?? {};
|
|
162
|
+
const nextSettings = {
|
|
163
|
+
...current,
|
|
164
|
+
model: {
|
|
165
|
+
...(typeof current.model === 'object' && current.model !== null
|
|
166
|
+
? current.model
|
|
167
|
+
: {}),
|
|
168
|
+
slug: descriptor.slug,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
fs.writeSettings(nextSettings);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
ctx.writeOutput(`/model: persist failed — ${message}. Selection is session-only.`);
|
|
176
|
+
}
|
|
177
|
+
ctx.writeOutput(`Model set to '${descriptor.label}' (${descriptor.slug}). ` +
|
|
178
|
+
`Cost: $${descriptor.inputUsdPerM.toFixed(2)}/M in, $${descriptor.outputUsdPerM.toFixed(2)}/M out.`);
|
|
179
|
+
return { command: 'model', status: 'selected', slug: descriptor.slug };
|
|
180
|
+
}
|
|
181
|
+
function renderMenu(ctx, fs, tier) {
|
|
182
|
+
const current = readCurrentSlug(fs);
|
|
183
|
+
const visible = modelsForTier(tier);
|
|
184
|
+
ctx.writeOutput(`Current model: ${current ?? '(unset, default)'}. Your tier: ${tier}.`);
|
|
185
|
+
ctx.writeOutput('');
|
|
186
|
+
ctx.writeOutput('Available models:');
|
|
187
|
+
for (const m of visible) {
|
|
188
|
+
const mark = current === m.slug ? '*' : ' ';
|
|
189
|
+
const price = `$${m.inputUsdPerM.toFixed(2)}/M in, $${m.outputUsdPerM.toFixed(2)}/M out`;
|
|
190
|
+
ctx.writeOutput(` ${mark} ${m.slug.padEnd(22)} ${m.label.padEnd(22)} ${price}`);
|
|
191
|
+
ctx.writeOutput(` ${m.summary}`);
|
|
192
|
+
}
|
|
193
|
+
const locked = MODEL_REGISTRY.filter((m) => TIER_RANK[m.minTier] > TIER_RANK[tier]);
|
|
194
|
+
if (locked.length > 0) {
|
|
195
|
+
ctx.writeOutput('');
|
|
196
|
+
ctx.writeOutput(`Higher-tier models (upgrade required):`);
|
|
197
|
+
for (const m of locked) {
|
|
198
|
+
ctx.writeOutput(` - ${m.slug} (${m.minTier})`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
ctx.writeOutput('');
|
|
202
|
+
ctx.writeOutput('Switch with `/model <slug>`. The choice persists to .pugi/settings.json.');
|
|
203
|
+
return { command: 'model', status: 'listed', slug: current ?? undefined };
|
|
204
|
+
}
|
|
205
|
+
function readCurrentSlug(fs) {
|
|
206
|
+
try {
|
|
207
|
+
const data = fs.readSettings();
|
|
208
|
+
if (!data || typeof data !== 'object')
|
|
209
|
+
return null;
|
|
210
|
+
const model = data.model;
|
|
211
|
+
if (!model || typeof model !== 'object')
|
|
212
|
+
return null;
|
|
213
|
+
const slug = model.slug;
|
|
214
|
+
return typeof slug === 'string' && slug.length > 0 ? slug : null;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function defaultFs(workspaceRoot) {
|
|
221
|
+
const path = resolve(workspaceRoot, '.pugi/settings.json');
|
|
222
|
+
return {
|
|
223
|
+
readSettings: () => {
|
|
224
|
+
if (!existsSync(path))
|
|
225
|
+
return null;
|
|
226
|
+
const raw = readFileSync(path, 'utf8');
|
|
227
|
+
if (raw.trim().length === 0)
|
|
228
|
+
return null;
|
|
229
|
+
return JSON.parse(raw);
|
|
230
|
+
},
|
|
231
|
+
writeSettings: (next) => {
|
|
232
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
233
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
//# sourceMappingURL=model.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.30');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tui/input-box.js
CHANGED
|
@@ -33,6 +33,14 @@ import { SlashPalette, completePalette, filterPalette, } from './slash-palette.j
|
|
|
33
33
|
import { EMPTY_KILL_RING, killToLineEnd, killToLineStart, killWordBackward, yankAtCursor, } from '../core/repl/kill-ring.js';
|
|
34
34
|
import { readClipboard } from '../core/repl/clipboard-read.js';
|
|
35
35
|
const CTRL_C_DOUBLE_TAP_MS = 1_000;
|
|
36
|
+
/**
|
|
37
|
+
* Wave 6 BT 8 (Claude Code parity): Esc-Esc walks the conversation back
|
|
38
|
+
* one turn. 500ms is tight enough that an operator clearing the buffer +
|
|
39
|
+
* later changing their mind does NOT accidentally pop a turn, while
|
|
40
|
+
* still feeling like one motion. Matches Claude Code's documented
|
|
41
|
+
* double-Esc window.
|
|
42
|
+
*/
|
|
43
|
+
const ESCAPE_DOUBLE_TAP_MS = 500;
|
|
36
44
|
/** Width subtracted from the terminal width so the border + padding fit. */
|
|
37
45
|
const FRAME_OVERHEAD_COLUMNS = 4;
|
|
38
46
|
/** Fallback width when ink cannot read stdout (e.g. test harness). */
|
|
@@ -75,6 +83,11 @@ export function InputBox(props) {
|
|
|
75
83
|
const [history, setHistory] = useState(seededHistory);
|
|
76
84
|
const [historyIndex, setHistoryIndex] = useState(-1);
|
|
77
85
|
const [lastCtrlCAt, setLastCtrlCAt] = useState(undefined);
|
|
86
|
+
// Wave 6 BT 8: Esc-Esc walkback double-tap window. Tracks the epoch
|
|
87
|
+
// ms of the most recent Esc press so the next Esc within
|
|
88
|
+
// ESCAPE_DOUBLE_TAP_MS triggers the walkback handler instead of
|
|
89
|
+
// re-clearing the buffer.
|
|
90
|
+
const [lastEscapeAt, setLastEscapeAt] = useState(undefined);
|
|
78
91
|
const [cursorVisible, setCursorVisible] = useState(true);
|
|
79
92
|
// Ctrl+R / Ctrl+S reverse-search mode. Undefined when idle, a
|
|
80
93
|
// HistorySearchState while the operator is searching.
|
|
@@ -375,10 +388,41 @@ export function InputBox(props) {
|
|
|
375
388
|
if (key.escape) {
|
|
376
389
|
if (paletteOpen) {
|
|
377
390
|
// Close the palette without clearing the buffer so the operator
|
|
378
|
-
// can still send `/help` as plain text if they want.
|
|
391
|
+
// can still send `/help` as plain text if they want. Palette
|
|
392
|
+
// takes precedence over walkback because the operator's mental
|
|
393
|
+
// model is "Esc closes the visible overlay first".
|
|
379
394
|
setPaletteSuppressed(true);
|
|
395
|
+
setLastEscapeAt(undefined);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// Wave 6 BT 8: Esc-Esc walkback. Two presses within
|
|
399
|
+
// ESCAPE_DOUBLE_TAP_MS step the conversation back by one turn.
|
|
400
|
+
// First press still clears the buffer (legacy behaviour for the
|
|
401
|
+
// single-Esc cancel UX); the second press calls the host's
|
|
402
|
+
// walkback handler. Buffer-clear on the first press is what makes
|
|
403
|
+
// the double-tap feel "free" - the operator did not have to
|
|
404
|
+
// memorise a new chord; they just have to keep pressing.
|
|
405
|
+
const tEsc = now();
|
|
406
|
+
const withinEscapeWindow = typeof lastEscapeAt === 'number'
|
|
407
|
+
&& tEsc - lastEscapeAt <= ESCAPE_DOUBLE_TAP_MS;
|
|
408
|
+
if (withinEscapeWindow && props.onWalkback) {
|
|
409
|
+
// Second tap inside the window. Buffer was already cleared on
|
|
410
|
+
// the first press, so the host sees a clean input box AND the
|
|
411
|
+
// walkback result. We clear the window so a third tap restarts
|
|
412
|
+
// the cycle (no run-on walkbacks from a stuck Esc key).
|
|
413
|
+
const verdict = props.onWalkback();
|
|
414
|
+
setLastEscapeAt(undefined);
|
|
415
|
+
if (verdict !== 'walked-back') {
|
|
416
|
+
// Host refused (dispatch in flight, no turns to pop). The
|
|
417
|
+
// host owns the refusal copy via its own writeOutput path;
|
|
418
|
+
// we do not double-message here.
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
380
421
|
return;
|
|
381
422
|
}
|
|
423
|
+
// First Esc (or no walkback wired). Arm the window + clear the
|
|
424
|
+
// buffer per the long-standing single-Esc cancel contract.
|
|
425
|
+
setLastEscapeAt(tEsc);
|
|
382
426
|
setLine('');
|
|
383
427
|
setCursor(0);
|
|
384
428
|
setHistoryIndex(-1);
|
package/dist/tui/repl.js
CHANGED
|
@@ -184,6 +184,18 @@ export function Repl(props) {
|
|
|
184
184
|
return undefined;
|
|
185
185
|
return props.session.cancel();
|
|
186
186
|
}, [props.session, modalActive]);
|
|
187
|
+
// Wave 6 BT 8 (Claude Code parity): Esc-Esc walkback. Forwards to
|
|
188
|
+
// ReplSession.walkbackLastTurn which trims the trailing operator
|
|
189
|
+
// turn + its persona response from the in-memory transcript. Returns
|
|
190
|
+
// `'walked-back'` so the input box knows the host did the work;
|
|
191
|
+
// `'nothing'` covers both the empty-transcript and dispatch-in-flight
|
|
192
|
+
// refusals (the session module owns the refusal copy in both cases).
|
|
193
|
+
const handleWalkback = useCallback(() => {
|
|
194
|
+
if (modalActive)
|
|
195
|
+
return 'nothing';
|
|
196
|
+
const verdict = props.session.walkbackLastTurn();
|
|
197
|
+
return verdict === 'walked-back' ? 'walked-back' : 'nothing';
|
|
198
|
+
}, [props.session, modalActive]);
|
|
187
199
|
// α6.14.5 CEO dogfood 2026-05-25 (parity with Claude Code): input
|
|
188
200
|
// box pinned to alt-screen BOTTOM, conversation grows above it.
|
|
189
201
|
// Beta.3's height={rows} fix broke keystroke focus - raw echo at
|
|
@@ -192,7 +204,7 @@ export function Repl(props) {
|
|
|
192
204
|
// input, and the input stays the sole focusable surface adjacent
|
|
193
205
|
// to the cursor row, so all keystrokes route through it.
|
|
194
206
|
const altScreenRows = process.stdout.rows ?? 24;
|
|
195
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, now: props.now,
|
|
207
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, minHeight: altScreenRows, children: [props.updateBanner ? _jsx(UpdateBanner, { result: props.updateBanner }) : null, splashVisible ? (_jsx(ReplSplash, { cliVersion: state.cliVersion, workspaceLabel: state.workspaceLabel, plan: props.splashPlan, model: props.splashModel, tenant: props.splashTenant, onDismiss: dismissSplash, mascotPrePrinted: props.mascotPrePrinted === true })) : null, _jsx(Header, { state: state }), _jsx(Box, { flexDirection: "column", marginTop: 1, flexGrow: 1, justifyContent: "flex-end", children: overlay === 'help' ? (_jsx(HelpOverlay, {})) : overlay === 'roster' ? (_jsx(RosterOverlay, {})) : overlay === 'farewell' ? (_jsx(FarewellOverlay, {})) : (_jsx(MainArea, { state: state, personaNames: personaNames, nowEpochMs: tickNow, hideToolStream: props.hideToolStream === true, toolStreamCollapsed: toolStreamCollapsed })) }), state.pendingAsk ? (_jsx(Box, { marginTop: 1, children: _jsx(AskModal, { tag: state.pendingAsk, onResolve: handleAskResolve }) })) : null, state.pendingPlanReview ? (_jsx(Box, { marginTop: 1, children: _jsx(PlanReviewModal, { tag: state.pendingPlanReview, onResolve: handlePlanReviewResolve }) })) : null, _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [overlay === 'farewell' || modalActive ? null : (_jsx(InputBox, { onSubmit: handleSubmit, onExit: handleExit, onCancel: handleCancel, onWalkback: handleWalkback, now: props.now,
|
|
196
208
|
// Slug from process.cwd() (full path) so two workspaces with
|
|
197
209
|
// the same basename do not share history. state.workspaceLabel
|
|
198
210
|
// is the basename only. Codex review P2.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.30",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"undici": "^8.3.0",
|
|
55
55
|
"zod": "^3.23.0",
|
|
56
56
|
"@pugi/personas": "0.1.2",
|
|
57
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
57
|
+
"@pugi/sdk": "0.1.0-beta.30"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.0.0",
|