principles-disciple 1.6.0 → 1.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 (67) hide show
  1. package/dist/commands/context.js +7 -3
  2. package/dist/commands/evolution-status.d.ts +4 -0
  3. package/dist/commands/evolution-status.js +138 -0
  4. package/dist/commands/export.d.ts +2 -0
  5. package/dist/commands/export.js +45 -0
  6. package/dist/commands/focus.js +9 -6
  7. package/dist/commands/pain.js +8 -0
  8. package/dist/commands/principle-rollback.d.ts +4 -0
  9. package/dist/commands/principle-rollback.js +22 -0
  10. package/dist/commands/samples.d.ts +2 -0
  11. package/dist/commands/samples.js +55 -0
  12. package/dist/core/config.d.ts +5 -0
  13. package/dist/core/control-ui-db.d.ts +68 -0
  14. package/dist/core/control-ui-db.js +274 -0
  15. package/dist/core/detection-funnel.d.ts +1 -1
  16. package/dist/core/detection-funnel.js +4 -0
  17. package/dist/core/dictionary.d.ts +2 -0
  18. package/dist/core/dictionary.js +13 -0
  19. package/dist/core/event-log.d.ts +2 -1
  20. package/dist/core/event-log.js +3 -0
  21. package/dist/core/evolution-engine.d.ts +5 -5
  22. package/dist/core/evolution-engine.js +18 -18
  23. package/dist/core/evolution-migration.d.ts +5 -0
  24. package/dist/core/evolution-migration.js +65 -0
  25. package/dist/core/evolution-reducer.d.ts +69 -0
  26. package/dist/core/evolution-reducer.js +369 -0
  27. package/dist/core/evolution-types.d.ts +103 -0
  28. package/dist/core/path-resolver.js +75 -36
  29. package/dist/core/paths.d.ts +7 -8
  30. package/dist/core/paths.js +48 -40
  31. package/dist/core/profile.js +1 -1
  32. package/dist/core/session-tracker.d.ts +4 -0
  33. package/dist/core/session-tracker.js +15 -0
  34. package/dist/core/thinking-models.d.ts +38 -0
  35. package/dist/core/thinking-models.js +170 -0
  36. package/dist/core/trajectory.d.ts +184 -0
  37. package/dist/core/trajectory.js +817 -0
  38. package/dist/core/trust-engine.d.ts +2 -0
  39. package/dist/core/trust-engine.js +30 -4
  40. package/dist/core/workspace-context.d.ts +13 -0
  41. package/dist/core/workspace-context.js +50 -7
  42. package/dist/hooks/gate.js +117 -48
  43. package/dist/hooks/llm.js +114 -69
  44. package/dist/hooks/pain.js +105 -5
  45. package/dist/hooks/prompt.d.ts +11 -14
  46. package/dist/hooks/prompt.js +283 -57
  47. package/dist/hooks/subagent.js +27 -1
  48. package/dist/http/principles-console-route.d.ts +2 -0
  49. package/dist/http/principles-console-route.js +257 -0
  50. package/dist/i18n/commands.js +16 -0
  51. package/dist/index.js +83 -4
  52. package/dist/service/control-ui-query-service.d.ts +217 -0
  53. package/dist/service/control-ui-query-service.js +537 -0
  54. package/dist/service/evolution-worker.d.ts +9 -0
  55. package/dist/service/evolution-worker.js +152 -22
  56. package/dist/service/trajectory-service.d.ts +2 -0
  57. package/dist/service/trajectory-service.js +15 -0
  58. package/dist/tools/agent-spawn.d.ts +27 -6
  59. package/dist/tools/agent-spawn.js +339 -87
  60. package/dist/tools/deep-reflect.d.ts +27 -7
  61. package/dist/tools/deep-reflect.js +210 -121
  62. package/dist/types/event-types.d.ts +9 -2
  63. package/dist/types.d.ts +10 -0
  64. package/dist/types.js +5 -0
  65. package/openclaw.plugin.json +43 -11
  66. package/package.json +14 -4
  67. package/templates/langs/zh/skills/pd-daily/SKILL.md +97 -13
