@graphorin/cli 0.6.1 → 0.7.0

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.
Files changed (87) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +3 -3
  3. package/dist/bin/graphorin.js +51 -13
  4. package/dist/bin/graphorin.js.map +1 -1
  5. package/dist/commands/audit.d.ts.map +1 -1
  6. package/dist/commands/audit.js +2 -1
  7. package/dist/commands/audit.js.map +1 -1
  8. package/dist/commands/consolidator.d.ts +56 -1
  9. package/dist/commands/consolidator.d.ts.map +1 -1
  10. package/dist/commands/consolidator.js +88 -2
  11. package/dist/commands/consolidator.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/index.d.ts +4 -4
  14. package/dist/commands/index.js +4 -4
  15. package/dist/commands/init.d.ts +11 -4
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +15 -11
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/memory.d.ts +26 -1
  20. package/dist/commands/memory.d.ts.map +1 -1
  21. package/dist/commands/memory.js +56 -3
  22. package/dist/commands/memory.js.map +1 -1
  23. package/dist/commands/pricing.d.ts +6 -0
  24. package/dist/commands/pricing.d.ts.map +1 -1
  25. package/dist/commands/pricing.js +5 -2
  26. package/dist/commands/pricing.js.map +1 -1
  27. package/dist/commands/secrets.d.ts.map +1 -1
  28. package/dist/commands/secrets.js +2 -2
  29. package/dist/commands/secrets.js.map +1 -1
  30. package/dist/commands/skills.d.ts.map +1 -1
  31. package/dist/commands/skills.js +1 -1
  32. package/dist/commands/skills.js.map +1 -1
  33. package/dist/commands/storage.d.ts +41 -1
  34. package/dist/commands/storage.d.ts.map +1 -1
  35. package/dist/commands/storage.js +75 -1
  36. package/dist/commands/storage.js.map +1 -1
  37. package/dist/commands/token.d.ts.map +1 -1
  38. package/dist/commands/token.js +2 -2
  39. package/dist/commands/token.js.map +1 -1
  40. package/dist/commands/tools-lint.js +1 -1
  41. package/dist/commands/tools-lint.js.map +1 -1
  42. package/dist/commands/traces.d.ts +14 -2
  43. package/dist/commands/traces.d.ts.map +1 -1
  44. package/dist/commands/traces.js +39 -22
  45. package/dist/commands/traces.js.map +1 -1
  46. package/dist/commands/triggers.d.ts.map +1 -1
  47. package/dist/commands/triggers.js +5 -2
  48. package/dist/commands/triggers.js.map +1 -1
  49. package/dist/index.d.ts +4 -4
  50. package/dist/index.js +4 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/output.js +6 -0
  53. package/dist/internal/output.js.map +1 -1
  54. package/dist/internal/store-context.js +13 -2
  55. package/dist/internal/store-context.js.map +1 -1
  56. package/dist/package.js +1 -1
  57. package/dist/package.js.map +1 -1
  58. package/package.json +18 -14
  59. package/src/bin/graphorin.ts +1387 -0
  60. package/src/commands/audit.ts +256 -0
  61. package/src/commands/auth.ts +238 -0
  62. package/src/commands/consolidator.ts +382 -0
  63. package/src/commands/doctor.ts +253 -0
  64. package/src/commands/guard.ts +144 -0
  65. package/src/commands/index.ts +223 -0
  66. package/src/commands/init.ts +194 -0
  67. package/src/commands/memory.ts +1052 -0
  68. package/src/commands/migrate-config.ts +77 -0
  69. package/src/commands/migrate-export.ts +117 -0
  70. package/src/commands/migrate.ts +83 -0
  71. package/src/commands/pricing.ts +244 -0
  72. package/src/commands/secrets.ts +309 -0
  73. package/src/commands/skills.ts +272 -0
  74. package/src/commands/start.ts +180 -0
  75. package/src/commands/storage.ts +659 -0
  76. package/src/commands/telemetry.ts +91 -0
  77. package/src/commands/token.ts +361 -0
  78. package/src/commands/tools-lint.ts +430 -0
  79. package/src/commands/traces.ts +188 -0
  80. package/src/commands/triggers.ts +237 -0
  81. package/src/index.ts +30 -0
  82. package/src/internal/exit.ts +62 -0
  83. package/src/internal/load-config.ts +107 -0
  84. package/src/internal/offline.ts +81 -0
  85. package/src/internal/output.ts +146 -0
  86. package/src/internal/prompts.ts +58 -0
  87. package/src/internal/store-context.ts +165 -0
