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,281 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only access to another application's SQLite store, with a private-copy
|
|
9
|
+
* fallback for stores a direct read cannot reach.
|
|
10
|
+
*
|
|
11
|
+
* Gradient ported from Wake@d9f702b6
|
|
12
|
+
* (`crates/wake-core/src/adapters/sqlite_ro.rs#L20-L46`): read-only open plus a
|
|
13
|
+
* probe query → copy the database and its sidecars into a private temp
|
|
14
|
+
* directory and read the copy → give up and surface the original error. A
|
|
15
|
+
* store held by its own writer (rollback-journal `BEGIN EXCLUSIVE`, WAL with
|
|
16
|
+
* `locking_mode = EXCLUSIVE`) answers `SQLITE_BUSY` to a direct reader but
|
|
17
|
+
* copies cleanly, because the copy is a different file with no lock holder.
|
|
18
|
+
*
|
|
19
|
+
* The source store is never written, and `immutable=1` is never used: an
|
|
20
|
+
* immutable open ignores the `-wal`, which is exactly where a live store keeps
|
|
21
|
+
* its newest sessions (`sqlite_ro.rs#L16-L19`).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Total bytes of database plus sidecars we will copy to reach a locked store. */
|
|
25
|
+
export const MAX_SQLITE_COPY_BYTES = 512 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
/** Root of the private copies; one directory per (database, process). */
|
|
28
|
+
export const NATIVE_COPY_ROOT = join(tmpdir(), "ledger-native-db");
|
|
29
|
+
|
|
30
|
+
const COPY_FILE = "db.sqlite";
|
|
31
|
+
// Sidecars in the order they are copied. `-shm` precedes `-wal` so the copied
|
|
32
|
+
// index can never claim frames the copied `-wal` does not contain: an older
|
|
33
|
+
// consistent snapshot is the safe direction, a short frame read is not.
|
|
34
|
+
// `-journal` is a Ledger addition to the pinned copy set — a rollback-journal
|
|
35
|
+
// writer can leave uncommitted pages in the database file, and the copy is
|
|
36
|
+
// opened writable so SQLite rolls them back (see `openCopy`) instead of
|
|
37
|
+
// reporting them as committed.
|
|
38
|
+
const SIDECARS = ["-shm", "-wal", "-journal"] as const;
|
|
39
|
+
const PROBE = "SELECT count(*) AS tables FROM sqlite_master";
|
|
40
|
+
|
|
41
|
+
interface PrivateCopy {
|
|
42
|
+
/** `nativeDbStamp` of the source when the copy was taken. */
|
|
43
|
+
stamp: number;
|
|
44
|
+
dir: string;
|
|
45
|
+
path: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const copies = new Map<string, PrivateCopy>();
|
|
49
|
+
let cleanupRegistered = false;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Invalidation key for a copied store: the newest mtime of the database and
|
|
53
|
+
* its `-wal`, ported from Wake's `db_cache_stamp` (`sqlite_ro.rs#L68-L76`).
|
|
54
|
+
* WAL writers leave the database file untouched until a checkpoint, so a stamp
|
|
55
|
+
* that watched only the database would pin a stale copy while new sessions
|
|
56
|
+
* pile up in the `-wal`. `-shm` is deliberately excluded: it changes on every
|
|
57
|
+
* reader's read-mark and would force a copy per call.
|
|
58
|
+
*/
|
|
59
|
+
export function nativeDbStamp(dbPath: string): number {
|
|
60
|
+
const mtime = (path: string): number => {
|
|
61
|
+
try { return statSync(path).mtimeMs; } catch { return 0; }
|
|
62
|
+
};
|
|
63
|
+
return Math.max(mtime(dbPath), mtime(`${dbPath}-wal`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Path of the private copy currently serving `dbPath`, or null when the store
|
|
68
|
+
* is read directly. A copy whose stamp no longer matches the source is
|
|
69
|
+
* discarded: the caller must not read a snapshot the source has moved past.
|
|
70
|
+
*/
|
|
71
|
+
export function cachedCopyPath(dbPath: string): string | null {
|
|
72
|
+
const target = resolve(dbPath);
|
|
73
|
+
const copy = copies.get(target);
|
|
74
|
+
if (!copy) return null;
|
|
75
|
+
if (copy.stamp === nativeDbStamp(target) && existsSync(copy.path)) return copy.path;
|
|
76
|
+
discard(target);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Runs `read` against a consistent snapshot of `dbPath`, in a deferred
|
|
82
|
+
* transaction so every query sees the same state. Never writes to the store.
|
|
83
|
+
*/
|
|
84
|
+
export function withReadOnlyDb<T>(dbPath: string, read: (db: Database) => T): T {
|
|
85
|
+
const target = resolve(dbPath);
|
|
86
|
+
const cached = cachedCopyPath(target);
|
|
87
|
+
const warm = cached ? openCopy(cached) : null;
|
|
88
|
+
// A private copy that cannot be opened is stale furniture, not a verdict:
|
|
89
|
+
// drop it and let the source answer.
|
|
90
|
+
if (cached && !warm) discard(target);
|
|
91
|
+
if (warm) {
|
|
92
|
+
let probed = false;
|
|
93
|
+
try {
|
|
94
|
+
return runInSnapshot(warm, read, () => { probed = true; });
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// A copy that cannot even be probed is not a verdict on the store; a
|
|
97
|
+
// read that ran on a readable copy fails for its own reasons and stands.
|
|
98
|
+
if (probed) throw error;
|
|
99
|
+
discard(target);
|
|
100
|
+
} finally { warm.close(); }
|
|
101
|
+
}
|
|
102
|
+
let probed = false;
|
|
103
|
+
try {
|
|
104
|
+
const db = new Database(target, { readonly: true });
|
|
105
|
+
try {
|
|
106
|
+
return runInSnapshot(db, read, () => { probed = true; });
|
|
107
|
+
} finally { db.close(); }
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// The store answered the probe and `read` failed: that verdict stands.
|
|
110
|
+
if (probed) throw error;
|
|
111
|
+
// The copy answers *concurrency*, not unreachable bytes: a store whose
|
|
112
|
+
// `-wal` was deleted out-of-band is not revived by copying it, and reading
|
|
113
|
+
// it at its last checkpoint would report partial evidence as the whole
|
|
114
|
+
// session. Everything but lock contention keeps the direct diagnosis.
|
|
115
|
+
if (!isLockContention(error)) throw error;
|
|
116
|
+
const copy = acquireCopy(target, nativeDbStamp(target));
|
|
117
|
+
const db = copy ? openCopy(copy) : null;
|
|
118
|
+
if (!db) throw error;
|
|
119
|
+
let copyProbed = false;
|
|
120
|
+
try {
|
|
121
|
+
return runInSnapshot(db, read, () => { copyProbed = true; });
|
|
122
|
+
} catch (copyError) {
|
|
123
|
+
// Copy unavailable is a copy problem, not a store verdict: the caller
|
|
124
|
+
// keeps the direct diagnosis, the same one this store produced before
|
|
125
|
+
// the fallback existed.
|
|
126
|
+
throw copyProbed ? copyError : error;
|
|
127
|
+
} finally { db.close(); }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Another connection holding the store surfaces as `SQLITE_BUSY`/`SQLITE_LOCKED`. */
|
|
132
|
+
function isLockContention(error: unknown): boolean {
|
|
133
|
+
if (typeof error !== "object" || error === null || !("code" in error)) return false;
|
|
134
|
+
const { code } = error;
|
|
135
|
+
return typeof code === "string" && /^SQLITE_(?:BUSY|LOCKED)/.test(code);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The copy is ours: written only by this process, mode 0600, under a 0700
|
|
140
|
+
* directory, removed on exit. Opening it writable is what lets SQLite recover
|
|
141
|
+
* it — roll back a copied hot journal, rebuild a `-shm` the source never had —
|
|
142
|
+
* so a store in recovery is readable rather than a second `read_error`.
|
|
143
|
+
*/
|
|
144
|
+
function openCopy(path: string): Database | null {
|
|
145
|
+
try { return new Database(path, { readwrite: true }); } catch { return null; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function runInSnapshot<T>(db: Database, read: (db: Database) => T, probed?: () => void): T {
|
|
149
|
+
db.exec("PRAGMA busy_timeout = 1000");
|
|
150
|
+
return db.transaction(() => {
|
|
151
|
+
db.query(PROBE).get();
|
|
152
|
+
probed?.();
|
|
153
|
+
return read(db);
|
|
154
|
+
}).deferred();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Copies `dbPath` and its sidecars into this process's private directory and
|
|
159
|
+
* returns the copied database path, or null when the store cannot be copied
|
|
160
|
+
* within budget — the caller then surfaces the direct error unchanged.
|
|
161
|
+
*/
|
|
162
|
+
function acquireCopy(target: string, stamp: number): string | null {
|
|
163
|
+
const dir = copyDir(target);
|
|
164
|
+
const path = join(dir, COPY_FILE);
|
|
165
|
+
try {
|
|
166
|
+
if (!privateRoot()) return null;
|
|
167
|
+
sweepOrphans();
|
|
168
|
+
if (setBytes(target) > MAX_SQLITE_COPY_BYTES) return null;
|
|
169
|
+
// Two attempts: a database that moves under the copy is rejected and read
|
|
170
|
+
// once more, because the move is usually a checkpoint that has finished by
|
|
171
|
+
// then.
|
|
172
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
173
|
+
// Recreate rather than overwrite: a sidecar the source no longer has (a
|
|
174
|
+
// checkpointed `-wal`) must not survive into the next snapshot.
|
|
175
|
+
rmSync(dir, { recursive: true, force: true });
|
|
176
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
177
|
+
const base = fileIdentity(target);
|
|
178
|
+
for (const source of [target, ...SIDECARS.map((suffix) => `${target}${suffix}`)]) {
|
|
179
|
+
if (!existsSync(source)) continue;
|
|
180
|
+
const destination = source === target ? path : `${path}${source.slice(target.length)}`;
|
|
181
|
+
// copyFileSync preserves the source mode; a read-only store would
|
|
182
|
+
// leave a copy SQLite cannot open for recovery.
|
|
183
|
+
copyFileSync(source, destination);
|
|
184
|
+
chmodSync(destination, 0o600);
|
|
185
|
+
}
|
|
186
|
+
// A checkpoint backfills pages into the database *and* restarts the wal
|
|
187
|
+
// generation. Frames and index then stay individually valid while the
|
|
188
|
+
// database base is stale, so the copy would silently lose every page the
|
|
189
|
+
// checkpoint moved — the reviewer's `t1=['A'] → t1=[]` case. The database
|
|
190
|
+
// file is the only witness: ordinary WAL commits never touch it, so its
|
|
191
|
+
// identity is stable for every copy that is actually consistent.
|
|
192
|
+
if (base !== null && base === fileIdentity(target)) {
|
|
193
|
+
// A store growing during the copy can outrun the pre-copy check; the
|
|
194
|
+
// overshoot is discarded rather than read.
|
|
195
|
+
if (setBytes(path) <= MAX_SQLITE_COPY_BYTES) {
|
|
196
|
+
copies.set(target, { stamp, dir, path });
|
|
197
|
+
registerCleanup();
|
|
198
|
+
return path;
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
rmSync(dir, { recursive: true, force: true });
|
|
204
|
+
return null;
|
|
205
|
+
} catch {
|
|
206
|
+
rmSync(dir, { recursive: true, force: true });
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `size:mtime` of a file, or null when it cannot be read — the copy base identity. */
|
|
212
|
+
function fileIdentity(path: string): string | null {
|
|
213
|
+
try {
|
|
214
|
+
const stat = statSync(path);
|
|
215
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
216
|
+
} catch { return null; }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The copy root must be ours and private. A pre-existing world-readable root —
|
|
221
|
+
* another user on a shared TMPDIR, or a planted directory — would expose
|
|
222
|
+
* transcript copies until each file is chmodded and would let foreign entries
|
|
223
|
+
* steer the orphan sweep, so the fallback refuses to use it at all.
|
|
224
|
+
*/
|
|
225
|
+
function privateRoot(): boolean {
|
|
226
|
+
mkdirSync(NATIVE_COPY_ROOT, { recursive: true, mode: 0o700 });
|
|
227
|
+
const root = statSync(NATIVE_COPY_ROOT);
|
|
228
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : root.uid;
|
|
229
|
+
return root.isDirectory() && root.uid === uid && (root.mode & 0o777) === 0o700;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Bytes of a database plus whatever sidecars exist beside it. */
|
|
233
|
+
function setBytes(dbPath: string): number {
|
|
234
|
+
let bytes = 0;
|
|
235
|
+
for (const path of [dbPath, ...SIDECARS.map((suffix) => `${dbPath}${suffix}`)]) {
|
|
236
|
+
try {
|
|
237
|
+
const stat = statSync(path);
|
|
238
|
+
if (stat.isFile()) bytes += stat.size;
|
|
239
|
+
} catch { /* missing sidecar */ }
|
|
240
|
+
}
|
|
241
|
+
return bytes;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function copyDir(target: string): string {
|
|
245
|
+
const key = createHash("sha256").update(target).digest("hex").slice(0, 16);
|
|
246
|
+
return join(NATIVE_COPY_ROOT, `copy-${key}-${process.pid}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function discard(target: string): void {
|
|
250
|
+
const copy = copies.get(target);
|
|
251
|
+
if (!copy) return;
|
|
252
|
+
copies.delete(target);
|
|
253
|
+
rmSync(copy.dir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Copies are private agent history; they do not outlive the process that took
|
|
258
|
+
* them. A crash skips this hook, so `sweepOrphans` removes the directories of
|
|
259
|
+
* processes that no longer exist before the next copy.
|
|
260
|
+
*/
|
|
261
|
+
function registerCleanup(): void {
|
|
262
|
+
if (cleanupRegistered) return;
|
|
263
|
+
cleanupRegistered = true;
|
|
264
|
+
process.once("exit", () => {
|
|
265
|
+
for (const copy of copies.values()) rmSync(copy.dir, { recursive: true, force: true });
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function sweepOrphans(): void {
|
|
270
|
+
for (const entry of readdirSync(NATIVE_COPY_ROOT, { withFileTypes: true })) {
|
|
271
|
+
if (!entry.isDirectory()) continue;
|
|
272
|
+
const pid = /^copy-[0-9a-f]{16}-(\d+)$/.exec(entry.name)?.[1];
|
|
273
|
+
if (pid === undefined || Number(pid) === process.pid || isLive(Number(pid))) continue;
|
|
274
|
+
rmSync(join(NATIVE_COPY_ROOT, entry.name), { recursive: true, force: true });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isLive(pid: number): boolean {
|
|
279
|
+
try { process.kill(pid, 0); return true; }
|
|
280
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
281
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { DiscoveredSession } from "./types.js";
|
|
5
|
+
import { readCursorSession } from "./native/cursor.js";
|
|
6
|
+
import { grokSessionFiles, readGrokSession } from "./native/grok.js";
|
|
7
|
+
import { kimiSessionFiles, readKimiSession } from "./native/kimi.js";
|
|
8
|
+
import { openCodeSessionFiles, readOpenCodeSession } from "./native/opencode.js";
|
|
9
|
+
import { MAX_NATIVE_RECORDS, type NativeReadOptions, type NativeTranscript } from "./native/common.js";
|
|
10
|
+
|
|
11
|
+
interface NativeIdentity {
|
|
12
|
+
nativeVersion: 2;
|
|
13
|
+
snapshot: string;
|
|
14
|
+
failureMaxBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function nativeIdentity(value?: string | null): NativeIdentity | null {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
try {
|
|
20
|
+
const identity = JSON.parse(value);
|
|
21
|
+
return identity?.nativeVersion === 2 && typeof identity.snapshot === "string" ? identity : null;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readNativeSession(item: DiscoveredSession, options: NativeReadOptions = {}): NativeTranscript {
|
|
26
|
+
if (item.native?.kind === "fx-directory") {
|
|
27
|
+
throw new Error("FX directory sessions require path-backed SDK normalization; readNativeSession cannot decode only session.json.");
|
|
28
|
+
}
|
|
29
|
+
return item.native?.kind === "cursor-sqlite" || item.native?.kind === "cursor-blobstore"
|
|
30
|
+
? readCursorSession(item, options)
|
|
31
|
+
: item.native?.kind === "grok-updates"
|
|
32
|
+
? readGrokSession(item, options)
|
|
33
|
+
: item.native?.kind === "kimi-wire"
|
|
34
|
+
? readKimiSession(item, options)
|
|
35
|
+
: readOpenCodeSession(item, options);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Database sidecars and legacy part files can change while the primary file does not. */
|
|
39
|
+
export function nativeSourceFingerprint(item: DiscoveredSession): {
|
|
40
|
+
mtime: string; size: number; file_identity: string;
|
|
41
|
+
} | null {
|
|
42
|
+
if (!item.native) return null;
|
|
43
|
+
try {
|
|
44
|
+
if (!statSync(item.path).isFile()) return null;
|
|
45
|
+
const sqlite = item.native.kind === "cursor-sqlite" || item.native.kind === "cursor-blobstore"
|
|
46
|
+
|| item.native.kind === "opencode-sqlite";
|
|
47
|
+
const paths = item.native.kind === "fx-directory"
|
|
48
|
+
? fxSessionFiles(item.path)
|
|
49
|
+
: sqlite
|
|
50
|
+
? [item.path, `${item.path}-wal`, `${item.path}-journal`]
|
|
51
|
+
: item.native.kind === "grok-updates"
|
|
52
|
+
? grokSessionFiles(item)
|
|
53
|
+
: item.native.kind === "kimi-wire" ? kimiSessionFiles(item) : openCodeSessionFiles(item);
|
|
54
|
+
if (paths.length > MAX_NATIVE_RECORDS) return null;
|
|
55
|
+
const hash = createHash("sha256").update(JSON.stringify([item.native, item.id]));
|
|
56
|
+
let bytes = 0;
|
|
57
|
+
let modified = 0;
|
|
58
|
+
for (const path of [...new Set(paths)].sort()) {
|
|
59
|
+
hash.update(JSON.stringify(path));
|
|
60
|
+
try {
|
|
61
|
+
const stat = statSync(path, { bigint: true });
|
|
62
|
+
// SQLite read-only opens may chmod the WAL to match database permissions,
|
|
63
|
+
// changing ctime without changing its bytes. Keep content-bearing mtime,
|
|
64
|
+
// size and generation identity; ordinary native files still include ctime.
|
|
65
|
+
hash.update(JSON.stringify([stat.dev, stat.ino, stat.birthtimeNs, stat.mtimeNs,
|
|
66
|
+
...(sqlite ? [] : [stat.ctimeNs]), stat.size].map(String)));
|
|
67
|
+
if (stat.isFile()) bytes += Number(stat.size);
|
|
68
|
+
modified = Math.max(modified, Number(stat.mtimeMs));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
71
|
+
hash.update("missing");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
mtime: new Date(modified).toISOString(),
|
|
76
|
+
// A shared database's full byte size is not each session's backlog.
|
|
77
|
+
size: sqlite ? item.sizeBytes ?? -1 : bytes,
|
|
78
|
+
file_identity: JSON.stringify({ nativeVersion: 2, snapshot: hash.digest("hex") } satisfies NativeIdentity),
|
|
79
|
+
};
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fxSessionFiles(sessionPath: string): string[] {
|
|
87
|
+
const directory = dirname(sessionPath);
|
|
88
|
+
const paths = [sessionPath, join(directory, "events.jsonl")];
|
|
89
|
+
try {
|
|
90
|
+
for (const entry of readdirSync(join(directory, "tool-results"), { withFileTypes: true })) {
|
|
91
|
+
if (entry.isFile() || entry.isSymbolicLink()) paths.push(join(directory, "tool-results", entry.name));
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
95
|
+
}
|
|
96
|
+
return paths;
|
|
97
|
+
}
|