@@ -0,0 +1,817 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+ import { withLock } from '../utils/file-lock.js';
6
+ import { resolvePdPath } from './paths.js';
7
+ const DEFAULT_INLINE_THRESHOLD = 16 * 1024;
8
+ const DEFAULT_BUSY_TIMEOUT_MS = 5000;
9
+ const DEFAULT_ORPHAN_BLOB_GRACE_DAYS = 7;
10
+ const SCHEMA_VERSION = 1;
11
+ function nowIso() {
12
+ return new Date().toISOString();
13
+ }
14
+ function safeJson(value) {
15
+ return JSON.stringify(value ?? {});
16
+ }
17
+ function fileSizeIfExists(filePath) {
18
+ try {
19
+ return fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
20
+ }
21
+ catch {
22
+ return 0;
23
+ }
24
+ }
25
+ function summarizeForDiff(text) {
26
+ return text.length > 240 ? `${text.slice(0, 237)}...` : text;
27
+ }
28
+ function redactText(text) {
29
+ return text
30
+ .replace(/[A-Za-z]:\\[^\s"'`]+/g, '<WINDOWS_PATH>')
31
+ .replace(/\/(?:[A-Za-z0-9._-]+\/){1,}[A-Za-z0-9._-]+(?:\.[A-Za-z0-9._-]+)?/g, '<PATH>')
32
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '<EMAIL>')
33
+ .replace(/\b(sk|rk|pk)_[A-Za-z0-9]+\b/g, '<TOKEN>');
34
+ }
35
+ export class TrajectoryDatabase {
36
+ workspaceDir;
37
+ stateDir;
38
+ dbPath;
39
+ blobDir;
40
+ exportDir;
41
+ blobInlineThresholdBytes;
42
+ orphanBlobGraceMs;
43
+ db;
44
+ constructor(opts) {
45
+ this.workspaceDir = path.resolve(opts.workspaceDir);
46
+ this.stateDir = resolvePdPath(this.workspaceDir, 'STATE_DIR');
47
+ this.dbPath = resolvePdPath(this.workspaceDir, 'TRAJECTORY_DB');
48
+ this.blobDir = resolvePdPath(this.workspaceDir, 'TRAJECTORY_BLOBS_DIR');
49
+ this.exportDir = resolvePdPath(this.workspaceDir, 'EXPORTS_DIR');
50
+ this.blobInlineThresholdBytes = opts.blobInlineThresholdBytes ?? DEFAULT_INLINE_THRESHOLD;
51
+ this.orphanBlobGraceMs = Math.max(0, (opts.orphanBlobGraceDays ?? DEFAULT_ORPHAN_BLOB_GRACE_DAYS) * 24 * 60 * 60 * 1000);
52
+ fs.mkdirSync(this.stateDir, { recursive: true });
53
+ fs.mkdirSync(this.blobDir, { recursive: true });
54
+ fs.mkdirSync(this.exportDir, { recursive: true });
55
+ this.db = new Database(this.dbPath);
56
+ this.db.pragma('journal_mode = WAL');
57
+ this.db.pragma('foreign_keys = ON');
58
+ this.db.pragma('synchronous = NORMAL');
59
+ this.db.pragma(`busy_timeout = ${Math.max(0, opts.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS)}`);
60
+ this.initSchema();
61
+ this.importLegacyArtifacts();
62
+ this.pruneUnreferencedBlobs();
63
+ }
64
+ dispose() {
65
+ this.db.close();
66
+ }
67
+ recordSession(input) {
68
+ const startedAt = input.startedAt ?? nowIso();
69
+ this.withWrite(() => {
70
+ this.db.prepare(`
71
+ INSERT INTO sessions (session_id, started_at, updated_at)
72
+ VALUES (?, ?, ?)
73
+ ON CONFLICT(session_id) DO UPDATE SET updated_at = excluded.updated_at
74
+ `).run(input.sessionId, startedAt, nowIso());
75
+ });
76
+ }
77
+ recordAssistantTurn(input) {
78
+ this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
79
+ const rawStorage = this.storeRawText('assistant', input.rawText);
80
+ const createdAt = input.createdAt ?? nowIso();
81
+ return this.withWrite(() => {
82
+ const result = this.db.prepare(`
83
+ INSERT INTO assistant_turns (
84
+ session_id, run_id, provider, model, raw_text, sanitized_text, usage_json,
85
+ empathy_signal_json, blob_ref, raw_excerpt, created_at
86
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
87
+ `).run(input.sessionId, input.runId, input.provider, input.model, rawStorage.inlineText, input.sanitizedText, safeJson(input.usageJson), safeJson(input.empathySignalJson), rawStorage.blobRef, rawStorage.excerpt, createdAt);
88
+ return Number(result.lastInsertRowid);
89
+ });
90
+ }
91
+ recordUserTurn(input) {
92
+ this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
93
+ const rawStorage = this.storeRawText('user', input.rawText);
94
+ const createdAt = input.createdAt ?? nowIso();
95
+ return this.withWrite(() => {
96
+ const result = this.db.prepare(`
97
+ INSERT INTO user_turns (
98
+ session_id, turn_index, raw_text, blob_ref, raw_excerpt,
99
+ correction_detected, correction_cue, references_assistant_turn_id, created_at
100
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
101
+ `).run(input.sessionId, input.turnIndex, rawStorage.inlineText, rawStorage.blobRef, rawStorage.excerpt, input.correctionDetected ? 1 : 0, input.correctionCue ?? null, input.referencesAssistantTurnId ?? null, createdAt);
102
+ return Number(result.lastInsertRowid);
103
+ });
104
+ }
105
+ recordToolCall(input) {
106
+ this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
107
+ const createdAt = input.createdAt ?? nowIso();
108
+ const rowId = this.withWrite(() => {
109
+ const result = this.db.prepare(`
110
+ INSERT INTO tool_calls (
111
+ session_id, tool_name, outcome, duration_ms, exit_code, error_type, error_message,
112
+ gfi_before, gfi_after, params_json, created_at
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
114
+ `).run(input.sessionId, input.toolName, input.outcome, input.durationMs ?? null, input.exitCode ?? null, input.errorType ?? null, input.errorMessage ?? null, input.gfiBefore ?? null, input.gfiAfter ?? null, safeJson(input.paramsJson), createdAt);
115
+ return Number(result.lastInsertRowid);
116
+ });
117
+ if (input.outcome === 'success') {
118
+ this.maybeCreateCorrectionSample(input.sessionId);
119
+ }
120
+ return rowId;
121
+ }
122
+ recordPainEvent(input) {
123
+ this.recordSession({ sessionId: input.sessionId, startedAt: input.createdAt });
124
+ this.withWrite(() => {
125
+ this.db.prepare(`
126
+ INSERT INTO pain_events (
127
+ session_id, source, score, reason, severity, origin, confidence, created_at
128
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
129
+ `).run(input.sessionId, input.source, input.score, input.reason ?? null, input.severity ?? null, input.origin ?? null, input.confidence ?? null, input.createdAt ?? nowIso());
130
+ });
131
+ }
132
+ recordGateBlock(input) {
133
+ this.withWrite(() => {
134
+ this.db.prepare(`
135
+ INSERT INTO gate_blocks (session_id, tool_name, file_path, reason, plan_status, created_at)
136
+ VALUES (?, ?, ?, ?, ?, ?)
137
+ `).run(input.sessionId ?? null, input.toolName, input.filePath ?? null, input.reason, input.planStatus ?? null, input.createdAt ?? nowIso());
138
+ });
139
+ }
140
+ recordTrustChange(input) {
141
+ this.withWrite(() => {
142
+ this.db.prepare(`
143
+ INSERT INTO trust_changes (session_id, previous_score, new_score, delta, reason, created_at)
144
+ VALUES (?, ?, ?, ?, ?, ?)
145
+ `).run(input.sessionId ?? null, input.previousScore, input.newScore, input.delta, input.reason, input.createdAt ?? nowIso());
146
+ });
147
+ }
148
+ recordPrincipleEvent(input) {
149
+ this.withWrite(() => {
150
+ this.db.prepare(`
151
+ INSERT INTO principle_events (principle_id, event_type, payload_json, created_at)
152
+ VALUES (?, ?, ?, ?)
153
+ `).run(input.principleId ?? null, input.eventType, safeJson(input.payload), input.createdAt ?? nowIso());
154
+ });
155
+ }
156
+ recordTaskOutcome(input) {
157
+ this.withWrite(() => {
158
+ this.db.prepare(`
159
+ INSERT INTO task_outcomes (session_id, task_id, outcome, summary, principle_ids_json, created_at)
160
+ VALUES (?, ?, ?, ?, ?, ?)
161
+ `).run(input.sessionId, input.taskId ?? null, input.outcome, input.summary ?? null, safeJson(input.principleIdsJson), input.createdAt ?? nowIso());
162
+ });
163
+ }
164
+ listAssistantTurns(sessionId) {
165
+ const rows = this.db.prepare(`
166
+ SELECT id, session_id, run_id, provider, model, raw_text, sanitized_text, blob_ref, created_at
167
+ FROM assistant_turns
168
+ WHERE session_id = ?
169
+ ORDER BY id ASC
170
+ `).all(sessionId);
171
+ return rows.map((row) => ({
172
+ id: Number(row.id),
173
+ sessionId: String(row.session_id),
174
+ runId: String(row.run_id),
175
+ provider: String(row.provider),
176
+ model: String(row.model),
177
+ rawText: this.restoreRawText(row.raw_text, row.blob_ref),
178
+ sanitizedText: String(row.sanitized_text ?? ''),
179
+ blobRef: row.blob_ref ? String(row.blob_ref) : null,
180
+ createdAt: String(row.created_at),
181
+ }));
182
+ }
183
+ listCorrectionSamples(status = 'pending') {
184
+ const rows = this.db.prepare(`
185
+ SELECT sample_id, session_id, bad_assistant_turn_id, user_correction_turn_id,
186
+ recovery_tool_span_json, diff_excerpt, principle_ids_json, quality_score,
187
+ review_status, export_mode, created_at, updated_at
188
+ FROM correction_samples
189
+ WHERE review_status = ?
190
+ ORDER BY created_at DESC
191
+ `).all(status);
192
+ return rows.map((row) => ({
193
+ sampleId: String(row.sample_id),
194
+ sessionId: String(row.session_id),
195
+ badAssistantTurnId: Number(row.bad_assistant_turn_id),
196
+ userCorrectionTurnId: Number(row.user_correction_turn_id),
197
+ recoveryToolSpanJson: String(row.recovery_tool_span_json),
198
+ diffExcerpt: String(row.diff_excerpt ?? ''),
199
+ principleIdsJson: String(row.principle_ids_json ?? '[]'),
200
+ qualityScore: Number(row.quality_score),
201
+ reviewStatus: row.review_status,
202
+ exportMode: row.export_mode,
203
+ createdAt: String(row.created_at),
204
+ updatedAt: String(row.updated_at),
205
+ }));
206
+ }
207
+ reviewCorrectionSample(sampleId, status, note) {
208
+ const updatedAt = nowIso();
209
+ const updated = this.withWrite(() => {
210
+ const updateResult = this.db.prepare(`
211
+ UPDATE correction_samples
212
+ SET review_status = ?, updated_at = ?
213
+ WHERE sample_id = ?
214
+ `).run(status, updatedAt, sampleId);
215
+ if (updateResult.changes === 0) {
216
+ return false;
217
+ }
218
+ this.db.prepare(`
219
+ INSERT INTO sample_reviews (sample_id, review_status, note, created_at)
220
+ VALUES (?, ?, ?, ?)
221
+ `).run(sampleId, status, note ?? null, updatedAt);
222
+ return true;
223
+ });
224
+ if (!updated) {
225
+ throw new Error(`Correction sample not found: ${sampleId}`);
226
+ }
227
+ const record = this.db.prepare(`
228
+ SELECT sample_id, session_id, bad_assistant_turn_id, user_correction_turn_id,
229
+ recovery_tool_span_json, diff_excerpt, principle_ids_json, quality_score,
230
+ review_status, export_mode, created_at, updated_at
231
+ FROM correction_samples
232
+ WHERE sample_id = ?
233
+ `).get(sampleId);
234
+ if (!record) {
235
+ throw new Error(`Correction sample not found after review update: ${sampleId}`);
236
+ }
237
+ return {
238
+ sampleId: String(record.sample_id),
239
+ sessionId: String(record.session_id),
240
+ badAssistantTurnId: Number(record.bad_assistant_turn_id),
241
+ userCorrectionTurnId: Number(record.user_correction_turn_id),
242
+ recoveryToolSpanJson: String(record.recovery_tool_span_json),
243
+ diffExcerpt: String(record.diff_excerpt ?? ''),
244
+ principleIdsJson: String(record.principle_ids_json ?? '[]'),
245
+ qualityScore: Number(record.quality_score),
246
+ reviewStatus: record.review_status,
247
+ exportMode: record.export_mode,
248
+ createdAt: String(record.created_at),
249
+ updatedAt: String(record.updated_at),
250
+ };
251
+ }
252
+ exportCorrections(opts) {
253
+ const rows = this.db.prepare(`
254
+ SELECT cs.sample_id, cs.session_id, cs.recovery_tool_span_json, cs.diff_excerpt, cs.quality_score,
255
+ at.raw_text AS assistant_raw_text, at.blob_ref AS assistant_blob_ref, at.sanitized_text,
256
+ ut.raw_text AS user_raw_text, ut.blob_ref AS user_blob_ref, ut.correction_cue
257
+ FROM correction_samples cs
258
+ JOIN assistant_turns at ON at.id = cs.bad_assistant_turn_id
259
+ JOIN user_turns ut ON ut.id = cs.user_correction_turn_id
260
+ WHERE (? = 0 OR cs.review_status = 'approved')
261
+ ORDER BY cs.created_at ASC
262
+ `).all(opts.approvedOnly ? 1 : 0);
263
+ const exportPath = path.join(this.exportDir, `corrections-${Date.now()}-${opts.mode}.jsonl`);
264
+ const lines = rows.map((row) => {
265
+ const assistantRaw = this.restoreRawText(row.assistant_raw_text, row.assistant_blob_ref);
266
+ const userRaw = this.restoreRawText(row.user_raw_text, row.user_blob_ref);
267
+ const assistantText = opts.mode === 'redacted' ? redactText(assistantRaw) : assistantRaw;
268
+ const userText = opts.mode === 'redacted' ? redactText(userRaw) : userRaw;
269
+ return JSON.stringify({
270
+ sample_id: row.sample_id,
271
+ session_id: row.session_id,
272
+ instruction: userText,
273
+ input_context: assistantText,
274
+ bad_attempt_summary: String(row.diff_excerpt ?? ''),
275
+ preferred_response: userText,
276
+ labels: {
277
+ correction_cue: row.correction_cue,
278
+ quality_score: row.quality_score,
279
+ },
280
+ metadata: {
281
+ mode: opts.mode,
282
+ recovery_tool_span_json: row.recovery_tool_span_json,
283
+ },
284
+ });
285
+ });
286
+ fs.writeFileSync(exportPath, `${lines.join('\n')}${lines.length > 0 ? '\n' : ''}`, 'utf8');
287
+ this.recordExportAudit('corrections', opts.mode, opts.approvedOnly, exportPath, rows.length);
288
+ return { filePath: exportPath, count: rows.length, mode: opts.mode };
289
+ }
290
+ exportAnalytics() {
291
+ const payload = {
292
+ generatedAt: nowIso(),
293
+ stats: this.getDataStats(),
294
+ dailyMetrics: this.dailyMetrics(),
295
+ errorClusters: this.db.prepare('SELECT * FROM v_error_clusters').all(),
296
+ principleEffectiveness: this.db.prepare('SELECT * FROM v_principle_effectiveness').all(),
297
+ sampleQueue: this.db.prepare('SELECT * FROM v_sample_queue').all(),
298
+ };
299
+ const exportPath = path.join(this.exportDir, `analytics-${Date.now()}.json`);
300
+ fs.writeFileSync(exportPath, JSON.stringify(payload, null, 2), 'utf8');
301
+ this.recordExportAudit('analytics', 'raw', true, exportPath, Array.isArray(payload.dailyMetrics) ? payload.dailyMetrics.length : 0);
302
+ return { filePath: exportPath, count: Array.isArray(payload.dailyMetrics) ? payload.dailyMetrics.length : 0 };
303
+ }
304
+ getDataStats() {
305
+ const getCount = (table, where) => {
306
+ const sql = where ? `SELECT COUNT(*) as count FROM ${table} WHERE ${where}` : `SELECT COUNT(*) as count FROM ${table}`;
307
+ return Number(this.db.prepare(sql).get().count);
308
+ };
309
+ const lastIngest = this.db.prepare(`
310
+ SELECT MAX(ts) AS ts FROM (
311
+ SELECT MAX(created_at) AS ts FROM assistant_turns
312
+ UNION ALL SELECT MAX(created_at) AS ts FROM user_turns
313
+ UNION ALL SELECT MAX(created_at) AS ts FROM tool_calls
314
+ UNION ALL SELECT MAX(created_at) AS ts FROM pain_events
315
+ )
316
+ `).get();
317
+ return {
318
+ dbPath: this.dbPath,
319
+ dbSizeBytes: fileSizeIfExists(this.dbPath),
320
+ assistantTurns: getCount('assistant_turns'),
321
+ userTurns: getCount('user_turns'),
322
+ toolCalls: getCount('tool_calls'),
323
+ painEvents: getCount('pain_events'),
324
+ pendingSamples: getCount('correction_samples', `review_status = 'pending'`),
325
+ approvedSamples: getCount('correction_samples', `review_status = 'approved'`),
326
+ blobBytes: this.computeBlobBytes(),
327
+ lastIngestAt: lastIngest.ts ?? null,
328
+ };
329
+ }
330
+ cleanupBlobStorage() {
331
+ return this.pruneUnreferencedBlobs();
332
+ }
333
+ initSchema() {
334
+ this.db.exec(`
335
+ CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL);
336
+ CREATE TABLE IF NOT EXISTS ingest_checkpoint (
337
+ source_key TEXT PRIMARY KEY,
338
+ imported_at TEXT NOT NULL
339
+ );
340
+ CREATE TABLE IF NOT EXISTS sessions (
341
+ session_id TEXT PRIMARY KEY,
342
+ started_at TEXT NOT NULL,
343
+ updated_at TEXT NOT NULL
344
+ );
345
+ CREATE TABLE IF NOT EXISTS assistant_turns (
346
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
347
+ session_id TEXT NOT NULL,
348
+ run_id TEXT NOT NULL,
349
+ provider TEXT NOT NULL,
350
+ model TEXT NOT NULL,
351
+ raw_text TEXT,
352
+ sanitized_text TEXT NOT NULL,
353
+ usage_json TEXT NOT NULL,
354
+ empathy_signal_json TEXT NOT NULL,
355
+ blob_ref TEXT,
356
+ raw_excerpt TEXT,
357
+ created_at TEXT NOT NULL
358
+ );
359
+ CREATE TABLE IF NOT EXISTS user_turns (
360
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
361
+ session_id TEXT NOT NULL,
362
+ turn_index INTEGER NOT NULL,
363
+ raw_text TEXT,
364
+ blob_ref TEXT,
365
+ raw_excerpt TEXT,
366
+ correction_detected INTEGER NOT NULL DEFAULT 0,
367
+ correction_cue TEXT,
368
+ references_assistant_turn_id INTEGER,
369
+ created_at TEXT NOT NULL
370
+ );
371
+ CREATE TABLE IF NOT EXISTS tool_calls (
372
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
373
+ session_id TEXT NOT NULL,
374
+ tool_name TEXT NOT NULL,
375
+ outcome TEXT NOT NULL,
376
+ duration_ms INTEGER,
377
+ exit_code INTEGER,
378
+ error_type TEXT,
379
+ error_message TEXT,
380
+ gfi_before REAL,
381
+ gfi_after REAL,
382
+ params_json TEXT NOT NULL,
383
+ created_at TEXT NOT NULL
384
+ );
385
+ CREATE TABLE IF NOT EXISTS pain_events (
386
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
387
+ session_id TEXT NOT NULL,
388
+ source TEXT NOT NULL,
389
+ score REAL NOT NULL,
390
+ reason TEXT,
391
+ severity TEXT,
392
+ origin TEXT,
393
+ confidence REAL,
394
+ created_at TEXT NOT NULL
395
+ );
396
+ CREATE TABLE IF NOT EXISTS gate_blocks (
397
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
398
+ session_id TEXT,
399
+ tool_name TEXT NOT NULL,
400
+ file_path TEXT,
401
+ reason TEXT NOT NULL,
402
+ plan_status TEXT,
403
+ created_at TEXT NOT NULL
404
+ );
405
+ CREATE TABLE IF NOT EXISTS trust_changes (
406
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
407
+ session_id TEXT,
408
+ previous_score REAL NOT NULL,
409
+ new_score REAL NOT NULL,
410
+ delta REAL NOT NULL,
411
+ reason TEXT NOT NULL,
412
+ created_at TEXT NOT NULL
413
+ );
414
+ CREATE TABLE IF NOT EXISTS principle_events (
415
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
416
+ principle_id TEXT,
417
+ event_type TEXT NOT NULL,
418
+ payload_json TEXT NOT NULL,
419
+ created_at TEXT NOT NULL
420
+ );
421
+ CREATE TABLE IF NOT EXISTS task_outcomes (
422
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
423
+ session_id TEXT NOT NULL,
424
+ task_id TEXT,
425
+ outcome TEXT NOT NULL,
426
+ summary TEXT,
427
+ principle_ids_json TEXT NOT NULL,
428
+ created_at TEXT NOT NULL
429
+ );
430
+ CREATE TABLE IF NOT EXISTS correction_samples (
431
+ sample_id TEXT PRIMARY KEY,
432
+ session_id TEXT NOT NULL,
433
+ bad_assistant_turn_id INTEGER NOT NULL,
434
+ user_correction_turn_id INTEGER NOT NULL,
435
+ recovery_tool_span_json TEXT NOT NULL,
436
+ diff_excerpt TEXT NOT NULL,
437
+ principle_ids_json TEXT NOT NULL,
438
+ quality_score REAL NOT NULL,
439
+ review_status TEXT NOT NULL,
440
+ export_mode TEXT NOT NULL,
441
+ created_at TEXT NOT NULL,
442
+ updated_at TEXT NOT NULL
443
+ );
444
+ CREATE TABLE IF NOT EXISTS sample_reviews (
445
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
446
+ sample_id TEXT NOT NULL,
447
+ review_status TEXT NOT NULL,
448
+ note TEXT,
449
+ created_at TEXT NOT NULL
450
+ );
451
+ CREATE TABLE IF NOT EXISTS exports_audit (
452
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
453
+ export_kind TEXT NOT NULL,
454
+ mode TEXT NOT NULL,
455
+ approved_only INTEGER NOT NULL,
456
+ file_path TEXT NOT NULL,
457
+ row_count INTEGER NOT NULL,
458
+ created_at TEXT NOT NULL
459
+ );
460
+ CREATE VIEW IF NOT EXISTS v_error_clusters AS
461
+ SELECT tool_name, COALESCE(error_type, 'unknown') AS error_type, COUNT(*) AS occurrences
462
+ FROM tool_calls
463
+ WHERE outcome = 'failure'
464
+ GROUP BY tool_name, COALESCE(error_type, 'unknown')
465
+ ORDER BY occurrences DESC;
466
+ CREATE VIEW IF NOT EXISTS v_principle_effectiveness AS
467
+ SELECT event_type, COUNT(*) AS total
468
+ FROM principle_events
469
+ GROUP BY event_type
470
+ ORDER BY total DESC;
471
+ CREATE VIEW IF NOT EXISTS v_sample_queue AS
472
+ SELECT review_status, COUNT(*) AS total
473
+ FROM correction_samples
474
+ GROUP BY review_status;
475
+ CREATE INDEX IF NOT EXISTS idx_assistant_turns_session_id ON assistant_turns(session_id);
476
+ CREATE INDEX IF NOT EXISTS idx_assistant_turns_created_at ON assistant_turns(created_at);
477
+ CREATE INDEX IF NOT EXISTS idx_assistant_turns_provider_model ON assistant_turns(provider, model);
478
+ CREATE INDEX IF NOT EXISTS idx_user_turns_session_id ON user_turns(session_id);
479
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_session_id ON tool_calls(session_id);
480
+ CREATE INDEX IF NOT EXISTS idx_tool_calls_created_at ON tool_calls(created_at);
481
+ CREATE INDEX IF NOT EXISTS idx_pain_events_session_id ON pain_events(session_id);
482
+ CREATE INDEX IF NOT EXISTS idx_correction_samples_review_status ON correction_samples(review_status);
483
+ `);
484
+ const row = this.db.prepare('SELECT version FROM schema_version LIMIT 1').get();
485
+ this.migrateSchema(row?.version);
486
+ if (!row) {
487
+ this.db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
488
+ }
489
+ else if (row.version !== SCHEMA_VERSION) {
490
+ this.db.prepare('UPDATE schema_version SET version = ?').run(SCHEMA_VERSION);
491
+ }
492
+ }
493
+ importLegacyArtifacts() {
494
+ this.importLegacySessions();
495
+ this.importLegacyEvents();
496
+ this.importLegacyEvolution();
497
+ }
498
+ migrateSchema(_fromVersion) {
499
+ this.db.exec(`
500
+ DROP VIEW IF EXISTS v_daily_metrics;
501
+ CREATE VIEW IF NOT EXISTS v_daily_metrics AS
502
+ WITH tool_daily AS (
503
+ SELECT
504
+ substr(created_at, 1, 10) AS day,
505
+ COUNT(*) AS tool_calls,
506
+ SUM(CASE WHEN outcome = 'failure' THEN 1 ELSE 0 END) AS failures
507
+ FROM tool_calls
508
+ GROUP BY substr(created_at, 1, 10)
509
+ ),
510
+ correction_daily AS (
511
+ SELECT
512
+ substr(created_at, 1, 10) AS day,
513
+ SUM(CASE WHEN correction_detected = 1 THEN 1 ELSE 0 END) AS user_corrections
514
+ FROM user_turns
515
+ GROUP BY substr(created_at, 1, 10)
516
+ )
517
+ SELECT
518
+ tool_daily.day AS day,
519
+ tool_daily.tool_calls AS tool_calls,
520
+ tool_daily.failures AS failures,
521
+ COALESCE(correction_daily.user_corrections, 0) AS user_corrections
522
+ FROM tool_daily
523
+ LEFT JOIN correction_daily ON correction_daily.day = tool_daily.day;
524
+ `);
525
+ }
526
+ dailyMetrics() {
527
+ return this.db.prepare('SELECT * FROM v_daily_metrics ORDER BY day ASC').all();
528
+ }
529
+ importLegacySessions() {
530
+ const key = 'legacy:sessions';
531
+ if (this.isImported(key))
532
+ return;
533
+ const sessionDir = resolvePdPath(this.workspaceDir, 'SESSION_DIR');
534
+ if (!fs.existsSync(sessionDir))
535
+ return;
536
+ for (const file of fs.readdirSync(sessionDir).filter((entry) => entry.endsWith('.json'))) {
537
+ try {
538
+ const content = JSON.parse(fs.readFileSync(path.join(sessionDir, file), 'utf8'));
539
+ if (content.sessionId) {
540
+ const startedAt = typeof content.lastActivityAt === 'number'
541
+ ? new Date(content.lastActivityAt).toISOString()
542
+ : nowIso();
543
+ this.recordSession({ sessionId: content.sessionId, startedAt });
544
+ }
545
+ }
546
+ catch {
547
+ // Ignore malformed legacy sessions.
548
+ }
549
+ }
550
+ this.markImported(key);
551
+ }
552
+ importLegacyEvents() {
553
+ const key = 'legacy:events';
554
+ if (this.isImported(key))
555
+ return;
556
+ const eventsPath = path.join(this.stateDir, 'logs', 'events.jsonl');
557
+ if (!fs.existsSync(eventsPath))
558
+ return;
559
+ const raw = fs.readFileSync(eventsPath, 'utf8').trim();
560
+ if (!raw) {
561
+ this.markImported(key);
562
+ return;
563
+ }
564
+ for (const line of raw.split('\n')) {
565
+ try {
566
+ const event = JSON.parse(line);
567
+ if (event.type === 'pain_signal' && event.sessionId) {
568
+ this.recordPainEvent({
569
+ sessionId: event.sessionId,
570
+ source: String(event.data?.source ?? 'legacy'),
571
+ score: Number(event.data?.score ?? 0),
572
+ reason: typeof event.data?.reason === 'string' ? event.data.reason : null,
573
+ severity: typeof event.data?.severity === 'string' ? event.data.severity : null,
574
+ origin: typeof event.data?.origin === 'string' ? event.data.origin : null,
575
+ confidence: typeof event.data?.confidence === 'number' ? event.data.confidence : null,
576
+ createdAt: event.ts,
577
+ });
578
+ }
579
+ if (event.type === 'trust_change') {
580
+ this.recordTrustChange({
581
+ sessionId: event.sessionId,
582
+ previousScore: Number(event.data?.previousScore ?? 0),
583
+ newScore: Number(event.data?.newScore ?? 0),
584
+ delta: Number(event.data?.delta ?? 0),
585
+ reason: String(event.data?.reason ?? 'legacy'),
586
+ createdAt: event.ts,
587
+ });
588
+ }
589
+ if (event.type === 'gate_block') {
590
+ this.recordGateBlock({
591
+ sessionId: event.sessionId,
592
+ toolName: String(event.data?.toolName ?? 'unknown'),
593
+ filePath: typeof event.data?.filePath === 'string' ? event.data.filePath : null,
594
+ reason: String(event.data?.reason ?? 'legacy'),
595
+ planStatus: typeof event.data?.planStatus === 'string' ? event.data.planStatus : null,
596
+ createdAt: event.ts,
597
+ });
598
+ }
599
+ }
600
+ catch {
601
+ // Ignore malformed legacy events.
602
+ }
603
+ }
604
+ this.markImported(key);
605
+ }
606
+ importLegacyEvolution() {
607
+ const key = 'legacy:evolution';
608
+ if (this.isImported(key))
609
+ return;
610
+ const evolutionPath = resolvePdPath(this.workspaceDir, 'EVOLUTION_STREAM');
611
+ if (!fs.existsSync(evolutionPath))
612
+ return;
613
+ const raw = fs.readFileSync(evolutionPath, 'utf8').trim();
614
+ if (!raw) {
615
+ this.markImported(key);
616
+ return;
617
+ }
618
+ for (const line of raw.split('\n')) {
619
+ try {
620
+ const event = JSON.parse(line);
621
+ this.recordPrincipleEvent({
622
+ principleId: typeof event.data?.principleId === 'string' ? event.data.principleId : null,
623
+ eventType: String(event.type ?? 'legacy'),
624
+ payload: event.data ?? {},
625
+ createdAt: event.ts,
626
+ });
627
+ }
628
+ catch {
629
+ // Ignore malformed legacy evolution events.
630
+ }
631
+ }
632
+ this.markImported(key);
633
+ }
634
+ markImported(sourceKey) {
635
+ this.withWrite(() => {
636
+ this.db.prepare(`
637
+ INSERT INTO ingest_checkpoint (source_key, imported_at)
638
+ VALUES (?, ?)
639
+ ON CONFLICT(source_key) DO UPDATE SET imported_at = excluded.imported_at
640
+ `).run(sourceKey, nowIso());
641
+ });
642
+ }
643
+ isImported(sourceKey) {
644
+ const row = this.db.prepare('SELECT source_key FROM ingest_checkpoint WHERE source_key = ?').get(sourceKey);
645
+ return Boolean(row);
646
+ }
647
+ maybeCreateCorrectionSample(sessionId) {
648
+ const pending = this.db.prepare(`
649
+ SELECT sample_id FROM correction_samples
650
+ WHERE session_id = ? AND review_status = 'pending'
651
+ ORDER BY created_at DESC
652
+ LIMIT 1
653
+ `).get(sessionId);
654
+ if (pending?.sample_id)
655
+ return;
656
+ const correctionTurn = this.db.prepare(`
657
+ SELECT id, references_assistant_turn_id, correction_cue, raw_text, blob_ref
658
+ FROM user_turns
659
+ WHERE session_id = ? AND correction_detected = 1
660
+ ORDER BY id DESC
661
+ LIMIT 1
662
+ `).get(sessionId);
663
+ if (!correctionTurn || !correctionTurn.references_assistant_turn_id)
664
+ return;
665
+ const failedCall = this.db.prepare(`
666
+ SELECT id, tool_name, error_type, error_message
667
+ FROM tool_calls
668
+ WHERE session_id = ? AND outcome = 'failure'
669
+ ORDER BY id DESC
670
+ LIMIT 1
671
+ `).get(sessionId);
672
+ if (!failedCall)
673
+ return;
674
+ const successfulCalls = this.db.prepare(`
675
+ SELECT id, tool_name
676
+ FROM tool_calls
677
+ WHERE session_id = ? AND outcome = 'success'
678
+ ORDER BY id DESC
679
+ LIMIT 3
680
+ `).all(sessionId);
681
+ if (successfulCalls.length === 0)
682
+ return;
683
+ const sampleId = `sample_${crypto.createHash('md5').update(`${sessionId}:${correctionTurn.id}:${successfulCalls[0].id}`).digest('hex').slice(0, 12)}`;
684
+ const userRawText = this.restoreRawText(correctionTurn.raw_text, correctionTurn.blob_ref);
685
+ const qualityScore = [
686
+ correctionTurn.references_assistant_turn_id ? 35 : 0,
687
+ correctionTurn.correction_cue ? 20 : 0,
688
+ failedCall ? 20 : 0,
689
+ successfulCalls.length > 0 ? 25 : 0,
690
+ ].reduce((sum, value) => sum + value, 0);
691
+ this.withWrite(() => {
692
+ this.db.prepare(`
693
+ INSERT OR IGNORE INTO correction_samples (
694
+ sample_id, session_id, bad_assistant_turn_id, user_correction_turn_id,
695
+ recovery_tool_span_json, diff_excerpt, principle_ids_json, quality_score,
696
+ review_status, export_mode, created_at, updated_at
697
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 'raw', ?, ?)
698
+ `).run(sampleId, sessionId, Number(correctionTurn.references_assistant_turn_id), Number(correctionTurn.id), safeJson(successfulCalls.map((call) => ({ id: call.id, toolName: call.tool_name }))), summarizeForDiff(userRawText || String(failedCall.error_message ?? failedCall.error_type ?? failedCall.tool_name)), '[]', qualityScore, nowIso(), nowIso());
699
+ });
700
+ }
701
+ recordExportAudit(exportKind, mode, approvedOnly, filePath, rowCount) {
702
+ this.withWrite(() => {
703
+ this.db.prepare(`
704
+ INSERT INTO exports_audit (export_kind, mode, approved_only, file_path, row_count, created_at)
705
+ VALUES (?, ?, ?, ?, ?, ?)
706
+ `).run(exportKind, mode, approvedOnly ? 1 : 0, filePath, rowCount, nowIso());
707
+ });
708
+ }
709
+ storeRawText(kind, text) {
710
+ const excerpt = text.length > 200 ? `${text.slice(0, 197)}...` : text;
711
+ const bytes = Buffer.byteLength(text, 'utf8');
712
+ if (bytes <= this.blobInlineThresholdBytes) {
713
+ return { inlineText: text, blobRef: null, excerpt };
714
+ }
715
+ const hash = crypto.createHash('sha256').update(text).digest('hex');
716
+ const relativePath = `${kind}-${hash}.txt`;
717
+ const fullPath = path.join(this.blobDir, relativePath);
718
+ if (!fs.existsSync(fullPath)) {
719
+ fs.writeFileSync(fullPath, text, 'utf8');
720
+ }
721
+ return { inlineText: null, blobRef: relativePath, excerpt };
722
+ }
723
+ restoreRawText(inlineText, blobRef) {
724
+ if (inlineText)
725
+ return inlineText;
726
+ if (!blobRef)
727
+ return '';
728
+ const fullPath = path.join(this.blobDir, blobRef);
729
+ return fs.existsSync(fullPath) ? fs.readFileSync(fullPath, 'utf8') : '';
730
+ }
731
+ computeBlobBytes() {
732
+ if (!fs.existsSync(this.blobDir))
733
+ return 0;
734
+ return fs.readdirSync(this.blobDir).reduce((sum, file) => sum + fileSizeIfExists(path.join(this.blobDir, file)), 0);
735
+ }
736
+ pruneUnreferencedBlobs() {
737
+ if (!fs.existsSync(this.blobDir)) {
738
+ return { removedFiles: 0, reclaimedBytes: 0 };
739
+ }
740
+ const referenced = new Set();
741
+ const rows = this.db.prepare(`
742
+ SELECT blob_ref FROM assistant_turns WHERE blob_ref IS NOT NULL
743
+ UNION
744
+ SELECT blob_ref FROM user_turns WHERE blob_ref IS NOT NULL
745
+ `).all();
746
+ for (const row of rows) {
747
+ if (row.blob_ref)
748
+ referenced.add(String(row.blob_ref));
749
+ }
750
+ const now = Date.now();
751
+ let removedFiles = 0;
752
+ let reclaimedBytes = 0;
753
+ for (const entry of fs.readdirSync(this.blobDir)) {
754
+ if (referenced.has(entry))
755
+ continue;
756
+ const fullPath = path.join(this.blobDir, entry);
757
+ let stat;
758
+ try {
759
+ stat = fs.statSync(fullPath);
760
+ }
761
+ catch {
762
+ continue;
763
+ }
764
+ if (!stat.isFile())
765
+ continue;
766
+ if (this.orphanBlobGraceMs > 0 && now - stat.mtimeMs < this.orphanBlobGraceMs)
767
+ continue;
768
+ reclaimedBytes += stat.size;
769
+ removedFiles += 1;
770
+ fs.rmSync(fullPath, { force: true });
771
+ }
772
+ return { removedFiles, reclaimedBytes };
773
+ }
774
+ withWrite(fn) {
775
+ return withLock(this.dbPath, fn, { lockSuffix: '.trajectory.lock', lockStaleMs: 30000 });
776
+ }
777
+ }
778
+ export class TrajectoryRegistry {
779
+ static instances = new Map();
780
+ static get(workspaceDir, opts = {}) {
781
+ const normalized = path.resolve(workspaceDir);
782
+ const existing = this.instances.get(normalized);
783
+ if (existing)
784
+ return existing;
785
+ const created = new TrajectoryDatabase({ workspaceDir: normalized, ...opts });
786
+ this.instances.set(normalized, created);
787
+ return created;
788
+ }
789
+ static dispose(workspaceDir) {
790
+ const normalized = path.resolve(workspaceDir);
791
+ const instance = this.instances.get(normalized);
792
+ if (instance) {
793
+ instance.dispose();
794
+ this.instances.delete(normalized);
795
+ }
796
+ }
797
+ static clear() {
798
+ for (const instance of this.instances.values()) {
799
+ instance.dispose();
800
+ }
801
+ this.instances.clear();
802
+ }
803
+ static use(workspaceDir, fn, opts = {}) {
804
+ const normalized = path.resolve(workspaceDir);
805
+ const existing = this.instances.get(normalized);
806
+ if (existing) {
807
+ return fn(existing);
808
+ }
809
+ const transient = new TrajectoryDatabase({ workspaceDir: normalized, ...opts });
810
+ try {
811
+ return fn(transient);
812
+ }
813
+ finally {
814
+ transient.dispose();
815
+ }
816
+ }
817
+ }