@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.
- package/CHANGELOG.md +63 -0
- package/README.md +3 -3
- package/dist/bin/graphorin.js +51 -13
- package/dist/bin/graphorin.js.map +1 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +2 -1
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/consolidator.d.ts +56 -1
- package/dist/commands/consolidator.d.ts.map +1 -1
- package/dist/commands/consolidator.js +88 -2
- package/dist/commands/consolidator.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/index.d.ts +4 -4
- package/dist/commands/index.js +4 -4
- package/dist/commands/init.d.ts +11 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/memory.d.ts +26 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +56 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/pricing.d.ts +6 -0
- package/dist/commands/pricing.d.ts.map +1 -1
- package/dist/commands/pricing.js +5 -2
- package/dist/commands/pricing.js.map +1 -1
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +2 -2
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +1 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/storage.d.ts +41 -1
- package/dist/commands/storage.d.ts.map +1 -1
- package/dist/commands/storage.js +75 -1
- package/dist/commands/storage.js.map +1 -1
- package/dist/commands/token.d.ts.map +1 -1
- package/dist/commands/token.js +2 -2
- package/dist/commands/token.js.map +1 -1
- package/dist/commands/tools-lint.js +1 -1
- package/dist/commands/tools-lint.js.map +1 -1
- package/dist/commands/traces.d.ts +14 -2
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +39 -22
- package/dist/commands/traces.js.map +1 -1
- package/dist/commands/triggers.d.ts.map +1 -1
- package/dist/commands/triggers.js +5 -2
- package/dist/commands/triggers.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/output.js +6 -0
- package/dist/internal/output.js.map +1 -1
- package/dist/internal/store-context.js +13 -2
- package/dist/internal/store-context.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -14
- package/src/bin/graphorin.ts +1387 -0
- package/src/commands/audit.ts +256 -0
- package/src/commands/auth.ts +238 -0
- package/src/commands/consolidator.ts +382 -0
- package/src/commands/doctor.ts +253 -0
- package/src/commands/guard.ts +144 -0
- package/src/commands/index.ts +223 -0
- package/src/commands/init.ts +194 -0
- package/src/commands/memory.ts +1052 -0
- package/src/commands/migrate-config.ts +77 -0
- package/src/commands/migrate-export.ts +117 -0
- package/src/commands/migrate.ts +83 -0
- package/src/commands/pricing.ts +244 -0
- package/src/commands/secrets.ts +309 -0
- package/src/commands/skills.ts +272 -0
- package/src/commands/start.ts +180 -0
- package/src/commands/storage.ts +659 -0
- package/src/commands/telemetry.ts +91 -0
- package/src/commands/token.ts +361 -0
- package/src/commands/tools-lint.ts +430 -0
- package/src/commands/traces.ts +188 -0
- package/src/commands/triggers.ts +237 -0
- package/src/index.ts +30 -0
- package/src/internal/exit.ts +62 -0
- package/src/internal/load-config.ts +107 -0
- package/src/internal/offline.ts +81 -0
- package/src/internal/output.ts +146 -0
- package/src/internal/prompts.ts +58 -0
- 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
|
+
}
|