throughline 0.5.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/.codex-sidecar.yml +5 -0
- package/CHANGELOG.md +60 -2
- package/README.ja.md +37 -21
- package/README.md +79 -29
- package/bin/throughline.mjs +10 -0
- package/docs/00_overview.md +34 -0
- package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
- package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
- package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
- package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
- package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
- package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
- package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
- package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
- package/docs/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +215 -0
- package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
- package/docs/archive/README.md +3 -3
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
- package/package.json +2 -1
- package/rag/01-hooks/raw/session-end-reasons.md +21 -0
- package/{docs/RAG → rag}/INDEX.md +20 -16
- package/src/auditor-context.mjs +330 -0
- package/src/auditor-context.test.mjs +296 -0
- package/src/baton.mjs +2 -2
- 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/db.mjs +2 -2
- package/src/hook-entrypoints.test.mjs +102 -0
- package/src/package-files.test.mjs +1 -0
- package/src/phase0-spotter-contract.test.mjs +280 -0
- package/src/prompt-submit.mjs +2 -2
- package/src/resume-context.mjs +1 -1
- package/src/session-merger.mjs +1 -1
- package/src/session-start.mjs +62 -3
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/state-file.mjs +1 -1
- package/src/token-monitor.mjs +1 -1
- package/src/transcript-reader.mjs +71 -0
- package/src/turn-backfill.mjs +131 -0
- package/src/turn-backfill.test.mjs +213 -0
- package/src/turn-processor.mjs +28 -40
- /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
- /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
- /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
- /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
- /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Claude Code Hooks — SessionEnd reason enum (Extract)
|
|
2
|
+
|
|
3
|
+
Source: <https://code.claude.com/docs/en/hooks> (fetched 2026-07-11)
|
|
4
|
+
確度: 公式 docs verbatim(Matcher patterns テーブルより)。実機検証は Throughline docs/12 A Phase 1 spike で実施予定。
|
|
5
|
+
|
|
6
|
+
## SessionEnd
|
|
7
|
+
|
|
8
|
+
- SessionEnd イベントの matcher は「why the session ended」でフィルタする。
|
|
9
|
+
- **reason enum**: `clear` / `resume` / `logout` / `prompt_input_exit` / `bypass_permissions_disabled` / `other`
|
|
10
|
+
- `clear` = "Session cleared with /clear command"
|
|
11
|
+
- **デフォルト timeout は 1.5 秒**で /clear にも適用される(hook が 1.5 秒を超えるとサイレント kill)→ 登録時は per-hook timeout を明示すること。
|
|
12
|
+
|
|
13
|
+
## SessionStart source(同 fetch での再確認)
|
|
14
|
+
|
|
15
|
+
- `source` enum: `"startup"`(新規)/ `"resume"` / `"clear"`(/clear 後)/ `"compact"`
|
|
16
|
+
|
|
17
|
+
## Throughline 的含意(実測とセット)
|
|
18
|
+
|
|
19
|
+
- VSCode(2.1.195〜2.1.207 実測)は /clear で `source:"clear"` を送る。Desktop(2.1.205 実測)は `source:"startup"` を送る=クライアント実装差(docs/12 §2)。
|
|
20
|
+
- ビルトイン /clear は UserPromptSubmit / UserPromptExpansion のどちらにも乗らない(UserPromptExpansion の対象は skill / custom command / mcp_prompt のみ)= /clear 検知は SessionEnd(reason='clear') が唯一の hook 経路候補。
|
|
21
|
+
- Desktop が SessionEnd(reason='clear') を実際に発火するかは未検証(2026-07-11 時点)。
|
|
@@ -7,17 +7,17 @@ This directory accumulates third-party specifications relevant to Throughline's
|
|
|
7
7
|
## Folder layout
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
|
|
10
|
+
rag/
|
|
11
11
|
├── INDEX.md (this file — synthesized findings, paths forward)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
├── 01-hooks/
|
|
13
|
+
│ ├── raw/hooks-reference-extract.md ← Claude Code hooks reference
|
|
14
|
+
│ └── raw/session-end-reasons.md ← SessionEnd reason enum + timeout 1.5s (2026-07-11 fetch)
|
|
15
|
+
├── 02-messages-api/
|
|
16
|
+
│ └── raw/messages-api-extract.md ← Anthropic Messages API spec
|
|
17
|
+
├── 03-settings/
|
|
18
|
+
│ └── raw/sessions-extract.md ← /clear, /compact, /resume behavior
|
|
19
|
+
└── 04-skills/
|
|
20
|
+
└── raw/initialUserMessage-investigation.md ← deep-dive on the killer field
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
---
|
|
@@ -33,7 +33,7 @@ docs/RAG/
|
|
|
33
33
|
### Finding 1: `additionalContext` is a system reminder, not a user message
|
|
34
34
|
|
|
35
35
|
> "Claude Code wraps the string in a system reminder and inserts it into the conversation at the point where the hook fired. Claude reads the reminder on the next model request, but it **does not appear as a chat message** in the interface."
|
|
36
|
-
> — [Hooks reference](
|
|
36
|
+
> — [Hooks reference](01-hooks/raw/hooks-reference-extract.md#what-additionalcontext-actually-does-critical)
|
|
37
37
|
|
|
38
38
|
→ システムリマインダ = ブリーフィング扱い。モデルが「他人事」と感じる構造的原因。
|
|
39
39
|
|
|
@@ -41,13 +41,13 @@ docs/RAG/
|
|
|
41
41
|
|
|
42
42
|
> "any non-JSON text written to stdout is added as context"
|
|
43
43
|
> "Claude Code wraps the string in a system reminder"
|
|
44
|
-
> — [Hooks reference](
|
|
44
|
+
> — [Hooks reference](01-hooks/raw/hooks-reference-extract.md#stdout)
|
|
45
45
|
|
|
46
46
|
→ 現行 Throughline v0.4.12 の stdout 注入はこの経路。`additionalContext` と同じカテゴリ = 同じ「他人事」問題。
|
|
47
47
|
|
|
48
48
|
### Finding 3: `initialUserMessage` exists in the schema, but is **HEADLESS-ONLY**
|
|
49
49
|
|
|
50
|
-
Verified via [openclaude source](
|
|
50
|
+
Verified via [openclaude source](04-skills/raw/initialUserMessage-investigation.md#the-critical-constraint-from-openclaude-source-comment):
|
|
51
51
|
|
|
52
52
|
```text
|
|
53
53
|
// SessionStart hooks can emit initialUserMessage — the first user turn for
|
|
@@ -56,19 +56,19 @@ Verified via [openclaude source](_raw/04-skills/initialUserMessage-investigation
|
|
|
56
56
|
|
|
57
57
|
→ Interactive mode (`/clear` シナリオ) では発火しない。我々の問題には使えない。
|
|
58
58
|
|
|
59
|
-
**2026-05-24 実機確認**: real Claude Code (v2.1.145) で `~/.throughline/initial-user-message-test.flag` を立てて SessionStart hook を JSON 出力モードに切り替え、`hookSpecificOutput.initialUserMessage` に 8 hex tracer 入りメッセージを乗せて `/clear` 後の cleared-me に「過去発話の tracer を message history だけ見て返して」と尋ねた。ラン (2) 13:33 tracer `9220a79c` (session `0979ad20-…`) → モデル応答 **「ない」**。openclaude のソースコメントが real CC でも妥当であることを実機で確認。詳細: [docs/
|
|
59
|
+
**2026-05-24 実機確認**: real Claude Code (v2.1.145) で `~/.throughline/initial-user-message-test.flag` を立てて SessionStart hook を JSON 出力モードに切り替え、`hookSpecificOutput.initialUserMessage` に 8 hex tracer 入りメッセージを乗せて `/clear` 後の cleared-me に「過去発話の tracer を message history だけ見て返して」と尋ねた。ラン (2) 13:33 tracer `9220a79c` (session `0979ad20-…`) → モデル応答 **「ない」**。openclaude のソースコメントが real CC でも妥当であることを実機で確認。詳細: [docs/10_transcript_injection_plan.md §6 Phase 0-6](../docs/10_transcript_injection_plan.md#phase-0-6--hookspecificoutputinitialusermessage-経路-spike)
|
|
60
60
|
|
|
61
61
|
### Finding 4: Messages API treats all messages[] entries equally
|
|
62
62
|
|
|
63
63
|
> "When creating a new Message, you specify the prior conversational turns with the messages parameter, and the model then generates the next Message in the conversation."
|
|
64
|
-
> — [Messages API](
|
|
64
|
+
> — [Messages API](02-messages-api/raw/messages-api-extract.md#no-differentiation-between-real--synthetic-messages-key)
|
|
65
65
|
|
|
66
66
|
→ もし messages[] に synthetic な過去 turn を入れられれば、モデルは「本物」と区別できない。問題は CC が messages[] を hook から制御させていないこと。
|
|
67
67
|
|
|
68
68
|
### Finding 5: `/clear` preserves the JSONL but resets in-memory state
|
|
69
69
|
|
|
70
70
|
> "/clear: start fresh with an empty context. The previous conversation is saved and resumable"
|
|
71
|
-
> — [Sessions](
|
|
71
|
+
> — [Sessions](03-settings/raw/sessions-extract.md#clear-behavior-key)
|
|
72
72
|
|
|
73
73
|
→ CC は in-memory state を一次ソースに messages[] を構築。JSONL を外から書き換えても in-memory には反映されない (= Phase 0 / Phase 0-5 で実測確認済み)。
|
|
74
74
|
|
|
@@ -82,6 +82,10 @@ Verified via [openclaude source](_raw/04-skills/initialUserMessage-investigation
|
|
|
82
82
|
|
|
83
83
|
[anthropics/claude-code/plugins/plugin-dev/skills/hook-development/SKILL.md](https://github.com/anthropics/claude-code/blob/main/plugins/plugin-dev/skills/hook-development/SKILL.md) は SessionStart hook の `hookSpecificOutput` の `additionalContext` も `initialUserMessage` も触れていない。Plugin 開発者向けの公式チュートリアルですら触れない = どちらも primary 経路として推奨されていない可能性。
|
|
84
84
|
|
|
85
|
+
### Finding 8: SessionEnd has a `clear` reason; built-in `/clear` never reaches UserPromptSubmit (2026-07-11)
|
|
86
|
+
|
|
87
|
+
SessionEnd reason enum: `clear|resume|logout|prompt_input_exit|bypass_permissions_disabled|other`、default timeout 1.5s(/clear にも適用)— [session-end-reasons.md](01-hooks/raw/session-end-reasons.md)。実測: ビルトイン /clear はどのクライアントでも UserPromptSubmit に届かない(同一セッション /tl 対照実験 ×2 + VSCode 2.1.207)。VSCode は `source:"clear"` を送るが Desktop 2.1.205 は `source:"startup"`(クライアント実装差・バージョン交絡棄却済み)。→ Desktop の /clear 検知は SessionEnd(reason='clear') が唯一の hook 経路候補(実機検証は [docs/12](../docs/12_desktop_clear_handoff_plan.md) A Phase 1)。
|
|
88
|
+
|
|
85
89
|
---
|
|
86
90
|
|
|
87
91
|
## Throughline 仮説の見直し
|
|
@@ -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
|
+
}
|