trajectories 0.0.0 → 0.1.2
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 +36 -0
- package/LICENSE +201 -0
- package/README.md +288 -2
- package/dist/cli.js +30210 -0
- package/dist/convert.d.ts +31 -0
- package/dist/convert.js +431 -0
- package/dist/deepagents_checkpoint.py +362 -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 +1233 -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 +150 -0
- package/ops/install-cli.sh +63 -0
- package/ops/install-native.sh +35 -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 +21 -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,791 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, fstatSync, openSync, readSync, statSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { TombstonedSessionError, type IngestCommitOutcome, type TrajectoryDb } from "./db.js";
|
|
4
|
+
import { aggregateIngestDiagnostics } from "./diagnostics.js";
|
|
5
|
+
import { nativeId } from "./identity.js";
|
|
6
|
+
import { canonicalRecordsWithDiagnostics, normalizeDiscoveredSession } from "./normalizer.js";
|
|
7
|
+
import type {
|
|
8
|
+
CanonicalDbRecord,
|
|
9
|
+
DiscoveredSession,
|
|
10
|
+
IngestCursor,
|
|
11
|
+
IngestDiagnosticAggregate,
|
|
12
|
+
SessionRecord,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import { nativeIdentity, nativeSourceFingerprint } from "./native.js";
|
|
15
|
+
import { NativeInputError } from "./native/common.js";
|
|
16
|
+
|
|
17
|
+
const INCREMENTAL_JSONL_SOURCES = new Set([
|
|
18
|
+
"claude-code", "codex", "cursor", "pi", "omp", "droid",
|
|
19
|
+
]);
|
|
20
|
+
export const DEFAULT_INGEST_CHUNK_BYTES = 1024 * 1024;
|
|
21
|
+
export const DEFAULT_INGEST_RECORD_BYTES = 64 * 1024 * 1024;
|
|
22
|
+
// Bump when normalization semantics change so cached deterministic failures are retried.
|
|
23
|
+
// Version 5 rebuilds old truncated records using the full-history normalizer.
|
|
24
|
+
export const INGEST_VERSION = 5;
|
|
25
|
+
export const APPEND_CHECKPOINT_VERSION = 1;
|
|
26
|
+
export const APPEND_TAIL_BYTES = 128 * 1024;
|
|
27
|
+
const HASH_BUFFER_BYTES = 64 * 1024;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `tombstoned` is not a failure and not a skip: it is the verdict a
|
|
31
|
+
* user-initiated deletion produces on the ingest side, and it is its own kind so
|
|
32
|
+
* a pass reports "the user deleted this" rather than folding it into a counter
|
|
33
|
+
* that means "the source had nothing for us".
|
|
34
|
+
*/
|
|
35
|
+
export type IngestKind = "imported" | "appended" | "deduped" | "skipped" | "unchanged" | "deferred" | "tombstoned";
|
|
36
|
+
|
|
37
|
+
export interface IngestWork {
|
|
38
|
+
source_bytes_read: number;
|
|
39
|
+
source_bytes_hashed: number;
|
|
40
|
+
historical_records_hashed: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IngestResult {
|
|
44
|
+
kind: IngestKind;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
warnings?: string[];
|
|
47
|
+
work?: IngestWork;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IngestOptions {
|
|
51
|
+
embed?: boolean;
|
|
52
|
+
/** Bounds one normalization call; larger individual lines require an explicit larger budget. */
|
|
53
|
+
maxChunkBytes?: number;
|
|
54
|
+
/** Hard bound for one JSONL record or one assembled native session. */
|
|
55
|
+
maxRecordBytes?: number;
|
|
56
|
+
/** Maximum source bytes this call may read before leaving its checkpoint unchanged. */
|
|
57
|
+
maxSourceBytes?: number;
|
|
58
|
+
/** Application-enforced source read rate. Omit for interactive foreground use. */
|
|
59
|
+
readBytesPerSecond?: number;
|
|
60
|
+
/** Cooperative deadline; the sync parent also enforces a kill deadline around the worker. */
|
|
61
|
+
maxWallMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SourceFingerprint {
|
|
65
|
+
mtime: string;
|
|
66
|
+
size: number;
|
|
67
|
+
file_identity?: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface FileIdentity {
|
|
71
|
+
version: number;
|
|
72
|
+
device: string;
|
|
73
|
+
inode: string;
|
|
74
|
+
birthtime: string;
|
|
75
|
+
modified: string;
|
|
76
|
+
changed: string;
|
|
77
|
+
waitingForNewline: boolean;
|
|
78
|
+
copilotProjection?: 1;
|
|
79
|
+
failureLimits?: { chunk: number; record: number };
|
|
80
|
+
claude?: ClaudeClassification;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ClaudeClassification {
|
|
84
|
+
mode: "parent" | "standalone";
|
|
85
|
+
groupId: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export class IngestBudgetExceeded extends Error {
|
|
89
|
+
constructor(
|
|
90
|
+
readonly budget: "source_bytes" | "wall_time",
|
|
91
|
+
readonly work: IngestWork,
|
|
92
|
+
) {
|
|
93
|
+
super(`Ingestion ${budget} budget exhausted`);
|
|
94
|
+
this.name = "IngestBudgetExceeded";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
class IngestWorkMeter {
|
|
99
|
+
sourceBytesRead = 0;
|
|
100
|
+
sourceBytesHashed = 0;
|
|
101
|
+
historicalRecordsHashed = 0;
|
|
102
|
+
private readonly startedAt = performance.now();
|
|
103
|
+
private readonly maxSourceBytes?: number;
|
|
104
|
+
private readonly readBytesPerSecond?: number;
|
|
105
|
+
private readonly deadline?: number;
|
|
106
|
+
|
|
107
|
+
constructor(options: IngestOptions) {
|
|
108
|
+
this.maxSourceBytes = positiveBudget(options.maxSourceBytes, "maxSourceBytes");
|
|
109
|
+
this.readBytesPerSecond = positiveBudget(options.readBytesPerSecond, "readBytesPerSecond");
|
|
110
|
+
const maxWallMs = positiveBudget(options.maxWallMs, "maxWallMs");
|
|
111
|
+
this.deadline = maxWallMs === undefined ? undefined : this.startedAt + maxWallMs;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
checkpoint(): void {
|
|
115
|
+
if (this.deadline !== undefined && performance.now() >= this.deadline) {
|
|
116
|
+
throw new IngestBudgetExceeded("wall_time", this.snapshot());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fail closed before a whole-artifact allocation that cannot finish under
|
|
122
|
+
* the remaining source-byte budget. Does not charge or rate-limit; later
|
|
123
|
+
* reads still go through prepareRead.
|
|
124
|
+
*/
|
|
125
|
+
rejectUnbudgetedAllocation(bytes: number): void {
|
|
126
|
+
this.checkpoint();
|
|
127
|
+
if (this.exceedsSourceBudget(bytes)) {
|
|
128
|
+
throw new IngestBudgetExceeded("source_bytes", this.snapshot());
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
prepareRead(bytes: number): void {
|
|
133
|
+
this.checkpoint();
|
|
134
|
+
if (this.exceedsSourceBudget(bytes)) {
|
|
135
|
+
throw new IngestBudgetExceeded("source_bytes", this.snapshot());
|
|
136
|
+
}
|
|
137
|
+
if (this.readBytesPerSecond !== undefined) {
|
|
138
|
+
const due = this.startedAt + ((this.sourceBytesRead + bytes) / this.readBytesPerSecond) * 1000;
|
|
139
|
+
if (this.deadline !== undefined && due >= this.deadline) {
|
|
140
|
+
throw new IngestBudgetExceeded("wall_time", this.snapshot());
|
|
141
|
+
}
|
|
142
|
+
const delay = due - performance.now();
|
|
143
|
+
if (delay > 0) Bun.sleepSync(delay);
|
|
144
|
+
this.checkpoint();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
read(bytes: number): void {
|
|
149
|
+
this.sourceBytesRead += bytes;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
hashed(bytes: number): void {
|
|
153
|
+
this.sourceBytesHashed += bytes;
|
|
154
|
+
this.checkpoint();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
snapshot(): IngestWork {
|
|
158
|
+
return {
|
|
159
|
+
source_bytes_read: this.sourceBytesRead,
|
|
160
|
+
source_bytes_hashed: this.sourceBytesHashed,
|
|
161
|
+
historical_records_hashed: this.historicalRecordsHashed,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private exceedsSourceBudget(bytes: number): boolean {
|
|
166
|
+
return this.maxSourceBytes !== undefined && this.sourceBytesRead + bytes > this.maxSourceBytes;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function positiveBudget(value: number | undefined, name: string): number | undefined {
|
|
171
|
+
if (value === undefined) return undefined;
|
|
172
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isIncrementalJsonl(item: DiscoveredSession): boolean {
|
|
177
|
+
return !item.native && INCREMENTAL_JSONL_SOURCES.has(item.source) && item.path.endsWith(".jsonl");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function fingerprintFromStat(stat: BigIntStats, source?: string): SourceFingerprint {
|
|
181
|
+
const identity: FileIdentity = {
|
|
182
|
+
version: INGEST_VERSION,
|
|
183
|
+
device: String(stat.dev), inode: String(stat.ino), birthtime: String(stat.birthtimeNs),
|
|
184
|
+
modified: String(stat.mtimeNs), changed: String(stat.ctimeNs), waitingForNewline: false,
|
|
185
|
+
// Rebuild this source's old lossy projection without invalidating other readers.
|
|
186
|
+
...(source === "copilot-cli" ? { copilotProjection: 1 as const } : {}),
|
|
187
|
+
};
|
|
188
|
+
return { mtime: stat.mtime.toISOString(), size: Number(stat.size), file_identity: JSON.stringify(identity) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function fileIdentity(value?: string | null): FileIdentity | null {
|
|
192
|
+
if (!value) return null;
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(value);
|
|
195
|
+
if (parsed?.version !== INGEST_VERSION || !["device", "inode", "birthtime", "modified", "changed"]
|
|
196
|
+
.every(key => typeof parsed[key] === "string") || typeof parsed.waitingForNewline !== "boolean") return null;
|
|
197
|
+
if (parsed.claude && (!["parent", "standalone"].includes(parsed.claude.mode) ||
|
|
198
|
+
typeof parsed.claude.groupId !== "string" || !parsed.claude.groupId)) return null;
|
|
199
|
+
return parsed;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sameGeneration(a: FileIdentity | null, b: FileIdentity | null): boolean {
|
|
206
|
+
return !!a && !!b && a.device === b.device && a.inode === b.inode && a.birthtime === b.birthtime;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sameSnapshot(a: SourceFingerprint, b: SourceFingerprint): boolean {
|
|
210
|
+
if (a.mtime !== b.mtime || a.size !== b.size) return false;
|
|
211
|
+
const native = nativeIdentity(a.file_identity);
|
|
212
|
+
if (native) return native.snapshot === nativeIdentity(b.file_identity)?.snapshot;
|
|
213
|
+
if (!a.file_identity && !b.file_identity) return true;
|
|
214
|
+
const left = fileIdentity(a.file_identity);
|
|
215
|
+
const right = fileIdentity(b.file_identity);
|
|
216
|
+
return sameGeneration(left, right) && left!.modified === right!.modified && left!.changed === right!.changed &&
|
|
217
|
+
left!.copilotProjection === right!.copilotProjection;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function sourceFingerprint(item: DiscoveredSession): SourceFingerprint | null {
|
|
221
|
+
if (item.native) return nativeSourceFingerprint(item);
|
|
222
|
+
if (item.source === "hermes") return { mtime: item.updatedAt ?? "", size: item.sizeBytes ?? -1 };
|
|
223
|
+
try {
|
|
224
|
+
const stat = statSync(item.path, { bigint: true });
|
|
225
|
+
return stat.isFile() ? fingerprintFromStat(stat, item.source) : null;
|
|
226
|
+
} catch {
|
|
227
|
+
if (!isIncrementalJsonl(item) && item.updatedAt != null && item.sizeBytes != null) {
|
|
228
|
+
return { mtime: item.updatedAt, size: item.sizeBytes };
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isUnchanged(item: DiscoveredSession, cursor: IngestCursor | null, fingerprint: SourceFingerprint, options: IngestOptions): boolean {
|
|
235
|
+
if (!cursor || !sameSnapshot(cursor, fingerprint)) return false;
|
|
236
|
+
if (cursor.status === "pending") return false;
|
|
237
|
+
if (cursor.status === "failed") {
|
|
238
|
+
if (item.native) return nativeIdentity(cursor.file_identity)?.failureMaxBytes === (options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES);
|
|
239
|
+
const limits = fileIdentity(cursor.file_identity)?.failureLimits;
|
|
240
|
+
return !!limits && limits.chunk === (options.maxChunkBytes ?? DEFAULT_INGEST_CHUNK_BYTES) &&
|
|
241
|
+
limits.record === (options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES);
|
|
242
|
+
}
|
|
243
|
+
if (!isIncrementalJsonl(item)) return true;
|
|
244
|
+
if (!cursor.prefix_hash || !fileIdentity(cursor.file_identity)) return false;
|
|
245
|
+
return cursor.bytes_ingested === fingerprint.size ||
|
|
246
|
+
(cursor.bytes_ingested < fingerprint.size && fileIdentity(cursor.file_identity)!.waitingForNewline);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function sessionNeedsIngest(db: TrajectoryDb, item: DiscoveredSession, options: IngestOptions = {}): boolean {
|
|
250
|
+
// A deleted session is never a candidate: the listing's own axis refuses it
|
|
251
|
+
// here, before the source is stat-ed, so no pass queues work for it and the
|
|
252
|
+
// sync planner's `removeQueuedIngest` drops one that was already queued.
|
|
253
|
+
if (db.tombstoneForListing(item)) return false;
|
|
254
|
+
const fingerprint = sourceFingerprint(item);
|
|
255
|
+
return !!fingerprint && !isUnchanged(item, db.getIngestCursor(item.source, item.id, item.path), fingerprint, options);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const TRANSIENT_NATIVE_CODES = new Set(["EBUSY", "EAGAIN", "ETIMEDOUT", "EINTR"]);
|
|
259
|
+
|
|
260
|
+
function publicErrorLabel(error: unknown): string {
|
|
261
|
+
const name = error instanceof NativeInputError ? "NativeInputError"
|
|
262
|
+
: error instanceof Error && error.name ? error.name : "Error";
|
|
263
|
+
if (!error || typeof error !== "object" || !("code" in error)) return name;
|
|
264
|
+
const code = error.code;
|
|
265
|
+
if (typeof code === "number" && Number.isFinite(code)) return `${name}/${code}`;
|
|
266
|
+
if (typeof code !== "string" || code.length === 0 || code.length > 64) return name;
|
|
267
|
+
if (code.includes("/") || code.includes("\\") || code.includes("\0")) return name;
|
|
268
|
+
return `${name}/${code}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function isTransientNativeError(error: unknown): boolean {
|
|
272
|
+
if (!error || typeof error !== "object" || !("code" in error) || typeof error.code !== "string") return false;
|
|
273
|
+
return TRANSIENT_NATIVE_CODES.has(error.code) ||
|
|
274
|
+
error.code.startsWith("SQLITE_BUSY") || error.code.startsWith("SQLITE_LOCKED");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** NativeInputError and lock/busy classes skip; unexpected failures rethrow. */
|
|
278
|
+
export function nativeIngestCatchResult(error: unknown, sessionId: string): IngestResult {
|
|
279
|
+
const label = publicErrorLabel(error);
|
|
280
|
+
if (error instanceof NativeInputError) {
|
|
281
|
+
return warning({ kind: "skipped", sessionId },
|
|
282
|
+
`Native assembly or normalization failed (${label}); retry after source or byte-limit changes.`);
|
|
283
|
+
}
|
|
284
|
+
if (isTransientNativeError(error)) {
|
|
285
|
+
return warning({ kind: "skipped", sessionId },
|
|
286
|
+
`Native ingestion deferred (${label}); source remains eligible for retry.`);
|
|
287
|
+
}
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The pass's verdict for a commit the store refused because the session was
|
|
293
|
+
* deleted: `tombstoned`, the same answer `sessionNeedsIngest` gives a listing
|
|
294
|
+
* before it is read, arriving later for the shapes that axis cannot name — a
|
|
295
|
+
* source that groups several files under one session id reads a member listing
|
|
296
|
+
* under a different id first, and a queued item can outlive the deletion that
|
|
297
|
+
* now covers it. Every other error is not this function's to interpret.
|
|
298
|
+
*/
|
|
299
|
+
function tombstonedIngestResult(error: unknown): IngestResult {
|
|
300
|
+
if (!(error instanceof TombstonedSessionError)) throw error;
|
|
301
|
+
return { kind: "tombstoned", sessionId: error.sessionId };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function ingestDiscoveredSession(
|
|
305
|
+
db: TrajectoryDb, item: DiscoveredSession, options: IngestOptions = {},
|
|
306
|
+
): Promise<IngestResult> {
|
|
307
|
+
const work = new IngestWorkMeter(options);
|
|
308
|
+
// Admission, before the source is opened or stat-ed: the double-track's
|
|
309
|
+
// listing axis, which the sync planner already consults and which every other
|
|
310
|
+
// caller (`build`, `watch`, the worker child) reaches through this function.
|
|
311
|
+
// The store's own write boundary refuses the same session again by its
|
|
312
|
+
// committed id, which is what covers a source whose listings name a session
|
|
313
|
+
// differently than its group does.
|
|
314
|
+
const tombstone = db.tombstoneForListing(item);
|
|
315
|
+
if (tombstone) return { kind: "tombstoned", sessionId: tombstone.session_id, work: work.snapshot() };
|
|
316
|
+
const fingerprint = sourceFingerprint(item);
|
|
317
|
+
if (!fingerprint) return { kind: "skipped", sessionId: item.id, work: work.snapshot() };
|
|
318
|
+
const cursor = db.getIngestCursor(item.source, item.id, item.path);
|
|
319
|
+
if (isUnchanged(item, cursor, fingerprint, options)) {
|
|
320
|
+
return { kind: "unchanged", sessionId: cursor!.session_id ?? item.id, work: work.snapshot() };
|
|
321
|
+
}
|
|
322
|
+
if (isIncrementalJsonl(item)) {
|
|
323
|
+
try {
|
|
324
|
+
const result = await ingestJsonl(db, item, cursor, options, work);
|
|
325
|
+
return { ...result, work: work.snapshot() };
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return { ...tombstonedIngestResult(error), work: work.snapshot() };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let deterministicFailure = true;
|
|
332
|
+
let norm: Awaited<ReturnType<typeof normalizeDiscoveredSession>>;
|
|
333
|
+
if (!item.native && item.source !== "hermes" && item.source !== "deepagents" && fingerprint.size >= 0) {
|
|
334
|
+
let fd: number;
|
|
335
|
+
try { fd = openSync(item.path, "r"); }
|
|
336
|
+
catch { return { ...warning({ kind: "skipped", sessionId: item.id }, "Source could not be opened; checkpoint unchanged."), work: work.snapshot() }; }
|
|
337
|
+
try {
|
|
338
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
339
|
+
if (!stat.isFile() || !sameSnapshot(fingerprint, fingerprintFromStat(stat, item.source))) {
|
|
340
|
+
return { ...changedDuringRead(item), work: work.snapshot() };
|
|
341
|
+
}
|
|
342
|
+
if (item.source === "copilot-cli" && Number(stat.size) > (options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES)) {
|
|
343
|
+
return { ...warning({ kind: "skipped", sessionId: item.id },
|
|
344
|
+
`Copilot complete journal exceeds maxRecordBytes=${options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES}; checkpoint unchanged. Increase the bound explicitly.`), work: work.snapshot() };
|
|
345
|
+
}
|
|
346
|
+
// Non-JSONL adapters need the whole artifact. Reject before alloc so a
|
|
347
|
+
// large file cannot OOM the worker ahead of the remaining source-byte budget.
|
|
348
|
+
work.rejectUnbudgetedAllocation(fingerprint.size);
|
|
349
|
+
const transcript = Buffer.alloc(fingerprint.size);
|
|
350
|
+
readExactly(fd, transcript, 0, work);
|
|
351
|
+
norm = await normalizeDiscoveredSession(item, { transcript: transcript.toString("utf8") });
|
|
352
|
+
} finally {
|
|
353
|
+
closeSync(fd);
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
// SQLite/checkpoint adapters own their reads. Conservatively admit and
|
|
357
|
+
// charge the discovered artifact size; sync still runs them in a worker.
|
|
358
|
+
const admitted = Math.max(fingerprint.size, 0);
|
|
359
|
+
work.prepareRead(admitted);
|
|
360
|
+
work.read(admitted);
|
|
361
|
+
norm = await normalizeDiscoveredSession(item, {
|
|
362
|
+
maxSourceBytes: options.maxRecordBytes,
|
|
363
|
+
onReadError: error => { deterministicFailure = error instanceof NativeInputError; },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
work.checkpoint();
|
|
367
|
+
const current = sourceFingerprint(item);
|
|
368
|
+
if (!current || !sameSnapshot(fingerprint, current) || !cursorStillCurrent(db, item, cursor)) {
|
|
369
|
+
return { ...changedDuringRead(item), work: work.snapshot() };
|
|
370
|
+
}
|
|
371
|
+
if (!norm) {
|
|
372
|
+
const identity = nativeIdentity(fingerprint.file_identity);
|
|
373
|
+
const failed = identity && deterministicFailure ? { ...fingerprint, file_identity: JSON.stringify({ ...identity, failureMaxBytes: options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES }) } : fingerprint;
|
|
374
|
+
db.upsertIngestCursor(makeCursor(item, failed, {
|
|
375
|
+
session_id: cursor?.session_id ?? null, bytes_ingested: cursor?.bytes_ingested ?? 0,
|
|
376
|
+
record_count: cursor?.record_count ?? 0, status: "failed",
|
|
377
|
+
}));
|
|
378
|
+
return { ...warning({ kind: "skipped", sessionId: item.id }, identity && deterministicFailure
|
|
379
|
+
? "Native assembly or normalization failed; retry after source or byte-limit changes."
|
|
380
|
+
: "Normalization failed; source remains eligible for retry."), work: work.snapshot() };
|
|
381
|
+
}
|
|
382
|
+
const next = makeCursor(item, fingerprint, {
|
|
383
|
+
session_id: norm.session.id, bytes_ingested: Math.max(fingerprint.size, 0),
|
|
384
|
+
record_count: norm.records.length, status: "ok",
|
|
385
|
+
});
|
|
386
|
+
let saved: IngestCommitOutcome;
|
|
387
|
+
try {
|
|
388
|
+
saved = db.commitIngest(norm.session, norm.records, next, false, true, {
|
|
389
|
+
diagnostics: aggregateIngestDiagnostics(norm.diagnostics),
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
return { ...tombstonedIngestResult(error), work: work.snapshot() };
|
|
393
|
+
}
|
|
394
|
+
work.historicalRecordsHashed += saved.historicalRecordsHashed;
|
|
395
|
+
return { ...await finishEmbedding(db, norm.session, norm.records, {
|
|
396
|
+
kind: saved.deduped ? "deduped" : "imported", sessionId: norm.session.id,
|
|
397
|
+
}, options.embed), work: work.snapshot() };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function ingestJsonl(
|
|
401
|
+
db: TrajectoryDb, item: DiscoveredSession, cursor: IngestCursor | null, options: IngestOptions,
|
|
402
|
+
work: IngestWorkMeter,
|
|
403
|
+
): Promise<IngestResult> {
|
|
404
|
+
const budget = options.maxChunkBytes ?? DEFAULT_INGEST_CHUNK_BYTES;
|
|
405
|
+
const recordLimit = options.maxRecordBytes ?? DEFAULT_INGEST_RECORD_BYTES;
|
|
406
|
+
if (!Number.isSafeInteger(budget) || budget <= 0) throw new Error("maxChunkBytes must be a positive safe integer");
|
|
407
|
+
if (!Number.isSafeInteger(recordLimit) || recordLimit < budget) throw new Error("maxRecordBytes must be a safe integer at least maxChunkBytes");
|
|
408
|
+
let fd: number;
|
|
409
|
+
try { fd = openSync(item.path, "r"); }
|
|
410
|
+
catch { return warning({ kind: "skipped", sessionId: item.id }, "Source could not be opened; checkpoint unchanged."); }
|
|
411
|
+
try {
|
|
412
|
+
const stat = fstatSync(fd, { bigint: true });
|
|
413
|
+
if (!stat.isFile()) return { kind: "skipped", sessionId: item.id };
|
|
414
|
+
const snapshot = fingerprintFromStat(stat);
|
|
415
|
+
let session = cursor ? db.getIngestSession(cursor) : null;
|
|
416
|
+
let offset = 0;
|
|
417
|
+
let prefixHash = initialSourceCheckpoint();
|
|
418
|
+
let lines = 0;
|
|
419
|
+
let previousTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
420
|
+
if (session && cursor && cursor.bytes_ingested > 0) {
|
|
421
|
+
const sameFile = cursor.bytes_ingested <= snapshot.size &&
|
|
422
|
+
sameGeneration(fileIdentity(cursor.file_identity), fileIdentity(snapshot.file_identity));
|
|
423
|
+
if (!sameFile || !hasAppendCheckpoint(cursor)) {
|
|
424
|
+
return deferForRebuild(db, item, cursor, snapshot,
|
|
425
|
+
"Existing history needs a bounded cold rebuild before another append.");
|
|
426
|
+
}
|
|
427
|
+
const candidate = verifiedAppendCheckpoint(fd, cursor, work);
|
|
428
|
+
if (!candidate) {
|
|
429
|
+
return deferForRebuild(db, item, cursor, snapshot,
|
|
430
|
+
"Source continuity anchor changed; live history was preserved for a bounded rebuild.");
|
|
431
|
+
}
|
|
432
|
+
offset = cursor.bytes_ingested;
|
|
433
|
+
prefixHash = cursor.prefix_hash!;
|
|
434
|
+
lines = cursor.line_count!;
|
|
435
|
+
previousTail = candidate.tail;
|
|
436
|
+
}
|
|
437
|
+
let claude: ClaudeClassification | undefined;
|
|
438
|
+
if (item.source === "claude-code") {
|
|
439
|
+
const previous = fileIdentity(cursor?.file_identity)?.claude;
|
|
440
|
+
const classification = previous && cursor && offset > 0
|
|
441
|
+
? previous : classifyClaude(fd, snapshot.size, budget, recordLimit, work);
|
|
442
|
+
if (typeof classification === "string") {
|
|
443
|
+
if (!snapshotStillCurrent(fd, item, snapshot) || !cursorStillCurrent(db, item, cursor)) return changedDuringRead(item);
|
|
444
|
+
const identity = fileIdentity(snapshot.file_identity)!;
|
|
445
|
+
identity.failureLimits = { chunk: budget, record: recordLimit };
|
|
446
|
+
db.upsertIngestCursor(makeCursor(item, { ...snapshot, file_identity: JSON.stringify(identity) }, {
|
|
447
|
+
session_id: session?.id ?? cursor?.session_id ?? null, bytes_ingested: offset,
|
|
448
|
+
record_count: offset ? session?.record_count ?? 0 : 0, status: "failed", prefix_hash: prefixHash,
|
|
449
|
+
checkpoint_version: APPEND_CHECKPOINT_VERSION, line_count: lines,
|
|
450
|
+
...tailFields(offset, previousTail),
|
|
451
|
+
}));
|
|
452
|
+
return warning({ kind: "skipped", sessionId: item.id }, classification);
|
|
453
|
+
}
|
|
454
|
+
claude = classification;
|
|
455
|
+
if (!previous || previous.mode !== claude.mode || previous.groupId !== claude.groupId) {
|
|
456
|
+
if (offset > 0) {
|
|
457
|
+
return deferForRebuild(db, item, cursor!, snapshot,
|
|
458
|
+
"Claude source classification changed; live history was preserved for a bounded rebuild.");
|
|
459
|
+
}
|
|
460
|
+
offset = 0;
|
|
461
|
+
prefixHash = initialSourceCheckpoint();
|
|
462
|
+
lines = 0;
|
|
463
|
+
previousTail = Buffer.alloc(0);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const append = offset > 0 && !!session;
|
|
467
|
+
if (!append) session = null;
|
|
468
|
+
|
|
469
|
+
const buffer = readChunk(fd, offset, snapshot.size, budget, recordLimit, work);
|
|
470
|
+
const consumed = buffer.lastIndexOf(0x0a) + 1;
|
|
471
|
+
const reachedEof = offset + buffer.length === snapshot.size;
|
|
472
|
+
const waitingForNewline = reachedEof && consumed < buffer.length;
|
|
473
|
+
const nextIdentity = fileIdentity(snapshot.file_identity)!;
|
|
474
|
+
nextIdentity.waitingForNewline = waitingForNewline;
|
|
475
|
+
if (claude) nextIdentity.claude = claude;
|
|
476
|
+
const checkpointSnapshot = { ...snapshot, file_identity: JSON.stringify(nextIdentity) };
|
|
477
|
+
const next = makeCursor(item, checkpointSnapshot, {
|
|
478
|
+
// Cold passes null `session` before building the checkpoint; keep claiming
|
|
479
|
+
// the prior committed owner so failure/skip writes cannot unclaim a live
|
|
480
|
+
// session and strand a later genuine replacement.
|
|
481
|
+
session_id: session?.id ?? cursor?.session_id ?? null, bytes_ingested: offset, record_count: session?.record_count ?? 0,
|
|
482
|
+
status: "ok", prefix_hash: prefixHash, checkpoint_version: APPEND_CHECKPOINT_VERSION,
|
|
483
|
+
line_count: lines, ...tailFields(offset, previousTail),
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
if (consumed === 0) {
|
|
487
|
+
if (!snapshotStillCurrent(fd, item, snapshot) || !cursorStillCurrent(db, item, cursor)) return changedDuringRead(item);
|
|
488
|
+
if (!reachedEof) {
|
|
489
|
+
next.status = "failed";
|
|
490
|
+
nextIdentity.failureLimits = { chunk: budget, record: recordLimit };
|
|
491
|
+
next.file_identity = JSON.stringify(nextIdentity);
|
|
492
|
+
db.upsertIngestCursor(next);
|
|
493
|
+
return warning({ kind: "skipped", sessionId: session?.id ?? item.id },
|
|
494
|
+
`JSONL line at byte ${offset} exceeds maxRecordBytes=${recordLimit}; no bytes skipped. Increase maxRecordBytes explicitly or use a bounded staged importer.`);
|
|
495
|
+
}
|
|
496
|
+
db.upsertIngestCursor(next);
|
|
497
|
+
return { kind: session ? "unchanged" : "skipped", sessionId: session?.id ?? item.id };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const complete = buffer.subarray(0, consumed);
|
|
501
|
+
const supplied = claude?.mode === "parent" ? maskClaudeSidechains(complete) : complete;
|
|
502
|
+
let records: CanonicalDbRecord[] | null;
|
|
503
|
+
let diagnostics: IngestDiagnosticAggregate[] | undefined;
|
|
504
|
+
if (append) {
|
|
505
|
+
// A continuation chunk is normalized on its own, so its diagnostics cover
|
|
506
|
+
// exactly the records it contributes to the staged replacement.
|
|
507
|
+
const chunked = canonicalRecordsWithDiagnostics(
|
|
508
|
+
item.source as Parameters<typeof canonicalRecordsWithDiagnostics>[0], supplied.toString("utf8"),
|
|
509
|
+
nativeId(item.source, session!.id), offset, lines,
|
|
510
|
+
);
|
|
511
|
+
records = chunked?.records ?? null;
|
|
512
|
+
if (chunked) diagnostics = aggregateIngestDiagnostics(chunked.diagnostics);
|
|
513
|
+
} else {
|
|
514
|
+
const norm = await normalizeDiscoveredSession(claude ? { ...item, id: claude.groupId } : item, {
|
|
515
|
+
transcript: supplied.toString("utf8"), ...(claude ? { groupId: claude.groupId } : {}),
|
|
516
|
+
});
|
|
517
|
+
session = norm?.session ?? null;
|
|
518
|
+
records = norm?.records ?? null;
|
|
519
|
+
if (norm) diagnostics = aggregateIngestDiagnostics(norm.diagnostics);
|
|
520
|
+
}
|
|
521
|
+
work.checkpoint();
|
|
522
|
+
if (!snapshotStillCurrent(fd, item, snapshot) || !cursorStillCurrent(db, item, cursor)) return changedDuringRead(item);
|
|
523
|
+
if (!session || !records) {
|
|
524
|
+
next.status = "failed";
|
|
525
|
+
nextIdentity.failureLimits = { chunk: budget, record: recordLimit };
|
|
526
|
+
next.file_identity = JSON.stringify(nextIdentity);
|
|
527
|
+
db.upsertIngestCursor(next);
|
|
528
|
+
return warning({ kind: "skipped", sessionId: item.id }, `Normalization failed at byte ${offset}; checkpoint did not advance.`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
prefixHash = extendSourceCheckpoint(prefixHash, complete, work);
|
|
532
|
+
next.bytes_ingested = offset + consumed;
|
|
533
|
+
next.prefix_hash = prefixHash;
|
|
534
|
+
next.line_count = lines + countNewlines(complete);
|
|
535
|
+
const nextTail = appendTail(previousTail, complete);
|
|
536
|
+
Object.assign(next, tailFields(next.bytes_ingested, nextTail));
|
|
537
|
+
next.session_id = session.id;
|
|
538
|
+
const saved = db.commitIngest(session, records, next, append, reachedEof, { diagnostics });
|
|
539
|
+
work.historicalRecordsHashed += saved.historicalRecordsHashed;
|
|
540
|
+
if (saved.deferred) {
|
|
541
|
+
return warning({ kind: "deferred", sessionId: session.id },
|
|
542
|
+
`Append requires cold rebuild (${saved.deferReason ?? "non_append_change"}); live history was preserved.`);
|
|
543
|
+
}
|
|
544
|
+
if (saved.staged) return { kind: "unchanged", sessionId: session.id };
|
|
545
|
+
return finishEmbedding(db, session, records, {
|
|
546
|
+
kind: saved.inserted || saved.updated ? "imported" :
|
|
547
|
+
(append ? (saved.appended > 0 ? "appended" : "unchanged") : "deduped"),
|
|
548
|
+
sessionId: session.id,
|
|
549
|
+
}, options.embed);
|
|
550
|
+
} finally {
|
|
551
|
+
closeSync(fd);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function* jsonlObjects(buffer: Buffer): Generator<{ value: Record<string, unknown>; start: number; end: number }> {
|
|
556
|
+
let start = 0;
|
|
557
|
+
for (let end = buffer.indexOf(0x0a); end >= 0; end = buffer.indexOf(0x0a, start)) {
|
|
558
|
+
try {
|
|
559
|
+
const value = JSON.parse(buffer.subarray(start, end).toString("utf8"));
|
|
560
|
+
if (value && typeof value === "object" && !Array.isArray(value)) yield { value, start, end };
|
|
561
|
+
} catch { /* Trajectory reports malformed complete lines as recoverable diagnostics. */ }
|
|
562
|
+
start = end + 1;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Resolve only format flags and native grouping; Trajectory still owns all event normalization.
|
|
567
|
+
function classifyClaude(
|
|
568
|
+
fd: number,
|
|
569
|
+
size: number,
|
|
570
|
+
budget: number,
|
|
571
|
+
recordLimit: number,
|
|
572
|
+
work: IngestWorkMeter,
|
|
573
|
+
): ClaudeClassification | string {
|
|
574
|
+
let agentId: string | undefined;
|
|
575
|
+
let sessionId: string | undefined;
|
|
576
|
+
let ambiguousAgent = false;
|
|
577
|
+
let ambiguousSession = false;
|
|
578
|
+
let ordinary = false;
|
|
579
|
+
for (let offset = 0; offset < size;) {
|
|
580
|
+
const buffer = readChunk(fd, offset, size, budget, recordLimit, work);
|
|
581
|
+
const consumed = buffer.lastIndexOf(0x0a) + 1;
|
|
582
|
+
if (!consumed) {
|
|
583
|
+
if (offset + buffer.length < size) return `Claude classification requires a line exceeding maxRecordBytes=${recordLimit} at byte ${offset}; no bytes skipped.`;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
for (const { value } of jsonlObjects(buffer.subarray(0, consumed))) {
|
|
587
|
+
const conversational = (value.type === "user" || value.type === "assistant") &&
|
|
588
|
+
value.message && typeof value.message === "object" && !Array.isArray(value.message);
|
|
589
|
+
if (!conversational) continue;
|
|
590
|
+
if (value.isSidechain !== true) {
|
|
591
|
+
ordinary = true;
|
|
592
|
+
if (typeof value.sessionId === "string" && value.sessionId) return { mode: "parent", groupId: value.sessionId };
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (typeof value.agentId === "string" && value.agentId) {
|
|
596
|
+
ambiguousAgent ||= agentId !== undefined && agentId !== value.agentId;
|
|
597
|
+
agentId ??= value.agentId;
|
|
598
|
+
}
|
|
599
|
+
if (typeof value.sessionId === "string" && value.sessionId) {
|
|
600
|
+
ambiguousSession ||= sessionId !== undefined && sessionId !== value.sessionId;
|
|
601
|
+
sessionId ??= value.sessionId;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
offset += consumed;
|
|
605
|
+
}
|
|
606
|
+
const groupId = !ambiguousAgent && (agentId || (!ambiguousSession && sessionId));
|
|
607
|
+
if (!ordinary && groupId) return { mode: "standalone", groupId };
|
|
608
|
+
return "Claude source classification has no unambiguous native group yet; no bytes skipped.";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function maskClaudeSidechains(original: Buffer): Buffer {
|
|
612
|
+
let masked: Buffer | undefined;
|
|
613
|
+
for (const { value, start, end } of jsonlObjects(original)) {
|
|
614
|
+
if (value.isSidechain !== true) continue;
|
|
615
|
+
masked ??= Buffer.from(original);
|
|
616
|
+
masked.fill(0x20, start, original[end - 1] === 0x0d ? end - 1 : end);
|
|
617
|
+
}
|
|
618
|
+
return masked ?? original;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function readChunk(
|
|
622
|
+
fd: number,
|
|
623
|
+
offset: number,
|
|
624
|
+
size: number,
|
|
625
|
+
budget: number,
|
|
626
|
+
recordLimit: number,
|
|
627
|
+
work: IngestWorkMeter,
|
|
628
|
+
): Buffer {
|
|
629
|
+
const first = Buffer.alloc(Math.min(budget, size - offset));
|
|
630
|
+
readExactly(fd, first, offset, work);
|
|
631
|
+
if (first.includes(0x0a) || offset + first.length === size) return first;
|
|
632
|
+
const pieces = [first];
|
|
633
|
+
let length = first.length;
|
|
634
|
+
while (length < recordLimit && offset + length < size) {
|
|
635
|
+
const piece = Buffer.alloc(Math.min(HASH_BUFFER_BYTES, recordLimit - length, size - offset - length));
|
|
636
|
+
readExactly(fd, piece, offset + length, work);
|
|
637
|
+
const newline = piece.indexOf(0x0a);
|
|
638
|
+
const used = newline < 0 ? piece : piece.subarray(0, newline + 1);
|
|
639
|
+
pieces.push(used);
|
|
640
|
+
length += used.length;
|
|
641
|
+
if (newline >= 0) break;
|
|
642
|
+
}
|
|
643
|
+
return Buffer.concat(pieces, length);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function countNewlines(buffer: Buffer): number {
|
|
647
|
+
let lines = 0;
|
|
648
|
+
for (let at = buffer.indexOf(0x0a); at >= 0; at = buffer.indexOf(0x0a, at + 1)) lines++;
|
|
649
|
+
return lines;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function readExactly(fd: number, buffer: Buffer, offset: number, work?: IngestWorkMeter): void {
|
|
653
|
+
let read = 0;
|
|
654
|
+
while (read < buffer.length) {
|
|
655
|
+
const requested = Math.min(buffer.length - read, work ? HASH_BUFFER_BYTES : buffer.length - read);
|
|
656
|
+
work?.prepareRead(requested);
|
|
657
|
+
const count = readSync(fd, buffer, read, requested, offset + read);
|
|
658
|
+
if (count === 0) throw new Error("Source shortened during snapshot read; checkpoint unchanged");
|
|
659
|
+
read += count;
|
|
660
|
+
work?.read(count);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function sha256(parts: Array<string | Buffer>): string {
|
|
665
|
+
const hash = createHash("sha256");
|
|
666
|
+
for (const part of parts) hash.update(part);
|
|
667
|
+
return hash.digest("hex");
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function initialSourceCheckpoint(): string {
|
|
671
|
+
return sha256([`ledger-source-checkpoint:v${APPEND_CHECKPOINT_VERSION}\n`]);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function extendSourceCheckpoint(previous: string, complete: Buffer, work: IngestWorkMeter): string {
|
|
675
|
+
if (!/^[a-f0-9]{64}$/.test(previous)) throw new Error("Invalid prior source checkpoint");
|
|
676
|
+
let state = previous;
|
|
677
|
+
let start = 0;
|
|
678
|
+
for (let end = complete.indexOf(0x0a); end >= 0; end = complete.indexOf(0x0a, start)) {
|
|
679
|
+
const line = complete.subarray(start, end + 1);
|
|
680
|
+
state = sha256([
|
|
681
|
+
`ledger-source-line:v${APPEND_CHECKPOINT_VERSION}\n`,
|
|
682
|
+
Buffer.from(state, "hex"),
|
|
683
|
+
"\n",
|
|
684
|
+
line,
|
|
685
|
+
]);
|
|
686
|
+
work.hashed(line.length);
|
|
687
|
+
start = end + 1;
|
|
688
|
+
}
|
|
689
|
+
if (start !== complete.length) throw new Error("Source checkpoint requires complete JSONL lines");
|
|
690
|
+
return state;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function hasAppendCheckpoint(cursor: IngestCursor): boolean {
|
|
694
|
+
const expectedTailLength = Math.min(APPEND_TAIL_BYTES, cursor.bytes_ingested);
|
|
695
|
+
return cursor.checkpoint_version === APPEND_CHECKPOINT_VERSION &&
|
|
696
|
+
Number.isSafeInteger(cursor.line_count) && cursor.line_count! >= 0 &&
|
|
697
|
+
typeof cursor.prefix_hash === "string" && /^[a-f0-9]{64}$/.test(cursor.prefix_hash) &&
|
|
698
|
+
cursor.tail_offset === cursor.bytes_ingested - expectedTailLength &&
|
|
699
|
+
typeof cursor.tail_hash === "string" && /^[a-f0-9]{64}$/.test(cursor.tail_hash);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function verifiedAppendCheckpoint(
|
|
703
|
+
fd: number,
|
|
704
|
+
cursor: IngestCursor,
|
|
705
|
+
work: IngestWorkMeter,
|
|
706
|
+
): { tail: Buffer } | null {
|
|
707
|
+
if (!hasAppendCheckpoint(cursor)) return null;
|
|
708
|
+
const length = cursor.bytes_ingested - cursor.tail_offset!;
|
|
709
|
+
const tail = Buffer.alloc(length);
|
|
710
|
+
try { readExactly(fd, tail, cursor.tail_offset!, work); }
|
|
711
|
+
catch { return null; }
|
|
712
|
+
return sha256([tail]) === cursor.tail_hash ? { tail } : null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function appendTail(previous: Buffer, complete: Buffer): Buffer {
|
|
716
|
+
if (complete.length >= APPEND_TAIL_BYTES) {
|
|
717
|
+
return Buffer.from(complete.subarray(complete.length - APPEND_TAIL_BYTES));
|
|
718
|
+
}
|
|
719
|
+
const keep = Math.min(previous.length, APPEND_TAIL_BYTES - complete.length);
|
|
720
|
+
return Buffer.concat([previous.subarray(previous.length - keep), complete], keep + complete.length);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function tailFields(offset: number, tail: Buffer): Pick<IngestCursor, "tail_offset" | "tail_hash"> {
|
|
724
|
+
if (offset === 0) return { tail_offset: null, tail_hash: null };
|
|
725
|
+
if (tail.length !== Math.min(APPEND_TAIL_BYTES, offset)) throw new Error("Invalid append continuity tail");
|
|
726
|
+
return { tail_offset: offset - tail.length, tail_hash: sha256([tail]) };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function deferForRebuild(
|
|
730
|
+
db: TrajectoryDb,
|
|
731
|
+
item: DiscoveredSession,
|
|
732
|
+
cursor: IngestCursor,
|
|
733
|
+
snapshot: SourceFingerprint,
|
|
734
|
+
message: string,
|
|
735
|
+
): IngestResult {
|
|
736
|
+
db.deferIngestRebuild(makeCursor(item, snapshot, {
|
|
737
|
+
session_id: cursor.session_id,
|
|
738
|
+
bytes_ingested: 0,
|
|
739
|
+
record_count: 0,
|
|
740
|
+
status: "pending",
|
|
741
|
+
prefix_hash: null,
|
|
742
|
+
checkpoint_version: APPEND_CHECKPOINT_VERSION,
|
|
743
|
+
line_count: 0,
|
|
744
|
+
tail_offset: null,
|
|
745
|
+
tail_hash: null,
|
|
746
|
+
}));
|
|
747
|
+
return warning({ kind: "deferred", sessionId: cursor.session_id ?? item.id }, message);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function snapshotStillCurrent(fd: number, item: DiscoveredSession, snapshot: SourceFingerprint): boolean {
|
|
751
|
+
try {
|
|
752
|
+
const pathStat = statSync(item.path, { bigint: true });
|
|
753
|
+
return pathStat.isFile() && sameSnapshot(snapshot, fingerprintFromStat(fstatSync(fd, { bigint: true }))) &&
|
|
754
|
+
sameSnapshot(snapshot, fingerprintFromStat(pathStat));
|
|
755
|
+
} catch { return false; }
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function cursorStillCurrent(db: TrajectoryDb, item: DiscoveredSession, expected: IngestCursor | null): boolean {
|
|
759
|
+
return JSON.stringify(db.getIngestCursor(item.source, item.id, item.path)) === JSON.stringify(expected);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function warning(result: IngestResult, message: string): IngestResult {
|
|
763
|
+
console.warn(`[ledger ingest] ${message}`);
|
|
764
|
+
return { ...result, warnings: [...(result.warnings ?? []), message] };
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function changedDuringRead(item: DiscoveredSession): IngestResult {
|
|
768
|
+
return warning({ kind: "skipped", sessionId: item.id }, "Source or cursor changed during ingestion; snapshot discarded for retry.");
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function finishEmbedding(
|
|
772
|
+
db: TrajectoryDb, session: SessionRecord, records: CanonicalDbRecord[], result: IngestResult, embed?: boolean,
|
|
773
|
+
): Promise<IngestResult> {
|
|
774
|
+
if (embed && records.length > 0) {
|
|
775
|
+
try { await db.embedSessionRecords(session.id, records); }
|
|
776
|
+
catch { return warning(result, "Embedding failed after the lexical checkpoint committed; use embedding backfill to retry."); }
|
|
777
|
+
}
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function makeCursor(
|
|
782
|
+
item: DiscoveredSession, fingerprint: SourceFingerprint,
|
|
783
|
+
fields: Pick<IngestCursor, "session_id" | "bytes_ingested" | "record_count" | "status"> &
|
|
784
|
+
Partial<Pick<IngestCursor, "prefix_hash" | "checkpoint_version" | "line_count" | "tail_offset" | "tail_hash">>,
|
|
785
|
+
): IngestCursor {
|
|
786
|
+
return {
|
|
787
|
+
source: item.source, listing_id: item.id, source_path: item.path,
|
|
788
|
+
mtime: fingerprint.mtime, size: fingerprint.size, file_identity: fingerprint.file_identity ?? null,
|
|
789
|
+
updated_at: new Date().toISOString(), ...fields,
|
|
790
|
+
};
|
|
791
|
+
}
|