@pugi/cli 0.1.0-beta.24 → 0.1.0-beta.26
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/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/compact/summarizer.js +12 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/engine/native-pugi.js +67 -3
- package/dist/core/engine/tool-bridge.js +123 -3
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/repl/session.js +73 -1
- package/dist/core/repl/slash-commands.js +20 -0
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/session.js +44 -0
- package/dist/core/settings.js +9 -0
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/runtime/cli.js +216 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +25 -23
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +23 -0
- package/package.json +2 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-edit diagnostics — Leak L15.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's leak intel surfaced this pattern: after a `FileEdit` /
|
|
5
|
+
* `Write` tool call lands, an LSP diagnostic pass runs on the touched
|
|
6
|
+
* file and the result is appended to the tool envelope before the
|
|
7
|
+
* model sees it. The model then self-corrects in the same turn —
|
|
8
|
+
* "TS2304: Cannot find name 'undef'" comes back, the model fixes the
|
|
9
|
+
* typo in the next tool call, no operator round-trip needed.
|
|
10
|
+
*
|
|
11
|
+
* This module is the Pugi side of that pattern:
|
|
12
|
+
*
|
|
13
|
+
* 1. The tool-bridge calls `runPostEditDiagnostics(path, ctx)` after
|
|
14
|
+
* a successful `edit` / `write` / `multi_edit`.
|
|
15
|
+
* 2. We infer the language from the extension (`language-detect`).
|
|
16
|
+
* Unsupported extension → `{ skip: true }` and the bridge appends
|
|
17
|
+
* nothing.
|
|
18
|
+
* 3. We borrow (or lazily start) the per-language cached client
|
|
19
|
+
* from `cache.ts`. A spawn failure → `{ skip: true }` and the
|
|
20
|
+
* envelope stays clean. Silence on failure is intentional: an
|
|
21
|
+
* operator who has not installed `typescript-language-server`
|
|
22
|
+
* should not see an LSP nag on every edit.
|
|
23
|
+
* 4. We pull diagnostics with a hard 5s ceiling. A timeout logs a
|
|
24
|
+
* warning on stderr (gated on `PUGI_LSP_DEBUG=1`) and skips —
|
|
25
|
+
* the envelope is never blocked on LSP.
|
|
26
|
+
* 5. We format the surviving diagnostics into a readable tail
|
|
27
|
+
* mirroring the leak format:
|
|
28
|
+
*
|
|
29
|
+
* LSP DIAGNOSTICS (typescript):
|
|
30
|
+
* foo.ts:42:5 error TS2304: Cannot find name 'undef'.
|
|
31
|
+
* foo.ts:51:1 warn TS6133: 'unused' is declared.
|
|
32
|
+
*
|
|
33
|
+
* The bridge concatenates this tail onto its existing `wrote ...` /
|
|
34
|
+
* `edited ...` body with a single newline separator. When there are
|
|
35
|
+
* zero diagnostics we return `{ skip: true }` so the existing body
|
|
36
|
+
* is unchanged — the "no news is good news" path stays terse.
|
|
37
|
+
*
|
|
38
|
+
* Brand voice: ASCII only, no emoji, no banned words.
|
|
39
|
+
*/
|
|
40
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
41
|
+
import { getOrStartLspClient } from './cache.js';
|
|
42
|
+
import { languageForFile } from './language-detect.js';
|
|
43
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
44
|
+
/**
|
|
45
|
+
* Hard cap on how many diagnostics we surface to the model. A file
|
|
46
|
+
* with 200 errors after a broken bulk edit would otherwise blow the
|
|
47
|
+
* context window; the model can re-run `pugi lsp diagnostics` if
|
|
48
|
+
* it needs the full list.
|
|
49
|
+
*/
|
|
50
|
+
const MAX_DIAGNOSTICS = 25;
|
|
51
|
+
export async function runPostEditDiagnostics(filePath, opts) {
|
|
52
|
+
const lang = languageForFile(filePath);
|
|
53
|
+
if (!lang) {
|
|
54
|
+
return { skip: true, reason: 'unsupported_language' };
|
|
55
|
+
}
|
|
56
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
57
|
+
const clientResult = await loadClient(lang, opts);
|
|
58
|
+
if (!clientResult.ok) {
|
|
59
|
+
return { skip: true, reason: mapStartFailure(clientResult.reason) };
|
|
60
|
+
}
|
|
61
|
+
// Run diagnostics with a hard timeout. The underlying LspClient has
|
|
62
|
+
// its own per-request timeout (5s default) but a slow handshake
|
|
63
|
+
// can blow past it; we belt-and-suspenders here so the agent loop
|
|
64
|
+
// never blocks on LSP.
|
|
65
|
+
const relPath = toWorkspaceRelative(filePath, opts.cwd);
|
|
66
|
+
const diagnosticsPromise = clientResult.client.diagnostics(relPath);
|
|
67
|
+
let timer;
|
|
68
|
+
const timeoutPromise = new Promise((resolveFn) => {
|
|
69
|
+
timer = setTimeout(() => resolveFn({ timedOut: true }), timeoutMs);
|
|
70
|
+
timer.unref();
|
|
71
|
+
});
|
|
72
|
+
const race = await Promise.race([
|
|
73
|
+
diagnosticsPromise.then((value) => ({ timedOut: false, value })),
|
|
74
|
+
timeoutPromise,
|
|
75
|
+
]);
|
|
76
|
+
if (timer)
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
if (race.timedOut) {
|
|
79
|
+
const writeFn = opts.debugWrite ?? ((line) => {
|
|
80
|
+
if (process.env.PUGI_LSP_DEBUG === '1')
|
|
81
|
+
process.stderr.write(`${line}\n`);
|
|
82
|
+
});
|
|
83
|
+
writeFn(`[pugi-lsp] post-edit diagnostics for ${relPath} timed out after ${timeoutMs}ms (lang=${lang})`);
|
|
84
|
+
return { skip: true, reason: 'timeout' };
|
|
85
|
+
}
|
|
86
|
+
const diag = race.value;
|
|
87
|
+
if (!diag.ok) {
|
|
88
|
+
return { skip: true, reason: 'lsp_error' };
|
|
89
|
+
}
|
|
90
|
+
if (diag.value.length === 0) {
|
|
91
|
+
return { skip: true, reason: 'no_diagnostics' };
|
|
92
|
+
}
|
|
93
|
+
const tail = formatDiagnosticsTail(relPath, lang, diag.value);
|
|
94
|
+
return { skip: false, tail, count: diag.value.length, language: lang };
|
|
95
|
+
}
|
|
96
|
+
async function loadClient(lang, opts) {
|
|
97
|
+
if (opts.clientLoader) {
|
|
98
|
+
return opts.clientLoader(lang);
|
|
99
|
+
}
|
|
100
|
+
const { cwd, timeoutMs, clientLoader: _ignoredA, debugWrite: _ignoredB, ...rest } = opts;
|
|
101
|
+
const result = await getOrStartLspClient(lang, { cwd, ...rest });
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return { ok: false, reason: result.reason, detail: result.detail };
|
|
104
|
+
}
|
|
105
|
+
return { ok: true, client: result.client };
|
|
106
|
+
}
|
|
107
|
+
function mapStartFailure(reason) {
|
|
108
|
+
if (reason === 'lsp_unavailable' || reason === 'language_unsupported')
|
|
109
|
+
return 'lsp_unavailable';
|
|
110
|
+
if (reason === 'lsp_disabled')
|
|
111
|
+
return 'lsp_disabled';
|
|
112
|
+
return 'lsp_error';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Convert an absolute or workspace-relative path into the form the
|
|
116
|
+
* LSP client expects — same shape as `runtime/commands/lsp.ts` uses.
|
|
117
|
+
*/
|
|
118
|
+
function toWorkspaceRelative(filePath, cwd) {
|
|
119
|
+
if (!isAbsolute(filePath))
|
|
120
|
+
return filePath;
|
|
121
|
+
const rel = relative(cwd, resolve(cwd, filePath));
|
|
122
|
+
return rel || filePath;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Format diagnostics into the leak-shaped envelope tail. Pure function
|
|
126
|
+
* exported for unit tests to assert the line format independent of
|
|
127
|
+
* any LSP plumbing.
|
|
128
|
+
*/
|
|
129
|
+
export function formatDiagnosticsTail(relPath, lang, diagnostics) {
|
|
130
|
+
const visible = diagnostics.slice(0, MAX_DIAGNOSTICS);
|
|
131
|
+
const truncated = diagnostics.length > visible.length;
|
|
132
|
+
const lines = [`LSP DIAGNOSTICS (${LANGUAGE_LABELS[lang]}):`];
|
|
133
|
+
for (const diag of visible) {
|
|
134
|
+
const line = diag.range.start.line + 1; // LSP is zero-based; humans expect 1-based.
|
|
135
|
+
const col = diag.range.start.character + 1;
|
|
136
|
+
const severity = SEVERITY_LABELS[diag.severityLabel];
|
|
137
|
+
const code = diag.code !== undefined && diag.code !== '' ? ` ${diag.code}` : '';
|
|
138
|
+
const source = diag.source ? `${diag.source}` : '';
|
|
139
|
+
const head = source ? `${severity}${code} (${source}):` : `${severity}${code}:`;
|
|
140
|
+
lines.push(` ${relPath}:${line}:${col} ${head} ${diag.message}`);
|
|
141
|
+
}
|
|
142
|
+
if (truncated) {
|
|
143
|
+
lines.push(` ... ${diagnostics.length - visible.length} more diagnostic(s) — re-run pugi lsp diagnostics ${relPath} for the full list`);
|
|
144
|
+
}
|
|
145
|
+
return lines.join('\n');
|
|
146
|
+
}
|
|
147
|
+
const LANGUAGE_LABELS = {
|
|
148
|
+
ts: 'typescript',
|
|
149
|
+
js: 'javascript',
|
|
150
|
+
py: 'python',
|
|
151
|
+
go: 'go',
|
|
152
|
+
rust: 'rust',
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Map LSP severity label → the short token the leak envelope uses.
|
|
156
|
+
* "warn" is shorter than "warning" and matches Claude Code's leak
|
|
157
|
+
* verbatim; the rest mirror LSP terminology.
|
|
158
|
+
*/
|
|
159
|
+
const SEVERITY_LABELS = {
|
|
160
|
+
error: 'error',
|
|
161
|
+
warning: 'warn ',
|
|
162
|
+
info: 'info ',
|
|
163
|
+
hint: 'hint ',
|
|
164
|
+
};
|
|
165
|
+
/** Test-only surface so specs can poke the pure helpers without LSP. */
|
|
166
|
+
export const __test__ = {
|
|
167
|
+
formatDiagnosticsTail,
|
|
168
|
+
MAX_DIAGNOSTICS,
|
|
169
|
+
DEFAULT_TIMEOUT_MS,
|
|
170
|
+
};
|
|
171
|
+
//# sourceMappingURL=post-edit-diagnostics.js.map
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pugi memory sync queue (ADR-0063 Day 4).
|
|
3
|
+
*
|
|
4
|
+
* Local pending-write queue for `pugi memory` commands when the
|
|
5
|
+
* operator is offline or the admin-api is unreachable. Each pending
|
|
6
|
+
* mutation lands on disk as one JSONL line; `pugi memory sync` reads
|
|
7
|
+
* the queue, fires them to the admin-api in order, and rewrites the
|
|
8
|
+
* file with only the entries that still failed.
|
|
9
|
+
*
|
|
10
|
+
* Storage:
|
|
11
|
+
*
|
|
12
|
+
* ~/.pugi/memory-queue.jsonl (mode 0600)
|
|
13
|
+
*
|
|
14
|
+
* Each line is a fully-typed `PendingMemoryOperation` envelope. The
|
|
15
|
+
* envelope is forward-compatible: an older CLI reading a JSONL file
|
|
16
|
+
* written by a newer CLI silently skips lines whose `op` field is
|
|
17
|
+
* not in its known set (so a partial-rollback scenario doesn't crash
|
|
18
|
+
* the queue).
|
|
19
|
+
*
|
|
20
|
+
* Design intent:
|
|
21
|
+
* - Append-only on disk for the hot path (`pugi memory write` /
|
|
22
|
+
* `pugi memory forget` queue when offline). Rewrites only on
|
|
23
|
+
* successful sync.
|
|
24
|
+
* - One file per operator (PUGI_HOME-aware). Queue is local to the
|
|
25
|
+
* machine — no cross-host coordination. Multi-device sync is
|
|
26
|
+
* deferred to Phase 6 (server-side outbox).
|
|
27
|
+
* - No fsync / atomic rename ceremony in v1 — best effort. The
|
|
28
|
+
* queue is a convenience surface, not a durability primitive;
|
|
29
|
+
* the source of truth is the admin-api row.
|
|
30
|
+
*/
|
|
31
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { dirname, resolve } from 'node:path';
|
|
34
|
+
import { z } from 'zod';
|
|
35
|
+
/** Six canonical kinds — must mirror `apps/admin-api/src/persona-memory/persona-memory.types.ts`. */
|
|
36
|
+
export const PERSONA_MEMORY_KINDS = [
|
|
37
|
+
'pattern',
|
|
38
|
+
'preference',
|
|
39
|
+
'architecture',
|
|
40
|
+
'bug',
|
|
41
|
+
'workflow',
|
|
42
|
+
'fact',
|
|
43
|
+
];
|
|
44
|
+
const writeOpSchema = z.object({
|
|
45
|
+
op: z.literal('write'),
|
|
46
|
+
enqueuedAt: z.string().datetime(),
|
|
47
|
+
personaSlug: z.string().min(1).max(64),
|
|
48
|
+
kind: z.enum(PERSONA_MEMORY_KINDS),
|
|
49
|
+
content: z.string().min(1).max(4000),
|
|
50
|
+
forgetAfter: z.string().datetime().nullable().optional(),
|
|
51
|
+
});
|
|
52
|
+
const forgetOpSchema = z.object({
|
|
53
|
+
op: z.literal('forget'),
|
|
54
|
+
enqueuedAt: z.string().datetime(),
|
|
55
|
+
id: z.string().min(1),
|
|
56
|
+
});
|
|
57
|
+
const pendingMemoryOpSchema = z.discriminatedUnion('op', [
|
|
58
|
+
writeOpSchema,
|
|
59
|
+
forgetOpSchema,
|
|
60
|
+
]);
|
|
61
|
+
/** Default storage path. Override via `PUGI_HOME` for tests / multi-account. */
|
|
62
|
+
export function defaultQueuePath() {
|
|
63
|
+
const root = process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
64
|
+
return resolve(root, 'memory-queue.jsonl');
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Append one pending operation to the queue file. Creates the parent
|
|
68
|
+
* directory + file with mode 0600 if missing. Pure-disk, no network.
|
|
69
|
+
*
|
|
70
|
+
* Returns the count of pending ops after the append (1-based) so the
|
|
71
|
+
* CLI command can render "queued (3 pending) — run `pugi memory sync`".
|
|
72
|
+
*/
|
|
73
|
+
export function enqueueMemoryOp(op, pathOverride) {
|
|
74
|
+
const fullOp = {
|
|
75
|
+
...op,
|
|
76
|
+
enqueuedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
pendingMemoryOpSchema.parse(fullOp);
|
|
79
|
+
const queuePath = pathOverride ?? defaultQueuePath();
|
|
80
|
+
ensureQueueFile(queuePath);
|
|
81
|
+
const existing = readFileSync(queuePath, 'utf-8');
|
|
82
|
+
const line = `${JSON.stringify(fullOp)}\n`;
|
|
83
|
+
writeFileSync(queuePath, `${existing}${line}`, { encoding: 'utf-8', mode: 0o600 });
|
|
84
|
+
// Best-effort chmod (in case the file existed already at the wrong mode).
|
|
85
|
+
try {
|
|
86
|
+
chmodSync(queuePath, 0o600);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// ignore — the file was just written above, mode might be platform-dependent.
|
|
90
|
+
}
|
|
91
|
+
return countPending(queuePath);
|
|
92
|
+
}
|
|
93
|
+
/** Read the queue file and return parsed entries. Skips unknown / malformed lines. */
|
|
94
|
+
export function readMemoryQueue(pathOverride) {
|
|
95
|
+
const queuePath = pathOverride ?? defaultQueuePath();
|
|
96
|
+
if (!existsSync(queuePath))
|
|
97
|
+
return [];
|
|
98
|
+
const raw = readFileSync(queuePath, 'utf-8');
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed)
|
|
103
|
+
continue;
|
|
104
|
+
try {
|
|
105
|
+
const parsed = pendingMemoryOpSchema.parse(JSON.parse(trimmed));
|
|
106
|
+
out.push(parsed);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// forward-compat: a future op kind we don't recognise should not
|
|
110
|
+
// crash the queue; just drop the line during this read.
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** Rewrite the queue file with `remaining` entries only. Empty array clears the file. */
|
|
117
|
+
export function rewriteMemoryQueue(remaining, pathOverride) {
|
|
118
|
+
const queuePath = pathOverride ?? defaultQueuePath();
|
|
119
|
+
ensureQueueFile(queuePath);
|
|
120
|
+
if (remaining.length === 0) {
|
|
121
|
+
writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const body = remaining.map((op) => JSON.stringify(op)).join('\n') + '\n';
|
|
125
|
+
writeFileSync(queuePath, body, { encoding: 'utf-8', mode: 0o600 });
|
|
126
|
+
}
|
|
127
|
+
/** Count pending ops without re-parsing every line individually for the typed shape. */
|
|
128
|
+
export function countPending(pathOverride) {
|
|
129
|
+
const queuePath = pathOverride ?? defaultQueuePath();
|
|
130
|
+
if (!existsSync(queuePath))
|
|
131
|
+
return 0;
|
|
132
|
+
const raw = readFileSync(queuePath, 'utf-8');
|
|
133
|
+
let n = 0;
|
|
134
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
135
|
+
if (line.trim().length > 0)
|
|
136
|
+
n++;
|
|
137
|
+
}
|
|
138
|
+
return n;
|
|
139
|
+
}
|
|
140
|
+
/** Quick predicate — was anything ever queued? */
|
|
141
|
+
export function hasPendingOps(pathOverride) {
|
|
142
|
+
return countPending(pathOverride) > 0;
|
|
143
|
+
}
|
|
144
|
+
function ensureQueueFile(queuePath) {
|
|
145
|
+
const dir = dirname(queuePath);
|
|
146
|
+
if (!existsSync(dir))
|
|
147
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
148
|
+
if (!existsSync(queuePath)) {
|
|
149
|
+
writeFileSync(queuePath, '', { encoding: 'utf-8', mode: 0o600 });
|
|
150
|
+
try {
|
|
151
|
+
chmodSync(queuePath, 0o600);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=queue.js.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { countPending, enqueueMemoryOp, hasPendingOps, readMemoryQueue, rewriteMemoryQueue, } from './queue.js';
|
|
7
|
+
let tmpRoot = '';
|
|
8
|
+
let queuePath = '';
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
tmpRoot = mkdtempSync(resolve(tmpdir(), 'pugi-memory-queue-'));
|
|
11
|
+
queuePath = resolve(tmpRoot, 'memory-queue.jsonl');
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
try {
|
|
15
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// ignore
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
describe('memory-sync queue', () => {
|
|
22
|
+
it('countPending returns 0 for missing file', () => {
|
|
23
|
+
assert.equal(countPending(queuePath), 0);
|
|
24
|
+
assert.equal(hasPendingOps(queuePath), false);
|
|
25
|
+
});
|
|
26
|
+
it('enqueueMemoryOp appends a write op and returns 1', () => {
|
|
27
|
+
const n = enqueueMemoryOp({
|
|
28
|
+
op: 'write',
|
|
29
|
+
personaSlug: 'mira',
|
|
30
|
+
kind: 'preference',
|
|
31
|
+
content: 'operator prefers pnpm',
|
|
32
|
+
}, queuePath);
|
|
33
|
+
assert.equal(n, 1);
|
|
34
|
+
assert.equal(hasPendingOps(queuePath), true);
|
|
35
|
+
});
|
|
36
|
+
it('enqueueMemoryOp appends multiple ops sequentially', () => {
|
|
37
|
+
enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
|
|
38
|
+
enqueueMemoryOp({ op: 'forget', id: 'mem-abc' }, queuePath);
|
|
39
|
+
assert.equal(countPending(queuePath), 2);
|
|
40
|
+
});
|
|
41
|
+
it('readMemoryQueue returns parsed entries in order', () => {
|
|
42
|
+
enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'workflow', content: 'first' }, queuePath);
|
|
43
|
+
enqueueMemoryOp({ op: 'forget', id: 'mem-1' }, queuePath);
|
|
44
|
+
const ops = readMemoryQueue(queuePath);
|
|
45
|
+
assert.equal(ops.length, 2);
|
|
46
|
+
assert.equal(ops[0]?.op, 'write');
|
|
47
|
+
if (ops[0]?.op === 'write')
|
|
48
|
+
assert.equal(ops[0].content, 'first');
|
|
49
|
+
assert.equal(ops[1]?.op, 'forget');
|
|
50
|
+
});
|
|
51
|
+
it('readMemoryQueue skips malformed lines without crashing', () => {
|
|
52
|
+
writeFileSync(queuePath, [
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
op: 'write',
|
|
55
|
+
personaSlug: 'mira',
|
|
56
|
+
kind: 'fact',
|
|
57
|
+
content: 'a',
|
|
58
|
+
enqueuedAt: new Date().toISOString(),
|
|
59
|
+
}),
|
|
60
|
+
'{not valid json',
|
|
61
|
+
JSON.stringify({ op: 'future_op', whatever: true }),
|
|
62
|
+
].join('\n'));
|
|
63
|
+
const ops = readMemoryQueue(queuePath);
|
|
64
|
+
assert.equal(ops.length, 1);
|
|
65
|
+
});
|
|
66
|
+
it('rewriteMemoryQueue with empty array clears the file', () => {
|
|
67
|
+
enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
|
|
68
|
+
rewriteMemoryQueue([], queuePath);
|
|
69
|
+
assert.equal(countPending(queuePath), 0);
|
|
70
|
+
const raw = readFileSync(queuePath, 'utf-8');
|
|
71
|
+
assert.equal(raw, '');
|
|
72
|
+
});
|
|
73
|
+
it('rewriteMemoryQueue with remaining entries persists them', () => {
|
|
74
|
+
enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'a' }, queuePath);
|
|
75
|
+
enqueueMemoryOp({ op: 'write', personaSlug: 'mira', kind: 'fact', content: 'b' }, queuePath);
|
|
76
|
+
const all = readMemoryQueue(queuePath);
|
|
77
|
+
rewriteMemoryQueue([all[1]], queuePath);
|
|
78
|
+
const after = readMemoryQueue(queuePath);
|
|
79
|
+
assert.equal(after.length, 1);
|
|
80
|
+
if (after[0]?.op === 'write')
|
|
81
|
+
assert.equal(after[0].content, 'b');
|
|
82
|
+
});
|
|
83
|
+
it('enqueueMemoryOp rejects an invalid kind via Zod', () => {
|
|
84
|
+
assert.throws(() => enqueueMemoryOp({
|
|
85
|
+
op: 'write',
|
|
86
|
+
personaSlug: 'mira',
|
|
87
|
+
// @ts-expect-error — intentional bad value
|
|
88
|
+
kind: 'whatever',
|
|
89
|
+
content: 'a',
|
|
90
|
+
}, queuePath));
|
|
91
|
+
});
|
|
92
|
+
it('enqueueMemoryOp rejects oversized content (>4000 chars)', () => {
|
|
93
|
+
assert.throws(() => enqueueMemoryOp({
|
|
94
|
+
op: 'write',
|
|
95
|
+
personaSlug: 'mira',
|
|
96
|
+
kind: 'fact',
|
|
97
|
+
content: 'x'.repeat(4001),
|
|
98
|
+
}, queuePath));
|
|
99
|
+
});
|
|
100
|
+
it('countPending counts non-empty lines only', () => {
|
|
101
|
+
writeFileSync(queuePath, 'line1\n\n\nline2\n');
|
|
102
|
+
assert.equal(countPending(queuePath), 2);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
//# sourceMappingURL=queue.spec.js.map
|
|
@@ -36,6 +36,7 @@ import { webFetchTool } from '../../tools/web-fetch.js';
|
|
|
36
36
|
import { loadSettings } from '../settings.js';
|
|
37
37
|
import { getJobRegistry } from '../jobs/registry.js';
|
|
38
38
|
import { applyCompactMask } from '../compact/buffer-rewriter.js';
|
|
39
|
+
import { applyRewindMask } from '../checkpoint/rewinder.js';
|
|
39
40
|
import { evaluateAutoCompact } from '../compact/auto-trigger.js';
|
|
40
41
|
import { estimateTokensInMany } from '../compact/token-counter.js';
|
|
41
42
|
import { extractAskTags, extractPlanReviewTags, signatureForAsk, } from './ask.js';
|
|
@@ -1015,6 +1016,37 @@ export class ReplSession {
|
|
|
1015
1016
|
await this.dispatchCompact('manual');
|
|
1016
1017
|
return verdict;
|
|
1017
1018
|
}
|
|
1019
|
+
case 'rewind': {
|
|
1020
|
+
// Leak L9 (2026-05-27): /rewind appends an append-only
|
|
1021
|
+
// tombstone marker that rolls the conversation back to a
|
|
1022
|
+
// checkpoint. The actual replay-mask is advisory — the on-disk
|
|
1023
|
+
// events stay durable so `pugi sessions undo-rewind` can
|
|
1024
|
+
// reverse the operation. We forward to the same runner the
|
|
1025
|
+
// top-level `pugi rewind` command uses to keep the surface
|
|
1026
|
+
// single-sourced. Dynamic import avoids pulling the checkpoint
|
|
1027
|
+
// graph into the dispatcher at module load.
|
|
1028
|
+
if (!this.store || !this.localSessionId) {
|
|
1029
|
+
this.appendSystemLine('Local session store is disabled — /rewind is unavailable.');
|
|
1030
|
+
return verdict;
|
|
1031
|
+
}
|
|
1032
|
+
try {
|
|
1033
|
+
const { runRewindCommand } = await import('../../runtime/commands/rewind.js');
|
|
1034
|
+
await runRewindCommand(verdict.args, {
|
|
1035
|
+
workspaceRoot: process.cwd(),
|
|
1036
|
+
sessionId: this.localSessionId,
|
|
1037
|
+
store: this.store,
|
|
1038
|
+
writeOutput: (_payload, text) => {
|
|
1039
|
+
if (text.length > 0)
|
|
1040
|
+
this.appendSystemLine(text);
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1046
|
+
this.appendSystemLine(`/rewind failed: ${message}`);
|
|
1047
|
+
}
|
|
1048
|
+
return verdict;
|
|
1049
|
+
}
|
|
1018
1050
|
case 'share': {
|
|
1019
1051
|
// Leak L20 (2026-05-27): /share forwards to the same runner the
|
|
1020
1052
|
// top-level `pugi share` command uses. The session module
|
|
@@ -1233,6 +1265,41 @@ export class ReplSession {
|
|
|
1233
1265
|
}
|
|
1234
1266
|
return verdict;
|
|
1235
1267
|
}
|
|
1268
|
+
case 'repo-map': {
|
|
1269
|
+
// Leak L28 (2026-05-27): AST-light workspace summary. Delegate
|
|
1270
|
+
// к the shared `runRepoMapCommand` so the slash + top-level
|
|
1271
|
+
// paths stay single-sourced. The rendered text lands on the
|
|
1272
|
+
// system pane via `appendSystemLine` (no fresh Ink mount) so
|
|
1273
|
+
// the listing flows into the conversation transcript like
|
|
1274
|
+
// any other command output.
|
|
1275
|
+
try {
|
|
1276
|
+
const { runRepoMapCommand } = await import('../../runtime/commands/repo-map.js');
|
|
1277
|
+
const lines = [];
|
|
1278
|
+
await runRepoMapCommand({
|
|
1279
|
+
cwd: process.cwd(),
|
|
1280
|
+
refresh: verdict.refresh,
|
|
1281
|
+
json: false,
|
|
1282
|
+
writeOutput: (_payload, text) => {
|
|
1283
|
+
for (const line of text.split('\n')) {
|
|
1284
|
+
const trimmed = line.replace(/\s+$/u, '');
|
|
1285
|
+
lines.push(trimmed);
|
|
1286
|
+
}
|
|
1287
|
+
},
|
|
1288
|
+
});
|
|
1289
|
+
if (lines.length === 0) {
|
|
1290
|
+
this.appendSystemLine('/repo-map: no output.');
|
|
1291
|
+
}
|
|
1292
|
+
else {
|
|
1293
|
+
for (const line of lines)
|
|
1294
|
+
this.appendSystemLine(line);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1299
|
+
this.appendSystemLine(`/repo-map failed: ${message}`);
|
|
1300
|
+
}
|
|
1301
|
+
return verdict;
|
|
1302
|
+
}
|
|
1236
1303
|
case 'stub': {
|
|
1237
1304
|
this.appendSystemLine(verdict.message);
|
|
1238
1305
|
return verdict;
|
|
@@ -2997,7 +3064,12 @@ export class ReplSession {
|
|
|
2997
3064
|
// condensed into the boundary's `keptTailTurns + marker` slice so
|
|
2998
3065
|
// the post-resume transcript starts at the most-recent context
|
|
2999
3066
|
// floor rather than re-playing the full pre-compaction history.
|
|
3000
|
-
|
|
3067
|
+
//
|
|
3068
|
+
// Leak L9 (2026-05-27): then apply rewind-marker masking. Any
|
|
3069
|
+
// event inside an active rewind range is stripped from the
|
|
3070
|
+
// visible transcript; the on-disk events stay durable so a
|
|
3071
|
+
// follow-up `pugi sessions undo-rewind` can restore them.
|
|
3072
|
+
const masked = applyRewindMask(applyCompactMask(events));
|
|
3001
3073
|
const rows = [];
|
|
3002
3074
|
for (const event of masked) {
|
|
3003
3075
|
const row = eventToTranscriptRow(event);
|
|
@@ -66,6 +66,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
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
68
|
{ name: 'compact', args: '', gloss: 'Summarise older turns into a boundary marker (leak L8)', group: 'Session' },
|
|
69
|
+
{ name: 'rewind', args: '[N | --to <id>]', gloss: 'Roll the conversation back to a checkpoint (leak L9)', group: 'Session' },
|
|
69
70
|
{ name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
|
|
70
71
|
{ name: 'init', args: '', gloss: 'Scaffold .pugi/ in the current workspace (β1 Sl11)', group: 'Session' },
|
|
71
72
|
// Pugi tools
|
|
@@ -75,6 +76,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
75
76
|
{ name: 'quota', args: '', gloss: 'Plan tier + monthly usage caps (sync / review / engine)', group: 'Pugi tools' },
|
|
76
77
|
{ name: 'status', args: '', gloss: 'Session snapshot — id · cwd · mode · tokens · dispatches · auth', group: 'Pugi tools' },
|
|
77
78
|
{ name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
|
|
79
|
+
{ name: 'repo-map', args: '[refresh]', gloss: 'AST-light symbol summary of the workspace (leak L28)', group: 'Pugi tools' },
|
|
78
80
|
// Settings
|
|
79
81
|
{ name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
|
|
80
82
|
{ name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
|
|
@@ -438,6 +440,15 @@ export function parseSlashCommand(input) {
|
|
|
438
440
|
// fresh shell.
|
|
439
441
|
return { kind: 'compact' };
|
|
440
442
|
}
|
|
443
|
+
case 'rewind': {
|
|
444
|
+
// Leak L9 (2026-05-27): `/rewind [N | --to <id>]`. Tokenize the
|
|
445
|
+
// tail unchanged so `runRewindCommand` (in `runtime/commands/
|
|
446
|
+
// rewind.ts`) handles every mode (picker / turns / to-event)
|
|
447
|
+
// through one parser. The slash + top-level CLI surfaces stay
|
|
448
|
+
// single-sourced — same separation as `/compact`.
|
|
449
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
450
|
+
return { kind: 'rewind', args: tokens };
|
|
451
|
+
}
|
|
441
452
|
case 'stickers': {
|
|
442
453
|
// Leak L33 (2026-05-27): brand-personality gimmick. Tail args
|
|
443
454
|
// are ignored — the surface is intentionally parameterless. The
|
|
@@ -462,6 +473,15 @@ export function parseSlashCommand(input) {
|
|
|
462
473
|
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
463
474
|
return { kind: 'share', args: tokens };
|
|
464
475
|
}
|
|
476
|
+
case 'repo-map':
|
|
477
|
+
case 'repomap': {
|
|
478
|
+
// Leak L28 (2026-05-27): build + show the AST-light symbol
|
|
479
|
+
// summary. Accepts `refresh` as a positional или `--refresh`
|
|
480
|
+
// flag so muscle memory from both shells lands the same way.
|
|
481
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
482
|
+
const refresh = tokens.includes('--refresh') || tokens.includes('refresh') || tokens.includes('-r');
|
|
483
|
+
return { kind: 'repo-map', refresh };
|
|
484
|
+
}
|
|
465
485
|
case 'release-notes':
|
|
466
486
|
case 'releasenotes':
|
|
467
487
|
case 'changelog': {
|
|
@@ -361,7 +361,7 @@ export class SqliteSessionStore {
|
|
|
361
361
|
// which maps to SQLITE_OPEN_READONLY. The option form is the
|
|
362
362
|
// documented API; the file-URI form (file:...?mode=ro) also works.
|
|
363
363
|
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
364
|
-
return new SqliteSessionStoreReadOnlyView(db);
|
|
364
|
+
return new SqliteSessionStoreReadOnlyView(db, projectStoreDir);
|
|
365
365
|
}
|
|
366
366
|
/* ------------------------------------------------------------ */
|
|
367
367
|
/* Internals */
|
|
@@ -584,8 +584,37 @@ export class SqliteSessionStore {
|
|
|
584
584
|
*/
|
|
585
585
|
export class SqliteSessionStoreReadOnlyView {
|
|
586
586
|
db;
|
|
587
|
-
|
|
587
|
+
projectStoreDir;
|
|
588
|
+
constructor(db,
|
|
589
|
+
/**
|
|
590
|
+
* Project store directory — required for the JSONL event read path.
|
|
591
|
+
* L9 (2026-05-27): `/rewind` + `/resume` need to walk events from
|
|
592
|
+
* inside the read-only view so the rewind picker + resume preview
|
|
593
|
+
* never take the writer lockfile.
|
|
594
|
+
*/
|
|
595
|
+
projectStoreDir) {
|
|
588
596
|
this.db = db;
|
|
597
|
+
this.projectStoreDir = projectStoreDir;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Read every event for a session via the durable JSONL log. The
|
|
601
|
+
* SQLite cache is NOT used here — JSONL is the source of truth and
|
|
602
|
+
* the cache only holds counters. The walk stitches across rotation
|
|
603
|
+
* files (`events.<n>.jsonl`) in the same order `JsonlEventLog.read`
|
|
604
|
+
* uses inside the writer path so consumers see one consistent stream
|
|
605
|
+
* whether they came in via the writer store OR the read-only view.
|
|
606
|
+
*/
|
|
607
|
+
async events(sessionId, opts) {
|
|
608
|
+
const sessionDir = resolve(this.projectStoreDir, 'sessions', sessionId);
|
|
609
|
+
if (!existsSync(sessionDir))
|
|
610
|
+
return [];
|
|
611
|
+
const log = new JsonlEventLog({ sessionDir });
|
|
612
|
+
try {
|
|
613
|
+
return log.read(opts);
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
log.close();
|
|
617
|
+
}
|
|
589
618
|
}
|
|
590
619
|
async list(opts) {
|
|
591
620
|
const limit = clampLimit(opts?.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT);
|