@@ -0,0 +1,1052 @@
1
+ /**
2
+ * `graphorin memory` - long-term memory operator commands.
3
+ *
4
+ * Surface (per Phase 15 § Memory):
5
+ *
6
+ * - `graphorin memory status` - counts + active embedder + migration
7
+ * state. Pure read-only inspection of the SQLite store.
8
+ * - `graphorin memory migrate --from <embedder> --to <embedder>
9
+ * --strategy <lock-on-first|auto-migrate|multi-active>` - embedder
10
+ * swap. The runner itself lives in `@graphorin/memory`
11
+ * ({@link migrateEmbedder}); the CLI is a thin wrapper that loads
12
+ * the operator's `embedders.ts` module if supplied through
13
+ * `--embedders <path>`. Without that module the command emits a
14
+ * pointer to the documentation rather than guessing which embedder
15
+ * factory to instantiate.
16
+ *
17
+ * @packageDocumentation
18
+ */
19
+
20
+ import process from 'node:process';
21
+
22
+ import { createMemory } from '@graphorin/memory';
23
+ import { EXIT_CODES } from '../internal/exit.js';
24
+ import {
25
+ brand,
26
+ type CommonOutputOptions,
27
+ defaultPrintSink,
28
+ emitReport,
29
+ statusMarker,
30
+ } from '../internal/output.js';
31
+ import { openStoreContext } from '../internal/store-context.js';
32
+
33
+ /** @stable */
34
+ export interface MemoryCommonOptions extends CommonOutputOptions {
35
+ readonly config?: string;
36
+ }
37
+
38
+ /** @stable */
39
+ export interface MemoryStatusEmbedder {
40
+ readonly id: string;
41
+ readonly model: string;
42
+ readonly dim: number;
43
+ readonly distanceMetric: string;
44
+ readonly retired: boolean;
45
+ readonly createdAt: string;
46
+ readonly retiredAt?: string;
47
+ }
48
+
49
+ /** @stable */
50
+ export interface MemoryStatusResult {
51
+ readonly storagePath: string;
52
+ readonly embedders: ReadonlyArray<MemoryStatusEmbedder>;
53
+ readonly counts: {
54
+ readonly facts: number;
55
+ readonly episodes: number;
56
+ readonly sessionMessages: number;
57
+ readonly procedures: number;
58
+ };
59
+ }
60
+
61
+ /** @stable */
62
+ export async function runMemoryStatus(
63
+ options: MemoryCommonOptions = {},
64
+ ): Promise<MemoryStatusResult> {
65
+ const ctx = await openStoreContext({
66
+ ...(options.config !== undefined ? { config: options.config } : {}),
67
+ });
68
+ try {
69
+ const embedders = ctx.store.embeddings.listAll().map(
70
+ (row): MemoryStatusEmbedder =>
71
+ Object.freeze({
72
+ id: row.id,
73
+ model: row.model,
74
+ dim: row.dim,
75
+ distanceMetric: row.distanceMetric,
76
+ retired: row.retiredAt !== null,
77
+ createdAt: new Date(row.createdAt).toISOString(),
78
+ ...(row.retiredAt !== null ? { retiredAt: new Date(row.retiredAt).toISOString() } : {}),
79
+ }),
80
+ );
81
+ const counts = await readMemoryCounts(ctx.store);
82
+ const out: MemoryStatusResult = Object.freeze({
83
+ storagePath: ctx.config.storage.path,
84
+ embedders: Object.freeze(embedders),
85
+ counts: Object.freeze(counts),
86
+ });
87
+ emitReport(options, out, () => {
88
+ const print = options.print ?? defaultPrintSink;
89
+ print(brand(`memory status (${out.storagePath})`));
90
+ if (out.embedders.length === 0) {
91
+ print(brand(` no embedders registered yet - run createMemory({ embedder }) once.`));
92
+ } else {
93
+ print(brand(` embedders (${out.embedders.length}):`));
94
+ for (const e of out.embedders) {
95
+ const status = e.retired ? statusMarker('warn') : statusMarker('ok');
96
+ print(` ${status} ${e.id} (model=${e.model}, dim=${e.dim}, retired=${e.retired})`);
97
+ }
98
+ }
99
+ print(
100
+ brand(
101
+ ` counts: facts=${out.counts.facts}, episodes=${out.counts.episodes}, sessionMessages=${out.counts.sessionMessages}, procedures=${out.counts.procedures}`,
102
+ ),
103
+ );
104
+ });
105
+ return out;
106
+ } finally {
107
+ await ctx.close();
108
+ }
109
+ }
110
+
111
+ /** @stable */
112
+ export interface MemoryMigrateOptions extends MemoryCommonOptions {
113
+ readonly from: string;
114
+ readonly to: string;
115
+ readonly strategy: 'lock-on-first' | 'auto-migrate' | 'multi-active';
116
+ /**
117
+ * Optional path to a JS / TS module exporting an
118
+ * `embedders` object: `{ <id>: () => EmbedderProvider }`. The CLI
119
+ * imports this module so it can construct the source / target
120
+ * embedder instances the runner needs. Without the module the
121
+ * command exits `2` with a pointer to the documentation.
122
+ */
123
+ readonly embeddersModule?: string;
124
+ }
125
+
126
+ /**
127
+ * `graphorin memory migrate` - embedder swap. The migration logic lives
128
+ * in `@graphorin/memory`'s `migrateEmbedder(...)`; the CLI prints a
129
+ * pointer when the operator did not supply the embedder factory module
130
+ * (the framework cannot guess the operator's embedder configuration).
131
+ *
132
+ * @stable
133
+ */
134
+ export async function runMemoryMigrate(options: MemoryMigrateOptions): Promise<never> {
135
+ const print = options.print ?? defaultPrintSink;
136
+ if (options.embeddersModule === undefined) {
137
+ print(
138
+ brand(
139
+ `'graphorin memory migrate' requires an --embedders module (a JS / TS file exporting { embedders: { '${options.from}': () => EmbedderProvider, '${options.to}': () => EmbedderProvider } }).`,
140
+ ),
141
+ );
142
+ print(
143
+ brand(
144
+ 'The CLI cannot guess which embedder factory to instantiate; the framework intentionally avoids implicit network downloads (DEC-154).',
145
+ ),
146
+ );
147
+ print(
148
+ brand(
149
+ `For programmatic migrations call migrateEmbedder({ source, target, embeddings, strategy: '${options.strategy}' }) from @graphorin/memory directly.`,
150
+ ),
151
+ );
152
+ process.exit(EXIT_CODES.UNSUPPORTED);
153
+ }
154
+ // The full --embeddersModule wiring is out of scope for the v0.1
155
+ // surface; the helpful message above plus the programmatic pointer
156
+ // are the contract per the working plan acceptance criteria.
157
+ print(
158
+ brand(
159
+ '--embeddersModule resolution is planned for v0.2; use migrateEmbedder() programmatically.',
160
+ ),
161
+ );
162
+ process.exit(EXIT_CODES.UNSUPPORTED);
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // `graphorin memory inspect` / `graphorin memory activity` (X-3) - read-only
167
+ // introspection over the SQLite store: a fact's supersede chain, quarantine /
168
+ // provenance, the insights that cite it, the conflict decisions and audit
169
+ // trail it appears in, plus a store-wide activity view (what the consolidator
170
+ // / reflection formed, merged, and quarantined). All reads go through
171
+ // `store.connection` exactly like `memory status`'s count queries - no embedder
172
+ // or provider required, fully offline.
173
+ // ---------------------------------------------------------------------------
174
+
175
+ const FACT_INSPECT_COLUMNS =
176
+ 'id, text, status, provenance, importance, valid_from, valid_to, supersedes, superseded_by, created_at';
177
+
178
+ interface FactInspectRow {
179
+ readonly id: string;
180
+ readonly text: string;
181
+ readonly status: string | null;
182
+ readonly provenance: string | null;
183
+ /** X-1 / migration 015: per-fact salience hint. */
184
+ readonly importance: number | null;
185
+ readonly valid_from: number | null;
186
+ readonly valid_to: number | null;
187
+ readonly supersedes: string | null;
188
+ readonly superseded_by: string | null;
189
+ readonly created_at: number;
190
+ }
191
+
192
+ /** @stable */
193
+ export interface MemoryInspectFact {
194
+ readonly id: string;
195
+ readonly text: string;
196
+ readonly status: string;
197
+ readonly provenance: string | null;
198
+ /** X-1 / migration 015: per-fact importance (salience hint), if set. */
199
+ readonly importance: number | null;
200
+ readonly validFrom: string | null;
201
+ readonly validTo: string | null;
202
+ readonly supersedes: string | null;
203
+ readonly supersededBy: string | null;
204
+ readonly createdAt: string;
205
+ }
206
+
207
+ /**
208
+ * A canonical entity a fact links to (P2-1 / migration 016). `name` follows
209
+ * `merged_into` to the surviving entity, so a merged link shows its canonical.
210
+ *
211
+ * @stable
212
+ */
213
+ export interface MemoryInspectEntity {
214
+ readonly entityId: string;
215
+ readonly name: string;
216
+ readonly role: string;
217
+ /** Set when the linked entity was merged into `entityId`/`name`. */
218
+ readonly mergedFrom: string | null;
219
+ }
220
+
221
+ /** @stable */
222
+ export interface MemoryHistoryEntry {
223
+ readonly event: string;
224
+ readonly source: string;
225
+ readonly createdAt: string;
226
+ }
227
+
228
+ /** @stable */
229
+ export interface MemoryConflictEntry {
230
+ readonly candidateId: string;
231
+ readonly existingId: string | null;
232
+ readonly decision: string;
233
+ readonly stage: string;
234
+ readonly similarity: number | null;
235
+ readonly detectedAt: string;
236
+ }
237
+
238
+ /** @stable */
239
+ export interface MemoryCitingInsight {
240
+ readonly id: string;
241
+ readonly text: string;
242
+ readonly status: string;
243
+ readonly salience: number;
244
+ }
245
+
246
+ /** @stable */
247
+ export interface MemoryInspectResult {
248
+ readonly found: boolean;
249
+ readonly fact: MemoryInspectFact | null;
250
+ readonly chain: ReadonlyArray<MemoryInspectFact>;
251
+ readonly history: ReadonlyArray<MemoryHistoryEntry>;
252
+ readonly conflicts: ReadonlyArray<MemoryConflictEntry>;
253
+ readonly citingInsights: ReadonlyArray<MemoryCitingInsight>;
254
+ /** Canonical entities this fact links to (P2-1 / migration 016). */
255
+ readonly linkedEntities: ReadonlyArray<MemoryInspectEntity>;
256
+ }
257
+
258
+ /** @stable */
259
+ export interface MemoryInspectOptions extends MemoryCommonOptions {
260
+ readonly factId: string;
261
+ }
262
+
263
+ /**
264
+ * `graphorin memory inspect <factId>` - surface everything the store
265
+ * knows about one fact: its retrieval-trust status + provenance, the
266
+ * full bi-temporal supersede chain it belongs to, the audit-log events
267
+ * recorded against it, the conflict decisions that referenced it, and
268
+ * the (quarantined) insights that cite it. Pure read-only inspection.
269
+ *
270
+ * @stable
271
+ */
272
+ export async function runMemoryInspect(
273
+ options: MemoryInspectOptions,
274
+ ): Promise<MemoryInspectResult> {
275
+ const ctx = await openStoreContext({
276
+ // W-068: read-only command - never auto-migrate a live database.
277
+ migrationPolicy: 'check',
278
+ ...(options.config !== undefined ? { config: options.config } : {}),
279
+ });
280
+ try {
281
+ const conn = ctx.store.connection;
282
+ const factRow = conn.get<FactInspectRow>(
283
+ `SELECT ${FACT_INSPECT_COLUMNS} FROM facts WHERE id = ?`,
284
+ [options.factId],
285
+ );
286
+ const fact = factRow !== undefined ? toInspectFact(factRow) : null;
287
+ const chain = factRow !== undefined ? readSupersedeChain(conn, options.factId) : [];
288
+ const history = conn
289
+ .all<{ event: string; source: string; created_at: number }>(
290
+ 'SELECT event, source, created_at FROM memory_history WHERE memory_id = ? ORDER BY created_at ASC, id ASC',
291
+ [options.factId],
292
+ )
293
+ .map(
294
+ (r): MemoryHistoryEntry =>
295
+ Object.freeze({
296
+ event: r.event,
297
+ source: r.source,
298
+ createdAt: epochToIso(r.created_at) ?? '',
299
+ }),
300
+ );
301
+ const conflicts = conn
302
+ .all<{
303
+ candidate_id: string;
304
+ existing_id: string | null;
305
+ decision: string;
306
+ stage: string;
307
+ similarity: number | null;
308
+ detected_at: number;
309
+ }>(
310
+ 'SELECT candidate_id, existing_id, decision, stage, similarity, detected_at FROM fact_conflicts WHERE candidate_id = ? OR existing_id = ? ORDER BY id DESC',
311
+ [options.factId, options.factId],
312
+ )
313
+ .map(
314
+ (r): MemoryConflictEntry =>
315
+ Object.freeze({
316
+ candidateId: r.candidate_id,
317
+ existingId: r.existing_id,
318
+ decision: r.decision,
319
+ stage: r.stage,
320
+ similarity: r.similarity,
321
+ detectedAt: epochToIso(r.detected_at) ?? '',
322
+ }),
323
+ );
324
+ const citingInsights = conn
325
+ .all<{ id: string; text: string; status: string; salience: number }>(
326
+ 'SELECT id, text, status, salience FROM insights WHERE deleted_at IS NULL AND cites_json LIKE ? ORDER BY salience DESC, created_at DESC',
327
+ [`%"${options.factId}"%`],
328
+ )
329
+ .map(
330
+ (r): MemoryCitingInsight =>
331
+ Object.freeze({ id: r.id, text: r.text, status: r.status, salience: r.salience }),
332
+ );
333
+
334
+ // IP-22: a fact's canonical-entity links (migration 016). Each link is
335
+ // followed through `entities.merged_into` so a link to a merged entity
336
+ // shows its surviving canonical.
337
+ const linkedEntities =
338
+ fact === null
339
+ ? []
340
+ : conn
341
+ .all<{ entity_id: string; name: string; role: string; merged_from: string | null }>(
342
+ `SELECT canon.id AS entity_id, canon.name AS name, fe.role AS role,
343
+ CASE WHEN e.merged_into IS NULL THEN NULL ELSE e.id END AS merged_from
344
+ FROM fact_entities fe
345
+ JOIN entities e ON e.id = fe.entity_id
346
+ JOIN entities canon ON canon.id = COALESCE(e.merged_into, e.id)
347
+ WHERE fe.fact_id = ?
348
+ ORDER BY fe.role, canon.name`,
349
+ [options.factId],
350
+ )
351
+ .map(
352
+ (r): MemoryInspectEntity =>
353
+ Object.freeze({
354
+ entityId: r.entity_id,
355
+ name: r.name,
356
+ role: r.role,
357
+ mergedFrom: r.merged_from,
358
+ }),
359
+ );
360
+
361
+ const out: MemoryInspectResult = Object.freeze({
362
+ found: fact !== null,
363
+ fact,
364
+ chain: Object.freeze(chain),
365
+ history: Object.freeze(history),
366
+ conflicts: Object.freeze(conflicts),
367
+ citingInsights: Object.freeze(citingInsights),
368
+ linkedEntities: Object.freeze(linkedEntities),
369
+ });
370
+
371
+ emitReport(options, out, () => {
372
+ const print = options.print ?? defaultPrintSink;
373
+ print(brand(`memory inspect ${options.factId}`));
374
+ if (out.fact === null) {
375
+ print(` ${statusMarker('fail')} fact not found`);
376
+ return;
377
+ }
378
+ const f = out.fact;
379
+ print(
380
+ ` ${statusMarker(f.status === 'quarantined' ? 'warn' : 'ok')} status=${f.status} provenance=${f.provenance ?? 'user (first-party)'}`,
381
+ );
382
+ print(` text: ${f.text}`);
383
+ print(
384
+ ` importance: ${f.importance !== null ? f.importance.toFixed(2) : '(unset - neutral salience)'}`,
385
+ );
386
+ print(` valid: ${f.validFrom ?? 'open'} .. ${f.validTo ?? 'open'}`);
387
+ if (out.linkedEntities.length > 0) {
388
+ print(brand(` linked entities (${out.linkedEntities.length}):`));
389
+ for (const e of out.linkedEntities) {
390
+ const merged = e.mergedFrom !== null ? ` (merged from ${e.mergedFrom})` : '';
391
+ print(` ${statusMarker('info')} ${e.role}: ${e.name} [${e.entityId}]${merged}`);
392
+ }
393
+ }
394
+ if (out.chain.length > 1) {
395
+ print(brand(` supersede chain (${out.chain.length}, oldest -> newest):`));
396
+ for (const c of out.chain) {
397
+ const mark = c.id === options.factId ? '*' : '-';
398
+ print(
399
+ ` ${mark} ${c.id} [${c.status}] ${c.validFrom ?? 'open'} .. ${c.validTo ?? 'open'}`,
400
+ );
401
+ }
402
+ }
403
+ print(brand(` audit history (${out.history.length}):`));
404
+ for (const h of out.history) {
405
+ print(` ${statusMarker('info')} ${h.event} by ${h.source} @ ${h.createdAt}`);
406
+ }
407
+ print(brand(` conflict decisions (${out.conflicts.length}):`));
408
+ for (const c of out.conflicts) {
409
+ const sim = c.similarity !== null ? ` sim=${c.similarity.toFixed(3)}` : '';
410
+ print(
411
+ ` ${c.decision}/${c.stage}${sim} ${c.candidateId} -> ${c.existingId ?? '-'} @ ${c.detectedAt}`,
412
+ );
413
+ }
414
+ print(brand(` citing insights (${out.citingInsights.length}):`));
415
+ for (const i of out.citingInsights) {
416
+ print(
417
+ ` ${statusMarker(i.status === 'quarantined' ? 'warn' : 'ok')} ${i.id} (salience=${i.salience}, ${i.status}) ${i.text}`,
418
+ );
419
+ }
420
+ });
421
+ return out;
422
+ } finally {
423
+ await ctx.close();
424
+ }
425
+ }
426
+
427
+ /** @stable */
428
+ export interface MemoryActivityEvent {
429
+ readonly memoryKind: string;
430
+ readonly memoryId: string;
431
+ readonly event: string;
432
+ readonly source: string;
433
+ readonly createdAt: string;
434
+ }
435
+
436
+ /** @stable */
437
+ export interface MemoryActivityConflict {
438
+ readonly candidateId: string;
439
+ readonly existingId: string | null;
440
+ readonly decision: string;
441
+ readonly stage: string;
442
+ readonly detectedAt: string;
443
+ }
444
+
445
+ /** @stable */
446
+ export interface MemoryActivityResult {
447
+ readonly quarantine: {
448
+ readonly facts: number;
449
+ readonly episodes: number;
450
+ readonly insights: number;
451
+ };
452
+ readonly recentHistory: ReadonlyArray<MemoryActivityEvent>;
453
+ readonly recentConflicts: ReadonlyArray<MemoryActivityConflict>;
454
+ }
455
+
456
+ /** @stable */
457
+ export interface MemoryActivityOptions extends MemoryCommonOptions {
458
+ /** Cap on the recent-history / recent-conflict rows returned. Default 20. */
459
+ readonly limit?: number;
460
+ }
461
+
462
+ /**
463
+ * `graphorin memory activity` - a store-wide view of what the
464
+ * consolidator and reflection passes have been doing: how many facts /
465
+ * episodes / insights currently sit in quarantine, the most recent
466
+ * audit-log events (supersede / validate / quarantine / archive), and
467
+ * the most recent conflict decisions. Pure read-only inspection.
468
+ *
469
+ * @stable
470
+ */
471
+ export async function runMemoryActivity(
472
+ options: MemoryActivityOptions = {},
473
+ ): Promise<MemoryActivityResult> {
474
+ const limit = Math.max(1, options.limit ?? 20);
475
+ const ctx = await openStoreContext({
476
+ // W-068: read-only command - never auto-migrate a live database.
477
+ migrationPolicy: 'check',
478
+ ...(options.config !== undefined ? { config: options.config } : {}),
479
+ });
480
+ try {
481
+ const conn = ctx.store.connection;
482
+ const quarantine = Object.freeze({
483
+ facts: countQuarantined(conn, 'facts'),
484
+ episodes: countQuarantined(conn, 'episodes'),
485
+ insights: countQuarantined(conn, 'insights'),
486
+ });
487
+ const recentHistory = conn
488
+ .all<{
489
+ memory_kind: string;
490
+ memory_id: string;
491
+ event: string;
492
+ source: string;
493
+ created_at: number;
494
+ }>(
495
+ 'SELECT memory_kind, memory_id, event, source, created_at FROM memory_history ORDER BY id DESC LIMIT ?',
496
+ [limit],
497
+ )
498
+ .map(
499
+ (r): MemoryActivityEvent =>
500
+ Object.freeze({
501
+ memoryKind: r.memory_kind,
502
+ memoryId: r.memory_id,
503
+ event: r.event,
504
+ source: r.source,
505
+ createdAt: epochToIso(r.created_at) ?? '',
506
+ }),
507
+ );
508
+ const recentConflicts = conn
509
+ .all<{
510
+ candidate_id: string;
511
+ existing_id: string | null;
512
+ decision: string;
513
+ stage: string;
514
+ detected_at: number;
515
+ }>(
516
+ 'SELECT candidate_id, existing_id, decision, stage, detected_at FROM fact_conflicts ORDER BY id DESC LIMIT ?',
517
+ [limit],
518
+ )
519
+ .map(
520
+ (r): MemoryActivityConflict =>
521
+ Object.freeze({
522
+ candidateId: r.candidate_id,
523
+ existingId: r.existing_id,
524
+ decision: r.decision,
525
+ stage: r.stage,
526
+ detectedAt: epochToIso(r.detected_at) ?? '',
527
+ }),
528
+ );
529
+
530
+ const out: MemoryActivityResult = Object.freeze({
531
+ quarantine,
532
+ recentHistory: Object.freeze(recentHistory),
533
+ recentConflicts: Object.freeze(recentConflicts),
534
+ });
535
+
536
+ emitReport(options, out, () => {
537
+ const print = options.print ?? defaultPrintSink;
538
+ print(brand('memory activity'));
539
+ const q = out.quarantine;
540
+ const qMarker =
541
+ q.facts + q.episodes + q.insights > 0 ? statusMarker('warn') : statusMarker('ok');
542
+ print(
543
+ ` ${qMarker} quarantined: facts=${q.facts}, episodes=${q.episodes}, insights=${q.insights}`,
544
+ );
545
+ print(brand(` recent history (${out.recentHistory.length}):`));
546
+ for (const h of out.recentHistory) {
547
+ print(
548
+ ` ${statusMarker('info')} ${h.event} ${h.memoryKind}:${h.memoryId} by ${h.source} @ ${h.createdAt}`,
549
+ );
550
+ }
551
+ print(brand(` recent conflict decisions (${out.recentConflicts.length}):`));
552
+ for (const c of out.recentConflicts) {
553
+ print(
554
+ ` ${c.decision}/${c.stage} ${c.candidateId} -> ${c.existingId ?? '-'} @ ${c.detectedAt}`,
555
+ );
556
+ }
557
+ });
558
+ return out;
559
+ } finally {
560
+ await ctx.close();
561
+ }
562
+ }
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // `graphorin memory why` (RP-17 / X-3) - "why was this recalled?" Decodes the
566
+ // `memory.search.semantic.explain` attribute the memory search records on each
567
+ // `memory.search.semantic` span (now durable via the RP-17 `spans` table) into
568
+ // the per-fact ranking signals (FTS bm25, vector similarity, fused RRF, decay).
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /** A single decoded recall explanation surfaced by `graphorin memory why`. */
572
+ export interface MemoryWhyRecall {
573
+ readonly spanId: string;
574
+ /** Span start time (unix nanos) of the recall. */
575
+ readonly at: number;
576
+ readonly results: ReadonlyArray<{
577
+ readonly id: string;
578
+ readonly rank: number;
579
+ readonly score: number;
580
+ readonly signals: Readonly<Record<string, number>>;
581
+ }>;
582
+ }
583
+
584
+ /** @stable */
585
+ export interface MemoryWhyResult {
586
+ readonly recalls: ReadonlyArray<MemoryWhyRecall>;
587
+ }
588
+
589
+ /** @stable */
590
+ export interface MemoryWhyOptions extends MemoryCommonOptions {
591
+ /** Restrict to one session's recall spans. */
592
+ readonly sessionId?: string;
593
+ /** Cap on the most-recent recall spans returned. Default 5. */
594
+ readonly limit?: number;
595
+ }
596
+
597
+ interface SpanExplainRow {
598
+ readonly span_id: string;
599
+ readonly start_unix_nano: number;
600
+ readonly attributes_json: string;
601
+ }
602
+
603
+ /**
604
+ * `graphorin memory why` - explain why facts were recalled, by decoding the
605
+ * `memory.search.semantic.explain` attribute off the persisted recall spans.
606
+ * Pure read-only inspection; requires the SQLite span exporter to have recorded
607
+ * spans (RP-17). Empty when nothing was recorded.
608
+ *
609
+ * @stable
610
+ */
611
+ export async function runMemoryWhy(options: MemoryWhyOptions): Promise<MemoryWhyResult> {
612
+ const ctx = await openStoreContext({
613
+ ...(options.config !== undefined ? { config: options.config } : {}),
614
+ });
615
+ try {
616
+ const conn = ctx.store.connection;
617
+ const limit = options.limit ?? 5;
618
+ const rows =
619
+ options.sessionId !== undefined
620
+ ? conn.all<SpanExplainRow>(
621
+ `SELECT span_id, start_unix_nano, attributes_json FROM spans
622
+ WHERE type = 'memory.search.semantic' AND session_id = ?
623
+ ORDER BY start_unix_nano DESC LIMIT ?`,
624
+ [options.sessionId, limit],
625
+ )
626
+ : conn.all<SpanExplainRow>(
627
+ `SELECT span_id, start_unix_nano, attributes_json FROM spans
628
+ WHERE type = 'memory.search.semantic'
629
+ ORDER BY start_unix_nano DESC LIMIT ?`,
630
+ [limit],
631
+ );
632
+ const recalls = rows.map((r): MemoryWhyRecall => {
633
+ let results: MemoryWhyRecall['results'] = [];
634
+ try {
635
+ const attrs = JSON.parse(r.attributes_json) as Record<string, unknown>;
636
+ const raw = attrs['memory.search.semantic.explain'];
637
+ const parsed: unknown = typeof raw === 'string' ? JSON.parse(raw) : raw;
638
+ if (Array.isArray(parsed)) {
639
+ results = Object.freeze(parsed) as MemoryWhyRecall['results'];
640
+ }
641
+ } catch {
642
+ // Malformed attribute payload - surface an empty recall, not a throw.
643
+ }
644
+ return Object.freeze({ spanId: r.span_id, at: r.start_unix_nano, results });
645
+ });
646
+ const out: MemoryWhyResult = Object.freeze({ recalls: Object.freeze(recalls) });
647
+ emitReport(options, out, () => {
648
+ const print = options.print ?? defaultPrintSink;
649
+ print(brand('memory why'));
650
+ if (recalls.length === 0) {
651
+ print(
652
+ ` ${statusMarker('info')} no recorded memory.search.semantic spans ` +
653
+ `(wire createSqliteSpanExporter into the tracer to record recall explanations)`,
654
+ );
655
+ return;
656
+ }
657
+ for (const rc of recalls) {
658
+ print(brand(` recall @ ${rc.at} (span ${rc.spanId}):`));
659
+ for (const item of rc.results) {
660
+ const signals = Object.entries(item.signals)
661
+ .map(([k, v]) => `${k}=${v.toFixed(3)}`)
662
+ .join(', ');
663
+ print(
664
+ ` ${statusMarker('info')} #${item.rank} ${item.id} ` +
665
+ `score=${item.score.toFixed(3)} [${signals}]`,
666
+ );
667
+ }
668
+ }
669
+ });
670
+ return out;
671
+ } finally {
672
+ await ctx.close();
673
+ }
674
+ }
675
+
676
+ // ---------------------------------------------------------------------------
677
+ // `graphorin memory review` (MCON-2) - list what the consolidator left in
678
+ // quarantine across every tier, and promote a reviewed item out of it. The
679
+ // promote path runs through the tier's `validate(...)`, so an injection-flagged
680
+ // memory is refused unless the operator passes `--force` after review.
681
+ // ---------------------------------------------------------------------------
682
+
683
+ /** A single quarantined memory row surfaced by `graphorin memory review`. */
684
+ export interface MemoryReviewItem {
685
+ readonly id: string;
686
+ readonly text: string;
687
+ readonly provenance: string | null;
688
+ }
689
+
690
+ /** @stable */
691
+ export interface MemoryReviewResult {
692
+ /** Set when `--promote <id>` succeeded. */
693
+ readonly promoted?: { readonly id: string; readonly type: string };
694
+ readonly facts: ReadonlyArray<MemoryReviewItem>;
695
+ readonly episodes: ReadonlyArray<MemoryReviewItem>;
696
+ readonly insights: ReadonlyArray<MemoryReviewItem>;
697
+ readonly procedures: ReadonlyArray<MemoryReviewItem>;
698
+ }
699
+
700
+ /** @stable */
701
+ export interface MemoryReviewOptions extends MemoryCommonOptions {
702
+ /** Cap on the rows listed per type. Default 20. */
703
+ readonly limit?: number;
704
+ /** Promote this id out of quarantine instead of listing. */
705
+ readonly promote?: string;
706
+ /** Audit reason recorded with the promotion. */
707
+ readonly reason?: string;
708
+ /** Override the injection-refusal gate (operator action, after review). */
709
+ readonly force?: boolean;
710
+ }
711
+
712
+ const EMPTY_REVIEW: MemoryReviewResult = Object.freeze({
713
+ facts: Object.freeze([]),
714
+ episodes: Object.freeze([]),
715
+ insights: Object.freeze([]),
716
+ procedures: Object.freeze([]),
717
+ });
718
+
719
+ /**
720
+ * `graphorin memory review` - list the facts / episodes / insights / induced
721
+ * procedures the consolidator left in quarantine (read-only), or promote a
722
+ * reviewed item out of quarantine with `--promote <id>`. The promote path runs
723
+ * through the tier `validate(...)`, so an injection-flagged memory is refused
724
+ * unless `--force` is supplied after review (MCON-2).
725
+ *
726
+ * @stable
727
+ */
728
+ export async function runMemoryReview(
729
+ options: MemoryReviewOptions = {},
730
+ ): Promise<MemoryReviewResult> {
731
+ const limit = Math.max(1, options.limit ?? 20);
732
+ const ctx = await openStoreContext({
733
+ ...(options.config !== undefined ? { config: options.config } : {}),
734
+ });
735
+ try {
736
+ const conn = ctx.store.connection;
737
+ const print = options.print ?? defaultPrintSink;
738
+
739
+ if (options.promote !== undefined) {
740
+ const located = locateQuarantined(conn, options.promote);
741
+ if (located === null) {
742
+ print(`${statusMarker('warn')} '${options.promote}' is not a quarantined memory.`);
743
+ process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
744
+ return EMPTY_REVIEW;
745
+ }
746
+ const memory = createMemory({ store: ctx.store.memory, embeddings: ctx.store.embeddings });
747
+ const scope = { userId: located.userId };
748
+ const validateOpts = options.force === true ? ({ force: true } as const) : undefined;
749
+ try {
750
+ await promoteByType(
751
+ memory,
752
+ located.type,
753
+ scope,
754
+ options.promote,
755
+ options.reason,
756
+ validateOpts,
757
+ );
758
+ } catch (err) {
759
+ if (err instanceof Error && err.name === 'QuarantinePromotionRefusedError') {
760
+ print(
761
+ `${statusMarker('warn')} refused: '${options.promote}' trips the injection heuristics.`,
762
+ );
763
+ print(' Review it, then re-run with --force from a trusted operator context.');
764
+ process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
765
+ return EMPTY_REVIEW;
766
+ }
767
+ throw err;
768
+ }
769
+ const out: MemoryReviewResult = Object.freeze({
770
+ ...EMPTY_REVIEW,
771
+ promoted: Object.freeze({ id: options.promote, type: located.type }),
772
+ });
773
+ emitReport(options, out, () => {
774
+ print(
775
+ `${statusMarker('ok')} promoted ${located.type} ${options.promote} out of quarantine.`,
776
+ );
777
+ });
778
+ return out;
779
+ }
780
+
781
+ const out: MemoryReviewResult = Object.freeze({
782
+ facts: listQuarantined(conn, 'facts', 'text', limit),
783
+ episodes: listQuarantined(conn, 'episodes', 'summary', limit),
784
+ insights: listQuarantined(conn, 'insights', 'text', limit),
785
+ procedures: listQuarantined(conn, 'rules', 'text', limit),
786
+ });
787
+ emitReport(options, out, () => {
788
+ print(brand('memory review - quarantined'));
789
+ printReviewItems(print, 'facts', out.facts);
790
+ printReviewItems(print, 'episodes', out.episodes);
791
+ printReviewItems(print, 'insights', out.insights);
792
+ printReviewItems(print, 'procedures', out.procedures);
793
+ const total =
794
+ out.facts.length + out.episodes.length + out.insights.length + out.procedures.length;
795
+ if (total === 0) {
796
+ print(` ${statusMarker('ok')} nothing in quarantine.`);
797
+ } else {
798
+ print(' promote with: graphorin memory review --promote <id> [--reason <text>] [--force]');
799
+ }
800
+ });
801
+ return out;
802
+ } finally {
803
+ await ctx.close();
804
+ }
805
+ }
806
+
807
+ function printReviewItems(
808
+ print: (line: string) => void,
809
+ label: string,
810
+ items: ReadonlyArray<MemoryReviewItem>,
811
+ ): void {
812
+ print(brand(` ${label} (${items.length}):`));
813
+ for (const item of items) {
814
+ const snippet = item.text.length > 80 ? `${item.text.slice(0, 77)}...` : item.text;
815
+ const prov = item.provenance !== null ? ` [${item.provenance}]` : '';
816
+ print(` ${statusMarker('warn')} ${item.id}${prov} ${snippet}`);
817
+ }
818
+ }
819
+
820
+ function listQuarantined(
821
+ conn: { all<T>(q: string, p?: ReadonlyArray<unknown>): T[] },
822
+ table: 'facts' | 'episodes' | 'insights' | 'rules',
823
+ textColumn: 'text' | 'summary',
824
+ limit: number,
825
+ ): ReadonlyArray<MemoryReviewItem> {
826
+ const rows = conn.all<{ id: string; text: string; provenance: string | null }>(
827
+ `SELECT id, ${textColumn} AS text, provenance FROM ${table} WHERE status = 'quarantined' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?`,
828
+ [limit],
829
+ );
830
+ return Object.freeze(
831
+ rows.map((r) => Object.freeze({ id: r.id, text: r.text, provenance: r.provenance ?? null })),
832
+ );
833
+ }
834
+
835
+ type QuarantineType = 'fact' | 'episode' | 'insight' | 'rule';
836
+
837
+ function locateQuarantined(
838
+ conn: { get<T>(q: string, p?: ReadonlyArray<unknown>): T | undefined },
839
+ id: string,
840
+ ): { type: QuarantineType; userId: string } | null {
841
+ const tables: ReadonlyArray<{ table: string; type: QuarantineType }> = [
842
+ { table: 'facts', type: 'fact' },
843
+ { table: 'episodes', type: 'episode' },
844
+ { table: 'insights', type: 'insight' },
845
+ { table: 'rules', type: 'rule' },
846
+ ];
847
+ for (const { table, type } of tables) {
848
+ const row = conn.get<{ uid: string }>(
849
+ `SELECT scope_user_id AS uid FROM ${table} WHERE id = ? AND status = 'quarantined' AND deleted_at IS NULL`,
850
+ [id],
851
+ );
852
+ if (row !== undefined) return { type, userId: row.uid };
853
+ }
854
+ return null;
855
+ }
856
+
857
+ async function promoteByType(
858
+ memory: ReturnType<typeof createMemory>,
859
+ type: QuarantineType,
860
+ scope: { userId: string },
861
+ id: string,
862
+ reason: string | undefined,
863
+ opts: { readonly force: true } | undefined,
864
+ ): Promise<void> {
865
+ switch (type) {
866
+ case 'fact':
867
+ return memory.semantic.validate(scope, id, reason, opts);
868
+ case 'episode':
869
+ return memory.episodic.validate(scope, id, reason, opts);
870
+ case 'insight':
871
+ return memory.insights.validate(scope, id, reason, opts);
872
+ case 'rule':
873
+ return memory.procedural.validate(scope, id, reason, opts);
874
+ }
875
+ }
876
+
877
+ /** Walk the bi-temporal supersede chain `factId` belongs to, oldest -> newest. */
878
+ function readSupersedeChain(
879
+ conn: {
880
+ get<T>(q: string, p?: ReadonlyArray<unknown>): T | undefined;
881
+ all<T>(q: string, p?: ReadonlyArray<unknown>): T[];
882
+ },
883
+ factId: string,
884
+ ): MemoryInspectFact[] {
885
+ const seen = new Set<string>();
886
+ const queue: string[] = [factId];
887
+ const rows: FactInspectRow[] = [];
888
+ while (queue.length > 0) {
889
+ const id = queue.shift();
890
+ if (id === undefined || seen.has(id)) continue;
891
+ seen.add(id);
892
+ const row = conn.get<FactInspectRow>(`SELECT ${FACT_INSPECT_COLUMNS} FROM facts WHERE id = ?`, [
893
+ id,
894
+ ]);
895
+ if (row === undefined) continue;
896
+ rows.push(row);
897
+ if (row.supersedes !== null) queue.push(row.supersedes);
898
+ if (row.superseded_by !== null) queue.push(row.superseded_by);
899
+ for (const linked of conn.all<{ id: string }>(
900
+ 'SELECT id FROM facts WHERE supersedes = ? OR superseded_by = ?',
901
+ [id, id],
902
+ )) {
903
+ queue.push(linked.id);
904
+ }
905
+ }
906
+ rows.sort((a, b) => (a.valid_from ?? a.created_at) - (b.valid_from ?? b.created_at));
907
+ return rows.map(toInspectFact);
908
+ }
909
+
910
+ function toInspectFact(row: FactInspectRow): MemoryInspectFact {
911
+ return Object.freeze({
912
+ id: row.id,
913
+ text: row.text,
914
+ status: row.status ?? 'active',
915
+ provenance: row.provenance,
916
+ importance: row.importance,
917
+ validFrom: epochToIso(row.valid_from),
918
+ validTo: epochToIso(row.valid_to),
919
+ supersedes: row.supersedes,
920
+ supersededBy: row.superseded_by,
921
+ createdAt: epochToIso(row.created_at) ?? new Date(0).toISOString(),
922
+ });
923
+ }
924
+
925
+ function countQuarantined(
926
+ conn: { get<T>(q: string, p?: ReadonlyArray<unknown>): T | undefined },
927
+ table: string,
928
+ ): number {
929
+ try {
930
+ const row = conn.get<{ n: number }>(
931
+ `SELECT COUNT(*) AS n FROM ${table} WHERE status = 'quarantined' AND deleted_at IS NULL`,
932
+ );
933
+ return typeof row?.n === 'number' ? row.n : 0;
934
+ } catch {
935
+ return 0;
936
+ }
937
+ }
938
+
939
+ function epochToIso(epoch: number | null | undefined): string | null {
940
+ if (epoch === null || epoch === undefined) return null;
941
+ return new Date(epoch).toISOString();
942
+ }
943
+
944
+ async function readMemoryCounts(
945
+ store: Awaited<ReturnType<typeof openStoreContext>>['store'],
946
+ ): Promise<MemoryStatusResult['counts']> {
947
+ const conn = store.connection;
948
+ return Object.freeze({
949
+ facts: countTable(conn, 'facts'),
950
+ episodes: countTable(conn, 'episodes'),
951
+ sessionMessages: countTable(conn, 'session_messages'),
952
+ procedures: countTable(conn, 'rules'),
953
+ });
954
+ }
955
+
956
+ function countTable(
957
+ conn: { readonly get: <T = unknown>(query: string) => T | undefined },
958
+ table: string,
959
+ ): number {
960
+ try {
961
+ const row = conn.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
962
+ return typeof row?.n === 'number' ? row.n : 0;
963
+ } catch {
964
+ return 0;
965
+ }
966
+ }
967
+
968
+ /** @stable */
969
+ export interface MemoryPruneHistoryOptions extends MemoryCommonOptions {
970
+ /**
971
+ * Age threshold: a duration (`'30d'`, `'12h'`, `'45m'`, `'30s'`) or
972
+ * an ISO date / `YYYY-MM-DD` strictly in the past. Mandatory - the
973
+ * command is destructive by design and must never default.
974
+ */
975
+ readonly olderThan: string;
976
+ }
977
+
978
+ /** @stable */
979
+ export interface MemoryPruneHistoryResult {
980
+ readonly deleted: number;
981
+ /** The resolved AGE in milliseconds passed to `pruneHistory`. */
982
+ readonly olderThanMs: number;
983
+ }
984
+
985
+ /**
986
+ * `graphorin memory prune-history --older-than <duration|date>` (W-066)
987
+ * - the supported surface over `MemoryStoreExt.pruneHistory`.
988
+ * `memory_history` grows by design (every supersede / quarantine
989
+ * transition appends) and `purge()` already scrubs sensitive text;
990
+ * this is the storage-cost hygiene lever.
991
+ *
992
+ * @stable
993
+ */
994
+ export async function runMemoryPruneHistory(
995
+ options: MemoryPruneHistoryOptions,
996
+ ): Promise<MemoryPruneHistoryResult> {
997
+ const olderThanMs = resolveOlderThanMs(options.olderThan);
998
+ const ctx = await openStoreContext({
999
+ ...(options.config !== undefined ? { config: options.config } : {}),
1000
+ });
1001
+ try {
1002
+ // UNIT SEMANTICS: pruneHistory takes an AGE in ms (the store
1003
+ // computes `cutoff = now - age`). An ISO date therefore converts
1004
+ // as `now - date`, NEVER as the raw epoch value - passing an epoch
1005
+ // here would be a silent near-no-op.
1006
+ const deleted = await ctx.store.memory.pruneHistory(olderThanMs);
1007
+ const out: MemoryPruneHistoryResult = Object.freeze({ deleted, olderThanMs });
1008
+ emitReport(options, out, () => {
1009
+ const print = options.print ?? defaultPrintSink;
1010
+ print(
1011
+ brand(
1012
+ `pruned ${deleted} memory_history row(s) older than ${options.olderThan} (age ${olderThanMs} ms).`,
1013
+ ),
1014
+ );
1015
+ });
1016
+ return out;
1017
+ } finally {
1018
+ await ctx.close();
1019
+ }
1020
+ }
1021
+
1022
+ /** Resolve `--older-than` into an AGE in ms; fail fast on nonsense. */
1023
+ function resolveOlderThanMs(input: string): number {
1024
+ const raw = input.trim();
1025
+ const duration = /^(\d+)\s*([smhd])$/i.exec(raw);
1026
+ if (duration !== null) {
1027
+ const value = Number.parseInt(duration[1] as string, 10);
1028
+ switch ((duration[2] as string).toLowerCase()) {
1029
+ case 's':
1030
+ return value * 1000;
1031
+ case 'm':
1032
+ return value * 60_000;
1033
+ case 'h':
1034
+ return value * 3_600_000;
1035
+ default:
1036
+ return value * 86_400_000;
1037
+ }
1038
+ }
1039
+ const date = new Date(raw);
1040
+ if (!Number.isNaN(date.getTime())) {
1041
+ const age = Date.now() - date.getTime();
1042
+ if (age <= 0) {
1043
+ throw new Error(
1044
+ `[graphorin/cli] --older-than '${input}' is not in the past - refusing (a future date would prune the entire history).`,
1045
+ );
1046
+ }
1047
+ return age;
1048
+ }
1049
+ throw new Error(
1050
+ `[graphorin/cli] invalid --older-than '${input}'. Use '<number><s|m|h|d>' (e.g. 30d) or a past ISO date.`,
1051
+ );
1052
+ }