throughline 0.6.0 → 0.6.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 +13 -0
- package/README.md +32 -3
- package/bin/throughline.mjs +10 -0
- package/docs/12_desktop_clear_handoff_plan.md +3 -3
- package/package.json +1 -1
- package/src/auditor-context.mjs +330 -0
- package/src/auditor-context.test.mjs +296 -0
- package/src/cli/auditor-context.mjs +141 -0
- package/src/cli/auditor-context.test.mjs +148 -0
- package/src/cli/codex-hook.mjs +20 -0
- package/src/codex-thread-index.mjs +11 -2
- package/src/phase0-spotter-contract.test.mjs +280 -0
- package/src/turn-backfill.test.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,19 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.6.1] — 2026-07-13
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Spotter auditor context v1.** `throughline auditor-context` adds an
|
|
18
|
+
opt-in, JSON-only, read-only projection for Spotter. It verifies the exact
|
|
19
|
+
session/project and the latest completed L2 user/assistant pair against an
|
|
20
|
+
origin/turn/SHA-256 freshness expectation, supplied explicitly or derived
|
|
21
|
+
from a Claude JSONL or Codex rollout. Only `fresh` returns bounded pair
|
|
22
|
+
bodies; all other states return no bodies. The command never creates,
|
|
23
|
+
migrates, or writes the Throughline DB. Opt-in and any onward transmission
|
|
24
|
+
remain Spotter responsibilities.
|
|
25
|
+
|
|
13
26
|
## [0.6.0] — 2026-07-12
|
|
14
27
|
|
|
15
28
|
L2 capture is rebuilt from "save only the last pair each Stop" to a
|
package/README.md
CHANGED
|
@@ -109,7 +109,7 @@ Anthropic API usage from the transcript JSONL (no `length / 4` heuristics).
|
|
|
109
109
|
|
|
110
110
|
---
|
|
111
111
|
|
|
112
|
-
## Three-layer memory model (schema
|
|
112
|
+
## Three-layer memory model (schema v8)
|
|
113
113
|
|
|
114
114
|
```mermaid
|
|
115
115
|
flowchart LR
|
|
@@ -789,6 +789,35 @@ project history.
|
|
|
789
789
|
|
|
790
790
|
---
|
|
791
791
|
|
|
792
|
+
## Spotter auditor context (read-only)
|
|
793
|
+
|
|
794
|
+
`throughline auditor-context` is a **Spotter-only, opt-in read-only
|
|
795
|
+
projection** for an auditor. It does not create, migrate, or write the
|
|
796
|
+
Throughline database. The caller must name an exact `--session` and `--project`;
|
|
797
|
+
the stored session must belong to that project root (or one of its descendants).
|
|
798
|
+
|
|
799
|
+
It returns only completed L2 user/assistant pairs—never L1 summaries, L3 tool
|
|
800
|
+
details, developer messages, or an in-flight Codex turn. Freshness is checked
|
|
801
|
+
against the latest completed pair by origin session, turn number, and normalized
|
|
802
|
+
SHA-256 hashes. Supply that expectation either explicitly, or derive it from a
|
|
803
|
+
Claude JSONL / Codex rollout with `--host claude|codex --transcript`; the two
|
|
804
|
+
sources are mutually exclusive. Returned context is bounded by `--recent-turns`
|
|
805
|
+
(default 2), `--max-body-chars` (1,200), and `--max-total-chars` (4,000).
|
|
806
|
+
|
|
807
|
+
The command is JSON-only. `fresh` is the only status that contains pair bodies;
|
|
808
|
+
`empty`, `stale`, `session_mismatch`, `unavailable`, and `schema_mismatch`
|
|
809
|
+
return no bodies and still exit successfully. Argument and internal errors are
|
|
810
|
+
fixed JSON errors on stderr with a non-zero exit. Spotter owns the
|
|
811
|
+
decision to opt in and any onward transmission of this local projection;
|
|
812
|
+
Throughline only reads and projects it.
|
|
813
|
+
|
|
814
|
+
```bash
|
|
815
|
+
throughline auditor-context --session claude-session-id --project "$PWD" \
|
|
816
|
+
--host claude --transcript "$HOME/.claude/projects/.../session.jsonl" --json
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
---
|
|
820
|
+
|
|
792
821
|
## Requirements
|
|
793
822
|
|
|
794
823
|
- **Node.js >= 22.5** (for the built-in `node:sqlite` module — no native build
|
|
@@ -812,7 +841,7 @@ plain `.mjs` files.
|
|
|
812
841
|
└── <session_id>.json Per-session activity state for the monitor
|
|
813
842
|
```
|
|
814
843
|
|
|
815
|
-
Schema
|
|
844
|
+
Schema v8:
|
|
816
845
|
|
|
817
846
|
- `sessions` — one row per `session_id`, with `project_path` and `merged_into`
|
|
818
847
|
- `skeletons` — L1 one-liners, keyed by `(session_id, origin_session_id, turn, role)`
|
|
@@ -970,7 +999,7 @@ unchanged here.
|
|
|
970
999
|
|
|
971
1000
|
**Database got corrupted / want a clean slate**
|
|
972
1001
|
Delete `~/.throughline/throughline.db` (and the `-shm` / `-wal` companion files)
|
|
973
|
-
and `~/.throughline/state/*.json`. A fresh database with schema
|
|
1002
|
+
and `~/.throughline/state/*.json`. A fresh database with schema v8 is created on
|
|
974
1003
|
the next hook fire.
|
|
975
1004
|
|
|
976
1005
|
**New session didn't inherit memory from the previous one**
|
package/bin/throughline.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* throughline session-start # SessionStart hook (Claude Code から呼ばれる)
|
|
11
11
|
* throughline detail <時刻> # L2+L3 詳細取得 (Claude が Bash 経由で呼ぶ想定)
|
|
12
12
|
* throughline handoff-preview # Codex-facing throughline_handoff JSON preview
|
|
13
|
+
* throughline auditor-context --json # Read-only bounded auditor context JSON
|
|
13
14
|
* throughline codex-capture # Capture active Codex rollout turns into Throughline DB
|
|
14
15
|
* throughline codex-hook user-prompt-submit # Codex current-session auto-refresh prompt hook
|
|
15
16
|
* throughline codex-hook post-tool-use # Codex current-session auto-refresh tool-loop hook
|
|
@@ -65,6 +66,11 @@ switch (cmd) {
|
|
|
65
66
|
case 'handoff-preview':
|
|
66
67
|
await (await import('../src/cli/handoff-preview.mjs')).run(rest);
|
|
67
68
|
break;
|
|
69
|
+
case 'auditor-context': {
|
|
70
|
+
const exitCode = (await import('../src/cli/auditor-context.mjs')).run(rest);
|
|
71
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
68
74
|
case 'codex-capture':
|
|
69
75
|
await (await import('../src/cli/codex-capture.mjs')).run(rest);
|
|
70
76
|
break;
|
|
@@ -149,6 +155,10 @@ Usage:
|
|
|
149
155
|
throughline monitor Multi-session token monitor (use --all, --session <id>)
|
|
150
156
|
throughline detail <time> Retrieve L2+L3 detail for a turn (e.g. 14:23:05 or 14:23-14:30)
|
|
151
157
|
throughline handoff-preview Print Codex-facing throughline_handoff JSON
|
|
158
|
+
throughline auditor-context --session <id> --project <root>
|
|
159
|
+
Read only bounded completed user/assistant context
|
|
160
|
+
for an auditor; requires either --host plus --transcript,
|
|
161
|
+
or explicit pair identity/hashes; always requires --json
|
|
152
162
|
throughline codex-capture Capture active Codex rollout turns into DB
|
|
153
163
|
(requires --codex-thread-id or env thread id)
|
|
154
164
|
throughline codex-hook user-prompt-submit
|
|
@@ -50,7 +50,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
|
|
|
50
50
|
|
|
51
51
|
### 3. 案A(startup 時間窓フォールバック)不採用の根拠
|
|
52
52
|
|
|
53
|
-
- 幽霊セッション: 2026-07-11 だけで project_path
|
|
53
|
+
- 幽霊セッション: 2026-07-11 だけで user home直下のproject_pathのstartupが **182 件**(03:02〜12:56、最短間隔 0.001 秒、全て bodies=0)。haiku-workdir に 207 件=headless `claude -p` も SessionStart hook を発火する。
|
|
54
54
|
- 幽霊がチェーンに入ると MAX_CHAIN_DEPTH=10([src/session-merger.mjs](../src/session-merger.mjs):14)へ数時間で到達し resolveMergeTarget throw → ターン捕捉が恒久停止=ここで記憶が本当に失われる。
|
|
55
55
|
- source='startup' は「/clear の後継」と「並行して開いた別窓」を原理的に区別できず、稼働中セッションのレコードを relabel して記憶を split する。「bodies>0 の前任だけ選ぶ」は前任側フィルタなので無効(refuter 検証済み)。
|
|
56
56
|
|
|
@@ -90,7 +90,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
|
|
|
90
90
|
- [x] 本プランを docs/12 として正本化(本文書)
|
|
91
91
|
- [x] rag/01-hooks に SessionEnd reason enum を還流([session-end-reasons.md](../rag/01-hooks/raw/session-end-reasons.md))、rag/INDEX.md に Finding 8 追記
|
|
92
92
|
- [x] 今日の調査を caveat に記録: public `claude-code-clear-userpromptsubmit-hook`(confirmed)/ private `claude-code-desktop-assistant-transcript-jsonl`(tentative・B-2 で更新)
|
|
93
|
-
- [x] 実稼働デプロイ(2026-07-12):
|
|
93
|
+
- [x] 実稼働デプロイ(2026-07-12): ローカルcheckoutをglobal install(symlink化=リポ変更が即時反映。リリース時はregistry版へ戻す)
|
|
94
94
|
|
|
95
95
|
## Workstream B-1 — 捕捉のバックフィル化(先行。A の E2E 品質の前提。挙動修正レーン=挙動差を明文化して個別承認)
|
|
96
96
|
|
|
@@ -117,7 +117,7 @@ docs 整合: ビルトインコマンドは UserPromptSubmit(prompt 送信時
|
|
|
117
117
|
| Desktop セッション削除(675493fb) | 発火(12 秒後) | **`other`** | payload 構造は /clear と完全同一 |
|
|
118
118
|
| VSCode `/clear`(fa43271f) | 即時 | **`clear`** | 42ms 後に後継 SessionStart(source=clear)→auto merge。仕組み自体は健全 |
|
|
119
119
|
|
|
120
|
-
副次発見: Desktop
|
|
120
|
+
副次発見: Desktop のuser home直下の幽霊セッションも SessionEnd(other) を高頻度で発火する。
|
|
121
121
|
- [x] 判定: **NO-GO**。Desktop は /clear で SessionEnd を即時発火するが reason を `other` にラベルし、**セッション削除(明示的破棄)と区別不能**。reason=other でバトンを書くと削除セッションの記憶が次セッションに蘇る誤注入 + 幽霊バトン汚染。reason 不問の退行案は不採用(計画どおり)。→ A Phase 2 は実装せず停止、fallback 裁定へ
|
|
122
122
|
- [x] spike 撤去: settings.json から SessionEnd 登録を削除(JSON 検証済み)、spike ファイル削除(git 履歴に残存)。実測ログ `~/.throughline/logs/session-end-spike.log` は証拠として保全
|
|
123
123
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { buildBodyRowsFromActiveTurns } from './codex-capture.mjs';
|
|
7
|
+
import { parseCodexRolloutFile } from './codex-rollout-memory.mjs';
|
|
8
|
+
import { getLogicalTurnGroups } from './transcript-reader.mjs';
|
|
9
|
+
|
|
10
|
+
export const AUDITOR_CONTEXT_SCHEMA = 'throughline.auditor_context.v1';
|
|
11
|
+
export const AUDITOR_CONTEXT_DB_SCHEMA_VERSION = 8;
|
|
12
|
+
export const DEFAULT_AUDITOR_RECENT_TURNS = 2;
|
|
13
|
+
export const DEFAULT_AUDITOR_MAX_BODY_CHARS = 1200;
|
|
14
|
+
export const DEFAULT_AUDITOR_MAX_TOTAL_CHARS = 4000;
|
|
15
|
+
|
|
16
|
+
export function defaultAuditorContextDbPath() {
|
|
17
|
+
return join(homedir(), '.throughline', 'throughline.db');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeAuditorBody(value) {
|
|
21
|
+
return String(value ?? '').normalize('NFC').replace(/\r\n?/g, '\n').trim();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hashAuditorBody(value) {
|
|
25
|
+
return createHash('sha256').update(normalizeAuditorBody(value), 'utf8').digest('hex');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function deriveAuditorFreshnessExpectation({ host, transcriptPath, sessionId } = {}) {
|
|
29
|
+
assertNonEmptyString(transcriptPath, 'transcriptPath');
|
|
30
|
+
assertNonEmptyString(sessionId, 'sessionId');
|
|
31
|
+
if (host === 'claude') {
|
|
32
|
+
const latest = getLogicalTurnGroups(transcriptPath).at(-1);
|
|
33
|
+
if (!latest) return null;
|
|
34
|
+
return {
|
|
35
|
+
expectedOriginSessionId: sessionId,
|
|
36
|
+
expectedTurnNumber: latest.representative.index,
|
|
37
|
+
expectedUserSha256: hashAuditorBody(latest.user.content),
|
|
38
|
+
expectedAssistantSha256: hashAuditorBody(latest.representative.content),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (host === 'codex') {
|
|
42
|
+
const parsed = parseCodexRolloutFile(transcriptPath, { includeInFlightTurn: false });
|
|
43
|
+
const rows = buildBodyRowsFromActiveTurns(parsed.activeTurns, { sessionId, now: 0 });
|
|
44
|
+
const latestTurnNumber = rows.reduce((max, row) => Math.max(max, row.turnNumber), 0);
|
|
45
|
+
if (latestTurnNumber < 1) return null;
|
|
46
|
+
const user = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'user');
|
|
47
|
+
const assistant = rows.find((row) => row.turnNumber === latestTurnNumber && row.role === 'assistant');
|
|
48
|
+
if (!user || !assistant) return null;
|
|
49
|
+
return {
|
|
50
|
+
expectedOriginSessionId: sessionId,
|
|
51
|
+
// Codex active turns are rebuilt after rollback/in-flight filtering. Their ordinal can
|
|
52
|
+
// shift between Stop capture and the next UserPromptSubmit, so it is not a stable identity.
|
|
53
|
+
// Exact session/origin plus both normalized pair hashes remain mandatory below.
|
|
54
|
+
expectedTurnNumber: null,
|
|
55
|
+
expectedUserSha256: hashAuditorBody(user.text),
|
|
56
|
+
expectedAssistantSha256: hashAuditorBody(assistant.text),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
throw new TypeError('host must be claude or codex');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function readAuditorContext({
|
|
63
|
+
dbPath = defaultAuditorContextDbPath(),
|
|
64
|
+
sessionId,
|
|
65
|
+
projectRoot,
|
|
66
|
+
expectedOriginSessionId,
|
|
67
|
+
expectedTurnNumber,
|
|
68
|
+
expectedUserSha256,
|
|
69
|
+
expectedAssistantSha256,
|
|
70
|
+
recentTurns = DEFAULT_AUDITOR_RECENT_TURNS,
|
|
71
|
+
maxBodyChars = DEFAULT_AUDITOR_MAX_BODY_CHARS,
|
|
72
|
+
maxTotalChars = DEFAULT_AUDITOR_MAX_TOTAL_CHARS,
|
|
73
|
+
} = {}) {
|
|
74
|
+
assertNonEmptyString(sessionId, 'sessionId');
|
|
75
|
+
assertNonEmptyString(projectRoot, 'projectRoot');
|
|
76
|
+
assertPositiveInteger(recentTurns, 'recentTurns');
|
|
77
|
+
assertPositiveInteger(maxBodyChars, 'maxBodyChars');
|
|
78
|
+
assertPositiveInteger(maxTotalChars, 'maxTotalChars');
|
|
79
|
+
|
|
80
|
+
if (!existsSync(dbPath)) {
|
|
81
|
+
return emptyResult('unavailable', 'db_not_found', { sessionId, projectRoot, recentTurns });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let db;
|
|
85
|
+
try {
|
|
86
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_DB_OPEN', 'auditor context DB could not be opened', { cause });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const version = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
93
|
+
if (version !== AUDITOR_CONTEXT_DB_SCHEMA_VERSION) {
|
|
94
|
+
return emptyResult('schema_mismatch', 'unsupported_db_schema', {
|
|
95
|
+
sessionId,
|
|
96
|
+
projectRoot,
|
|
97
|
+
recentTurns,
|
|
98
|
+
dbSchemaVersion: version,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const session = db.prepare(
|
|
103
|
+
`SELECT session_id, project_path
|
|
104
|
+
FROM sessions
|
|
105
|
+
WHERE session_id = ?`,
|
|
106
|
+
).get(sessionId);
|
|
107
|
+
if (!session) {
|
|
108
|
+
return emptyResult('empty', 'session_not_found', { sessionId, projectRoot, recentTurns });
|
|
109
|
+
}
|
|
110
|
+
if (!isSameProjectOrDescendant(session.project_path, projectRoot)) {
|
|
111
|
+
return emptyResult('session_mismatch', 'project_mismatch', { sessionId, projectRoot, recentTurns });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rows = db.prepare(
|
|
115
|
+
`SELECT id, origin_session_id, turn_number, role, text, created_at
|
|
116
|
+
FROM bodies
|
|
117
|
+
WHERE session_id = ? AND role IN ('user', 'assistant')
|
|
118
|
+
ORDER BY created_at ASC, id ASC`,
|
|
119
|
+
).all(sessionId);
|
|
120
|
+
const completed = buildCompletedPairs(rows);
|
|
121
|
+
if (completed.length === 0) {
|
|
122
|
+
return emptyResult('empty', 'completed_pair_not_found', { sessionId, projectRoot, recentTurns });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const latest = completed.at(-1);
|
|
126
|
+
const stableTurnIdentityAvailable = Number.isInteger(expectedTurnNumber) && expectedTurnNumber >= 0;
|
|
127
|
+
const codexPairIdentity = sessionId.startsWith('codex:') && expectedOriginSessionId === sessionId;
|
|
128
|
+
const expectationComplete =
|
|
129
|
+
typeof expectedOriginSessionId === 'string' && expectedOriginSessionId.length > 0 &&
|
|
130
|
+
(stableTurnIdentityAvailable || codexPairIdentity) &&
|
|
131
|
+
isSha256(expectedUserSha256) && isSha256(expectedAssistantSha256);
|
|
132
|
+
if (!expectationComplete) {
|
|
133
|
+
return emptyResult('stale', 'freshness_expectation_incomplete', { sessionId, projectRoot, recentTurns });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const expectedUserHash = expectedUserSha256.toLowerCase();
|
|
137
|
+
const expectedAssistantHash = expectedAssistantSha256.toLowerCase();
|
|
138
|
+
const matchedIndex = stableTurnIdentityAvailable
|
|
139
|
+
? completed.length - 1
|
|
140
|
+
: completed.findLastIndex((pair) =>
|
|
141
|
+
pair.originSessionId === expectedOriginSessionId &&
|
|
142
|
+
hashAuditorBody(pair.user) === expectedUserHash &&
|
|
143
|
+
hashAuditorBody(pair.assistant) === expectedAssistantHash);
|
|
144
|
+
const matched = matchedIndex >= 0 ? completed[matchedIndex] : latest;
|
|
145
|
+
const identityMatched = matchedIndex >= 0 && matched.originSessionId === expectedOriginSessionId &&
|
|
146
|
+
(!stableTurnIdentityAvailable || matched.turnNumber === expectedTurnNumber);
|
|
147
|
+
const userMatched = matchedIndex >= 0 && hashAuditorBody(matched.user) === expectedUserHash;
|
|
148
|
+
const assistantMatched = matchedIndex >= 0 && hashAuditorBody(matched.assistant) === expectedAssistantHash;
|
|
149
|
+
if (!identityMatched || !userMatched || !assistantMatched) {
|
|
150
|
+
return emptyResult('stale', 'latest_pair_mismatch', { sessionId, projectRoot, recentTurns });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const stableCompleted = completed.slice(0, matchedIndex + 1);
|
|
154
|
+
const bounded = boundCompletedPairs(stableCompleted.slice(-recentTurns), { maxBodyChars, maxTotalChars });
|
|
155
|
+
return {
|
|
156
|
+
schema: AUDITOR_CONTEXT_SCHEMA,
|
|
157
|
+
status: 'fresh',
|
|
158
|
+
reason: 'latest_pair_matched',
|
|
159
|
+
sessionId,
|
|
160
|
+
projectPath: canonicalProjectPath(projectRoot),
|
|
161
|
+
source: 'throughline-db-l2',
|
|
162
|
+
freshness: {
|
|
163
|
+
originSessionId: matched.originSessionId,
|
|
164
|
+
turnNumber: matched.turnNumber,
|
|
165
|
+
identityMatched: true,
|
|
166
|
+
userMatched: true,
|
|
167
|
+
assistantMatched: true,
|
|
168
|
+
},
|
|
169
|
+
turns: bounded.turns,
|
|
170
|
+
stats: {
|
|
171
|
+
requestedTurns: recentTurns,
|
|
172
|
+
returnedTurns: bounded.turns.length,
|
|
173
|
+
chars: bounded.chars,
|
|
174
|
+
truncated: bounded.truncated,
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
} catch (cause) {
|
|
178
|
+
if (cause instanceof AuditorContextError) throw cause;
|
|
179
|
+
throw new AuditorContextError('E_AUDITOR_CONTEXT_QUERY', 'auditor context query failed', { cause });
|
|
180
|
+
} finally {
|
|
181
|
+
db.close();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export class AuditorContextError extends Error {
|
|
186
|
+
constructor(code, message, { cause } = {}) {
|
|
187
|
+
super(message, { cause });
|
|
188
|
+
this.name = 'AuditorContextError';
|
|
189
|
+
this.code = code;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildCompletedPairs(rows) {
|
|
194
|
+
const grouped = new Map();
|
|
195
|
+
for (const row of rows) {
|
|
196
|
+
if (!row?.origin_session_id || !Number.isInteger(row.turn_number)) continue;
|
|
197
|
+
const key = `${row.origin_session_id}\u0000${row.turn_number}`;
|
|
198
|
+
const pair = grouped.get(key) ?? {
|
|
199
|
+
originSessionId: row.origin_session_id,
|
|
200
|
+
turnNumber: row.turn_number,
|
|
201
|
+
user: null,
|
|
202
|
+
assistant: null,
|
|
203
|
+
createdAt: Number(row.created_at) || 0,
|
|
204
|
+
lastId: Number(row.id) || 0,
|
|
205
|
+
};
|
|
206
|
+
if (row.role === 'user' && pair.user === null) pair.user = normalizeAuditorBody(row.text);
|
|
207
|
+
if (row.role === 'assistant' && pair.assistant === null) pair.assistant = normalizeAuditorBody(row.text);
|
|
208
|
+
pair.createdAt = Math.max(pair.createdAt, Number(row.created_at) || 0);
|
|
209
|
+
pair.lastId = Math.max(pair.lastId, Number(row.id) || 0);
|
|
210
|
+
grouped.set(key, pair);
|
|
211
|
+
}
|
|
212
|
+
return [...grouped.values()]
|
|
213
|
+
.filter((pair) => pair.user !== null && pair.assistant !== null)
|
|
214
|
+
.sort((a, b) => a.createdAt - b.createdAt || a.lastId - b.lastId)
|
|
215
|
+
.map(({ lastId: _lastId, ...pair }) => pair);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function boundCompletedPairs(pairs, { maxBodyChars, maxTotalChars }) {
|
|
219
|
+
const prepared = pairs.map((pair) => {
|
|
220
|
+
const user = tail(pair.user, maxBodyChars);
|
|
221
|
+
const assistant = tail(pair.assistant, maxBodyChars);
|
|
222
|
+
return {
|
|
223
|
+
...pair,
|
|
224
|
+
user,
|
|
225
|
+
assistant,
|
|
226
|
+
truncated: user.length < pair.user.length || assistant.length < pair.assistant.length,
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const selected = [];
|
|
231
|
+
let chars = 0;
|
|
232
|
+
let truncated = prepared.some((pair) => pair.truncated);
|
|
233
|
+
for (let i = prepared.length - 1; i >= 0; i--) {
|
|
234
|
+
const pair = prepared[i];
|
|
235
|
+
const remaining = maxTotalChars - chars;
|
|
236
|
+
if (remaining <= 0) {
|
|
237
|
+
truncated = true;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
let user = pair.user;
|
|
241
|
+
let assistant = pair.assistant;
|
|
242
|
+
if (user.length + assistant.length > remaining) {
|
|
243
|
+
const assistantBudget = Math.min(assistant.length, Math.ceil(remaining / 2));
|
|
244
|
+
const userBudget = Math.max(0, remaining - assistantBudget);
|
|
245
|
+
user = tail(user, userBudget);
|
|
246
|
+
assistant = tail(assistant, assistantBudget);
|
|
247
|
+
truncated = true;
|
|
248
|
+
}
|
|
249
|
+
if (user.length === 0 || assistant.length === 0) {
|
|
250
|
+
truncated = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
chars += user.length + assistant.length;
|
|
254
|
+
selected.unshift({
|
|
255
|
+
originSessionId: pair.originSessionId,
|
|
256
|
+
turnNumber: pair.turnNumber,
|
|
257
|
+
user,
|
|
258
|
+
assistant,
|
|
259
|
+
createdAt: pair.createdAt,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return { turns: selected, chars, truncated };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function emptyResult(status, reason, { sessionId, projectRoot, recentTurns, dbSchemaVersion } = {}) {
|
|
266
|
+
return {
|
|
267
|
+
schema: AUDITOR_CONTEXT_SCHEMA,
|
|
268
|
+
status,
|
|
269
|
+
reason,
|
|
270
|
+
sessionId,
|
|
271
|
+
projectPath: canonicalProjectPath(projectRoot),
|
|
272
|
+
source: 'throughline-db-l2',
|
|
273
|
+
freshness: {
|
|
274
|
+
identityMatched: false,
|
|
275
|
+
userMatched: false,
|
|
276
|
+
assistantMatched: false,
|
|
277
|
+
},
|
|
278
|
+
turns: [],
|
|
279
|
+
stats: {
|
|
280
|
+
requestedTurns: recentTurns,
|
|
281
|
+
returnedTurns: 0,
|
|
282
|
+
chars: 0,
|
|
283
|
+
truncated: false,
|
|
284
|
+
...(Number.isInteger(dbSchemaVersion) ? { dbSchemaVersion } : {}),
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function canonicalProjectPath(value) {
|
|
290
|
+
const raw = String(value ?? '');
|
|
291
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) {
|
|
292
|
+
return raw.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
293
|
+
}
|
|
294
|
+
let normalized = isAbsolute(raw) ? raw : resolve(raw);
|
|
295
|
+
try {
|
|
296
|
+
if (existsSync(normalized)) normalized = realpathSync.native(normalized);
|
|
297
|
+
} catch {
|
|
298
|
+
// Keep the lexical path when the filesystem cannot resolve it.
|
|
299
|
+
}
|
|
300
|
+
return normalized.split(sep).join('/').replace(/\/+$/, '');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isSameProjectOrDescendant(candidate, root) {
|
|
304
|
+
const normalizedCandidate = canonicalProjectPath(candidate);
|
|
305
|
+
const normalizedRoot = canonicalProjectPath(root);
|
|
306
|
+
const left = /^[A-Za-z]:\//.test(normalizedCandidate) ? normalizedCandidate.toLowerCase() : normalizedCandidate;
|
|
307
|
+
const right = /^[A-Za-z]:\//.test(normalizedRoot) ? normalizedRoot.toLowerCase() : normalizedRoot;
|
|
308
|
+
return left === right || left.startsWith(`${right}/`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function tail(value, maxChars) {
|
|
312
|
+
if (maxChars <= 0) return '';
|
|
313
|
+
return value.length <= maxChars ? value : value.slice(value.length - maxChars);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function isSha256(value) {
|
|
317
|
+
return typeof value === 'string' && /^[a-fA-F0-9]{64}$/.test(value);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function assertNonEmptyString(value, name) {
|
|
321
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
322
|
+
throw new TypeError(`${name} must be a non-empty string`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function assertPositiveInteger(value, name) {
|
|
327
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
328
|
+
throw new TypeError(`${name} must be an integer >= 1`);
|
|
329
|
+
}
|
|
330
|
+
}
|