trajectories 0.0.0 → 0.1.1
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 +23 -0
- package/LICENSE +201 -0
- package/README.md +274 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +8542 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8402 -0
- package/dist/log-baseline.d.ts +26 -0
- package/dist/log-baseline.js +104 -0
- package/dist/log-content.d.ts +16 -0
- package/dist/log-content.js +80 -0
- package/dist/log-io.d.ts +10 -0
- package/dist/log-io.js +121 -0
- package/dist/log-read.d.ts +24 -0
- package/dist/log-read.js +117 -0
- package/dist/log-reconcile.d.ts +5 -0
- package/dist/log-reconcile.js +38 -0
- package/dist/log-records.d.ts +35 -0
- package/dist/log-records.js +85 -0
- package/dist/log-recovery.d.ts +18 -0
- package/dist/log-recovery.js +123 -0
- package/dist/log-stage.d.ts +14 -0
- package/dist/log-stage.js +138 -0
- package/dist/log-state.d.ts +89 -0
- package/dist/log-state.js +92 -0
- package/dist/log-switch.d.ts +3 -0
- package/dist/log-switch.js +98 -0
- package/dist/log-types.d.ts +129 -0
- package/dist/log-types.js +8 -0
- package/dist/log.d.ts +8 -0
- package/dist/log.js +62 -0
- package/dist/migrate.d.ts +29 -0
- package/dist/migrate.js +173 -0
- package/dist/normalization-runtime.d.ts +1 -0
- package/dist/normalization-runtime.js +3150 -0
- package/dist/portable.d.ts +9 -0
- package/dist/portable.js +26 -0
- package/dist/readers/canonical.d.ts +13 -0
- package/dist/readers/canonical.js +54 -0
- package/dist/readers/cline.d.ts +10 -0
- package/dist/readers/cline.js +107 -0
- package/dist/readers/cursor.d.ts +20 -0
- package/dist/readers/cursor.js +374 -0
- package/dist/readers/deepagents.d.ts +41 -0
- package/dist/readers/deepagents.js +163 -0
- package/dist/readers/fx.d.ts +11 -0
- package/dist/readers/fx.js +573 -0
- package/dist/readers/github-copilot.d.ts +8 -0
- package/dist/readers/github-copilot.js +216 -0
- package/dist/readers/grok-build.d.ts +12 -0
- package/dist/readers/grok-build.js +391 -0
- package/dist/readers/guards.d.ts +4 -0
- package/dist/readers/guards.js +121 -0
- package/dist/readers/pi-compaction.d.ts +7 -0
- package/dist/readers/pi-compaction.js +112 -0
- package/dist/readers/pi-context.d.ts +13 -0
- package/dist/readers/pi-context.js +64 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.js +218 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/writers/claude-code.d.ts +5 -0
- package/dist/writers/claude-code.js +51 -0
- package/dist/writers/cline.d.ts +3 -0
- package/dist/writers/cline.js +77 -0
- package/dist/writers/codex.d.ts +3 -0
- package/dist/writers/codex.js +32 -0
- package/dist/writers/cursor.d.ts +3 -0
- package/dist/writers/cursor.js +197 -0
- package/dist/writers/deepagents.d.ts +3 -0
- package/dist/writers/deepagents.js +116 -0
- package/dist/writers/entropy.d.ts +5 -0
- package/dist/writers/entropy.js +33 -0
- package/dist/writers/fx.d.ts +3 -0
- package/dist/writers/fx.js +135 -0
- package/dist/writers/github-copilot.d.ts +3 -0
- package/dist/writers/github-copilot.js +71 -0
- package/dist/writers/grok-build.d.ts +3 -0
- package/dist/writers/grok-build.js +43 -0
- package/dist/writers/helpers.d.ts +17 -0
- package/dist/writers/helpers.js +75 -0
- package/dist/writers/omp.d.ts +3 -0
- package/dist/writers/omp.js +7 -0
- package/dist/writers/opencode.d.ts +3 -0
- package/dist/writers/opencode.js +65 -0
- package/dist/writers/pi.d.ts +3 -0
- package/dist/writers/pi.js +45 -0
- package/docs/cline-native-format.md +43 -0
- package/docs/copilot-native-format.md +78 -0
- package/docs/cursor-native-format.md +103 -0
- package/docs/deepagents-native-format.md +98 -0
- package/docs/evidence-api.md +170 -0
- package/docs/fx-native-format.md +87 -0
- package/docs/harness-parity-bootstrap.md +78 -0
- package/docs/harness-support-parity.md +107 -0
- package/docs/inspirations.md +103 -0
- package/docs/ledger-migration.md +141 -0
- package/docs/pi-compaction-context.md +75 -0
- package/docs/pi-provider-context.md +76 -0
- package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
- package/docs/research/ledger/README.md +96 -0
- package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
- package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
- package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
- package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
- package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
- package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
- package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
- package/docs/research/ledger/docs/inspirations.md +320 -0
- package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
- package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
- package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
- package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
- package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
- package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
- package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
- package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
- package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
- package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
- package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
- package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
- package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
- package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
- package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
- package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
- package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
- package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
- package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
- package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
- package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
- package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
- package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
- package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
- package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
- package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
- package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
- package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
- package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
- package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
- package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
- package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
- package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
- package/docs/research/ledger/manifest.json +540 -0
- package/legacy/NOTICE.md +50 -0
- package/legacy/bin/ledger-core.ts +7 -0
- package/legacy/bin/ledger.ts +7 -0
- package/legacy/bin/traj-bridge.ts +7 -0
- package/legacy/scripts/import-claude-history.ts +130 -0
- package/legacy/scripts/native-history-smoke.ts +52 -0
- package/legacy/scripts/observe-daemon.ts +570 -0
- package/legacy/scripts/retitle-sessions.md +19 -0
- package/legacy/scripts/verify-runtime-rollout.ts +151 -0
- package/legacy/src/canonical.ts +108 -0
- package/legacy/src/cli.ts +1293 -0
- package/legacy/src/db.ts +2254 -0
- package/legacy/src/diagnostics.ts +87 -0
- package/legacy/src/discovery.ts +56 -0
- package/legacy/src/embeddings.ts +382 -0
- package/legacy/src/filter.ts +602 -0
- package/legacy/src/gemini_embeddings.ts +103 -0
- package/legacy/src/identity.ts +9 -0
- package/legacy/src/index.ts +10 -0
- package/legacy/src/ingest.ts +791 -0
- package/legacy/src/managed.ts +178 -0
- package/legacy/src/native/common.ts +68 -0
- package/legacy/src/native/cursor.ts +647 -0
- package/legacy/src/native/grok.ts +540 -0
- package/legacy/src/native/kimi.ts +706 -0
- package/legacy/src/native/opencode.ts +470 -0
- package/legacy/src/native/sqlite-ro.ts +281 -0
- package/legacy/src/native.ts +97 -0
- package/legacy/src/normalizer.ts +457 -0
- package/legacy/src/parentage.ts +562 -0
- package/legacy/src/pressure.ts +162 -0
- package/legacy/src/private-storage.ts +74 -0
- package/legacy/src/reader.ts +1255 -0
- package/legacy/src/scanner.ts +591 -0
- package/legacy/src/schema-gate.ts +263 -0
- package/legacy/src/schema.ts +829 -0
- package/legacy/src/semantic-top-k.ts +71 -0
- package/legacy/src/session-hash.ts +49 -0
- package/legacy/src/store-status.ts +218 -0
- package/legacy/src/sync-preparation.ts +45 -0
- package/legacy/src/sync-truth.ts +843 -0
- package/legacy/src/sync.ts +1219 -0
- package/legacy/src/types.ts +436 -0
- package/legacy/src/vocab.txt +30522 -0
- package/legacy/src/watch.ts +673 -0
- package/legacy/src/writer-lease.ts +310 -0
- package/ops/README.md +120 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-release.sh +49 -0
- package/ops/linux/install-systemd-user.sh +103 -0
- package/ops/macos/install-launchd.sh +72 -0
- package/ops/sync-settings.sh +37 -0
- package/package.json +19 -5
- package/skills/trajectories/SKILL.md +138 -0
- package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
- package/skills/trajectories/scripts/trajectories_search.sh +26 -0
- package/index.js +0 -1
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { SessionNotFoundError, StoreNotInitializedError, TrajectoryDb } from "./db.js";
|
|
3
|
+
import { aggregateIngestDiagnostics } from "./diagnostics.js";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_MODEL_NAME,
|
|
6
|
+
pullModel,
|
|
7
|
+
setEmbeddingProvider,
|
|
8
|
+
} from "./embeddings.js";
|
|
9
|
+
import {
|
|
10
|
+
GeminiEmbeddingProvider,
|
|
11
|
+
resolveGeminiKey,
|
|
12
|
+
} from "./gemini_embeddings.js";
|
|
13
|
+
import { ledgerId } from "./identity.js";
|
|
14
|
+
import { DEFAULT_INGEST_RECORD_BYTES, ingestDiscoveredSession, nativeIngestCatchResult } from "./ingest.js";
|
|
15
|
+
import { normalizeDiscoveredSession } from "./normalizer.js";
|
|
16
|
+
import { FTS_SYNTAX_FALLBACK_REASON, openReadOnlyStore, ReaderError, TrajectoryReader } from "./reader.js";
|
|
17
|
+
import { SCHEMA_VERSION } from "./schema.js";
|
|
18
|
+
import { scanSessions } from "./scanner.js";
|
|
19
|
+
import { readStoreStatus, StoreStatusError } from "./store-status.js";
|
|
20
|
+
import { executeSyncMaintenanceStdio, executeSyncWorkerStdio, executeSyncPreparationStdio, SyncPreparationError, runSync } from "./sync.js";
|
|
21
|
+
import type { BuildSummary, DiscoveredSession, QueryResult, ScanOutcome, TombstoneRecord } from "./types.js";
|
|
22
|
+
import { LedgerWatcher } from "./watch.js";
|
|
23
|
+
import {
|
|
24
|
+
acquireWriterLease,
|
|
25
|
+
DEFAULT_WRITER_LEASE_WAIT_MS,
|
|
26
|
+
describeLeaseHolder,
|
|
27
|
+
isMemoryStorePath,
|
|
28
|
+
WriterLeaseBusyError,
|
|
29
|
+
} from "./writer-lease.js";
|
|
30
|
+
|
|
31
|
+
const VERSION: string = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
32
|
+
|
|
33
|
+
/** The provider a `--embedding` value names; an absent flag asks for local, a bare `--embedding` asks for Gemini. */
|
|
34
|
+
function requestedEmbedding(value: string | boolean | undefined): string {
|
|
35
|
+
return value === true ? "gemini" : typeof value === "string" ? value : "local";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The one place that decides which `--embedding` values exist. The flag parser
|
|
40
|
+
* runs it for every verb — a verb that never embeds still has to reject a typo
|
|
41
|
+
* rather than accept a provider it will ignore — and the wiring below runs it
|
|
42
|
+
* again before selecting a provider, so neither site can drift.
|
|
43
|
+
*/
|
|
44
|
+
function assertKnownEmbedding(value: string | boolean | undefined): void {
|
|
45
|
+
const requested = requestedEmbedding(value);
|
|
46
|
+
if (requested !== "local" && requested !== "gemini" && requested !== "none") {
|
|
47
|
+
throw new Error(`Unknown embedding provider: ${requested} (use local|gemini|none)`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyEmbeddingProvider(
|
|
52
|
+
flags: Record<string, string | boolean>,
|
|
53
|
+
isRobot: boolean,
|
|
54
|
+
): void {
|
|
55
|
+
assertKnownEmbedding(flags.embedding);
|
|
56
|
+
// Local embedding is the privacy-safe default. Cloud embedding is enabled
|
|
57
|
+
// only when the caller explicitly asks for Gemini.
|
|
58
|
+
if (requestedEmbedding(flags.embedding) !== "gemini") return;
|
|
59
|
+
if (resolveGeminiKey()) {
|
|
60
|
+
setEmbeddingProvider(new GeminiEmbeddingProvider());
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const warn = "No Gemini API key found; falling back to local MiniLM embeddings.";
|
|
64
|
+
if (isRobot) console.error(warn);
|
|
65
|
+
else console.warn(warn);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Options that distinguish the canonical `ledger` executable from legacy entrypoints. */
|
|
69
|
+
export interface CliOptions {
|
|
70
|
+
/** Name used in help, version, and error strings (default: "traj-bridge"). */
|
|
71
|
+
programName?: string;
|
|
72
|
+
/** Help-text suffix describing the default database resolution. */
|
|
73
|
+
dbHelpNote?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function runCli(argv: string[], options: CliOptions = {}): Promise<void> {
|
|
77
|
+
const isRobot = argv.includes("--robot");
|
|
78
|
+
const programName = options.programName ?? "traj-bridge";
|
|
79
|
+
const dbHelpNote = options.dbHelpNote ?? " (default: ledger.sqlite)";
|
|
80
|
+
|
|
81
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
|
|
82
|
+
if (isRobot && argv.length === 0) {
|
|
83
|
+
emitRobotError(
|
|
84
|
+
"MISSING_COMMAND",
|
|
85
|
+
"Please specify a command (search, recent, sync, status, scan, build, watch, models).",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
printHelp(programName, dbHelpNote);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
93
|
+
if (programName === "trajectories") {
|
|
94
|
+
if (isRobot) {
|
|
95
|
+
console.log(JSON.stringify({ version: VERSION, name: "trajectories" }));
|
|
96
|
+
} else {
|
|
97
|
+
console.log(`Trajectories v${VERSION}`);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (isRobot) {
|
|
102
|
+
console.log(JSON.stringify({ version: VERSION, name: "traj-bridge" }));
|
|
103
|
+
} else {
|
|
104
|
+
console.log(`traj-bridge v${VERSION}`);
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const firstArg = argv[0];
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
if (firstArg === "scan") {
|
|
113
|
+
await handleScan(argv.slice(1), isRobot);
|
|
114
|
+
} else if (firstArg === "sync") {
|
|
115
|
+
await handleSync(argv.slice(1), isRobot);
|
|
116
|
+
} else if (firstArg === "build") {
|
|
117
|
+
await handleBuild(argv.slice(1), isRobot);
|
|
118
|
+
} else if (firstArg === "search" || firstArg === "query") {
|
|
119
|
+
await handleQuery(argv.slice(1), isRobot, programName);
|
|
120
|
+
} else if (firstArg === "recent" || firstArg === "last") {
|
|
121
|
+
await handleLast(argv.slice(1), isRobot);
|
|
122
|
+
} else if (firstArg === "status") {
|
|
123
|
+
await handleStatus(argv.slice(1), isRobot);
|
|
124
|
+
} else if (firstArg === "delete") {
|
|
125
|
+
await handleDelete(argv.slice(1), isRobot);
|
|
126
|
+
} else if (firstArg === "watch") {
|
|
127
|
+
await handleWatch(argv.slice(1), isRobot);
|
|
128
|
+
} else if (firstArg === "models") {
|
|
129
|
+
await handleModels(argv.slice(1), isRobot);
|
|
130
|
+
} else if (firstArg === "__sync-prepare") {
|
|
131
|
+
await executeSyncPreparationStdio();
|
|
132
|
+
} else if (firstArg === "__sync-worker") {
|
|
133
|
+
// Private verb: this process IS the spawned worker child, so it writes on
|
|
134
|
+
// its parent's lease authority rather than trusting the transport marker.
|
|
135
|
+
await executeSyncWorkerStdio({ workerChild: true });
|
|
136
|
+
} else if (firstArg === "__sync-maintenance") {
|
|
137
|
+
await executeSyncMaintenanceStdio({ workerChild: true });
|
|
138
|
+
} else {
|
|
139
|
+
if (isRobot) {
|
|
140
|
+
emitRobotError("UNKNOWN_COMMAND", `Unknown command: ${firstArg}`);
|
|
141
|
+
}
|
|
142
|
+
console.error(`Unknown command: ${firstArg}\n`);
|
|
143
|
+
printHelp(programName, dbHelpNote);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
} catch (err: unknown) {
|
|
147
|
+
if (err instanceof ReaderError) {
|
|
148
|
+
// A read verb's typed refusal: the store is missing, unreadable, or too
|
|
149
|
+
// new for this build. It never creates, upgrades, or repairs anything.
|
|
150
|
+
if (isRobot) {
|
|
151
|
+
emitRobotError(err.code, err.message);
|
|
152
|
+
}
|
|
153
|
+
console.error(`Error: ${err.message}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
if (err instanceof SyncPreparationError) {
|
|
157
|
+
if (isRobot) emitRobotError(err.code, err.message);
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
if (err instanceof WriterLeaseBusyError) {
|
|
161
|
+
// Another writer owns the store: a typed, actionable refusal instead of
|
|
162
|
+
// silently blocking on SQLite's busy_timeout.
|
|
163
|
+
if (isRobot) {
|
|
164
|
+
emitRobotError(err.code, err.message);
|
|
165
|
+
}
|
|
166
|
+
console.error(`Error: ${err.message}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
if (err instanceof SessionNotFoundError) {
|
|
170
|
+
// `delete`'s typed refusal: the store does not hold the session the user
|
|
171
|
+
// named, so there is nothing to record. A tombstone for a session that was
|
|
172
|
+
// never ingested would be an exclusion rule, which this store does not
|
|
173
|
+
// have (db.ts).
|
|
174
|
+
if (isRobot) {
|
|
175
|
+
emitRobotError(err.code, err.message);
|
|
176
|
+
}
|
|
177
|
+
console.error(`Error: ${err.message}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
if (isRobot) {
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
const code = message.includes("schema version") ? "SCHEMA_MISMATCH" : "RUNTIME_ERROR";
|
|
183
|
+
emitRobotError(code, message);
|
|
184
|
+
}
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function emitRobotError(code: string, message: string, exitCode = 1): never {
|
|
190
|
+
console.log(JSON.stringify({ error: { code, message } }));
|
|
191
|
+
process.exit(exitCode);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function printHelp(programName: string, dbHelpNote: string): void {
|
|
195
|
+
console.log(`Trajectories CLI v${VERSION} — invoked as ${programName}
|
|
196
|
+
|
|
197
|
+
Canonical daily commands:
|
|
198
|
+
search <query> Search normalized sessions and records using SQLite FTS5
|
|
199
|
+
recent List most recently updated sessions
|
|
200
|
+
sync Run one bounded lexical ingestion pass and exit
|
|
201
|
+
status Report store path, schema version, and counts (read-only)
|
|
202
|
+
|
|
203
|
+
Advanced commands:
|
|
204
|
+
scan Scan and list available sessions across local coding-agent stores
|
|
205
|
+
build Normalize all local sessions and store in hash-deduped SQLite DB
|
|
206
|
+
watch Watch session stores for new/modified sessions and auto-import
|
|
207
|
+
models pull Download the local embedding model for semantic/hybrid search
|
|
208
|
+
delete <session> Delete one session: stop serving it and refuse to re-import it
|
|
209
|
+
|
|
210
|
+
Usage:
|
|
211
|
+
${programName} search <query> [options]
|
|
212
|
+
${programName} recent [options]
|
|
213
|
+
${programName} sync [options]
|
|
214
|
+
${programName} status [options]
|
|
215
|
+
${programName} scan [options]
|
|
216
|
+
${programName} build [options]
|
|
217
|
+
${programName} watch [options]
|
|
218
|
+
${programName} models pull [options]
|
|
219
|
+
${programName} delete <session-id> --reason <text> [--hard] [options]
|
|
220
|
+
${programName} delete --list [options]
|
|
221
|
+
|
|
222
|
+
Aliases: query = search; last = recent. The canonical executable is \`trajectories\`;
|
|
223
|
+
traj-bridge and ledger-core are compatibility entrypoints for the same CLI.
|
|
224
|
+
|
|
225
|
+
Options:
|
|
226
|
+
--db <path> Path to SQLite database${dbHelpNote}
|
|
227
|
+
--sources <list> Comma-separated list of sources (e.g. codex,pi,claude-code,hermes)
|
|
228
|
+
--source <name> Filter search/recent results to a single source adapter
|
|
229
|
+
--embedding <provider> Embedding provider: local (default), gemini, or none (build)
|
|
230
|
+
--debounce <ms> Debounce interval in milliseconds for batch processing (default: 2000)
|
|
231
|
+
--limit <num> Limit number of sessions per source (default: 1000)
|
|
232
|
+
--embed Generate embeddings during watch (off by default)
|
|
233
|
+
--reconcile-ms <ms> Watch catch-up interval (default: 60000; 0 disables)
|
|
234
|
+
--import-budget <num> Max sessions to normalize per source per watch tick (default: 25)
|
|
235
|
+
--max-record-bytes <n> Max JSONL record bytes during sync/watch (default: 67108864)
|
|
236
|
+
--session-budget <n> Max distinct queued sessions per sync run (default: 25)
|
|
237
|
+
--source-byte-budget <n> Max source bytes read per sync run (default: 67108864)
|
|
238
|
+
--wall-ms <n> Sync wall-time budget in milliseconds (default: 120000)
|
|
239
|
+
--retry-budget <n> Failed worker allowance per sync run (default: 3)
|
|
240
|
+
--attempts-per-session <n> Max chunks per session per sync run (default: 8)
|
|
241
|
+
--read-bytes-per-second <n> Source read-rate ceiling (default: 8388608)
|
|
242
|
+
--worker-ms <n> Per-worker deadline in milliseconds (default: 30000)
|
|
243
|
+
--workers <n> Max concurrent session workers (default: 2)
|
|
244
|
+
--fresh-session-budget <n> Reserved recent sessions per sync run (default: 5)
|
|
245
|
+
--fresh-window-ms <n> Recent-session window in milliseconds (default: 3600000)
|
|
246
|
+
--sync-chunk-bytes <n> Cold-rebuild chunk size (default: 1048576)
|
|
247
|
+
--pressure <mode> Adaptive Linux pressure control: auto|off (default: auto)
|
|
248
|
+
--maintenance-ms <n> Disposable maintenance deadline; 0 disables (default: 5000)
|
|
249
|
+
--fts-merge-pages <n> FTS pages merged per maintenance pass; 0 disables (default: 64)
|
|
250
|
+
--json Output results as JSON
|
|
251
|
+
--robot Machine-readable JSONL on stdout; human status on stderr
|
|
252
|
+
--reason <text> Why the session is deleted; what the store's audit row records (delete)
|
|
253
|
+
--hard Remove the records as well, not only serving and admission (delete)
|
|
254
|
+
--list List the deletions this store records instead of deleting (delete)
|
|
255
|
+
--output-version <n> Search/query robot format: 1 (legacy default) or 2 (metadata)
|
|
256
|
+
--semantic Vector semantic search (search)
|
|
257
|
+
--hybrid Hybrid FTS5 + semantic search via RRF (search)
|
|
258
|
+
--verbose Show detailed progress during normalization
|
|
259
|
+
-h, --help Show help
|
|
260
|
+
-v, --version Show version
|
|
261
|
+
`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const BOOLEAN_FLAGS = new Set(["robot", "json", "verbose", "semantic", "hybrid", "embed", "hard", "list"]);
|
|
265
|
+
|
|
266
|
+
function parseFlags(args: string[]): {
|
|
267
|
+
positionals: string[];
|
|
268
|
+
flags: Record<string, string | boolean>;
|
|
269
|
+
} {
|
|
270
|
+
const positionals: string[] = [];
|
|
271
|
+
const flags: Record<string, string | boolean> = {};
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < args.length; i++) {
|
|
274
|
+
const arg = args[i];
|
|
275
|
+
if (arg.startsWith("--")) {
|
|
276
|
+
const eq = arg.indexOf("=");
|
|
277
|
+
const key = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
|
|
278
|
+
if (eq !== -1) {
|
|
279
|
+
// --flag=value form (e.g. --db=PATH); an empty value stays a string.
|
|
280
|
+
flags[key] = arg.slice(eq + 1);
|
|
281
|
+
} else if (BOOLEAN_FLAGS.has(key)) {
|
|
282
|
+
flags[key] = true;
|
|
283
|
+
} else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
284
|
+
flags[key] = args[i + 1];
|
|
285
|
+
i++;
|
|
286
|
+
} else {
|
|
287
|
+
flags[key] = true;
|
|
288
|
+
}
|
|
289
|
+
} else if (arg.startsWith("-")) {
|
|
290
|
+
const key = arg.slice(1);
|
|
291
|
+
flags[key] = true;
|
|
292
|
+
} else {
|
|
293
|
+
positionals.push(arg);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Every verb that parses flags gets the same answer for a nonsense provider,
|
|
298
|
+
// whether or not it embeds: silently ignoring a typo is how `--embedding
|
|
299
|
+
// gemini` on a lexical verb, or `--embedding bogus` on any verb, becomes a
|
|
300
|
+
// search that only looks semantic. Cheap, and before any verb body runs.
|
|
301
|
+
assertKnownEmbedding(flags.embedding);
|
|
302
|
+
|
|
303
|
+
return { positionals, flags };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* One operator-facing line per source that could not be listed. Human scan and
|
|
308
|
+
* verbose build share it so the same failure reads the same in both verbs.
|
|
309
|
+
*/
|
|
310
|
+
function formatScanOutcome(outcome: ScanOutcome): string {
|
|
311
|
+
const root = outcome.root ? ` (root ${outcome.root})` : "";
|
|
312
|
+
return `note: source ${outcome.source}: ${outcome.status}${root}${outcome.detail ? ` - ${outcome.detail}` : ""}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function handleScan(args: string[], isRobot: boolean): Promise<void> {
|
|
316
|
+
const { flags } = parseFlags(args);
|
|
317
|
+
const sources = typeof flags.sources === "string" ? flags.sources.split(",") : undefined;
|
|
318
|
+
const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
|
|
319
|
+
|
|
320
|
+
if (isRobot) {
|
|
321
|
+
console.error("Scanning local agent session stores...");
|
|
322
|
+
} else {
|
|
323
|
+
console.log("Scanning local agent session stores...");
|
|
324
|
+
}
|
|
325
|
+
const { sessions: discovered, outcomes } = await scanSessions({ sources, limit });
|
|
326
|
+
|
|
327
|
+
const bySource: Record<string, number> = {};
|
|
328
|
+
for (const s of discovered) {
|
|
329
|
+
bySource[s.source] = (bySource[s.source] ?? 0) + 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (isRobot) {
|
|
333
|
+
if (flags.json) {
|
|
334
|
+
console.log(JSON.stringify({ total: discovered.length, bySource, sessions: discovered, outcomes }));
|
|
335
|
+
} else {
|
|
336
|
+
// The outcomes line leads, like search_meta: a consumer that filters
|
|
337
|
+
// sessions still learns which sources were covered.
|
|
338
|
+
console.log(JSON.stringify({ type: "scan_outcomes", outcomes }));
|
|
339
|
+
for (const s of discovered) {
|
|
340
|
+
console.log(
|
|
341
|
+
JSON.stringify({
|
|
342
|
+
id: s.id,
|
|
343
|
+
source: s.source,
|
|
344
|
+
path: s.path,
|
|
345
|
+
updatedAt: s.updatedAt,
|
|
346
|
+
sizeBytes: s.sizeBytes,
|
|
347
|
+
}),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// A source that is simply not installed is the normal case, not news: only
|
|
355
|
+
// anomalies reach stderr. Robots still get every row, including missing_root.
|
|
356
|
+
for (const outcome of outcomes) {
|
|
357
|
+
if (outcome.status === "listable" || outcome.status === "missing_root") continue;
|
|
358
|
+
console.error(formatScanOutcome(outcome));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (flags.json) {
|
|
362
|
+
console.log(JSON.stringify({ total: discovered.length, bySource, sessions: discovered, outcomes }, null, 2));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
console.log(`\nDiscovered ${discovered.length} sessions across ${Object.keys(bySource).length} sources:\n`);
|
|
367
|
+
for (const [source, count] of Object.entries(bySource).sort((a, b) => b[1] - a[1])) {
|
|
368
|
+
console.log(` - ${source.padEnd(16)}: ${count} sessions`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
console.log(`\nTotal: ${discovered.length} sessions`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function handleBuild(args: string[], isRobot: boolean): Promise<void> {
|
|
375
|
+
const { flags } = parseFlags(args);
|
|
376
|
+
// "none" builds lexical-only: no provider wiring at all, so a quota-limited
|
|
377
|
+
// host needs neither the Gemini key nor the local ONNX download (issue #4).
|
|
378
|
+
// Any other value wires the global provider as before; buildStore only
|
|
379
|
+
// consults the mode for the none shortcut.
|
|
380
|
+
const embedMode = requestedEmbedding(flags.embedding) === "none" ? "none" : "provider";
|
|
381
|
+
if (embedMode !== "none") applyEmbeddingProvider(flags, isRobot);
|
|
382
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
383
|
+
// The lease attaches before any store handle exists: a refused build must
|
|
384
|
+
// leave the database exactly as it found it.
|
|
385
|
+
const lease = isMemoryStorePath(dbPath)
|
|
386
|
+
? null
|
|
387
|
+
: acquireWriterLease(dbPath, { waitMs: DEFAULT_WRITER_LEASE_WAIT_MS, holder: "build" });
|
|
388
|
+
try {
|
|
389
|
+
await buildStore(dbPath, isRobot, {
|
|
390
|
+
sources: typeof flags.sources === "string" ? flags.sources.split(",") : undefined,
|
|
391
|
+
limit: typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined,
|
|
392
|
+
verbose: Boolean(flags.verbose),
|
|
393
|
+
embedMode,
|
|
394
|
+
});
|
|
395
|
+
} finally {
|
|
396
|
+
lease?.close();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
interface BuildOptions {
|
|
401
|
+
sources?: string[];
|
|
402
|
+
limit?: number;
|
|
403
|
+
verbose: boolean;
|
|
404
|
+
/** `none` builds lexical-only; `provider` defers to the wired global provider. */
|
|
405
|
+
embedMode: "provider" | "none";
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function buildStore(dbPath: string, isRobot: boolean, options: BuildOptions): Promise<void> {
|
|
409
|
+
const { sources, limit, verbose, embedMode } = options;
|
|
410
|
+
|
|
411
|
+
const logProse = isRobot ? console.error : console.log;
|
|
412
|
+
|
|
413
|
+
logProse(`Building SQLite store at ${dbPath}...`);
|
|
414
|
+
const { sessions: discovered, outcomes } = await scanSessions({ sources, limit });
|
|
415
|
+
logProse(`Discovered ${discovered.length} candidate sessions to import.`);
|
|
416
|
+
if (verbose) {
|
|
417
|
+
for (const outcome of outcomes) {
|
|
418
|
+
if (outcome.status === "listable" || outcome.status === "missing_root") continue;
|
|
419
|
+
if (isRobot) console.log(JSON.stringify({ type: "scan_outcome", ...outcome }));
|
|
420
|
+
else console.log(` ${formatScanOutcome(outcome)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const db = new TrajectoryDb(dbPath);
|
|
425
|
+
try {
|
|
426
|
+
// The build holds the lease for this whole verb, so a pending index
|
|
427
|
+
// re-derivation (derive-rule counter, C2) is this verb's one-shot too — the
|
|
428
|
+
// same work `sync` runs, taken before the import loop so the rest of the build
|
|
429
|
+
// reads the index it just derived. Without it an upgraded store would keep
|
|
430
|
+
// answering from an index derived under other rules until some other pass ran.
|
|
431
|
+
const deriveStarted = performance.now();
|
|
432
|
+
const deriveMs = db.reindexFts() ? performance.now() - deriveStarted : 0;
|
|
433
|
+
|
|
434
|
+
// The build holds the lease for this whole verb, so the scan verdicts land
|
|
435
|
+
// durably here too — the same single choke point sync uses, under the store
|
|
436
|
+
// handle the pass already migrated.
|
|
437
|
+
db.recordSourceCoverage(outcomes);
|
|
438
|
+
|
|
439
|
+
const summary: BuildSummary = {
|
|
440
|
+
scanned: discovered.length,
|
|
441
|
+
imported: 0,
|
|
442
|
+
deduped: 0,
|
|
443
|
+
failed: 0,
|
|
444
|
+
unembedded: 0,
|
|
445
|
+
embedding_skipped: 0,
|
|
446
|
+
embedding_failed: 0,
|
|
447
|
+
tombstoned: 0,
|
|
448
|
+
totalRecords: 0,
|
|
449
|
+
sources: {},
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const CHUNK_SIZE = 16;
|
|
453
|
+
// One place that counts and reports a refused listing, so the two axes
|
|
454
|
+
// (refused before the read, refused by the store's own guard inside ingest)
|
|
455
|
+
// cannot be counted differently or reported in different words.
|
|
456
|
+
const countTombstoned = (item: DiscoveredSession, tombstone: TombstoneRecord): void => {
|
|
457
|
+
summary.tombstoned++;
|
|
458
|
+
summary.sources[item.source].tombstoned++;
|
|
459
|
+
if (!verbose) return;
|
|
460
|
+
if (isRobot) {
|
|
461
|
+
console.log(JSON.stringify({
|
|
462
|
+
type: "session", action: "tombstoned", source: item.source, id: item.id,
|
|
463
|
+
mode: tombstone.mode, deleted_at: tombstone.deleted_at,
|
|
464
|
+
}));
|
|
465
|
+
} else {
|
|
466
|
+
console.log(` [TOMBSTONED] ${item.source}/${item.id} (${tombstone.mode}; deleted ${tombstone.deleted_at})`);
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
for (let i = 0; i < discovered.length; i += CHUNK_SIZE) {
|
|
470
|
+
const chunk = discovered.slice(i, i + CHUNK_SIZE);
|
|
471
|
+
const normalizedChunk = await Promise.all(
|
|
472
|
+
chunk.map(async (item) => {
|
|
473
|
+
// The deletion's listing axis, first because it is the cheap one and the
|
|
474
|
+
// one that saves the most work: a listing whose native id names a deleted
|
|
475
|
+
// session is refused before its file is opened. The committed id is
|
|
476
|
+
// checked in the loop below, after normalization, for the sources that
|
|
477
|
+
// group several files under one session id.
|
|
478
|
+
if (db.tombstoneForListing(item)) return { item, norm: null };
|
|
479
|
+
// Native stores must be read at commit time, not retained across earlier embeddings.
|
|
480
|
+
const norm = item.native ? null : await normalizeDiscoveredSession(item);
|
|
481
|
+
return { item, norm };
|
|
482
|
+
}),
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
for (const { item, norm } of normalizedChunk) {
|
|
486
|
+
if (!summary.sources[item.source]) {
|
|
487
|
+
summary.sources[item.source] = { scanned: 0, imported: 0, deduped: 0, failed: 0, tombstoned: 0 };
|
|
488
|
+
}
|
|
489
|
+
summary.sources[item.source].scanned++;
|
|
490
|
+
|
|
491
|
+
// The deletion's two axes, in one verdict: the listing's native id (the
|
|
492
|
+
// same probe that kept the file from being read above, and the one that
|
|
493
|
+
// covers a re-scan or a copy at another path) and, for a source that
|
|
494
|
+
// groups files under a group id, the committed session id.
|
|
495
|
+
const tombstone = db.tombstoneForListing(item) ?? (norm ? db.getTombstone(norm.session.id) : null);
|
|
496
|
+
if (tombstone) {
|
|
497
|
+
countTombstoned(item, tombstone);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
let session = norm?.session;
|
|
502
|
+
let records = norm?.records;
|
|
503
|
+
let updated = false;
|
|
504
|
+
let deduped = false;
|
|
505
|
+
if (item.native) {
|
|
506
|
+
const existed = db.getSession(ledgerId(item.source, item.id)) !== null;
|
|
507
|
+
const result = await ingestDiscoveredSession(db, item, {
|
|
508
|
+
maxRecordBytes: DEFAULT_INGEST_RECORD_BYTES,
|
|
509
|
+
}).catch((error: unknown) => nativeIngestCatchResult(error, item.id));
|
|
510
|
+
const cachedFailure = result.kind === "unchanged" &&
|
|
511
|
+
db.getIngestCursor(item.source, item.id, item.path)?.status === "failed";
|
|
512
|
+
const warnings = cachedFailure
|
|
513
|
+
? ["Native ingestion previously failed; the unchanged source remains skipped."]
|
|
514
|
+
: result.warnings ?? [];
|
|
515
|
+
for (const warning of warnings) {
|
|
516
|
+
console.error("[ledger] ingest warning:", JSON.stringify({ source: item.source, sourcePath: item.path, warning }));
|
|
517
|
+
}
|
|
518
|
+
if (result.kind === "tombstoned") {
|
|
519
|
+
// The store's own guard refused the commit: a source grouped this
|
|
520
|
+
// listing under a session id the pass could not name before the read,
|
|
521
|
+
// or the session was deleted while the pass held it. The refusal names
|
|
522
|
+
// the session, so the audit row is reachable from here.
|
|
523
|
+
const refused = db.tombstoneForListing(item) ?? (result.sessionId ? db.getTombstone(result.sessionId) : null);
|
|
524
|
+
if (refused) countTombstoned(item, refused);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
if (result.kind !== "skipped" && !cachedFailure && result.sessionId) {
|
|
528
|
+
session = db.getSession(result.sessionId) ?? undefined;
|
|
529
|
+
records = session ? db.getSessionRecords(session.id) : undefined;
|
|
530
|
+
updated = existed;
|
|
531
|
+
deduped = result.kind === "deduped" || result.kind === "unchanged";
|
|
532
|
+
}
|
|
533
|
+
} else if (norm) {
|
|
534
|
+
({ updated, deduped } = db.saveSession(
|
|
535
|
+
norm.session, norm.records, aggregateIngestDiagnostics(norm.diagnostics),
|
|
536
|
+
));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (!session || !records) {
|
|
540
|
+
summary.failed++;
|
|
541
|
+
summary.sources[item.source].failed++;
|
|
542
|
+
if (item.native && verbose) {
|
|
543
|
+
if (isRobot) console.log(JSON.stringify({ type: "session", action: "skip", source: item.source, id: item.id }));
|
|
544
|
+
else console.log(` [SKIP] ${item.source}/${item.id}`);
|
|
545
|
+
}
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (embedMode === "none") {
|
|
550
|
+
summary.embedding_skipped++;
|
|
551
|
+
} else if (!deduped || !db.hasSessionEmbeddings(session.id)) {
|
|
552
|
+
try {
|
|
553
|
+
await db.embedSessionRecords(session.id, records);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
// Embeddings are enrichment. Persisted canonical records remain valid
|
|
556
|
+
// and later build/watch runs can retry the missing vectors.
|
|
557
|
+
summary.embedding_failed++;
|
|
558
|
+
if (verbose) {
|
|
559
|
+
if (isRobot) {
|
|
560
|
+
console.log(JSON.stringify({ type: "session", action: "unembedded", source: item.source, id: session.id }));
|
|
561
|
+
} else {
|
|
562
|
+
console.log(` [UNEMBED] ${item.source}/${session.id}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
// Stderr is the human-progress channel in robot mode too: stdout
|
|
566
|
+
// stays data-only, but the outage must not be silent.
|
|
567
|
+
console.error(`[ledger] embedding failed for ${item.source}/${session.id}:`, err);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Work skipped/failing is not proof that previously stored vectors vanished.
|
|
571
|
+
if (!db.hasStoredSessionEmbeddings(session.id)) summary.unembedded++;
|
|
572
|
+
if (deduped) {
|
|
573
|
+
summary.deduped++;
|
|
574
|
+
summary.sources[item.source].deduped++;
|
|
575
|
+
if (verbose) {
|
|
576
|
+
if (isRobot) {
|
|
577
|
+
console.log(JSON.stringify({ type: "session", action: "dedup", source: item.source, id: item.id }));
|
|
578
|
+
} else {
|
|
579
|
+
console.log(` [DEDUP] ${item.source}/${item.id}`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
summary.imported++;
|
|
584
|
+
summary.totalRecords += records.length;
|
|
585
|
+
summary.sources[item.source].imported++;
|
|
586
|
+
if (verbose) {
|
|
587
|
+
if (isRobot) {
|
|
588
|
+
console.log(
|
|
589
|
+
JSON.stringify({
|
|
590
|
+
type: "session",
|
|
591
|
+
action: updated ? "update" : "import",
|
|
592
|
+
source: item.source,
|
|
593
|
+
id: item.id,
|
|
594
|
+
records: records.length,
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
} else {
|
|
598
|
+
console.log(` [${updated ? "UPDATE" : "IMPORT"}] ${item.source}/${item.id} (${records.length} records)`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (isRobot) {
|
|
606
|
+
console.log(
|
|
607
|
+
JSON.stringify({
|
|
608
|
+
type: "summary",
|
|
609
|
+
scanned: summary.scanned,
|
|
610
|
+
imported: summary.imported,
|
|
611
|
+
deduped: summary.deduped,
|
|
612
|
+
failed: summary.failed,
|
|
613
|
+
unembedded: summary.unembedded,
|
|
614
|
+
embedding_skipped: summary.embedding_skipped,
|
|
615
|
+
embedding_failed: summary.embedding_failed,
|
|
616
|
+
tombstoned: summary.tombstoned,
|
|
617
|
+
totalRecords: summary.totalRecords,
|
|
618
|
+
deriveMs,
|
|
619
|
+
sources: summary.sources,
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
} else {
|
|
623
|
+
console.log("\nBuild complete:");
|
|
624
|
+
console.log(` Sessions in DB : ${db.getSessionCount()}`);
|
|
625
|
+
console.log(` Records in DB : ${db.getRecordCount()}`);
|
|
626
|
+
console.log(` Imported : ${summary.imported}`);
|
|
627
|
+
console.log(` Deduped : ${summary.deduped}`);
|
|
628
|
+
console.log(` Skipped/Empty : ${summary.failed}`);
|
|
629
|
+
if (summary.unembedded > 0) {
|
|
630
|
+
console.log(` Unembedded : ${summary.unembedded} (vectors absent; later builds retry)`);
|
|
631
|
+
}
|
|
632
|
+
if (summary.embedding_skipped) console.log(` Embedding skipped: ${summary.embedding_skipped}`);
|
|
633
|
+
if (summary.embedding_failed) console.log(` Embedding failed : ${summary.embedding_failed} (coverage may be partial)`);
|
|
634
|
+
if (summary.tombstoned > 0) {
|
|
635
|
+
console.log(` Tombstoned : ${summary.tombstoned} (deleted by the user; not re-imported, see \`delete --list\`)`);
|
|
636
|
+
}
|
|
637
|
+
if (deriveMs > 0) {
|
|
638
|
+
console.log(
|
|
639
|
+
` Re-derived : ${Math.round(deriveMs)}ms (records_fts and titles_fts rebuilt from this store; no source was re-read)`,
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
console.log("\nBreakdown by source:");
|
|
643
|
+
for (const [source, stats] of Object.entries(summary.sources)) {
|
|
644
|
+
console.log(
|
|
645
|
+
` - ${source.padEnd(14)}: ${stats.imported} imported, ${stats.deduped} deduped, ${stats.failed} skipped` +
|
|
646
|
+
`${stats.tombstoned ? `, ${stats.tombstoned} tombstoned` : ""} (from ${stats.scanned} scanned)`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
} finally { db.close(); }
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function numericFlag(flags: Record<string, string | boolean>, name: string): number | undefined {
|
|
654
|
+
const value = flags[name];
|
|
655
|
+
return value === undefined ? undefined : typeof value === "string" ? Number(value) : NaN;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function handleSync(args: string[], isRobot: boolean): Promise<void> {
|
|
659
|
+
const { flags } = parseFlags(args);
|
|
660
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
661
|
+
const sources = typeof flags.sources === "string" ? flags.sources.split(",").filter(Boolean) : undefined;
|
|
662
|
+
const pressureMode = flags.pressure === undefined ? undefined
|
|
663
|
+
: flags.pressure === "auto" || flags.pressure === "off" ? flags.pressure
|
|
664
|
+
: (() => { throw new Error("--pressure must be auto or off"); })();
|
|
665
|
+
const summary = await runSync({
|
|
666
|
+
dbPath,
|
|
667
|
+
sources,
|
|
668
|
+
scanLimit: numericFlag(flags, "limit"),
|
|
669
|
+
maxRecordBytes: numericFlag(flags, "max-record-bytes"),
|
|
670
|
+
budgets: {
|
|
671
|
+
sessions: numericFlag(flags, "session-budget"),
|
|
672
|
+
sourceBytes: numericFlag(flags, "source-byte-budget"),
|
|
673
|
+
wallMs: numericFlag(flags, "wall-ms"),
|
|
674
|
+
retries: numericFlag(flags, "retry-budget"),
|
|
675
|
+
attemptsPerSession: numericFlag(flags, "attempts-per-session"),
|
|
676
|
+
readBytesPerSecond: numericFlag(flags, "read-bytes-per-second"),
|
|
677
|
+
workerMs: numericFlag(flags, "worker-ms"),
|
|
678
|
+
workers: numericFlag(flags, "workers"),
|
|
679
|
+
freshSessions: numericFlag(flags, "fresh-session-budget"),
|
|
680
|
+
freshWindowMs: numericFlag(flags, "fresh-window-ms"),
|
|
681
|
+
chunkBytes: numericFlag(flags, "sync-chunk-bytes"),
|
|
682
|
+
maintenanceMs: numericFlag(flags, "maintenance-ms"),
|
|
683
|
+
ftsMergePages: numericFlag(flags, "fts-merge-pages"),
|
|
684
|
+
},
|
|
685
|
+
pressureMode,
|
|
686
|
+
});
|
|
687
|
+
if (isRobot || flags.json) {
|
|
688
|
+
console.log(JSON.stringify(summary, null, flags.json === true && !isRobot ? 2 : undefined));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (summary.skipped) {
|
|
692
|
+
console.log(
|
|
693
|
+
`Trajectories sync skipped: another writer holds the store (${describeLeaseHolder(summary.skipped.holder)}).`,
|
|
694
|
+
);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
console.log(`Trajectories sync ${summary.outcome}: ${summary.completed} completed, ${summary.pending.total} pending.`);
|
|
698
|
+
if (summary.outcome === "wall_budget_exceeded") {
|
|
699
|
+
console.log("Wall budget reached: the pass stopped there, and pending sessions stay queued for the next run.");
|
|
700
|
+
}
|
|
701
|
+
console.log(`Workers: ${summary.worker_processes} processes / ${summary.worker_attempts} attempts; source read: ${summary.work.source_bytes_read} bytes; elapsed: ${Math.round(summary.elapsed_ms)}ms.`);
|
|
702
|
+
// A pass that re-derived the index spent a one-shot inside it; saying so is
|
|
703
|
+
// what separates a 20-second upgrade from an unexplained stall.
|
|
704
|
+
if (summary.timing.derive_ms > 0) {
|
|
705
|
+
console.log(`Index re-derivation: ${Math.round(summary.timing.derive_ms)}ms (records_fts and titles_fts rebuilt from this store; no source was re-read).`);
|
|
706
|
+
}
|
|
707
|
+
if (summary.budgets.exhausted.length) console.log(`Budget boundary: ${summary.budgets.exhausted.join(", ")}.`);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async function handleQuery(args: string[], isRobot: boolean, programName: string): Promise<void> {
|
|
711
|
+
const { positionals, flags } = parseFlags(args);
|
|
712
|
+
const outputVersion = flags["output-version"] ?? "1";
|
|
713
|
+
const outputVersionError = flags["output-version"] !== undefined && !isRobot
|
|
714
|
+
? "--output-version requires --robot."
|
|
715
|
+
: outputVersion !== "1" && outputVersion !== "2"
|
|
716
|
+
? "--output-version requires a version: 1 or 2."
|
|
717
|
+
: null;
|
|
718
|
+
if (outputVersionError) {
|
|
719
|
+
if (isRobot) emitRobotError("INVALID_ARGUMENT", outputVersionError);
|
|
720
|
+
console.error(`Error: INVALID_ARGUMENT: ${outputVersionError}`);
|
|
721
|
+
process.exit(1);
|
|
722
|
+
}
|
|
723
|
+
applyEmbeddingProvider(flags, isRobot);
|
|
724
|
+
const queryText = positionals.join(" ").trim();
|
|
725
|
+
|
|
726
|
+
if (!queryText) {
|
|
727
|
+
if (isRobot) {
|
|
728
|
+
emitRobotError("MISSING_QUERY", "Please provide a search query.");
|
|
729
|
+
}
|
|
730
|
+
console.error("Error: Please provide a search query.");
|
|
731
|
+
console.error(`Example: ${programName} search "artifacts hosting subdomain"`);
|
|
732
|
+
process.exit(1);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
736
|
+
|
|
737
|
+
const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : 20;
|
|
738
|
+
const sourceFilter = typeof flags.source === "string" ? flags.source : undefined;
|
|
739
|
+
|
|
740
|
+
const isSemantic = flags.semantic === true;
|
|
741
|
+
const isHybrid = flags.hybrid === true;
|
|
742
|
+
|
|
743
|
+
const db = new TrajectoryReader(dbPath);
|
|
744
|
+
let result: QueryResult;
|
|
745
|
+
const parentTitles = new Map<string, string | null>();
|
|
746
|
+
let coverageSignal: ReturnType<TrajectoryReader["getSourceCoverageSummary"]>;
|
|
747
|
+
let discoverySignal: ReturnType<TrajectoryReader["getDiscoverySummary"]>;
|
|
748
|
+
try {
|
|
749
|
+
if (isSemantic) {
|
|
750
|
+
result = await db.querySemantic(queryText, { limit, source: sourceFilter });
|
|
751
|
+
} else if (isHybrid) {
|
|
752
|
+
result = await db.queryHybrid(queryText, { limit, source: sourceFilter });
|
|
753
|
+
} else {
|
|
754
|
+
result = db.query(queryText, { limit, source: sourceFilter });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
for (const hit of result.hits) {
|
|
758
|
+
const parentId = hit.parent_session_id;
|
|
759
|
+
if (!parentId || parentTitles.has(parentId)) continue;
|
|
760
|
+
parentTitles.set(parentId, db.getSession(parentId)?.title ?? null);
|
|
761
|
+
}
|
|
762
|
+
// The durable coverage signal rides the v2 envelope (issue #37); read it
|
|
763
|
+
// while the reader handle is open — the connection is short-lived by design.
|
|
764
|
+
coverageSignal = db.getSourceCoverageSummary();
|
|
765
|
+
discoverySignal = db.getDiscoverySummary();
|
|
766
|
+
} finally { db.close(); }
|
|
767
|
+
|
|
768
|
+
if ((isRobot && outputVersion === "1") || (!isRobot && !flags.json)) {
|
|
769
|
+
if (result.meta.widened?.applied) {
|
|
770
|
+
console.error(`note: 0 phrase matches; widened to prefix terms (${result.meta.widened.widened_hits} hits)`);
|
|
771
|
+
} else if (isRobot && result.meta.widened?.skip_reason) {
|
|
772
|
+
console.error(`note: prefix widening not applied (${result.meta.widened.skip_reason})`);
|
|
773
|
+
}
|
|
774
|
+
if (result.meta.fallback_reason === FTS_SYNTAX_FALLBACK_REASON) {
|
|
775
|
+
console.error(`note: fts syntax fallback used (${result.meta.fallback_reason})`);
|
|
776
|
+
} else if (result.meta.fallback_reason) {
|
|
777
|
+
// A different reason means a different thing happened -- an unavailable
|
|
778
|
+
// embedding provider, not an unparsable FTS expression.
|
|
779
|
+
console.error(`note: answered lexically (${result.meta.fallback_reason})`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (isRobot) {
|
|
784
|
+
const robotHits = result.hits.map((hit) => ({
|
|
785
|
+
session_id: hit.session_id,
|
|
786
|
+
source: hit.source,
|
|
787
|
+
title: hit.title,
|
|
788
|
+
cwd: hit.cwd,
|
|
789
|
+
timestamp: hit.started_at,
|
|
790
|
+
score: typeof hit.rank === "number" ? hit.rank : 0,
|
|
791
|
+
snippet: hit.snippet.slice(0, 240),
|
|
792
|
+
parent_session_id: hit.parent_session_id ?? null,
|
|
793
|
+
parent_title: hit.parent_session_id
|
|
794
|
+
? parentTitles.get(hit.parent_session_id) ?? null
|
|
795
|
+
: null,
|
|
796
|
+
agent_nickname: hit.agent_nickname ?? null,
|
|
797
|
+
thread_role: hit.thread_role ?? null,
|
|
798
|
+
...(outputVersion === "2" ? { match_type: hit.match_type } : {}),
|
|
799
|
+
}));
|
|
800
|
+
|
|
801
|
+
// V1 preserves the pre-PR20 hit stream/array, including empty results.
|
|
802
|
+
// V2 adds the envelope and per-hit provenance for agents that opt in.
|
|
803
|
+
const searchMeta = outputVersion === "2" ? {
|
|
804
|
+
type: "search_meta",
|
|
805
|
+
...result.meta,
|
|
806
|
+
total_hits: result.totalHits,
|
|
807
|
+
sessions_count: result.sessionsCount,
|
|
808
|
+
...(coverageSignal ? { coverage: coverageSignal } : {}),
|
|
809
|
+
...(discoverySignal ? { discovery: discoverySignal } : {}),
|
|
810
|
+
} : null;
|
|
811
|
+
|
|
812
|
+
if (flags.json) {
|
|
813
|
+
console.log(JSON.stringify(searchMeta ? { search_meta: searchMeta, hits: robotHits } : robotHits));
|
|
814
|
+
} else {
|
|
815
|
+
if (searchMeta) console.log(JSON.stringify(searchMeta));
|
|
816
|
+
for (const hit of robotHits) {
|
|
817
|
+
console.log(JSON.stringify(hit));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (flags.json) {
|
|
824
|
+
console.log(JSON.stringify(result, null, 2));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
console.log(`Found ${result.totalHits} matching records across ${result.sessionsCount} sessions for: "${queryText}"\n`);
|
|
829
|
+
|
|
830
|
+
if (result.hits.length === 0) {
|
|
831
|
+
console.log("No matching records found.");
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const widenedSuffix = result.meta.widened?.applied ? " [widened]" : "";
|
|
836
|
+
for (let i = 0; i < result.hits.length; i++) {
|
|
837
|
+
const hit = result.hits[i];
|
|
838
|
+
const header = `[${i + 1}] [${hit.source}] Session: ${hit.session_id} (${hit.started_at ?? "unknown date"})${widenedSuffix}`;
|
|
839
|
+
console.log(header);
|
|
840
|
+
if (hit.title) {
|
|
841
|
+
console.log(` Title: ${hit.title}`);
|
|
842
|
+
}
|
|
843
|
+
if (hit.cwd) {
|
|
844
|
+
console.log(` Cwd: ${hit.cwd}`);
|
|
845
|
+
}
|
|
846
|
+
if (hit.parent_session_id) {
|
|
847
|
+
const nick = hit.agent_nickname ? ` (${hit.agent_nickname})` : "";
|
|
848
|
+
console.log(` Parent:${nick} ${hit.parent_session_id}`);
|
|
849
|
+
}
|
|
850
|
+
console.log(` Role: ${hit.record_type}`);
|
|
851
|
+
console.log(` Match: ${hit.snippet}`);
|
|
852
|
+
console.log();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function handleLast(args: string[], isRobot: boolean): Promise<void> {
|
|
857
|
+
const { flags } = parseFlags(args);
|
|
858
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
859
|
+
|
|
860
|
+
const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : 20;
|
|
861
|
+
const sourceFilter = typeof flags.source === "string" ? flags.source : undefined;
|
|
862
|
+
|
|
863
|
+
const db = new TrajectoryReader(dbPath);
|
|
864
|
+
const sessions = db.getLastSessions({ limit, source: sourceFilter });
|
|
865
|
+
const formattedSessions = sessions.map((s) => {
|
|
866
|
+
const parent = s.parent_session_id ? db.getSession(s.parent_session_id) : null;
|
|
867
|
+
return {
|
|
868
|
+
session_id: s.id,
|
|
869
|
+
source: s.source,
|
|
870
|
+
title: s.title,
|
|
871
|
+
cwd: s.cwd,
|
|
872
|
+
started_at: s.started_at,
|
|
873
|
+
ended_at: s.ended_at,
|
|
874
|
+
updated_at: s.updated_at,
|
|
875
|
+
timestamp: s.updated_at ?? s.started_at,
|
|
876
|
+
record_count: s.record_count,
|
|
877
|
+
model: s.model,
|
|
878
|
+
parent_session_id: s.parent_session_id ?? null,
|
|
879
|
+
parent_title: parent?.title ?? null,
|
|
880
|
+
parent_tool_use_id: s.parent_tool_use_id ?? null,
|
|
881
|
+
agent_nickname: s.agent_nickname ?? null,
|
|
882
|
+
thread_role: s.thread_role ?? null,
|
|
883
|
+
};
|
|
884
|
+
});
|
|
885
|
+
db.close();
|
|
886
|
+
|
|
887
|
+
if (isRobot) {
|
|
888
|
+
if (flags.json) {
|
|
889
|
+
console.log(JSON.stringify(formattedSessions));
|
|
890
|
+
} else {
|
|
891
|
+
for (const s of formattedSessions) {
|
|
892
|
+
console.log(JSON.stringify(s));
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (flags.json) {
|
|
899
|
+
console.log(JSON.stringify(formattedSessions, null, 2));
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
console.log(`Recent sessions (limit ${limit}${sourceFilter ? `, source: ${sourceFilter}` : ""}):\n`);
|
|
904
|
+
if (formattedSessions.length === 0) {
|
|
905
|
+
console.log("No sessions found.");
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for (let i = 0; i < formattedSessions.length; i++) {
|
|
910
|
+
const s = formattedSessions[i];
|
|
911
|
+
console.log(`[${i + 1}] [${s.source}] Session: ${s.session_id} (${s.updated_at || s.started_at || "unknown date"})`);
|
|
912
|
+
if (s.title) {
|
|
913
|
+
console.log(` Title: ${s.title.replace(/\n/g, " ").slice(0, 100)}`);
|
|
914
|
+
}
|
|
915
|
+
if (s.cwd) {
|
|
916
|
+
console.log(` Cwd: ${s.cwd}`);
|
|
917
|
+
}
|
|
918
|
+
if (s.parent_session_id) {
|
|
919
|
+
const label = s.agent_nickname ? `${s.agent_nickname} → ` : "";
|
|
920
|
+
const parentLabel = s.parent_title
|
|
921
|
+
? `${s.parent_title.replace(/\n/g, " ").slice(0, 80)} (${s.parent_session_id})`
|
|
922
|
+
: s.parent_session_id;
|
|
923
|
+
console.log(` Parent: ${label}${parentLabel}`);
|
|
924
|
+
}
|
|
925
|
+
console.log(` Records: ${s.record_count}`);
|
|
926
|
+
console.log();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* `trajectories delete <session-id>` — the store's one deletion verb, and the only
|
|
932
|
+
* caller of the store's tombstone API.
|
|
933
|
+
*
|
|
934
|
+
* The verb names a session, records why, and either closes the session to
|
|
935
|
+
* serving and admission (the default) or removes its records as well (`--hard`).
|
|
936
|
+
* It takes the writer lease like every other verb that writes: a deletion
|
|
937
|
+
* racing a sync pass would otherwise interleave with the pass that is
|
|
938
|
+
* re-admitting the same session, and the lease is this repository's answer to
|
|
939
|
+
* that (writer-lease.ts). `--list` reports the deletions the store records,
|
|
940
|
+
* which is the audit readout — the reason a deletion stores is worth nothing if
|
|
941
|
+
* nothing can read it back.
|
|
942
|
+
*
|
|
943
|
+
* The policy half of deletion is deliberately absent: who may delete, how long
|
|
944
|
+
* a record may be kept, and whether a deletion must be explained are open
|
|
945
|
+
* questions (docs/research/2026-09-16-c7-tombstones.md). What the verb fixes is
|
|
946
|
+
* what the store *does* once asked — and that only a user asking can make it do
|
|
947
|
+
* it.
|
|
948
|
+
*/
|
|
949
|
+
async function handleDelete(args: string[], isRobot: boolean): Promise<void> {
|
|
950
|
+
const { positionals, flags } = parseFlags(args);
|
|
951
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
952
|
+
const listAudit = flags.list === true;
|
|
953
|
+
const sessionId = positionals[0];
|
|
954
|
+
const reason = typeof flags.reason === "string" ? flags.reason.trim() : "";
|
|
955
|
+
|
|
956
|
+
const usageError =
|
|
957
|
+
listAudit && sessionId !== undefined
|
|
958
|
+
? "--list takes no session id."
|
|
959
|
+
: listAudit && flags.hard === true
|
|
960
|
+
? "--list deletes nothing, so --hard has no meaning with it."
|
|
961
|
+
: !listAudit && !sessionId
|
|
962
|
+
? "Please provide a session id to delete."
|
|
963
|
+
: !listAudit && !reason
|
|
964
|
+
? "Please say why: --reason <text>. The reason is what the stored deletion records."
|
|
965
|
+
: null;
|
|
966
|
+
if (usageError) {
|
|
967
|
+
if (isRobot) emitRobotError("INVALID_ARGUMENT", usageError);
|
|
968
|
+
console.error(`Error: INVALID_ARGUMENT: ${usageError}`);
|
|
969
|
+
process.exit(1);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// A deletion needs a store to act on, and this verb must not create or adopt
|
|
973
|
+
// one: a writer open initializes an empty or foreign file — that is what
|
|
974
|
+
// `sync` and `build` are for — and conjuring a database to delete from is not a
|
|
975
|
+
// deletion. Two guards, because they answer different questions: the read gate
|
|
976
|
+
// classifies the store and refuses it with the read verbs' own codes and
|
|
977
|
+
// wording, and the writer open below is told not to initialize one, which is
|
|
978
|
+
// the invariant even if the file changes between the two statements.
|
|
979
|
+
if (!isMemoryStorePath(dbPath)) openReadOnlyStore(dbPath).close();
|
|
980
|
+
|
|
981
|
+
// The lease attaches before any store handle exists, as `build`'s does: a
|
|
982
|
+
// refused deletion must leave the database exactly as it found it.
|
|
983
|
+
const lease = isMemoryStorePath(dbPath)
|
|
984
|
+
? null
|
|
985
|
+
: acquireWriterLease(dbPath, { waitMs: DEFAULT_WRITER_LEASE_WAIT_MS, holder: "delete" });
|
|
986
|
+
try {
|
|
987
|
+
let db: TrajectoryDb;
|
|
988
|
+
try {
|
|
989
|
+
db = new TrajectoryDb(dbPath, { requireExisting: true });
|
|
990
|
+
} catch (error) {
|
|
991
|
+
// The store vanished (or was replaced by something that is not one)
|
|
992
|
+
// between the classification above and this open. The refusal is the
|
|
993
|
+
// classification's own wording, and nothing has been created.
|
|
994
|
+
if (error instanceof StoreNotInitializedError) {
|
|
995
|
+
throw new ReaderError(
|
|
996
|
+
"STORE_NOT_INITIALIZED",
|
|
997
|
+
`${error.message} There is nothing to delete; run \`trajectories sync\` or \`trajectories build\` first.`,
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
throw error;
|
|
1001
|
+
}
|
|
1002
|
+
try {
|
|
1003
|
+
if (listAudit) {
|
|
1004
|
+
const tombstones = db.listTombstones();
|
|
1005
|
+
if (isRobot || flags.json) {
|
|
1006
|
+
const rows = tombstones.map((row) => ({ type: "tombstone", ...row }));
|
|
1007
|
+
if (flags.json === true && !isRobot) {
|
|
1008
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
1009
|
+
} else {
|
|
1010
|
+
// Robot mode is JSONL: one row per line, and an empty stream when the
|
|
1011
|
+
// store records no deletion — which is the honest answer, not an error.
|
|
1012
|
+
for (const row of rows) console.log(JSON.stringify(row));
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (tombstones.length === 0) {
|
|
1017
|
+
console.log("No deletions recorded in this store.");
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
console.log(`${tombstones.length} deletion${tombstones.length === 1 ? "" : "s"} recorded:\n`);
|
|
1021
|
+
for (const row of tombstones) {
|
|
1022
|
+
console.log(` ${row.session_id} [${row.mode}] ${row.deleted_at}`);
|
|
1023
|
+
console.log(` ${row.reason}`);
|
|
1024
|
+
}
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// A refusal (no such session) propagates to the CLI's typed-error handler,
|
|
1029
|
+
// so the lease and the store handle are released before the process exits.
|
|
1030
|
+
const outcome = db.tombstoneSession(sessionId, { reason, hard: flags.hard === true });
|
|
1031
|
+
const { tombstone, created, escalated, recordsRemoved } = outcome;
|
|
1032
|
+
if (isRobot || flags.json) {
|
|
1033
|
+
console.log(
|
|
1034
|
+
JSON.stringify(
|
|
1035
|
+
{
|
|
1036
|
+
type: "tombstone",
|
|
1037
|
+
session_id: tombstone.session_id,
|
|
1038
|
+
source: tombstone.source,
|
|
1039
|
+
native_id: tombstone.native_id,
|
|
1040
|
+
source_path: tombstone.source_path,
|
|
1041
|
+
mode: tombstone.mode,
|
|
1042
|
+
reason: tombstone.reason,
|
|
1043
|
+
record_count: tombstone.record_count,
|
|
1044
|
+
records_removed: recordsRemoved,
|
|
1045
|
+
deleted_at: tombstone.deleted_at,
|
|
1046
|
+
created,
|
|
1047
|
+
escalated,
|
|
1048
|
+
},
|
|
1049
|
+
null,
|
|
1050
|
+
flags.json === true && !isRobot ? 2 : undefined,
|
|
1051
|
+
),
|
|
1052
|
+
);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const headline = !created && !escalated
|
|
1057
|
+
? `Already deleted: ${tombstone.session_id} (${tombstone.mode})`
|
|
1058
|
+
: tombstone.mode === "hard"
|
|
1059
|
+
? `Deleted ${tombstone.session_id} (hard: ${recordsRemoved} records removed from this store)`
|
|
1060
|
+
: `Deleted ${tombstone.session_id} (${tombstone.record_count} records stay in the store, withheld from search and reads)`;
|
|
1061
|
+
console.log(headline);
|
|
1062
|
+
console.log(` reason: ${tombstone.reason}`);
|
|
1063
|
+
console.log(` deleted_at: ${tombstone.deleted_at}`);
|
|
1064
|
+
if (!created && !escalated) {
|
|
1065
|
+
console.log(" Nothing changed; use --hard to remove the records as well.");
|
|
1066
|
+
} else if (escalated) {
|
|
1067
|
+
console.log(" The same deletion, carried through to the records: the reason and timestamp above are the ones first given.");
|
|
1068
|
+
} else if (tombstone.mode === "hard") {
|
|
1069
|
+
console.log(" The tombstone is all that is left of it; no later scan can re-import this session.");
|
|
1070
|
+
} else {
|
|
1071
|
+
console.log(" The records stay as evidence and no scan can re-import this session; --hard removes them.");
|
|
1072
|
+
}
|
|
1073
|
+
} finally {
|
|
1074
|
+
db.close();
|
|
1075
|
+
}
|
|
1076
|
+
} finally {
|
|
1077
|
+
lease?.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
async function handleStatus(args: string[], isRobot: boolean): Promise<void> {
|
|
1082
|
+
const { flags } = parseFlags(args);
|
|
1083
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
1084
|
+
|
|
1085
|
+
let status;
|
|
1086
|
+
try {
|
|
1087
|
+
status = readStoreStatus(dbPath);
|
|
1088
|
+
} catch (err: unknown) {
|
|
1089
|
+
if (err instanceof StoreStatusError) {
|
|
1090
|
+
if (isRobot) {
|
|
1091
|
+
emitRobotError(err.code, err.message);
|
|
1092
|
+
}
|
|
1093
|
+
console.error(`Error: ${err.message}`);
|
|
1094
|
+
process.exit(1);
|
|
1095
|
+
}
|
|
1096
|
+
throw err;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const payload = {
|
|
1100
|
+
path: status.path,
|
|
1101
|
+
initialized: status.initialized,
|
|
1102
|
+
schema_version: status.schemaVersion,
|
|
1103
|
+
supported_schema_version: SCHEMA_VERSION,
|
|
1104
|
+
sessions: status.sessions,
|
|
1105
|
+
records: status.records,
|
|
1106
|
+
echo_filtered: status.echoFiltered,
|
|
1107
|
+
tombstones: status.tombstones,
|
|
1108
|
+
tombstoned_records: status.tombstonedRecords,
|
|
1109
|
+
diagnostics: status.diagnostics,
|
|
1110
|
+
coverage: status.coverage,
|
|
1111
|
+
...(status.discovery ? { discovery: status.discovery } : {}),
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
if (isRobot || flags.json) {
|
|
1115
|
+
console.log(JSON.stringify(payload, null, flags.json === true && !isRobot ? 2 : undefined));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
console.log(`Trajectories store: ${status.path}`);
|
|
1120
|
+
if (!status.initialized) {
|
|
1121
|
+
console.log("Initialized: no (no store found at this path)");
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
console.log(`Initialized: yes`);
|
|
1125
|
+
console.log(`Schema version: ${status.schemaVersion} (supported: ${SCHEMA_VERSION})`);
|
|
1126
|
+
console.log(`Sessions: ${status.sessions}`);
|
|
1127
|
+
console.log(`Records: ${status.records}`);
|
|
1128
|
+
console.log(
|
|
1129
|
+
status.echoFiltered === null
|
|
1130
|
+
? "Echo filtered: unknown (not derived under this build's rules yet; the next writer pass derives it)"
|
|
1131
|
+
: `Echo filtered: ${status.echoFiltered} records (agent lookups of this store; never searchable)`,
|
|
1132
|
+
);
|
|
1133
|
+
// Holdings are reported as they are, tombstones included (the counts above
|
|
1134
|
+
// name what the store keeps); this line says how much of that is withheld.
|
|
1135
|
+
console.log(
|
|
1136
|
+
status.tombstones
|
|
1137
|
+
? `Tombstones: ${status.tombstones} session${status.tombstones === 1 ? "" : "s"} deleted by the user; ` +
|
|
1138
|
+
`${status.tombstonedRecords} of the records above are held back from search and reads`
|
|
1139
|
+
: "Tombstones: none (no session has been deleted by the user)",
|
|
1140
|
+
);
|
|
1141
|
+
const diagnostics = status.diagnostics;
|
|
1142
|
+
if (!diagnostics) {
|
|
1143
|
+
console.log("Diagnostics: unknown (store predates diagnostics schema)");
|
|
1144
|
+
} else if (!diagnostics.byCode.length) {
|
|
1145
|
+
console.log("Diagnostics: none");
|
|
1146
|
+
} else {
|
|
1147
|
+
console.log(
|
|
1148
|
+
`Diagnostics: ${diagnostics.totalOccurrences} occurrences in ${diagnostics.totalSessions} sessions`,
|
|
1149
|
+
);
|
|
1150
|
+
for (const { code, sessions, occurrences } of diagnostics.byCode.slice(0, 5)) {
|
|
1151
|
+
console.log(` ${code}: ${occurrences} occurrences in ${sessions} sessions`);
|
|
1152
|
+
}
|
|
1153
|
+
if (diagnostics.byCode.length > 5) {
|
|
1154
|
+
console.log(` ... ${diagnostics.byCode.length - 5} more codes (see --robot)`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
if (status.discovery) {
|
|
1158
|
+
const { pending_sources, unknown_sources, exhausted_sources } = status.discovery;
|
|
1159
|
+
console.log(`Discovery: ${exhausted_sources} exhausted, ${pending_sources} pending, ${unknown_sources} unknown (observed sweeps, not archive completeness)`);
|
|
1160
|
+
}
|
|
1161
|
+
const coverage = status.coverage;
|
|
1162
|
+
if (!coverage) {
|
|
1163
|
+
console.log("Coverage: unknown (store predates source coverage schema)");
|
|
1164
|
+
} else if (!coverage.length) {
|
|
1165
|
+
console.log("Coverage: none (no pass has scanned this store)");
|
|
1166
|
+
} else {
|
|
1167
|
+
const listable = coverage.filter((row) => row.status === "listable").length;
|
|
1168
|
+
const missing = coverage.filter((row) => row.status === "missing_root").length;
|
|
1169
|
+
console.log(
|
|
1170
|
+
`Coverage: ${listable}/${coverage.length} sources listable${missing ? `, ${missing} missing` : ""}`,
|
|
1171
|
+
);
|
|
1172
|
+
// An uninstalled source is not news, the same rule the scan notes use — it
|
|
1173
|
+
// is counted above and stays quiet here. Everything else that could not be
|
|
1174
|
+
// listed is an anomaly and reads exactly as the scan would have said it.
|
|
1175
|
+
for (const row of coverage) {
|
|
1176
|
+
if (row.status === "listable" || row.status === "missing_root") continue;
|
|
1177
|
+
console.log(` ${formatScanOutcome(row)}`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function handleWatch(args: string[], isRobot: boolean): Promise<void> {
|
|
1183
|
+
const { flags } = parseFlags(args);
|
|
1184
|
+
const embed = Boolean(flags.embed);
|
|
1185
|
+
if (embed) applyEmbeddingProvider(flags, isRobot);
|
|
1186
|
+
const dbPath = typeof flags.db === "string" ? flags.db : "ledger.sqlite";
|
|
1187
|
+
const sources = typeof flags.sources === "string" ? flags.sources.split(",") : undefined;
|
|
1188
|
+
const debounceMs = typeof flags.debounce === "string" ? parseInt(flags.debounce, 10) : 2000;
|
|
1189
|
+
const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
|
|
1190
|
+
const reconcileMs =
|
|
1191
|
+
typeof flags["reconcile-ms"] === "string"
|
|
1192
|
+
? parseInt(flags["reconcile-ms"], 10)
|
|
1193
|
+
: 60000;
|
|
1194
|
+
const importBudget =
|
|
1195
|
+
typeof flags["import-budget"] === "string"
|
|
1196
|
+
? parseInt(flags["import-budget"], 10)
|
|
1197
|
+
: 25;
|
|
1198
|
+
const maxRecordBytes = flags["max-record-bytes"] === undefined
|
|
1199
|
+
? undefined
|
|
1200
|
+
: typeof flags["max-record-bytes"] === "string"
|
|
1201
|
+
? Number(flags["max-record-bytes"])
|
|
1202
|
+
: NaN;
|
|
1203
|
+
const verbose = Boolean(flags.verbose);
|
|
1204
|
+
|
|
1205
|
+
const logProse = isRobot ? console.error : console.log;
|
|
1206
|
+
logProse("Watching local agent session stores...");
|
|
1207
|
+
logProse(` Database : ${dbPath}`);
|
|
1208
|
+
logProse(` Sources : ${sources ? sources.join(", ") : "all detected"}`);
|
|
1209
|
+
logProse(` Debounce : ${debounceMs}ms`);
|
|
1210
|
+
logProse(` Embed : ${embed ? "on" : "off (lexical)"}`);
|
|
1211
|
+
logProse(` Reconcile: ${reconcileMs}ms`);
|
|
1212
|
+
logProse(` Budget : ${importBudget} sessions/source/tick`);
|
|
1213
|
+
|
|
1214
|
+
const watcher = new LedgerWatcher({
|
|
1215
|
+
dbPath,
|
|
1216
|
+
sources,
|
|
1217
|
+
debounceMs,
|
|
1218
|
+
limit,
|
|
1219
|
+
verbose,
|
|
1220
|
+
embed,
|
|
1221
|
+
reconcileMs,
|
|
1222
|
+
importBudget,
|
|
1223
|
+
maxRecordBytes,
|
|
1224
|
+
onBatch: (summary) => {
|
|
1225
|
+
if (isRobot) {
|
|
1226
|
+
console.log(
|
|
1227
|
+
JSON.stringify({
|
|
1228
|
+
type: "watch_status",
|
|
1229
|
+
source: summary.source,
|
|
1230
|
+
imported: summary.imported,
|
|
1231
|
+
appended: summary.appended,
|
|
1232
|
+
deduped: summary.deduped,
|
|
1233
|
+
skipped: summary.skipped,
|
|
1234
|
+
scanned: summary.scanned,
|
|
1235
|
+
}),
|
|
1236
|
+
);
|
|
1237
|
+
} else {
|
|
1238
|
+
console.log(
|
|
1239
|
+
`[watch] ${summary.source}: ${summary.imported} imported, ${summary.appended} appended, ${summary.deduped} deduped, ${summary.skipped} skipped (from ${summary.scanned} scanned)`,
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
},
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
const { promise: exitPromise, resolve: exitResolve } = Promise.withResolvers<void>();
|
|
1246
|
+
const shutdown = () => {
|
|
1247
|
+
void watcher.stop().then(exitResolve, () => {
|
|
1248
|
+
console.error("[ledger] watcher shutdown failed.");
|
|
1249
|
+
process.exitCode = 1;
|
|
1250
|
+
exitResolve();
|
|
1251
|
+
});
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
process.on("SIGINT", shutdown);
|
|
1255
|
+
process.on("SIGTERM", shutdown);
|
|
1256
|
+
|
|
1257
|
+
try {
|
|
1258
|
+
await watcher.start();
|
|
1259
|
+
await exitPromise;
|
|
1260
|
+
} finally {
|
|
1261
|
+
await watcher.stop();
|
|
1262
|
+
process.off("SIGINT", shutdown);
|
|
1263
|
+
process.off("SIGTERM", shutdown);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
async function handleModels(args: string[], isRobot: boolean): Promise<void> {
|
|
1268
|
+
const { positionals } = parseFlags(args);
|
|
1269
|
+
const subCmd = positionals[0];
|
|
1270
|
+
if (subCmd === "pull") {
|
|
1271
|
+
const { modelPath, vocabPath } = await pullModel({ verbose: !isRobot });
|
|
1272
|
+
if (isRobot) {
|
|
1273
|
+
console.log(
|
|
1274
|
+
JSON.stringify({
|
|
1275
|
+
type: "models_pull",
|
|
1276
|
+
model: DEFAULT_MODEL_NAME,
|
|
1277
|
+
modelPath,
|
|
1278
|
+
vocabPath,
|
|
1279
|
+
status: "ok",
|
|
1280
|
+
}),
|
|
1281
|
+
);
|
|
1282
|
+
} else {
|
|
1283
|
+
console.log(`Model ${DEFAULT_MODEL_NAME} pulled successfully to ${modelPath}`);
|
|
1284
|
+
}
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
if (isRobot) {
|
|
1289
|
+
emitRobotError("UNKNOWN_COMMAND", `Unknown models subcommand: ${subCmd}`);
|
|
1290
|
+
}
|
|
1291
|
+
console.error(`Unknown models subcommand: ${subCmd}\nUsage: trajectories models pull`);
|
|
1292
|
+
process.exit(1);
|
|
1293
|
+
}
|