@pugi/cli 0.1.0-beta.25 → 0.1.0-beta.27
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/memory-sync/queue.js +158 -0
- package/dist/core/memory-sync/queue.spec.js +105 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/repl/session.js +82 -1
- package/dist/core/repl/slash-commands.js +19 -0
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/runtime/cli.js +190 -0
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/memory.spec.js +174 -0
- package/dist/runtime/commands/prd-check.js +235 -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/dist/tui/repl-splash-mascot.js +7 -19
- package/package.json +3 -3
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork-subagent prompt-cache inheritance (Leak L10 — 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's leaked sub-agent spawn pattern: when a parent agent
|
|
5
|
+
* dispatches a child via the Task tool, the child boots with the parent's
|
|
6
|
+
* prompt cache REFERENCE inherited — not the conversation transcript
|
|
7
|
+
* verbatim, but a provider-native cache handle that lets the child reuse
|
|
8
|
+
* the parent's system prompt + cached tool definitions without re-paying
|
|
9
|
+
* the prompt-prefix tokens on the first turn.
|
|
10
|
+
*
|
|
11
|
+
* Anthropic's `cache_control` block in their messages API exposes this
|
|
12
|
+
* directly via cache breakpoints. OpenAI / xAI / Gemini have similar
|
|
13
|
+
* primitives at different granularities; the wire payload our Anvil
|
|
14
|
+
* proxy speaks is provider-agnostic, so we forward a generic
|
|
15
|
+
* `parent_cache_id` hint and Anvil routes it onto the underlying
|
|
16
|
+
* provider's cache primitive (or silently drops it if the provider
|
|
17
|
+
* doesn't support cache inheritance — graceful degrade).
|
|
18
|
+
*
|
|
19
|
+
* What this module does:
|
|
20
|
+
*
|
|
21
|
+
* 1. `inheritCacheContext(parentSessionId, childAgentId)` —
|
|
22
|
+
* synthesises a cache handle for a child dispatch. Persists the
|
|
23
|
+
* handle to `.pugi/cache-refs/<child-agent-id>.json` so:
|
|
24
|
+
*
|
|
25
|
+
* a. The child's own boot path can read it (e.g. a child engine
|
|
26
|
+
* loop running in a worktree subshell where the in-memory
|
|
27
|
+
* DispatcherContext isn't reachable).
|
|
28
|
+
* b. `pugi dispatch list-cache-refs` can surface active refs
|
|
29
|
+
* for debugging "why is my cache hit rate so low".
|
|
30
|
+
* c. `pugi dispatch clear-cache-refs --older-than 1h` can
|
|
31
|
+
* garbage-collect stale refs from crashed/killed children.
|
|
32
|
+
*
|
|
33
|
+
* 2. `readCacheRef(workspaceRoot, childAgentId)` — child-side read.
|
|
34
|
+
* Returns the persisted handle so the child's first engine loop
|
|
35
|
+
* turn can include the parent_cache_id hint in its request.
|
|
36
|
+
*
|
|
37
|
+
* 3. `cacheHandoffHookForRequest(handle)` — produces the wire payload
|
|
38
|
+
* shape that the Anvil bridge can splice into the
|
|
39
|
+
* `engineLoopServerRequest` body. Lives here (not in the bridge)
|
|
40
|
+
* so the cache-control schema is in one place.
|
|
41
|
+
*
|
|
42
|
+
* What this module does NOT do:
|
|
43
|
+
*
|
|
44
|
+
* - It does not replay the parent's conversation transcript into the
|
|
45
|
+
* child. That would defeat the cyber-zoo isolation contract
|
|
46
|
+
* (`shared_fs_readonly` etc.). The child gets a CACHE HINT — the
|
|
47
|
+
* provider may use it to skip re-tokenising shared prompt prefix,
|
|
48
|
+
* but the LOGICAL conversation always starts fresh from the child's
|
|
49
|
+
* own system prompt + brief.
|
|
50
|
+
*
|
|
51
|
+
* - It does not assume any provider honours the hint. Cache-miss is
|
|
52
|
+
* the default path. If Anvil routes the request to a model whose
|
|
53
|
+
* provider doesn't expose cache inheritance, the request still
|
|
54
|
+
* succeeds — just at full prompt-prefix cost.
|
|
55
|
+
*
|
|
56
|
+
* - It does not rotate or invalidate the parent's cache on the
|
|
57
|
+
* parent's side. Parent cache lifecycle is owned by the parent
|
|
58
|
+
* engine loop; the child holds a read-only reference.
|
|
59
|
+
*
|
|
60
|
+
* Cross-reference:
|
|
61
|
+
* - apps/pugi-cli/src/core/subagents/dispatcher-real.ts — where the
|
|
62
|
+
* child engine loop is driven; the cache_id from this module is
|
|
63
|
+
* forwarded onto Anvil via the `extensions` field of the engine
|
|
64
|
+
* loop server request (β2 forward-compat slot).
|
|
65
|
+
* - packages/pugi-sdk/src/engine-loop.ts — engineLoopServerRequest
|
|
66
|
+
* schema; cache-handoff field is OPTIONAL and additive.
|
|
67
|
+
*
|
|
68
|
+
* Leak research §L10 (Claude Code sub-agent spawn fork pattern):
|
|
69
|
+
* docs/research/2026-05-27-leak-parity-sprint.md (research memo).
|
|
70
|
+
*/
|
|
71
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
72
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
73
|
+
import { randomUUID } from 'node:crypto';
|
|
74
|
+
import { z } from 'zod';
|
|
75
|
+
/* ------------------------------------------------------------------ */
|
|
76
|
+
/* Types + schema */
|
|
77
|
+
/* ------------------------------------------------------------------ */
|
|
78
|
+
/**
|
|
79
|
+
* Persisted cache reference. The shape is intentionally minimal —
|
|
80
|
+
* provider-specific cache tokens are opaque strings so the file format
|
|
81
|
+
* does not couple to any one provider's API.
|
|
82
|
+
*
|
|
83
|
+
* - `cacheId` — opaque provider hint forwarded to Anvil.
|
|
84
|
+
* Synthesized client-side as `pugi-cache-<uuid>`
|
|
85
|
+
* so the server has a stable correlation key
|
|
86
|
+
* even when the underlying provider's cache
|
|
87
|
+
* object is not yet provisioned.
|
|
88
|
+
* - `contextRef` — logical reference key the parent uses to tag
|
|
89
|
+
* which prompt segments to re-use. Same value
|
|
90
|
+
* as cacheId for the v1 wire (single breakpoint
|
|
91
|
+
* per parent); reserved for future multi-breakpoint
|
|
92
|
+
* schemes (split system + tools + first-user
|
|
93
|
+
* segments).
|
|
94
|
+
* - `parentSessionId` — debugging / GC scoping. `list-cache-refs`
|
|
95
|
+
* can group by parent session.
|
|
96
|
+
* - `childAgentId` — the dispatch this ref is scoped to.
|
|
97
|
+
* - `createdAt` — ISO timestamp for `--older-than` cleanup.
|
|
98
|
+
* - `schemaVersion` — pinned to 1; bumped on breaking shape change.
|
|
99
|
+
*/
|
|
100
|
+
export const cacheRefSchema = z.object({
|
|
101
|
+
schemaVersion: z.literal(1),
|
|
102
|
+
cacheId: z.string().min(1).max(256),
|
|
103
|
+
contextRef: z.string().min(1).max(256),
|
|
104
|
+
parentSessionId: z.string().min(1).max(256),
|
|
105
|
+
childAgentId: z.string().min(1).max(256),
|
|
106
|
+
createdAt: z.string().min(1),
|
|
107
|
+
});
|
|
108
|
+
/* ------------------------------------------------------------------ */
|
|
109
|
+
/* Path resolution */
|
|
110
|
+
/* ------------------------------------------------------------------ */
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the directory under `.pugi/` where cache refs live. The
|
|
113
|
+
* directory is created on first write — readers tolerate its absence
|
|
114
|
+
* (no refs = empty list, not an error).
|
|
115
|
+
*/
|
|
116
|
+
export function cacheRefDir(workspaceRoot) {
|
|
117
|
+
return resolvePath(workspaceRoot, '.pugi', 'cache-refs');
|
|
118
|
+
}
|
|
119
|
+
function cacheRefPath(workspaceRoot, childAgentId) {
|
|
120
|
+
// Sanitise the child id — directory traversal via crafted child id
|
|
121
|
+
// would let a dispatch target write outside the cache-refs dir. The
|
|
122
|
+
// child id format is owned by spawn.ts (`subagent-<uuid>`) but we
|
|
123
|
+
// defend in depth.
|
|
124
|
+
const safe = childAgentId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 200);
|
|
125
|
+
return join(cacheRefDir(workspaceRoot), `${safe}.json`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Synthesize a cache handle for a child dispatch and persist it to
|
|
129
|
+
* `.pugi/cache-refs/<childAgentId>.json`. Returns the in-memory handle
|
|
130
|
+
* the dispatcher feeds into the Anvil wire request.
|
|
131
|
+
*
|
|
132
|
+
* Idempotent: calling twice with the same `childAgentId` overwrites
|
|
133
|
+
* the existing ref. The dispatcher's `taskId` is a UUID per dispatch,
|
|
134
|
+
* so collisions are not expected, but the overwrite semantic is safer
|
|
135
|
+
* than failing — a stale ref from a previous run (e.g. process killed
|
|
136
|
+
* between spawn and run) should not block a fresh dispatch.
|
|
137
|
+
*/
|
|
138
|
+
export function inheritCacheContext(parentSessionId, childAgentId, options) {
|
|
139
|
+
if (!parentSessionId) {
|
|
140
|
+
throw new Error('inheritCacheContext: parentSessionId must be non-empty');
|
|
141
|
+
}
|
|
142
|
+
if (!childAgentId) {
|
|
143
|
+
throw new Error('inheritCacheContext: childAgentId must be non-empty');
|
|
144
|
+
}
|
|
145
|
+
const now = options.now ?? defaultNow;
|
|
146
|
+
const cacheIdFactory = options.cacheIdFactory ?? defaultCacheId;
|
|
147
|
+
const cacheId = cacheIdFactory();
|
|
148
|
+
const ref = {
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
cacheId,
|
|
151
|
+
contextRef: cacheId,
|
|
152
|
+
parentSessionId,
|
|
153
|
+
childAgentId,
|
|
154
|
+
createdAt: now(),
|
|
155
|
+
};
|
|
156
|
+
const dir = cacheRefDir(options.workspaceRoot);
|
|
157
|
+
mkdirSync(dir, { recursive: true });
|
|
158
|
+
const path = cacheRefPath(options.workspaceRoot, childAgentId);
|
|
159
|
+
writeFileSync(path, JSON.stringify(ref, null, 2), 'utf8');
|
|
160
|
+
return {
|
|
161
|
+
cacheId: ref.cacheId,
|
|
162
|
+
contextRef: ref.contextRef,
|
|
163
|
+
persistedPath: path,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Child-side read. Returns null when the ref is missing or malformed
|
|
168
|
+
* (the child boots without cache inheritance — degrade is silent so a
|
|
169
|
+
* corrupted ref does not block a dispatch).
|
|
170
|
+
*
|
|
171
|
+
* Malformed ref files are NOT auto-deleted by this read path — that's
|
|
172
|
+
* the cleanup command's job. A failed parse here just returns null so
|
|
173
|
+
* the dispatch proceeds at full prompt-prefix cost.
|
|
174
|
+
*/
|
|
175
|
+
export function readCacheRef(workspaceRoot, childAgentId) {
|
|
176
|
+
const path = cacheRefPath(workspaceRoot, childAgentId);
|
|
177
|
+
let raw;
|
|
178
|
+
try {
|
|
179
|
+
raw = readFileSync(path, 'utf8');
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
let parsed;
|
|
185
|
+
try {
|
|
186
|
+
parsed = JSON.parse(raw);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const validated = cacheRefSchema.safeParse(parsed);
|
|
192
|
+
if (!validated.success)
|
|
193
|
+
return null;
|
|
194
|
+
return validated.data;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* List every persisted cache ref under `.pugi/cache-refs/`. Used by
|
|
198
|
+
* `pugi dispatch list-cache-refs` and by GC sweeps in
|
|
199
|
+
* `cache-cleanup.ts`. Returns refs in deterministic
|
|
200
|
+
* (filename-ascending) order so the CLI output is stable.
|
|
201
|
+
*
|
|
202
|
+
* Malformed ref files are silently skipped — the cleanup command can
|
|
203
|
+
* surface them via a separate `--show-corrupt` flag if needed.
|
|
204
|
+
*/
|
|
205
|
+
export function listCacheRefs(workspaceRoot) {
|
|
206
|
+
const dir = cacheRefDir(workspaceRoot);
|
|
207
|
+
let entries;
|
|
208
|
+
try {
|
|
209
|
+
entries = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const refs = [];
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const full = join(dir, entry);
|
|
217
|
+
let raw;
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(full, 'utf8');
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let parsed;
|
|
225
|
+
try {
|
|
226
|
+
parsed = JSON.parse(raw);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const validated = cacheRefSchema.safeParse(parsed);
|
|
232
|
+
if (!validated.success)
|
|
233
|
+
continue;
|
|
234
|
+
refs.push(validated.data);
|
|
235
|
+
}
|
|
236
|
+
return refs;
|
|
237
|
+
}
|
|
238
|
+
/* ------------------------------------------------------------------ */
|
|
239
|
+
/* Wire-format hook */
|
|
240
|
+
/* ------------------------------------------------------------------ */
|
|
241
|
+
/**
|
|
242
|
+
* Project the in-memory handle (or persisted ref) onto the wire-format
|
|
243
|
+
* hint object that the Anvil bridge merges into the engine-loop
|
|
244
|
+
* request body. Pulled out so callers (dispatcher-real, future bridge
|
|
245
|
+
* adapters) all speak the same shape.
|
|
246
|
+
*/
|
|
247
|
+
export function cacheHandoffHookForRequest(source) {
|
|
248
|
+
return {
|
|
249
|
+
parent_cache_id: source.cacheId,
|
|
250
|
+
cache_context_ref: source.contextRef,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/* ------------------------------------------------------------------ */
|
|
254
|
+
/* Internals */
|
|
255
|
+
/* ------------------------------------------------------------------ */
|
|
256
|
+
function defaultNow() {
|
|
257
|
+
return new Date().toISOString();
|
|
258
|
+
}
|
|
259
|
+
function defaultCacheId() {
|
|
260
|
+
return `pugi-cache-${randomUUID()}`;
|
|
261
|
+
}
|
|
262
|
+
/* ------------------------------------------------------------------ */
|
|
263
|
+
/* Stat helper (used by cache-cleanup.ts) */
|
|
264
|
+
/* ------------------------------------------------------------------ */
|
|
265
|
+
/**
|
|
266
|
+
* Exposed for cache-cleanup.ts so the GC sweep can read mtime without
|
|
267
|
+
* re-implementing the path-resolution logic. Returns null when the
|
|
268
|
+
* file is gone (a concurrent cleanup may have raced us — silent miss).
|
|
269
|
+
*/
|
|
270
|
+
export function cacheRefMtime(workspaceRoot, childAgentId) {
|
|
271
|
+
try {
|
|
272
|
+
const stat = statSync(cacheRefPath(workspaceRoot, childAgentId));
|
|
273
|
+
return stat.mtime;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Delete a single cache ref. Returns true when the file existed and
|
|
281
|
+
* was removed, false when it was already gone. Used by both the
|
|
282
|
+
* `clear-cache-refs` CLI and by post-dispatch cleanup (a successful
|
|
283
|
+
* subagent run does not need the cache ref to outlive its dispatch).
|
|
284
|
+
*/
|
|
285
|
+
export function deleteCacheRef(workspaceRoot, childAgentId) {
|
|
286
|
+
const path = cacheRefPath(workspaceRoot, childAgentId);
|
|
287
|
+
try {
|
|
288
|
+
rmSync(path, { force: false });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=cache-handoff.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
|