@titan-design/session-graph 0.9.1 → 0.11.0

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/README.md CHANGED
@@ -46,9 +46,10 @@ passes 1002.
46
46
  complete `gh pr create` sightings into new PR rows, subagent end times and parentage
47
47
  from child sessions.
48
48
  6. `enrichPrs` runs if the caller passed a `resolvePrs` resolver. See below.
49
- 7. `enrichTasks` runs if the caller passed a `resolveTasks` resolver, once over the whole
49
+ 7. `projectReviewRounds` resolves chat verdicts and writes review rounds. See below.
50
+ 8. `enrichTasks` runs if the caller passed a `resolveTasks` resolver, once over the whole
50
51
  task table. See below.
51
- 8. Rows whose source file has vanished are marked `missing`. Their facts stay: surviving
52
+ 9. Rows whose source file has vanished are marked `missing`. Their facts stay: surviving
52
53
  Claude Code's own pruning is much of the point.
53
54
 
54
55
  `resetIndex` clears every derived table and rewinds watermarks; the next refresh rebuilds
@@ -93,10 +94,55 @@ await refreshCorpus(graph, transcripts, {
93
94
  - **Merged is sticky.** A resolver's `open` or `closed` never replaces a `merged` state, so a
94
95
  stale forge cache cannot reopen a PR. States are stored lower-case.
95
96
  - **Batching.** One call per refresh with every PR that has a repo and number and whose
96
- outcome may still change: never checked, or not yet merged. A merged PR is asked about
97
- once. The resolver only updates rows; a PR enters the graph from a transcript.
98
- - **`review_rounds`** is stored as the resolver counts it. What counts as a round is an open
99
- question in the TP-256 design (Q6).
97
+ outcome may still change: never checked, not yet merged, or merged with no commit times
98
+ stored. PRs never checked come first, then open and closed PRs, then merged PRs offered only
99
+ for their commit times, so a resolver that caps its batch reaches open PRs before the
100
+ backlog. The resolver only updates rows; a PR enters the graph from a transcript.
101
+ - **`commitTimes`** is stored as a JSON array in `commit_times`. Send an empty array for a PR
102
+ with no commits, so it is not offered again; omitting the field leaves the stored value.
103
+ - **`reviews`** replaces the PR's forge rows in `pr_review` (`APPROVED` and
104
+ `CHANGES_REQUESTED`, keyed `gh:<pr_ref>:<submittedAt>`); omitting it leaves them. With
105
+ `reviews`, the round rule below counts `review_rounds_gh` against the sent `commitTimes`, or
106
+ the stored ones when none are sent. With no usable commit times, `reviewRounds` is stored
107
+ there as the forge counted it, and omitting that too leaves the stored value.
108
+
109
+ ## Review rounds
110
+
111
+ `projectReviewRounds` runs after `enrichPrs` on every pass. It first resolves each chat
112
+ verdict's `pr_ref`, then writes `review_rounds_chat` and `review_rounds` for every PR.
113
+
114
+ **The rule.** A review's head is the number of the PR's commits at or before it. A
115
+ changes-requested review counts when its head is below the commit count, which means a later
116
+ commit answered it. A PR's rounds are the distinct heads among its counting reviews. A second
117
+ changes-requested review on one head adds nothing, whether it comes from the same reviewer, a
118
+ second reviewer, or the other surface. An approval is stored and never adds a round.
119
+
120
+ - **Senders.** A chat verdict counts only when the sender's `session_origin.profile` passes
121
+ `isReviewerProfile`, a `refreshCorpus` option. The default accepts `reviewer` and any
122
+ profile ending in `-reviewer`.
123
+ - **Totals.** `review_rounds` is `review_rounds_gh` plus the chat heads no forge review
124
+ already sits on. A resolver that sends only a `reviewRounds` count therefore still adds to
125
+ the chat rounds unchanged.
126
+ - **Count-only resolvers overcount.** Without `reviews`, the forge reviews' heads are unknown,
127
+ so a chat verdict and a forge review on the same head both count. `review_rounds` is then
128
+ an upper bound. `review_rounds_gh` and `review_rounds_chat` are each exact, and the total
129
+ is exact once the resolver sends `reviews` and `commitTimes`.
130
+ - **Unknown commits.** While `commit_times` is null, `review_rounds_chat` stays null and
131
+ `review_rounds` equals `review_rounds_gh`.
132
+ - **Unparseable times.** A review whose time does not parse is ignored by the rule and stays
133
+ stored. If any of a PR's commit times does not parse, its commit times count as unknown,
134
+ since a dropped commit would shift every later head. `countRounds` returns null in that
135
+ case. `summary.reviews.invalidTimes` counts the ignored reviews plus the PRs with unusable
136
+ commit times.
137
+
138
+ **Resolving a chat verdict.** An exact `owner/name` must match a `pr` row, or the verdict
139
+ stays unresolved. Otherwise the candidates are the `pr` rows with that number, narrowed in
140
+ order. A repo hint keeps the rows whose repo's last path segment matches it, in any case; a
141
+ hint that matches none is ignored. Then the sender's family links keep the PRs `linked` from
142
+ the sender, its parent session, or any session that parent spawned. Last, the sender's
143
+ working directory repo, a bare name, is compared with each repo's last path segment. The first
144
+ step that leaves exactly one row resolves the verdict. A resolved `pr_ref` is kept on later
145
+ passes, and `summary.reviews` reports `resolved` (this pass) and `unresolved` (still null).
100
146
  - **Failure.** Same as the task resolver: the pass completes, the rows stand, and
101
147
  `summary.prs` carries `failed` plus `error`.
102
148
 
@@ -110,12 +156,62 @@ Kit tables: `transcript` (watermark), `edge` (bi-temporal, `session:… touched
110
156
  `search_span` + `search_fts` (contentless full-text over prompts, responses, tool inputs
111
157
  and results, keyed by the session ref). Domain tables: `fact`, `session`,
112
158
  `session_model_usage`, `turn`, `permission_phase`, `human_edit`, `file_checkpoint`, `pr`,
113
- `branch`, `file`, `task`, `subagent`, `artifact`, and the two PR observation tables.
159
+ `branch`, `file`, `task`, `subagent`, `artifact`, the two PR observation tables, and
160
+ `pr_review`.
161
+
162
+ `pr_review` holds one row per review verdict. A chat row comes from a `chat_send` tool use
163
+ that session-read parsed a verdict from, keyed `chat:<tool_use_id>:<n>` where `n` is the
164
+ verdict's `ordinal` in that message. An event from a session-read that predates `ordinal`
165
+ takes the next index its tool use has not used in that `applyDelta` call; that session-read
166
+ emits all of one tool use's verdicts from one line, so the keys cannot collide across calls. It keeps only the parsed fields (verdict, repo, repo hint,
167
+ PR number, and the repo of the sender's working directory), never message text, and is purged
168
+ with its transcript. `pr_ref` stays null until a later pass resolves it.
169
+
170
+ `resetIndex` drops `pr` rows and forge review rows with everything else, so `commit_times`,
171
+ `review_rounds_gh` and the forge reviews return only when a resolver answers again.
114
172
 
115
173
  Everything except `transcript` is derivable, which is what makes the schema safe to evolve
116
174
  by drop-and-rederive.
117
175
 
118
176
 
177
+ ## Injected context
178
+
179
+ A `user` record carries more than the human typed. The harness, hooks and agent-chat put
180
+ their own blocks there, and indexing them would make every session match its own
181
+ reminders and briefs. `stripInjected` removes them before a prompt reaches `search_fts`,
182
+ and `readIndexedText` applies it again on readback, so a miner excerpt shows the same
183
+ text. Only prompt spans change. `fact`, `normalized_event` and the audit tables keep
184
+ every line, and a turn with nothing left indexes no prompt span.
185
+ An index built before this rule keeps its old prompt spans until `resetIndex` rebuilds it.
186
+
187
+ - **Whole line.** A line whose session-read inbound cause is anything but `human_typed` or
188
+ `tool_result` indexes no prompt. That covers `isMeta` lines (hook and SessionStart
189
+ bootstrap output, skill loads, harness resumes), peer channel messages, task
190
+ notifications, compaction summaries, scheduled wakeups, image notes and local commands.
191
+ - **Headless prompts.** A line whose `promptSource` is `sdk` indexes no prompt. The host
192
+ writes `sdk` for every headless turn, which is how an agent-chat spawn brief with no
193
+ framing is told apart from a typed prompt. The cost: a person running `claude -p` also
194
+ writes `sdk` turns, and those are excluded too. Pass `indexSdkPrompts: true` to
195
+ `refreshCorpus`, `indexTranscript` or `applyDelta` to keep them. `isUntypedPrompt` exposes
196
+ the rule.
197
+ - **Blocks cut on their own lines.** session-read's closed `INJECTED_MARKERS`
198
+ (`<system-reminder>`, `<channel source="...">`, `<task-notification>`), plus
199
+ `<user-prompt-submit-hook>`, `<command-name>`, `<command-message>`,
200
+ `<local-command-caveat>`, `<local-command-stdout>`, `<local-command-stderr>`,
201
+ `<bash-stdout>` and `<bash-stderr>`. A block is cut only when its opening tag starts a
202
+ line and its closing tag ends one (another cut block may follow on the same line). Nested
203
+ tags of the same name are balanced. A tag quoted inside a sentence stays.
204
+ `[Request interrupted by user…]` notes are cut wherever they sit.
205
+ - **Kept words.** `<command-args>` and `<bash-input>` lose their tags and keep their
206
+ contents, because the human typed them.
207
+ - **Whole turn by text.** session-read's unclosed markers heading the text (local-command
208
+ output, compaction summary, image note, loop wakeup, the agent-chat orientation header).
209
+ agent-chat puts no tag around a spawn brief, so a brief typed into a live session is known
210
+ by the framing agent-chat writes itself: a `# Predecessor:` handover, or the isolated or
211
+ shared worktree note that ends the brief.
212
+ - **Tool-result echoes.** `tool_result` blocks never enter prompt text; a user line's prompt
213
+ span holds its text blocks only. Background results arrive as `<task-notification>`.
214
+
119
215
  ## Mixed harnesses
120
216
 
121
217
  `indexCodexSource(graph, source)` consumes descriptors returned by session-read's
@@ -175,6 +271,25 @@ Migration 6, `episode transcript ids`, adds nullable `start_transcript_id` and
175
271
  `endTranscriptId`. A session resumed across two transcripts then orders by timestamp,
176
272
  transcript id, and byte offset, because byte offsets reset with the new file.
177
273
 
274
+ Migration 7, `origin task link`, adds nullable `task_ids` (a JSON array, primary id first)
275
+ and `task_source` columns to `session_origin`, surfaced as `ResolvedOrigin.taskIds` and
276
+ `taskSource`. It changes no existing row or edge. A row whose `task_source` is null is
277
+ offered to the resolver again on every pass, so the first pass with a task-aware resolver
278
+ is the backfill. A resolver that sets `taskIds`, even to an empty array or null, always
279
+ leaves `task_source` non-null: the given source, or `none` (`NO_TASK_LINK`) when there are
280
+ no ids. A resolver that leaves `taskIds` undefined leaves a stored link as it was, and a
281
+ row without one stays on offer. A task-aware resolver therefore returns an entry for every
282
+ requested session it examined, with `taskIds` empty when nothing links; a session it leaves
283
+ out keeps a null `task_source` and is offered again on every pass. The
284
+ upsert updates only the columns it names, so a later column keeps its value. Each linked id
285
+ projects a `task` row and a `session ran task` edge with `attrs = { via: "origin", source }`
286
+ and a confidence of 1.0 for `name` or `name-over-brief`, 0.9 for `brief-anchor` and 0.6 for
287
+ `brief-paragraph`. Only rows resolved in the current pass are projected. When a
288
+ re-resolution drops an id, only that origin-made edge expires. A transcript's `ran` edge
289
+ carries no `via`, and when a transcript claims an edge the origin made first, the edge is
290
+ superseded without `via`, so origin expiry never removes a transcript's claim.
291
+ session-graph stores and projects the ids a resolver hands it; it does not compute them.
292
+
178
293
  For snapshot-only usage across multiple physical sources, queries select one source
179
294
  by latest native usage timestamp, then greatest usage-record coverage and stable
180
295
  source ID. Source-local reset epochs cannot safely be summed across copies. This is
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Db, WatermarkTable, EdgeTable, SpanFtsTables, Migration } from '@titan-design/store-sqlite';
2
- import { TranscriptDelta, DiscoveredTranscript, SessionSourceDescriptor, SessionUsageSummary } from '@titan-design/session-read';
2
+ import { TranscriptDelta, DiscoveredTranscript, SessionSourceDescriptor, WakeCause, Inbound, SessionUsageSummary } from '@titan-design/session-read';
3
3
 
4
4
  /** One open session graph: the connection plus the kit helpers bound to its tables. */
5
5
  interface SessionGraph {
@@ -39,7 +39,7 @@ declare const KIT: {
39
39
  */
40
40
  declare const DOMAIN_DDL = "\n CREATE TABLE IF NOT EXISTS fact (\n fact_id INTEGER PRIMARY KEY,\n transcript_id INTEGER NOT NULL,\n byte_offset INTEGER NOT NULL,\n byte_length INTEGER NOT NULL,\n event_type TEXT NOT NULL,\n ts TEXT NOT NULL,\n seq INTEGER NOT NULL,\n session_id TEXT NOT NULL,\n prompt_id TEXT,\n tool_use_id TEXT,\n t_indexed TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),\n UNIQUE (transcript_id, byte_offset)\n );\n CREATE INDEX IF NOT EXISTS idx_fact_session_ts ON fact(session_id, ts);\n CREATE INDEX IF NOT EXISTS idx_fact_prompt ON fact(prompt_id);\n CREATE INDEX IF NOT EXISTS idx_fact_tool_use ON fact(tool_use_id);\n\n CREATE TABLE IF NOT EXISTS session (\n session_id TEXT PRIMARY KEY,\n transcript_id INTEGER,\n started_at TEXT,\n ended_at TEXT,\n start_type TEXT,\n cwd TEXT,\n git_branch TEXT,\n ai_title TEXT,\n seed_prompt TEXT,\n cli_version TEXT,\n turn_count INTEGER NOT NULL DEFAULT 0,\n commit_count INTEGER NOT NULL DEFAULT 0,\n push_count INTEGER NOT NULL DEFAULT 0\n );\n CREATE INDEX IF NOT EXISTS idx_session_started ON session(started_at);\n\n CREATE TABLE IF NOT EXISTS session_model_usage (\n session_id TEXT NOT NULL,\n model TEXT NOT NULL,\n input_tokens INTEGER NOT NULL DEFAULT 0,\n output_tokens INTEGER NOT NULL DEFAULT 0,\n cache_read_tokens INTEGER NOT NULL DEFAULT 0,\n cache_creation_tokens INTEGER NOT NULL DEFAULT 0,\n thinking_tokens INTEGER NOT NULL DEFAULT 0,\n request_count INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (session_id, model)\n );\n\n CREATE TABLE IF NOT EXISTS turn (\n prompt_id TEXT PRIMARY KEY,\n session_id TEXT NOT NULL,\n turn_index INTEGER NOT NULL,\n started_at TEXT NOT NULL,\n ended_at TEXT,\n duration_ms INTEGER,\n tool_call_count INTEGER NOT NULL DEFAULT 0,\n thinking_ms INTEGER NOT NULL DEFAULT 0,\n fact_id_start INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_turn_session ON turn(session_id, turn_index);\n\n CREATE TABLE IF NOT EXISTS permission_phase (\n phase_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n from_mode TEXT,\n to_mode TEXT NOT NULL,\n trigger TEXT NOT NULL,\n t_valid TEXT NOT NULL,\n t_invalid TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_phase_session ON permission_phase(session_id, t_valid);\n\n CREATE TABLE IF NOT EXISTS human_edit (\n edit_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n ts TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, ts)\n );\n\n CREATE TABLE IF NOT EXISTS file_checkpoint (\n checkpoint_id INTEGER PRIMARY KEY,\n session_id TEXT NOT NULL,\n file_path TEXT NOT NULL,\n backup_file_name TEXT NOT NULL,\n version INTEGER NOT NULL,\n backup_time TEXT NOT NULL,\n fact_id INTEGER,\n UNIQUE (session_id, file_path, backup_file_name)\n );\n\n CREATE TABLE IF NOT EXISTS pr (\n pr_ref TEXT PRIMARY KEY, number INTEGER, repo TEXT, title TEXT, state TEXT, url TEXT, merged_at TEXT\n );\n CREATE TABLE IF NOT EXISTS branch (\n branch_ref TEXT PRIMARY KEY, repo TEXT, name TEXT NOT NULL, base TEXT, created_at TEXT, deleted_at TEXT\n );\n CREATE TABLE IF NOT EXISTS file (\n file_ref TEXT PRIMARY KEY, repo TEXT, path TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS task (\n task_ref TEXT PRIMARY KEY, task_id TEXT NOT NULL, initiative TEXT, title TEXT, status TEXT\n );\n CREATE TABLE IF NOT EXISTS subagent (\n agent_ref TEXT PRIMARY KEY,\n session_id TEXT,\n child_session_id TEXT,\n parent_agent_ref TEXT,\n agent_type TEXT,\n label TEXT,\n started_at TEXT,\n ended_at TEXT,\n fact_id INTEGER\n );\n CREATE INDEX IF NOT EXISTS idx_subagent_child ON subagent(child_session_id);\n CREATE TABLE IF NOT EXISTS artifact (\n artifact_ref TEXT PRIMARY KEY, kind TEXT, title TEXT, url TEXT, path TEXT, created_at TEXT\n );\n CREATE TABLE IF NOT EXISTS pr_merge_observation (\n number INTEGER NOT NULL, repo_hint TEXT, merged_at TEXT NOT NULL,\n PRIMARY KEY (number, repo_hint, merged_at)\n );\n CREATE TABLE IF NOT EXISTS pr_create_observation (\n tool_use_id TEXT PRIMARY KEY, title TEXT, number INTEGER, repo TEXT, url TEXT\n );\n";
41
41
  /** Every derived table, in an order safe to clear. The watermark table is not derived. */
42
- declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "branch", "file", "task", "artifact"];
42
+ declare const DERIVED_TABLES: readonly ["normalized_span", "normalized_event", "normalized_source", "search_span", "edge", "turn", "permission_phase", "human_edit", "file_checkpoint", "subagent", "request", "tool_call", "inbound", "context_block", "compaction", "queue_op", "session_signal", "cost_state_observation", "transcript_facet", "session_origin", "session_external_event", "episode", "session_model_usage", "session", "fact", "pr", "pr_merge_observation", "pr_create_observation", "pr_review", "branch", "file", "task", "artifact"];
43
43
  declare const MIGRATIONS: Migration[];
44
44
 
45
45
  /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
@@ -59,6 +59,18 @@ declare const EPISODE_TABLE = "episode";
59
59
  declare const ORIGIN_DDL = "\n CREATE TABLE IF NOT EXISTS session_origin ( -- not derived from transcripts\n session_id TEXT PRIMARY KEY,\n origin_system TEXT NOT NULL, -- 'agent-chat'\n agent_id TEXT, agent_name TEXT, parent_name TEXT, parent_session_id TEXT,\n profile TEXT, model_alias TEXT, surface TEXT, isolation TEXT, depth INTEGER,\n origin_kind TEXT, -- spawned | adopted | inherited | human\n config_dir TEXT, spawn_cwd TEXT, spawned_at TEXT,\n brief_chars INTEGER, brief_excerpt TEXT, brief_path TEXT, launch_args TEXT,\n resolved_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_origin_parent ON session_origin(parent_session_id);\n\n CREATE TABLE IF NOT EXISTS session_external_event ( -- teleport, handoff, retire, exit\n session_id TEXT NOT NULL, ts TEXT NOT NULL, origin_system TEXT NOT NULL,\n kind TEXT NOT NULL, detail TEXT,\n PRIMARY KEY (session_id, ts, kind)\n ) WITHOUT ROWID;\n\n CREATE TABLE IF NOT EXISTS episode ( -- provisional, see section 9\n session_id TEXT NOT NULL, episode_index INTEGER NOT NULL,\n heuristic TEXT NOT NULL, heuristic_version INTEGER NOT NULL,\n started_at TEXT NOT NULL, ended_at TEXT NOT NULL,\n start_offset INTEGER NOT NULL, end_offset INTEGER NOT NULL,\n opened_by TEXT NOT NULL, -- brief | channel_followup | idle_gap | compaction\n assignment_offset INTEGER, first_deliverable_offset INTEGER, first_deliverable_signal TEXT,\n first_status_offset INTEGER,\n PRIMARY KEY (session_id, heuristic, episode_index)\n ) WITHOUT ROWID;\n\n CREATE TABLE IF NOT EXISTS price (\n model TEXT NOT NULL, effective_from TEXT NOT NULL, table_version INTEGER NOT NULL,\n input_usd_mtok REAL NOT NULL, cache_read_usd_mtok REAL NOT NULL,\n cache_write_5m_usd_mtok REAL NOT NULL, cache_write_1h_usd_mtok REAL NOT NULL,\n output_usd_mtok REAL NOT NULL, source TEXT,\n PRIMARY KEY (model, effective_from)\n ) WITHOUT ROWID;\n";
60
60
  declare const ORIGIN_VIEWS: readonly ["\n CREATE VIEW IF NOT EXISTS request_dedup AS\n SELECT transcript_id, request_id, byte_offset, session_id, message_id, ts, model,\n input_tokens, cache_read_tokens, cache_creation_tokens, cache_creation_5m, cache_creation_1h,\n output_tokens, thinking_tokens, context_tokens, service_tier, is_sidechain,\n seq_in_session, gap_ms, ctx_delta, wake_cause, wake_delivery, wake_detail,\n wake_tool_family, wake_mcp_server, wake_offset FROM (\n SELECT r.*, ROW_NUMBER() OVER (PARTITION BY r.request_id ORDER BY r.ts, r.transcript_id) AS copy_rank\n FROM request r\n ) WHERE copy_rank = 1;\n", "\n CREATE VIEW IF NOT EXISTS request_cost AS\n WITH candidate AS (\n SELECT d.request_id, p.model AS price_model, p.effective_from AS price_effective_from,\n p.input_usd_mtok, p.cache_read_usd_mtok, p.cache_write_5m_usd_mtok, p.cache_write_1h_usd_mtok, p.output_usd_mtok,\n ROW_NUMBER() OVER (PARTITION BY d.request_id ORDER BY length(p.model) DESC, p.effective_from DESC) AS match_rank\n FROM request_dedup d\n JOIN price p ON substr(d.model, 1, length(p.model)) = p.model AND p.effective_from <= d.ts\n ), component AS (\n SELECT d.*, c.price_model, c.price_effective_from, c.price_model IS NOT NULL AS priced,\n COALESCE(d.input_tokens * c.input_usd_mtok, 0) / 1e6 AS input_cost_usd,\n COALESCE(d.cache_read_tokens * c.cache_read_usd_mtok, 0) / 1e6 AS cache_read_cost_usd,\n COALESCE(CASE WHEN d.cache_creation_5m + d.cache_creation_1h = 0 THEN d.cache_creation_tokens ELSE d.cache_creation_5m END\n * c.cache_write_5m_usd_mtok, 0) / 1e6 AS cache_write_5m_cost_usd,\n COALESCE(d.cache_creation_1h * c.cache_write_1h_usd_mtok, 0) / 1e6 AS cache_write_1h_cost_usd,\n COALESCE(d.output_tokens * c.output_usd_mtok, 0) / 1e6 AS output_cost_usd\n FROM request_dedup d\n LEFT JOIN candidate c ON c.request_id = d.request_id AND c.match_rank = 1\n )\n SELECT *,\n input_cost_usd + cache_read_cost_usd + cache_write_5m_cost_usd + cache_write_1h_cost_usd + output_cost_usd AS cost_usd,\n (cache_read_tokens < 0.2 * context_tokens AND cache_creation_tokens >= 20000) AS is_cold,\n CASE WHEN context_tokens < 50000 THEN '<50k' WHEN context_tokens < 100000 THEN '50-100k'\n WHEN context_tokens < 200000 THEN '100-200k' ELSE '200k+' END AS context_band,\n CASE WHEN gap_ms IS NULL THEN NULL WHEN gap_ms < 300000 THEN '<5m'\n WHEN gap_ms < 3600000 THEN '5-60m' ELSE '>60m' END AS gap_band\n FROM component;\n", "\n CREATE VIEW IF NOT EXISTS context_contribution AS\n WITH stream AS (\n SELECT transcript_id, byte_offset, 0 AS lane, block_index, NULL AS request_id FROM context_block\n UNION ALL\n SELECT transcript_id, byte_offset, -1 AS lane, 0, request_id FROM request\n ), ordered AS (\n SELECT *, COUNT(request_id) OVER (PARTITION BY transcript_id ORDER BY byte_offset, lane, block_index ROWS UNBOUNDED PRECEDING) AS requests_before\n FROM stream\n ), owner AS (\n SELECT b.transcript_id, b.byte_offset, b.block_index, r.request_id\n FROM ordered b JOIN ordered r ON r.transcript_id = b.transcript_id AND r.lane = -1 AND r.requests_before = b.requests_before + 1\n WHERE b.lane = 0\n ), tool AS (\n SELECT tool_use_id, name, family, mcp_server,\n ROW_NUMBER() OVER (PARTITION BY tool_use_id ORDER BY transcript_id, byte_offset, block_index) AS copy_rank\n FROM tool_call\n )\n SELECT cb.transcript_id, cb.byte_offset, cb.block_index, cb.session_id, cb.ts, cb.source,\n cb.tool_use_id, cb.attachment_type, cb.chars, cb.is_media, o.request_id, d.ctx_delta,\n CASE WHEN cb.source IN ('assistant_text', 'assistant_thinking', 'assistant_tool_input') THEN NULL\n ELSE d.ctx_delta * cb.chars * 1.0 / NULLIF(SUM(CASE WHEN cb.source IN ('assistant_text', 'assistant_thinking', 'assistant_tool_input') THEN 0 ELSE cb.chars END)\n OVER (PARTITION BY o.transcript_id, o.request_id), 0) END AS est_tokens,\n t.name AS tool_name, t.family AS tool_family, t.mcp_server\n FROM context_block cb\n JOIN owner o USING (transcript_id, byte_offset, block_index)\n JOIN request_dedup d ON d.transcript_id = o.transcript_id AND d.request_id = o.request_id\n LEFT JOIN tool t ON t.tool_use_id = cb.tool_use_id AND t.copy_rank = 1;\n"];
61
61
 
62
+ /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
63
+ declare const ORIGIN_TASK_LINK_MIGRATION_NAME = "origin task link";
64
+
65
+ /** Recorded in `_migration`; store-sqlite refuses a database whose applied name differs, so never rename it. */
66
+ declare const REVIEW_VERDICT_MIGRATION_NAME = "review verdicts";
67
+ declare const REVIEW_TABLE = "pr_review";
68
+ /**
69
+ * One row per review verdict, from either surface. Only parsed fields are kept, never message text.
70
+ * `source_key` is `chat:<tool_use_id>:<n>` or `gh:<pr_ref>:<submitted_at>`; `pr_ref` is resolved later.
71
+ */
72
+ declare const REVIEW_DDL = "\n CREATE TABLE IF NOT EXISTS pr_review (\n source_key TEXT PRIMARY KEY,\n surface TEXT NOT NULL,\n verdict TEXT NOT NULL,\n ts TEXT NOT NULL,\n session_id TEXT,\n transcript_id INTEGER,\n repo TEXT,\n repo_hint TEXT,\n cwd_repo TEXT,\n number INTEGER NOT NULL,\n pr_ref TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_pr_review_pr_ref ON pr_review(pr_ref);\n CREATE INDEX IF NOT EXISTS idx_pr_review_transcript ON pr_review(transcript_id);\n";
73
+
62
74
  /** Write one delta's audit events. Runs inside `applyDelta`'s transaction, so it opens none of its own. */
63
75
  declare function applyAudit(db: Db, transcriptId: number, delta: TranscriptDelta): void;
64
76
 
@@ -99,6 +111,8 @@ declare function applyDelta(graph: SessionGraph, transcriptId: number, delta: Tr
99
111
  interface DeltaSource {
100
112
  /** The Claude config dir the transcript was found under; `null` when discovery did not say. */
101
113
  account?: string | null;
114
+ /** Index prompts the host labels `promptSource: "sdk"` (spawned agents and `claude -p`). Off by default; see README "Injected context". */
115
+ indexSdkPrompts?: boolean;
102
116
  }
103
117
 
104
118
  /**
@@ -119,8 +133,8 @@ interface DeltaSource {
119
133
  * the only source. The two observation tables are left for the same reason —
120
134
  * `reconcile` folds them, and re-reading re-asserts the same rows.
121
135
  *
122
- * Audit rows and facet state carry `transcript_id` themselves, so they go by it
123
- * directly rather than through `session`.
136
+ * Audit rows, facet state and chat review verdicts carry `transcript_id`
137
+ * themselves, so they go by it directly rather than through `session`.
124
138
  */
125
139
  declare function purgeTranscript(graph: SessionGraph, transcriptId: number): void;
126
140
 
@@ -150,8 +164,17 @@ interface ResolvedPr {
150
164
  state?: string | null;
151
165
  mergedAt?: string | null;
152
166
  closedAt?: string | null;
153
- /** Stored as the resolver counts it; the round definition is open question Q6 in the TP-256 design. */
167
+ /** Stored as the forge's round count when `reviews` or `commitTimes` is absent; otherwise the round rule counts it. */
154
168
  reviewRounds?: number | null;
169
+ /** The PR's commit times; an empty array is stored as known-empty, an omitted field leaves the stored value. */
170
+ commitTimes?: readonly string[];
171
+ /** Replaces the PR's stored forge reviews; an omitted field leaves them. */
172
+ reviews?: readonly ResolvedReview[];
173
+ }
174
+ /** One forge review. `APPROVED` and `CHANGES_REQUESTED`, in any case, are stored; other states are ignored. */
175
+ interface ResolvedReview {
176
+ state: string;
177
+ submittedAt: string;
155
178
  }
156
179
  /** Resolutions keyed by `pr_ref` (`pr:acme/demo#7`). */
157
180
  type PrResolution = ReadonlyMap<string, ResolvedPr | null | undefined>;
@@ -206,7 +229,18 @@ interface ResolvedOrigin {
206
229
  briefExcerpt?: string | null;
207
230
  briefPath?: string | null;
208
231
  launchArgs?: string | null;
232
+ /**
233
+ * Task ids the spawn record assigned, primary first; each projects a `ran` edge. Leave it
234
+ * undefined only if the resolver does not look for tasks: a stored link then stands and a
235
+ * row without one is offered again. A task-aware resolver sets it, empty when nothing links.
236
+ */
237
+ taskIds?: readonly string[] | null;
238
+ /** How the ids were found. Stored as `none` when `taskIds` is given but empty. */
239
+ taskSource?: TaskLinkSource | null;
209
240
  }
241
+ /** Stored when a task-aware resolver found no id, so the row is not offered again. */
242
+ declare const NO_TASK_LINK = "none";
243
+ type TaskLinkSource = "name" | "name-over-brief" | "brief-anchor" | "brief-paragraph" | typeof NO_TASK_LINK;
210
244
  /** A lifecycle event the launcher saw outside the transcript: `teleport`, `handoff`, `retired`, `exited`. */
211
245
  interface ExternalEvent {
212
246
  sessionId: string;
@@ -224,7 +258,9 @@ interface OriginResolution {
224
258
  /**
225
259
  * Supplied by the caller, never by this package: session-graph is tier 2 and
226
260
  * must not learn where a launcher keeps its records. Called once per pass with
227
- * every session that has no origin row or a stale one.
261
+ * every session that has no origin row, a stale one, or a null `task_source`.
262
+ * A task-aware resolver returns an entry for every requested session it examined,
263
+ * with `taskIds` empty when nothing links; a session left out is offered again.
228
264
  */
229
265
  type OriginResolver = (sessionIds: readonly string[]) => PromiseLike<OriginResolution> | OriginResolution;
230
266
  interface OriginEnrichment {
@@ -249,6 +285,34 @@ declare function sessionsNeedingOrigin(graph: SessionGraph): string[];
249
285
  */
250
286
  declare function resolveOrigins(graph: SessionGraph, resolver: OriginResolver | undefined): Promise<OriginEnrichment>;
251
287
 
288
+ /** Decides from a sender's `session_origin.profile` whether its chat verdicts count. */
289
+ type ReviewerProfilePredicate = (profile: string) => boolean;
290
+ /** The default role set: `reviewer`, or any profile ending in `-reviewer`. */
291
+ declare const isReviewerProfile: ReviewerProfilePredicate;
292
+ interface ReviewRoundOptions {
293
+ isReviewerProfile?: ReviewerProfilePredicate;
294
+ }
295
+ interface ReviewProjection {
296
+ /** Chat verdicts given a `pr_ref` this pass. */
297
+ resolved: number;
298
+ /** Chat verdicts whose PR is still unknown. */
299
+ unresolved: number;
300
+ /** Reviews ignored for a time that does not parse, plus PRs whose commit times were unusable. */
301
+ invalidTimes: number;
302
+ }
303
+ /**
304
+ * The number of distinct heads among changes-requested reviews that a later commit answered.
305
+ * A review's head is the count of commits at or before it, so two reviews on one head count once.
306
+ * A review whose time does not parse is ignored. Returns null when any commit time does not
307
+ * parse, because a missing commit would shift every later head.
308
+ */
309
+ declare function countRounds(changesRequestedAt: readonly string[], commitTimes: readonly string[]): number | null;
310
+ /**
311
+ * Resolve each unresolved chat verdict to a PR, then write `review_rounds_chat` and `review_rounds`
312
+ * for every PR. Runs after `enrichPrs`, so forge reviews and commit times are current.
313
+ */
314
+ declare function projectReviewRounds(graph: SessionGraph, options?: ReviewRoundOptions): ReviewProjection;
315
+
252
316
  /**
253
317
  * What a product's task store knows and a transcript cannot state: the task's
254
318
  * present title, its initiative, and the status it holds right now rather than
@@ -303,8 +367,12 @@ interface IndexOptions {
303
367
  resolveOrigins?: OriginResolver;
304
368
  /** Fill PR state, merge, close and review rounds from the caller's forge. Runs once per `refreshCorpus` pass. Absent means transcripts alone. */
305
369
  resolvePrs?: PrResolver;
370
+ /** Index prompts the host labels `promptSource: "sdk"`: spawned agents' briefs, and `claude -p` runs a person typed. Default false. */
371
+ indexSdkPrompts?: boolean;
306
372
  }
307
373
  interface RefreshOptions extends IndexOptions {
374
+ /** Which sender profiles' chat verdicts count toward review rounds. Defaults to `reviewer` and `*-reviewer`. */
375
+ isReviewerProfile?: ReviewerProfilePredicate;
308
376
  /** Roll up every session, not just the ones this pass touched. */
309
377
  full?: boolean;
310
378
  /** Stale audit facets re-extracted per pass (default 40). `Infinity` clears the backlog. */
@@ -343,6 +411,7 @@ interface RefreshSummary {
343
411
  tasks: TaskEnrichment;
344
412
  origins: OriginEnrichment;
345
413
  prs: PrEnrichment;
414
+ reviews: ReviewProjection;
346
415
  markedMissing: number;
347
416
  facetsBackfilled: number;
348
417
  /** Transcripts whose audit facet is still stale after this pass. */
@@ -352,7 +421,7 @@ interface RefreshSummary {
352
421
  * One pass over a corpus: index every transcript, re-extract a bounded batch of
353
422
  * stale audit facets, roll up the sessions that changed, resolve session
354
423
  * origins, reconcile cross-transcript observations, resolve PR outcomes,
355
- * enrich tasks, and mark rows whose source file is gone. Idempotent: a second pass over unchanged
424
+ * project review rounds, enrich tasks, and mark rows whose source file is gone. Idempotent: a second pass over unchanged
356
425
  * files changes nothing.
357
426
  *
358
427
  * The resolver is hoisted out of the per-transcript loop and run once over the
@@ -420,6 +489,12 @@ interface NormalizedIndexResult {
420
489
  /** Replay changed sources into temporary staging; swap rows and watermark atomically. */
421
490
  declare function indexCodexSource(graph: SessionGraph, source: SessionSourceDescriptor): Promise<NormalizedIndexResult>;
422
491
 
492
+ declare function isInjectedCause(cause: WakeCause): boolean;
493
+ /** A line nobody typed: an injected cause, or a headless `sdk` prompt unless the caller keeps those. */
494
+ declare function isUntypedPrompt(inbound: Pick<Inbound, "cause" | "promptSource">, indexSdkPrompts?: boolean): boolean;
495
+ /** Prompt text with every injected block removed; empty when nothing the human wrote is left. */
496
+ declare function stripInjected(text: string): string;
497
+
423
498
  interface IndexedSpan {
424
499
  sourceId: number;
425
500
  byteOffset: number;
@@ -455,4 +530,4 @@ declare function normalizedUsage(graph: SessionGraph, ref: string): NormalizedUs
455
530
  /** Ambiguity is explicit; workspace session bodies remain in their original namespace. */
456
531
  declare function resolveConversationAlias(db: Db, legacyRef: string): string | null;
457
532
 
458
- export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, type ReconcileCounts, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedTask, type SessionGraph, type SyncPricesOptions, type TaskEnrichment, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, enrichPrs, enrichTasks, indexCodexSource, indexTranscript, normalizedSessions, normalizedUsage, openSessionGraph, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, syncPrices };
533
+ export { AUDIT_DDL, AUDIT_FACET, AUDIT_MIGRATION_NAME, AUDIT_TABLES, type BackfillOptions, type BackfillSummary, type ConversationSummary, DEFAULT_FACET_LIMIT, DERIVED_TABLES, DOMAIN_DDL, type DeltaSource, EPISODE_TABLE, type EpisodeRow, type ExternalEvent, FACET_TABLE, type IndexOptions, type IndexedSpan, KIT, MIGRATIONS, NO_ENRICHMENT, NO_ORIGINS, NO_PR_OUTCOMES, NO_TASK_LINK, type NormalizedIndexResult, type NormalizedUsageSummary, ORIGIN_DDL, ORIGIN_MIGRATION_NAME, ORIGIN_TABLES, ORIGIN_TASK_LINK_MIGRATION_NAME, ORIGIN_VIEWS, type OpenSessionGraphOptions, type OriginEnrichment, type OriginResolution, type OriginResolver, type PrEnrichment, type PrKey, type PrResolution, type PrResolver, type PriceInput, REVIEW_DDL, REVIEW_TABLE, REVIEW_VERDICT_MIGRATION_NAME, type ReconcileCounts, type RefreshOptions, type RefreshSummary, type ResolvedOrigin, type ResolvedPr, type ResolvedReview, type ResolvedTask, type ReviewProjection, type ReviewRoundOptions, type ReviewerProfilePredicate, type SessionGraph, type SyncPricesOptions, type TaskEnrichment, type TaskLinkSource, type TaskResolution, type TaskResolver, type TranscriptOutcome, allSessionIds, allTaskIds, applyAudit, applyDelta, backfillFacets, countRounds, enrichPrs, enrichTasks, indexCodexSource, indexTranscript, isInjectedCause, isReviewerProfile, isUntypedPrompt, normalizedSessions, normalizedUsage, openSessionGraph, projectReviewRounds, prsNeedingOutcome, purgeTranscript, readIndexedText, reconcile, refreshCorpus, replaceEpisodes, resetIndex, resolveConversationAlias, resolveOrigins, rollupSessions, sessionsNeedingOrigin, stripInjected, syncPrices };