@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.
@@ -0,0 +1,508 @@
1
+ /**
2
+ * `pugi memory` — operator surface for the persona-memory layer
3
+ * (ADR-0063 Day 4).
4
+ *
5
+ * Subcommands:
6
+ *
7
+ * pugi memory list [--persona <slug>] [--kind <kind>] [--limit <n>]
8
+ * List memories for the current tenant — recency-first. Renders a
9
+ * table with TTL countdown.
10
+ *
11
+ * pugi memory recall <query> [--persona <slug>] [--top-k <n>]
12
+ * Hybrid recall (vector + BM25 + recency) for one persona.
13
+ *
14
+ * pugi memory write <kind> <content> [--persona <slug>] [--forget-after <iso>]
15
+ * Persist a new memory. Queues locally + retries on next sync when
16
+ * the admin-api is unreachable.
17
+ *
18
+ * pugi memory forget <id>
19
+ * Hard-delete one memory by id.
20
+ *
21
+ * pugi memory sync
22
+ * Push the local pending-write queue to the admin-api. Idempotent:
23
+ * successful ops are dropped from the queue, failed ops stay queued.
24
+ *
25
+ * Auth surface: the same `resolveActiveCredential` flow every other
26
+ * pugi command uses — bearer token + apiUrl from `~/.pugi/credentials.json`.
27
+ * The admin-api enforces the tenant boundary via `BridgeOrJwtGuard` +
28
+ * `prisma.withTenant`.
29
+ *
30
+ * Feature flag: the admin-api side honours `PERSONA_MEMORY_ENABLED` and
31
+ * returns HTTP 503 `{error: feature_disabled}` until the flag is
32
+ * flipped. The CLI surfaces that response verbatim so the operator
33
+ * sees the actual gate.
34
+ */
35
+ import { request } from 'undici';
36
+ import { resolveActiveCredential } from '../../core/credentials.js';
37
+ import { PERSONA_MEMORY_KINDS, countPending, defaultQueuePath, enqueueMemoryOp, readMemoryQueue, rewriteMemoryQueue, } from '../../core/memory-sync/queue.js';
38
+ const SUB_USAGE = [
39
+ 'pugi memory list [--persona <slug>] [--kind <k>] [--limit <n>]',
40
+ 'pugi memory recall <query> [--persona <slug>] [--top-k <n>]',
41
+ 'pugi memory write <kind> <content> [--persona <slug>] [--forget-after <iso>]',
42
+ 'pugi memory forget <id>',
43
+ 'pugi memory sync',
44
+ ].join('\n ');
45
+ const DEFAULT_PERSONA = 'mira';
46
+ /** Single CLI entry — top-level `pugi memory` AND the in-REPL `/memory` slash both call this. */
47
+ export async function runMemoryCommand(args, ctx) {
48
+ const sub = (args[0] ?? '').toLowerCase();
49
+ const tail = args.slice(1);
50
+ switch (sub) {
51
+ case '':
52
+ case '-h':
53
+ case '--help':
54
+ return printUsage(ctx);
55
+ case 'list':
56
+ return runList(tail, ctx);
57
+ case 'recall':
58
+ return runRecall(tail, ctx);
59
+ case 'write':
60
+ case 'remember':
61
+ return runWrite(tail, ctx);
62
+ case 'forget':
63
+ case 'delete':
64
+ return runForget(tail, ctx);
65
+ case 'sync':
66
+ return runSync(ctx);
67
+ default:
68
+ ctx.writeOutput({ command: 'memory', sub, status: 'unknown_sub' }, `Unknown sub-command "${sub}".\nUsage:\n ${SUB_USAGE}`);
69
+ return { command: 'memory', sub, status: 'unknown_sub' };
70
+ }
71
+ }
72
+ function printUsage(ctx) {
73
+ ctx.writeOutput({ command: 'memory', sub: 'help', usage: SUB_USAGE }, `pugi memory — persona-memory operator surface (ADR-0063).\nUsage:\n ${SUB_USAGE}`);
74
+ return { command: 'memory', sub: 'help', status: 'listed' };
75
+ }
76
+ // ===========================================================================
77
+ // list
78
+ // ===========================================================================
79
+ async function runList(args, ctx) {
80
+ const flags = parseFlags(args);
81
+ const cred = resolveActiveCredential();
82
+ if (!cred)
83
+ return emitUnauth('list', ctx);
84
+ const url = new URL(`${stripTrailing(cred.apiUrl)}/api/persona-memory`);
85
+ if (flags.persona)
86
+ url.searchParams.set('personaSlug', flags.persona);
87
+ if (flags.kind)
88
+ url.searchParams.set('kind', flags.kind);
89
+ if (flags.limit)
90
+ url.searchParams.set('limit', String(flags.limit));
91
+ const res = await safeGet(url.toString(), cred.apiKey);
92
+ if (res.status === 503)
93
+ return emitFeatureDisabled('list', ctx);
94
+ if (!res.ok) {
95
+ ctx.writeOutput({ command: 'memory', sub: 'list', status: 'invalid_args', reason: res.detail }, `pugi memory list: HTTP ${res.statusCode} — ${res.detail}`);
96
+ return { command: 'memory', sub: 'list', status: 'invalid_args', reason: res.detail };
97
+ }
98
+ const items = (res.body ?? []);
99
+ const now = (ctx.now ?? (() => new Date()))();
100
+ const lines = items.map((item) => formatMemoryRow(item, now));
101
+ const text = items.length === 0
102
+ ? 'No memories for the active tenant + filters.'
103
+ : ['kind strength age ttl id content', ...lines].join('\n');
104
+ ctx.writeOutput({ command: 'memory', sub: 'list', count: items.length, items }, text);
105
+ return { command: 'memory', sub: 'list', status: 'listed', count: items.length };
106
+ }
107
+ // ===========================================================================
108
+ // recall
109
+ // ===========================================================================
110
+ async function runRecall(args, ctx) {
111
+ const { positional, flags } = splitPositionalFlags(args);
112
+ if (positional.length === 0) {
113
+ ctx.writeOutput({ command: 'memory', sub: 'recall', status: 'invalid_args' }, 'pugi memory recall <query> — query is required.');
114
+ return { command: 'memory', sub: 'recall', status: 'invalid_args' };
115
+ }
116
+ const query = positional.join(' ').trim();
117
+ const persona = flags.persona ?? DEFAULT_PERSONA;
118
+ const topK = flags.topK ?? 5;
119
+ const cred = resolveActiveCredential();
120
+ if (!cred)
121
+ return emitUnauth('recall', ctx);
122
+ const url = new URL(`${stripTrailing(cred.apiUrl)}/api/persona-memory/recall`);
123
+ url.searchParams.set('personaSlug', persona);
124
+ url.searchParams.set('query', query);
125
+ url.searchParams.set('topK', String(topK));
126
+ const res = await safeGet(url.toString(), cred.apiKey);
127
+ if (res.status === 503)
128
+ return emitFeatureDisabled('recall', ctx);
129
+ if (!res.ok) {
130
+ ctx.writeOutput({ command: 'memory', sub: 'recall', status: 'invalid_args', reason: res.detail }, `pugi memory recall: HTTP ${res.statusCode} — ${res.detail}`);
131
+ return { command: 'memory', sub: 'recall', status: 'invalid_args', reason: res.detail };
132
+ }
133
+ const results = (res.body ?? []);
134
+ const text = results.length === 0
135
+ ? `No matches for "${query.slice(0, 64)}" on persona "${persona}".`
136
+ : results
137
+ .map((r, idx) => `${(idx + 1).toString().padStart(2, ' ')}. [${r.item.kind}] score=${r.score.toFixed(3)} ${r.item.content}`)
138
+ .join('\n');
139
+ ctx.writeOutput({ command: 'memory', sub: 'recall', count: results.length, results }, text);
140
+ return { command: 'memory', sub: 'recall', status: 'recalled', count: results.length };
141
+ }
142
+ // ===========================================================================
143
+ // write
144
+ // ===========================================================================
145
+ async function runWrite(args, ctx) {
146
+ const { positional, flags } = splitPositionalFlags(args);
147
+ if (positional.length < 2) {
148
+ ctx.writeOutput({ command: 'memory', sub: 'write', status: 'invalid_args' }, 'pugi memory write <kind> <content> — kind + content required.');
149
+ return { command: 'memory', sub: 'write', status: 'invalid_args' };
150
+ }
151
+ const kind = positional[0]?.toLowerCase() ?? '';
152
+ const content = positional.slice(1).join(' ').trim();
153
+ if (!PERSONA_MEMORY_KINDS.includes(kind)) {
154
+ ctx.writeOutput({
155
+ command: 'memory',
156
+ sub: 'write',
157
+ status: 'invalid_args',
158
+ reason: 'unknown_kind',
159
+ }, `pugi memory write: unknown kind "${kind}". Expected one of ${PERSONA_MEMORY_KINDS.join(' | ')}.`);
160
+ return { command: 'memory', sub: 'write', status: 'invalid_args', reason: 'unknown_kind' };
161
+ }
162
+ if (content.length === 0) {
163
+ ctx.writeOutput({ command: 'memory', sub: 'write', status: 'invalid_args' }, 'pugi memory write: content is empty.');
164
+ return { command: 'memory', sub: 'write', status: 'invalid_args' };
165
+ }
166
+ const persona = flags.persona ?? DEFAULT_PERSONA;
167
+ const cred = resolveActiveCredential();
168
+ if (!cred) {
169
+ // Queue offline. Operator can run `pugi memory sync` once auth is up.
170
+ const pending = enqueueMemoryOp({
171
+ op: 'write',
172
+ personaSlug: persona,
173
+ kind: kind,
174
+ content,
175
+ forgetAfter: flags.forgetAfter ?? null,
176
+ });
177
+ ctx.writeOutput({
178
+ command: 'memory',
179
+ sub: 'write',
180
+ status: 'queued_offline',
181
+ pending,
182
+ }, `Queued offline (no active credential). ${pending} pending — run \`pugi memory sync\` after \`pugi login\`.`);
183
+ return { command: 'memory', sub: 'write', status: 'queued_offline', pending };
184
+ }
185
+ const url = `${stripTrailing(cred.apiUrl)}/api/persona-memory`;
186
+ const body = {
187
+ personaSlug: persona,
188
+ kind,
189
+ content,
190
+ forgetAfter: flags.forgetAfter ?? null,
191
+ };
192
+ const res = await safePost(url, cred.apiKey, body);
193
+ if (res.status === 503)
194
+ return emitFeatureDisabled('write', ctx);
195
+ if (!res.ok) {
196
+ // Server unreachable / 5xx → queue for retry. 4xx → surface error,
197
+ // don't queue (a malformed write would just loop on sync).
198
+ if (res.statusCode >= 500 || res.statusCode === 0) {
199
+ const pending = enqueueMemoryOp({
200
+ op: 'write',
201
+ personaSlug: persona,
202
+ kind: kind,
203
+ content,
204
+ forgetAfter: flags.forgetAfter ?? null,
205
+ });
206
+ ctx.writeOutput({
207
+ command: 'memory',
208
+ sub: 'write',
209
+ status: 'queued_offline',
210
+ pending,
211
+ reason: res.detail,
212
+ }, `Server unreachable (${res.detail}). Queued — ${pending} pending. Run \`pugi memory sync\` later.`);
213
+ return {
214
+ command: 'memory',
215
+ sub: 'write',
216
+ status: 'queued_offline',
217
+ pending,
218
+ reason: res.detail,
219
+ };
220
+ }
221
+ ctx.writeOutput({ command: 'memory', sub: 'write', status: 'invalid_args', reason: res.detail }, `pugi memory write: HTTP ${res.statusCode} — ${res.detail}`);
222
+ return {
223
+ command: 'memory',
224
+ sub: 'write',
225
+ status: 'invalid_args',
226
+ reason: res.detail,
227
+ };
228
+ }
229
+ const created = (res.body ?? {});
230
+ ctx.writeOutput({ command: 'memory', sub: 'write', status: 'written', item: created }, `Persisted [${kind}] on persona "${persona}": ${created.id}`);
231
+ return { command: 'memory', sub: 'write', status: 'written' };
232
+ }
233
+ // ===========================================================================
234
+ // forget
235
+ // ===========================================================================
236
+ async function runForget(args, ctx) {
237
+ const id = (args[0] ?? '').trim();
238
+ if (!id) {
239
+ ctx.writeOutput({ command: 'memory', sub: 'forget', status: 'invalid_args' }, 'pugi memory forget <id> — memory id is required.');
240
+ return { command: 'memory', sub: 'forget', status: 'invalid_args' };
241
+ }
242
+ const cred = resolveActiveCredential();
243
+ if (!cred) {
244
+ const pending = enqueueMemoryOp({ op: 'forget', id });
245
+ ctx.writeOutput({ command: 'memory', sub: 'forget', status: 'queued_offline', pending }, `Queued offline (no active credential). ${pending} pending — run \`pugi memory sync\` after \`pugi login\`.`);
246
+ return { command: 'memory', sub: 'forget', status: 'queued_offline', pending };
247
+ }
248
+ const url = `${stripTrailing(cred.apiUrl)}/api/persona-memory/${encodeURIComponent(id)}`;
249
+ const res = await safeDelete(url, cred.apiKey);
250
+ if (res.status === 503)
251
+ return emitFeatureDisabled('forget', ctx);
252
+ if (res.statusCode === 404) {
253
+ ctx.writeOutput({ command: 'memory', sub: 'forget', status: 'forget_not_found', id }, `No memory with id "${id}" in this tenant.`);
254
+ return { command: 'memory', sub: 'forget', status: 'forget_not_found' };
255
+ }
256
+ if (!res.ok) {
257
+ if (res.statusCode >= 500 || res.statusCode === 0) {
258
+ const pending = enqueueMemoryOp({ op: 'forget', id });
259
+ ctx.writeOutput({
260
+ command: 'memory',
261
+ sub: 'forget',
262
+ status: 'queued_offline',
263
+ pending,
264
+ reason: res.detail,
265
+ }, `Server unreachable. Queued — ${pending} pending.`);
266
+ return {
267
+ command: 'memory',
268
+ sub: 'forget',
269
+ status: 'queued_offline',
270
+ pending,
271
+ reason: res.detail,
272
+ };
273
+ }
274
+ ctx.writeOutput({ command: 'memory', sub: 'forget', status: 'invalid_args', reason: res.detail }, `pugi memory forget: HTTP ${res.statusCode} — ${res.detail}`);
275
+ return {
276
+ command: 'memory',
277
+ sub: 'forget',
278
+ status: 'invalid_args',
279
+ reason: res.detail,
280
+ };
281
+ }
282
+ ctx.writeOutput({ command: 'memory', sub: 'forget', status: 'forgot', id }, `Forgot "${id}".`);
283
+ return { command: 'memory', sub: 'forget', status: 'forgot' };
284
+ }
285
+ // ===========================================================================
286
+ // sync
287
+ // ===========================================================================
288
+ async function runSync(ctx) {
289
+ const cred = resolveActiveCredential();
290
+ if (!cred)
291
+ return emitUnauth('sync', ctx);
292
+ const pendingBefore = countPending();
293
+ if (pendingBefore === 0) {
294
+ ctx.writeOutput({ command: 'memory', sub: 'sync', status: 'sync_noop', pending: 0 }, 'No pending operations to sync.');
295
+ return { command: 'memory', sub: 'sync', status: 'sync_noop', pending: 0 };
296
+ }
297
+ const ops = readMemoryQueue();
298
+ const remaining = [];
299
+ let synced = 0;
300
+ for (const op of ops) {
301
+ const ok = await flushOne(cred.apiUrl, cred.apiKey, op);
302
+ if (ok) {
303
+ synced++;
304
+ }
305
+ else {
306
+ remaining.push(op);
307
+ }
308
+ }
309
+ rewriteMemoryQueue(remaining);
310
+ if (remaining.length === 0) {
311
+ ctx.writeOutput({
312
+ command: 'memory',
313
+ sub: 'sync',
314
+ status: 'synced',
315
+ pending: 0,
316
+ count: synced,
317
+ }, `Synced ${synced} pending operation(s). Queue is empty.`);
318
+ return { command: 'memory', sub: 'sync', status: 'synced', pending: 0, count: synced };
319
+ }
320
+ ctx.writeOutput({
321
+ command: 'memory',
322
+ sub: 'sync',
323
+ status: 'sync_partial',
324
+ pending: remaining.length,
325
+ count: synced,
326
+ }, `Synced ${synced} — ${remaining.length} still pending (server unreachable / 4xx).`);
327
+ return {
328
+ command: 'memory',
329
+ sub: 'sync',
330
+ status: 'sync_partial',
331
+ pending: remaining.length,
332
+ count: synced,
333
+ };
334
+ }
335
+ async function flushOne(apiUrl, apiKey, op) {
336
+ if (op.op === 'write') {
337
+ const res = await safePost(`${stripTrailing(apiUrl)}/api/persona-memory`, apiKey, {
338
+ personaSlug: op.personaSlug,
339
+ kind: op.kind,
340
+ content: op.content,
341
+ forgetAfter: op.forgetAfter ?? null,
342
+ });
343
+ if (res.ok)
344
+ return true;
345
+ // 4xx → drop from queue (would loop forever otherwise).
346
+ if (res.statusCode >= 400 && res.statusCode < 500)
347
+ return true;
348
+ return false;
349
+ }
350
+ // op.op === 'forget'
351
+ const res = await safeDelete(`${stripTrailing(apiUrl)}/api/persona-memory/${encodeURIComponent(op.id)}`, apiKey);
352
+ if (res.ok)
353
+ return true;
354
+ // 404 → drop (already gone); other 4xx → drop too.
355
+ if (res.statusCode >= 400 && res.statusCode < 500)
356
+ return true;
357
+ return false;
358
+ }
359
+ function parseFlags(args) {
360
+ const out = {};
361
+ for (let i = 0; i < args.length; i++) {
362
+ const a = args[i] ?? '';
363
+ if (a === '--persona' && args[i + 1]) {
364
+ out.persona = args[i + 1];
365
+ i++;
366
+ }
367
+ else if (a === '--kind' && args[i + 1]) {
368
+ out.kind = args[i + 1];
369
+ i++;
370
+ }
371
+ else if (a === '--limit' && args[i + 1]) {
372
+ const n = Number.parseInt(args[i + 1] ?? '', 10);
373
+ if (Number.isFinite(n))
374
+ out.limit = n;
375
+ i++;
376
+ }
377
+ else if (a === '--top-k' && args[i + 1]) {
378
+ const n = Number.parseInt(args[i + 1] ?? '', 10);
379
+ if (Number.isFinite(n))
380
+ out.topK = n;
381
+ i++;
382
+ }
383
+ else if (a === '--forget-after' && args[i + 1]) {
384
+ out.forgetAfter = args[i + 1];
385
+ i++;
386
+ }
387
+ }
388
+ return out;
389
+ }
390
+ function splitPositionalFlags(args) {
391
+ const positional = [];
392
+ const consumed = new Set();
393
+ for (let i = 0; i < args.length; i++) {
394
+ const a = args[i] ?? '';
395
+ if (a.startsWith('--')) {
396
+ consumed.add(i);
397
+ if (i + 1 < args.length && !(args[i + 1] ?? '').startsWith('--')) {
398
+ consumed.add(i + 1);
399
+ }
400
+ }
401
+ else if (!consumed.has(i)) {
402
+ positional.push(a);
403
+ }
404
+ }
405
+ const flagArgs = [];
406
+ for (let i = 0; i < args.length; i++) {
407
+ if (consumed.has(i))
408
+ flagArgs.push(args[i] ?? '');
409
+ }
410
+ return { positional, flags: parseFlags(flagArgs) };
411
+ }
412
+ function stripTrailing(url) {
413
+ return url.endsWith('/') ? url.slice(0, -1) : url;
414
+ }
415
+ function formatMemoryRow(item, now) {
416
+ const age = ageHuman(item.lastUsedAt, now);
417
+ const ttl = item.forgetAfter ? ttlHuman(item.forgetAfter, now) : 'none';
418
+ const content = item.content.replace(/[\r\n\t]+/g, ' ').slice(0, 60);
419
+ return `${item.kind.padEnd(12)} ${item.strength.toFixed(2).padEnd(8)} ${age.padEnd(8)} ${ttl.padEnd(11)} ${item.id.padEnd(36)} ${content}`;
420
+ }
421
+ function ageHuman(iso, now) {
422
+ const t = Date.parse(iso);
423
+ if (!Number.isFinite(t))
424
+ return '?';
425
+ const days = Math.floor((now.getTime() - t) / (1000 * 60 * 60 * 24));
426
+ if (days <= 0)
427
+ return '<1d';
428
+ if (days < 30)
429
+ return `${days}d`;
430
+ return `${Math.floor(days / 30)}mo`;
431
+ }
432
+ function ttlHuman(iso, now) {
433
+ const t = Date.parse(iso);
434
+ if (!Number.isFinite(t))
435
+ return '?';
436
+ const ms = t - now.getTime();
437
+ if (ms <= 0)
438
+ return 'expired';
439
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24));
440
+ if (days >= 1)
441
+ return `${days}d`;
442
+ const hours = Math.floor(ms / (1000 * 60 * 60));
443
+ return `${hours}h`;
444
+ }
445
+ async function safeGet(url, apiKey) {
446
+ return safeRequest(url, apiKey, 'GET');
447
+ }
448
+ async function safeDelete(url, apiKey) {
449
+ return safeRequest(url, apiKey, 'DELETE');
450
+ }
451
+ async function safePost(url, apiKey, body) {
452
+ return safeRequest(url, apiKey, 'POST', body);
453
+ }
454
+ async function safeRequest(url, apiKey, method, body) {
455
+ try {
456
+ const headers = {
457
+ authorization: `Bearer ${apiKey}`,
458
+ accept: 'application/json',
459
+ };
460
+ let bodyText;
461
+ if (body !== undefined) {
462
+ bodyText = JSON.stringify(body);
463
+ headers['content-type'] = 'application/json';
464
+ }
465
+ const res = await request(url, { method, headers, body: bodyText });
466
+ const sc = res.statusCode;
467
+ if (sc < 200 || sc >= 300) {
468
+ const detail = await res.body.text().catch(() => '');
469
+ return {
470
+ ok: false,
471
+ status: sc,
472
+ statusCode: sc,
473
+ detail: detail.slice(0, 300),
474
+ };
475
+ }
476
+ const parsed = await res.body.json().catch(() => undefined);
477
+ return { ok: true, status: sc, statusCode: sc, body: parsed, detail: '' };
478
+ }
479
+ catch (err) {
480
+ return {
481
+ ok: false,
482
+ status: 0,
483
+ statusCode: 0,
484
+ detail: err instanceof Error ? err.message : String(err),
485
+ };
486
+ }
487
+ }
488
+ function emitUnauth(sub, ctx) {
489
+ ctx.writeOutput({
490
+ command: 'memory',
491
+ sub,
492
+ status: 'unauthenticated',
493
+ hint: 'pugi login',
494
+ }, 'pugi memory: no active credential. Run `pugi login` first.');
495
+ return { command: 'memory', sub, status: 'unauthenticated' };
496
+ }
497
+ function emitFeatureDisabled(sub, ctx) {
498
+ ctx.writeOutput({
499
+ command: 'memory',
500
+ sub,
501
+ status: 'feature_disabled',
502
+ hint: 'set PERSONA_MEMORY_ENABLED=true on the admin-api host',
503
+ }, 'persona-memory feature is disabled on the admin-api host (HTTP 503).');
504
+ return { command: 'memory', sub, status: 'feature_disabled' };
505
+ }
506
+ /** Re-export for the `pugi doctor` queue-pending probe + the spec. */
507
+ export { defaultQueuePath };
508
+ //# sourceMappingURL=memory.js.map
@@ -0,0 +1,174 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { afterEach, beforeEach, describe, it } from 'node:test';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { resolve } from 'node:path';
6
+ import { runMemoryCommand } from './memory.js';
7
+ import { countPending } from '../../core/memory-sync/queue.js';
8
+ /**
9
+ * Integration tests for the `pugi memory` runner.
10
+ *
11
+ * Authenticated server paths require mocking undici's `MockAgent`; the
12
+ * specs below focus on the un-authenticated + arg-parsing branches
13
+ * (which fully exercise the routing + queue interactions). The shared
14
+ * `runMemoryCommand` is decoupled from the network for those paths
15
+ * because `resolveActiveCredential` returns null without a configured
16
+ * credential file under PUGI_HOME.
17
+ */
18
+ let tmpHome = '';
19
+ const originalPugiHome = process.env.PUGI_HOME;
20
+ const originalHome = process.env.HOME;
21
+ const originalApiKey = process.env.PUGI_API_KEY;
22
+ const originalApiUrl = process.env.PUGI_API_URL;
23
+ function makeCtx() {
24
+ const capture = { payloads: [], texts: [] };
25
+ const ctx = {
26
+ workspaceRoot: process.cwd(),
27
+ json: false,
28
+ writeOutput: (payload, text) => {
29
+ capture.payloads.push(payload);
30
+ capture.texts.push(text);
31
+ },
32
+ };
33
+ return { ctx, capture };
34
+ }
35
+ beforeEach(() => {
36
+ tmpHome = mkdtempSync(resolve(tmpdir(), 'pugi-memory-cli-'));
37
+ // Both PUGI_HOME (used by the queue) AND HOME (used by
38
+ // resolveActiveCredential -> os.homedir()) must point at the temp
39
+ // dir so the spec is hermetic from the operator's real
40
+ // ~/.pugi/credentials.json.
41
+ process.env.PUGI_HOME = tmpHome;
42
+ process.env.HOME = tmpHome;
43
+ // The test machine may export real prod credentials — strip them so
44
+ // the unauthenticated-paths block actually sees no credential.
45
+ delete process.env.PUGI_API_KEY;
46
+ delete process.env.PUGI_API_URL;
47
+ });
48
+ afterEach(() => {
49
+ if (originalPugiHome === undefined)
50
+ delete process.env.PUGI_HOME;
51
+ else
52
+ process.env.PUGI_HOME = originalPugiHome;
53
+ if (originalHome === undefined)
54
+ delete process.env.HOME;
55
+ else
56
+ process.env.HOME = originalHome;
57
+ if (originalApiKey === undefined)
58
+ delete process.env.PUGI_API_KEY;
59
+ else
60
+ process.env.PUGI_API_KEY = originalApiKey;
61
+ if (originalApiUrl === undefined)
62
+ delete process.env.PUGI_API_URL;
63
+ else
64
+ process.env.PUGI_API_URL = originalApiUrl;
65
+ try {
66
+ rmSync(tmpHome, { recursive: true, force: true });
67
+ }
68
+ catch {
69
+ // ignore
70
+ }
71
+ });
72
+ describe('runMemoryCommand — unauthenticated paths', () => {
73
+ it('prints usage on empty subcommand', async () => {
74
+ const { ctx, capture } = makeCtx();
75
+ const result = await runMemoryCommand([], ctx);
76
+ assert.equal(result.sub, 'help');
77
+ assert.ok(capture.texts[0]?.includes('pugi memory'));
78
+ });
79
+ it('rejects unknown subcommand with status unknown_sub', async () => {
80
+ const { ctx } = makeCtx();
81
+ const result = await runMemoryCommand(['blarg'], ctx);
82
+ assert.equal(result.status, 'unknown_sub');
83
+ });
84
+ it('returns unauthenticated for list when no credential', async () => {
85
+ const { ctx } = makeCtx();
86
+ const result = await runMemoryCommand(['list'], ctx);
87
+ assert.equal(result.status, 'unauthenticated');
88
+ });
89
+ it('returns unauthenticated for sync when no credential', async () => {
90
+ const { ctx } = makeCtx();
91
+ const result = await runMemoryCommand(['sync'], ctx);
92
+ assert.equal(result.status, 'unauthenticated');
93
+ });
94
+ it('queues offline writes when no credential present', async () => {
95
+ const { ctx } = makeCtx();
96
+ const result = await runMemoryCommand(['write', 'preference', 'pnpm', '--persona', 'mira'], ctx);
97
+ assert.equal(result.status, 'queued_offline');
98
+ assert.equal(result.pending, 1);
99
+ // Confirm the queue file actually grew.
100
+ assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 1);
101
+ });
102
+ it('queues offline forget when no credential present', async () => {
103
+ const { ctx } = makeCtx();
104
+ const result = await runMemoryCommand(['forget', 'mem-abc'], ctx);
105
+ assert.equal(result.status, 'queued_offline');
106
+ assert.equal(result.pending, 1);
107
+ });
108
+ it('rejects write missing kind', async () => {
109
+ const { ctx } = makeCtx();
110
+ const result = await runMemoryCommand(['write'], ctx);
111
+ assert.equal(result.status, 'invalid_args');
112
+ });
113
+ it('rejects write with unknown kind', async () => {
114
+ const { ctx } = makeCtx();
115
+ const result = await runMemoryCommand(['write', 'notakind', 'content here'], ctx);
116
+ assert.equal(result.status, 'invalid_args');
117
+ assert.equal(result.reason, 'unknown_kind');
118
+ });
119
+ it('rejects write with empty content', async () => {
120
+ const { ctx } = makeCtx();
121
+ const result = await runMemoryCommand(['write', 'fact', ' '], ctx);
122
+ assert.equal(result.status, 'invalid_args');
123
+ });
124
+ it('rejects forget without id', async () => {
125
+ const { ctx } = makeCtx();
126
+ const result = await runMemoryCommand(['forget'], ctx);
127
+ assert.equal(result.status, 'invalid_args');
128
+ });
129
+ it('rejects recall without query', async () => {
130
+ const { ctx } = makeCtx();
131
+ const result = await runMemoryCommand(['recall'], ctx);
132
+ assert.equal(result.status, 'invalid_args');
133
+ });
134
+ it('sync with no pending ops returns sync_noop when offline-then-online flow', async () => {
135
+ // Two writes queued offline (no credential present), then sync called
136
+ // — sync without credential still returns unauthenticated, but the
137
+ // queued ops persist. After credential lands, sync would push them.
138
+ const { ctx } = makeCtx();
139
+ await runMemoryCommand(['write', 'fact', 'queued one'], ctx);
140
+ await runMemoryCommand(['write', 'fact', 'queued two'], ctx);
141
+ assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 2);
142
+ const { ctx: ctx2 } = makeCtx();
143
+ const result = await runMemoryCommand(['sync'], ctx2);
144
+ // Without an active credential, sync returns unauthenticated.
145
+ assert.equal(result.status, 'unauthenticated');
146
+ // The queued ops are still on disk for a later authenticated sync.
147
+ assert.equal(countPending(resolve(tmpHome, 'memory-queue.jsonl')), 2);
148
+ });
149
+ it('write+sync flow keeps queue intact across calls', async () => {
150
+ const { ctx } = makeCtx();
151
+ await runMemoryCommand(['write', 'workflow', 'sample workflow'], ctx);
152
+ const path = resolve(tmpHome, 'memory-queue.jsonl');
153
+ assert.equal(countPending(path), 1);
154
+ // A second call should append, not overwrite.
155
+ await runMemoryCommand(['write', 'fact', 'another fact'], ctx);
156
+ assert.equal(countPending(path), 2);
157
+ });
158
+ });
159
+ describe('runMemoryCommand — flag parsing', () => {
160
+ it('parses --persona and --kind flags on list', async () => {
161
+ const { ctx, capture } = makeCtx();
162
+ // list path requires auth — we only verify the flag-parsing path
163
+ // does not throw + surfaces the unauth status.
164
+ const result = await runMemoryCommand(['list', '--persona', 'marcus', '--kind', 'pattern', '--limit', '5'], ctx);
165
+ assert.equal(result.status, 'unauthenticated');
166
+ assert.equal(capture.payloads.length, 1);
167
+ });
168
+ it('parses --top-k on recall', async () => {
169
+ const { ctx } = makeCtx();
170
+ const result = await runMemoryCommand(['recall', 'what is pugi', '--top-k', '3'], ctx);
171
+ assert.equal(result.status, 'unauthenticated');
172
+ });
173
+ });
174
+ //# sourceMappingURL=memory.spec.js.map