@chroxify/eunoe 1.0.0 → 1.2.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.
Files changed (88) hide show
  1. package/README.md +68 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +13 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +245 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +56 -21
  13. package/src/cli/commands/stats.ts +233 -0
  14. package/src/cli/commands/status.ts +98 -55
  15. package/src/cli/commands/stop.ts +8 -6
  16. package/src/cli/commands/uninstall.ts +10 -12
  17. package/src/cli/commands/watch.ts +16 -0
  18. package/src/cli/logo/art.ts +99 -0
  19. package/src/cli/logo/index.ts +12 -18
  20. package/src/cli/main.ts +3 -3
  21. package/src/cli/proxy/claude-settings.ts +69 -0
  22. package/src/cli/proxy/inspect.ts +67 -0
  23. package/src/cli/proxy/launchd.ts +53 -0
  24. package/src/cli/proxy/live.ts +57 -0
  25. package/src/cli/proxy/service.ts +53 -0
  26. package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
  27. package/src/cli/proxy/watch.ts +60 -0
  28. package/src/cli/replay/index.ts +146 -0
  29. package/src/cli/replay/worker.ts +20 -0
  30. package/src/cli/sessions/browse.ts +88 -0
  31. package/src/cli/sessions/index.ts +76 -0
  32. package/src/cli/sessions/source.ts +74 -0
  33. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  34. package/src/cli/terminal/prompt.ts +114 -0
  35. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  36. package/src/cli/usage/index.ts +151 -0
  37. package/src/config/index.ts +99 -34
  38. package/src/config/paths.ts +17 -0
  39. package/src/context/content.ts +40 -14
  40. package/src/context/fold/evidence.ts +145 -0
  41. package/src/context/fold/index.ts +65 -0
  42. package/src/context/fold/strategies.ts +94 -0
  43. package/src/context/fold/turns.ts +76 -0
  44. package/src/context/prompts.ts +141 -22
  45. package/src/context/recall/index.ts +214 -0
  46. package/src/context/recall/rerank.ts +71 -0
  47. package/src/proxy/canonical.ts +17 -9
  48. package/src/proxy/compaction.ts +106 -53
  49. package/src/proxy/estimate.ts +14 -9
  50. package/src/proxy/fingerprint.ts +23 -9
  51. package/src/proxy/handoff.ts +71 -0
  52. package/src/proxy/log.ts +2 -2
  53. package/src/proxy/recall.ts +58 -0
  54. package/src/proxy/rewrite.ts +141 -119
  55. package/src/proxy/server.ts +108 -51
  56. package/src/proxy/sessions.ts +32 -14
  57. package/src/proxy/state.ts +17 -3
  58. package/src/proxy/usage.ts +50 -34
  59. package/src/proxy/window.ts +32 -34
  60. package/src/transcript/format.ts +88 -25
  61. package/src/transcript/locate.ts +38 -17
  62. package/src/transcript/messages.ts +3 -14
  63. package/src/transcript/parse.ts +137 -56
  64. package/src/transcript/render.ts +44 -17
  65. package/src/transcript/search.ts +20 -56
  66. package/src/transcript/subagents.ts +150 -0
  67. package/src/cli/claude-settings.ts +0 -42
  68. package/src/cli/constants.ts +0 -52
  69. package/src/cli/inspect.ts +0 -41
  70. package/src/cli/launchd.ts +0 -39
  71. package/src/cli/logo/pixels.ts +0 -34
  72. package/src/cli/prompt.ts +0 -91
  73. package/src/cli/service.ts +0 -33
  74. package/src/cli/sessions.ts +0 -25
  75. package/src/config/constants.ts +0 -39
  76. package/src/config/types.ts +0 -50
  77. package/src/context/constants.ts +0 -42
  78. package/src/context/evidence.ts +0 -92
  79. package/src/context/recall.ts +0 -155
  80. package/src/context/rerank.ts +0 -44
  81. package/src/context/strategies.ts +0 -89
  82. package/src/context/turns.ts +0 -87
  83. package/src/context/types.ts +0 -66
  84. package/src/proxy/constants.ts +0 -29
  85. package/src/proxy/types.ts +0 -82
  86. package/src/transcript/constants.ts +0 -15
  87. package/src/transcript/types.ts +0 -43
  88. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,41 +1,57 @@
1
- import { MAX_EVENT_BUFFER } from "./constants"
2
- import type { Usage } from "./types"
1
+ type Usage = Record<string, number>
2
+
3
+ function usageIn(line: string): Usage | null {
4
+ if (!line.startsWith("data:") || !line.includes("message_start")) return null
5
+ try {
6
+ return JSON.parse(line.slice(5)).message?.usage ?? null
7
+ } catch {
8
+ return null
9
+ }
10
+ }
11
+
12
+ function trailingUsage(buffer: string): Usage | null {
13
+ if (!buffer.trim().startsWith("{")) return null
14
+ try {
15
+ const json = JSON.parse(buffer)
16
+ if (json.usage) return json.usage
17
+ return typeof json.input_tokens === "number" ? { input_tokens: json.input_tokens } : null
18
+ } catch {
19
+ return null
20
+ }
21
+ }
22
+
23
+ function scanEvents(buffer: string): { usage: Usage | null; rest: string } {
24
+ let rest = buffer
25
+ for (let newline = rest.indexOf("\n"); newline !== -1; newline = rest.indexOf("\n")) {
26
+ const usage = usageIn(rest.slice(0, newline).trim())
27
+ rest = rest.slice(newline + 1)
28
+ if (usage) return { usage, rest }
29
+ }
30
+ return { usage: null, rest }
31
+ }
32
+
33
+ async function readUsage(stream: ReadableStream): Promise<Usage | null> {
34
+ const reader = stream.pipeThrough(new TextDecoderStream()).getReader()
35
+ let buffer = ""
36
+ let usage: Usage | null = null
37
+ while (true) {
38
+ const { value, done } = await reader.read()
39
+ if (done) return usage ?? trailingUsage(buffer)
40
+ if (usage) continue
41
+ const scanned = scanEvents(buffer + value)
42
+ usage = scanned.usage
43
+ buffer = scanned.rest.length > 4_000_000 ? "" : scanned.rest
44
+ }
45
+ }
3
46
 
4
47
  export function watchUsage(response: Response, onUsage: (usage: Usage) => void): Response {
5
48
  if (!response.body) return response
6
49
  const [toClient, toUs] = response.body.tee()
7
- void (async () => {
8
- const reader = toUs.pipeThrough(new TextDecoderStream()).getReader()
9
- let buffer = ""
10
- let found = false
11
- while (true) {
12
- const { value, done } = await reader.read()
13
- if (done) break
14
- if (found) continue
15
- buffer += value
16
- let newline: number
17
- while (!found && (newline = buffer.indexOf("\n")) !== -1) {
18
- const line = buffer.slice(0, newline).trim()
19
- buffer = buffer.slice(newline + 1)
20
- if (!line.startsWith("data:") || !line.includes("message_start")) continue
21
- try {
22
- const event = JSON.parse(line.slice(5))
23
- if (event.message?.usage) {
24
- found = true
25
- onUsage(event.message.usage)
26
- }
27
- } catch {}
28
- }
29
- if (!found && buffer.length > MAX_EVENT_BUFFER) buffer = ""
30
- }
31
- if (!found && buffer.trim().startsWith("{")) {
32
- try {
33
- const json = JSON.parse(buffer)
34
- if (json.usage) onUsage(json.usage)
35
- else if (typeof json.input_tokens === "number") onUsage({ input_tokens: json.input_tokens })
36
- } catch {}
37
- }
38
- })().catch(() => undefined)
50
+ readUsage(toUs)
51
+ .then((usage) => {
52
+ if (usage) onUsage(usage)
53
+ })
54
+ .catch(() => undefined)
39
55
  return new Response(toClient, { status: response.status, statusText: response.statusText, headers: response.headers })
40
56
  }
41
57
 
@@ -1,12 +1,9 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
- import { EUNOE_DIR, paths } from "../config/constants"
3
- import { ANTHROPIC_VERSION, MODEL_HEADERS, MODEL_LOOKUP_TIMEOUT_MS } from "./constants"
4
-
5
- const CACHE = paths.models
2
+ import { EUNOE_DIR, paths } from "../config/paths"
6
3
 
7
4
  function readCache(): Map<string, number> {
8
5
  try {
9
- return new Map(existsSync(CACHE) ? Object.entries(JSON.parse(readFileSync(CACHE, "utf8"))) : [])
6
+ return new Map(existsSync(paths.models) ? Object.entries(JSON.parse(readFileSync(paths.models, "utf8"))) : [])
10
7
  } catch {
11
8
  return new Map()
12
9
  }
@@ -15,36 +12,37 @@ function readCache(): Map<string, number> {
15
12
  const windows = readCache()
16
13
  const lookups = new Map<string, Promise<number | null>>()
17
14
 
18
- function modelId(model: unknown) {
19
- return String(model ?? "").replace(/\[[^\]]*\]$/, "")
15
+ function lookupHeaders(requestHeaders: Headers): Headers {
16
+ const headers = new Headers()
17
+ for (const name of ["authorization", "x-api-key", "anthropic-version", "anthropic-beta"]) {
18
+ const value = requestHeaders.get(name)
19
+ if (value) headers.set(name, value)
20
+ }
21
+ if (!headers.has("anthropic-version")) headers.set("anthropic-version", "2023-06-01")
22
+ return headers
20
23
  }
21
24
 
22
- export async function resolveWindow(model: unknown, requestHeaders: Headers, upstream: string): Promise<number | null> {
23
- const id = modelId(model)
24
- if (!id) return null
25
+ async function lookup(id: string, requestHeaders: Headers, upstream: string): Promise<number | null> {
26
+ const response = await fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers: lookupHeaders(requestHeaders), signal: AbortSignal.timeout(5_000) })
27
+ if (!response.ok) return null
28
+ const info = (await response.json()) as { max_input_tokens?: number }
29
+ if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
30
+ windows.set(id, info.max_input_tokens)
31
+ mkdirSync(EUNOE_DIR, { recursive: true })
32
+ writeFileSync(paths.models, JSON.stringify(Object.fromEntries(windows), null, 2))
33
+ return info.max_input_tokens
34
+ }
35
+
36
+ export function resolveWindow(model: unknown, requestHeaders: Headers, upstream: string): Promise<number | null> {
37
+ const id = String(model ?? "").replace(/\[[^\]]*\]$/, "")
38
+ if (!id) return Promise.resolve(null)
25
39
  const known = windows.get(id)
26
- if (known) return known
27
- let lookup = lookups.get(id)
28
- if (!lookup) {
29
- const headers = new Headers()
30
- for (const name of MODEL_HEADERS) {
31
- const value = requestHeaders.get(name)
32
- if (value) headers.set(name, value)
33
- }
34
- if (!headers.has("anthropic-version")) headers.set("anthropic-version", ANTHROPIC_VERSION)
35
- lookup = fetch(`${upstream}/v1/models/${encodeURIComponent(id)}`, { headers, signal: AbortSignal.timeout(MODEL_LOOKUP_TIMEOUT_MS) })
36
- .then(async (response) => {
37
- if (!response.ok) return null
38
- const info = await response.json() as { max_input_tokens?: number }
39
- if (typeof info.max_input_tokens !== "number" || info.max_input_tokens <= 0) return null
40
- windows.set(id, info.max_input_tokens)
41
- mkdirSync(EUNOE_DIR, { recursive: true })
42
- writeFileSync(CACHE, JSON.stringify(Object.fromEntries(windows), null, 2))
43
- return info.max_input_tokens
44
- })
45
- .catch(() => null)
46
- .finally(() => lookups.delete(id))
47
- lookups.set(id, lookup)
48
- }
49
- return lookup
40
+ if (known) return Promise.resolve(known)
41
+ const inFlight = lookups.get(id)
42
+ if (inFlight) return inFlight
43
+ const pending = lookup(id, requestHeaders, upstream)
44
+ .catch(() => null)
45
+ .finally(() => lookups.delete(id))
46
+ lookups.set(id, pending)
47
+ return pending
50
48
  }
@@ -1,19 +1,43 @@
1
1
  import { clip } from "../context/content"
2
- import { INDEX_PROMPT_CHARS, INDEX_REPLY_CHARS } from "./constants"
3
- import type { TranscriptFormat, Turn } from "./types"
2
+ import type { ToolPart, TranscriptFormat, Turn } from "./parse"
3
+ import type { SubagentRun } from "./subagents"
4
+
5
+ export interface SubagentRecord {
6
+ run: SubagentRun
7
+ turns: Turn[]
8
+ parentTurn: number
9
+ file: string
10
+ }
11
+
12
+ const extension = (format: TranscriptFormat) => (format === "xml" ? "xml" : "md")
13
+ const padded = (n: number) => String(n).padStart(4, "0")
14
+ const calls = (count: number) => `${count} tool call${count === 1 ? "" : "s"}`
4
15
 
5
16
  export function turnFile(turn: Turn, format: TranscriptFormat) {
6
- return `turn-${String(turn.number).padStart(4, "0")}.${format === "xml" ? "xml" : "md"}`
17
+ return `turn-${padded(turn.number)}.${extension(format)}`
18
+ }
19
+
20
+ export function subagentFile(parentTurn: number, k: number, format: TranscriptFormat) {
21
+ return `turn-${padded(parentTurn)}-subagent-${k}.${extension(format)}`
22
+ }
23
+
24
+ function toolMarkdown(part: ToolPart, total: number) {
25
+ return [
26
+ `### [tool ${part.n}/${total}] ${part.name}: ${part.call}`,
27
+ part.record ? `Full record of this subagent run: ${part.record}` : "",
28
+ part.body,
29
+ part.output ? `[output of tool ${part.n}${part.error ? ", error" : ""}]\n\`\`\`\n${part.output}\n\`\`\`` : `[tool ${part.n}: no output]`,
30
+ ]
31
+ .filter(Boolean)
32
+ .join("\n\n")
33
+ }
34
+
35
+ function workOf(turn: Turn) {
36
+ const work = turn.parts.map((part) => (part.kind === "say" ? `> ${part.text.replace(/\n/g, "\n> ")}` : toolMarkdown(part, turn.calls)))
37
+ return work.join("\n\n") || "_No tool calls._"
7
38
  }
8
39
 
9
40
  export function renderMarkdown(turn: Turn) {
10
- const work = turn.parts.map((part) => part.kind === "say"
11
- ? `> ${part.text.replace(/\n/g, "\n> ")}`
12
- : [
13
- `### [tool ${part.n}/${turn.calls}] ${part.name}: ${part.call}`,
14
- part.body,
15
- part.output ? `[output of tool ${part.n}${part.error ? ", error" : ""}]\n\`\`\`\n${part.output}\n\`\`\`` : `[tool ${part.n}: no output]`,
16
- ].filter(Boolean).join("\n\n"))
17
41
  return [
18
42
  `# Turn ${turn.number}${turn.time ? ` · ${turn.time}` : ""}`,
19
43
  "",
@@ -21,9 +45,9 @@ export function renderMarkdown(turn: Turn) {
21
45
  "",
22
46
  turn.prompt,
23
47
  "",
24
- `## Work (${turn.calls} tool call${turn.calls === 1 ? "" : "s"})`,
48
+ `## Work (${calls(turn.calls)})`,
25
49
  "",
26
- work.join("\n\n") || "_No tool calls._",
50
+ workOf(turn),
27
51
  "",
28
52
  "## Final reply",
29
53
  "",
@@ -36,33 +60,72 @@ function attr(value: string) {
36
60
  return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/\n/g, " ")
37
61
  }
38
62
 
63
+ function toolXml(part: ToolPart, total: number) {
64
+ return [
65
+ `<tool n="${part.n}" of="${total}" name="${attr(part.name)}">`,
66
+ `<call>${part.call.replace(/^`|`$/g, "")}</call>`,
67
+ part.record ? `<record>${part.record}</record>` : "",
68
+ part.body ? `<input>\n${part.body}\n</input>` : "",
69
+ part.output ? `<output${part.error ? ' error="true"' : ""}>\n${part.output}\n</output>` : "<output/>",
70
+ "</tool>",
71
+ ]
72
+ .filter(Boolean)
73
+ .join("\n")
74
+ }
75
+
39
76
  export function renderXml(turn: Turn) {
40
- const work = turn.parts.map((part) => part.kind === "say"
41
- ? `<said>\n${part.text}\n</said>`
42
- : [
43
- `<tool n="${part.n}" of="${turn.calls}" name="${attr(part.name)}">`,
44
- `<call>${part.call.replace(/^`|`$/g, "")}</call>`,
45
- part.body ? `<input>\n${part.body}\n</input>` : "",
46
- part.output ? `<output${part.error ? ' error="true"' : ""}>\n${part.output}\n</output>` : "<output/>",
47
- "</tool>",
48
- ].filter(Boolean).join("\n"))
49
77
  return [
50
78
  `<turn n="${turn.number}"${turn.time ? ` time="${turn.time}"` : ""} tool_calls="${turn.calls}">`,
51
79
  `<user>\n${turn.prompt}\n</user>`,
52
- ...work,
80
+ ...turn.parts.map((part) => (part.kind === "say" ? `<said>\n${part.text}\n</said>` : toolXml(part, turn.calls))),
53
81
  turn.final ? `<final_reply>\n${turn.final}\n</final_reply>` : `<final_reply interrupted="true"/>`,
54
82
  "</turn>",
55
83
  "",
56
84
  ].join("\n")
57
85
  }
58
86
 
59
- export function renderIndex(turns: Turn[], format: TranscriptFormat) {
87
+ const finalOf = (final: string | undefined) => (final ? clip(final, 160) : "_(no final reply)_")
88
+
89
+ const turnLine = (turn: Turn, format: TranscriptFormat) => `- **${turnFile(turn, format)}** (${turn.time}, ${turn.calls} calls): ${clip(turn.prompt, 140)} → ${finalOf(turn.final)}`
90
+
91
+ const recordLine = (record: SubagentRecord) => {
92
+ const total = record.turns.reduce((sum, turn) => sum + turn.calls, 0)
93
+ return ` - **${record.file}**: subagent "${clip(record.run.description, 140)}" (${total} calls) → ${finalOf(record.turns.at(-1)?.final)}`
94
+ }
95
+
96
+ export function renderIndex(turns: Turn[], format: TranscriptFormat, records: SubagentRecord[] = []) {
97
+ const ext = extension(format)
98
+ const subagentNote = records.length > 0 ? ` Indented lines are subagents the agent launched in that turn; turn-NNNN-subagent-K.${ext} holds the subagent's whole run, which the turn itself only has the report of.` : ""
60
99
  return [
61
100
  "# Session index",
62
101
  "",
63
- `One line per turn: what the user asked → how the turn ended. Open turn-NNNN.${format === "xml" ? "xml" : "md"} for everything that happened in it.`,
102
+ `One line per turn: what the user asked → how the turn ended. Open turn-NNNN.${ext} for everything that happened in it.${subagentNote}`,
64
103
  "",
65
- ...turns.map((turn) => `- **${turnFile(turn, format)}** (${turn.time}, ${turn.calls} calls): ${clip(turn.prompt, INDEX_PROMPT_CHARS)} → ${turn.final ? clip(turn.final, INDEX_REPLY_CHARS) : "_(no final reply)_"}`),
104
+ ...turns.flatMap((turn) => [turnLine(turn, format), ...records.filter((record) => record.parentTurn === turn.number).map(recordLine)]),
105
+ ...records.filter((record) => record.parentTurn === 0).map(recordLine),
66
106
  "",
67
107
  ].join("\n")
68
108
  }
109
+
110
+ export function renderSubagentMarkdown(record: SubagentRecord) {
111
+ const parent = record.parentTurn || "?"
112
+ const body = record.turns.flatMap((turn, i) => [
113
+ record.turns.length > 1 ? `## Message ${i + 1}` : "## Task",
114
+ "",
115
+ turn.prompt,
116
+ "",
117
+ `## Work (${calls(turn.calls)})`,
118
+ "",
119
+ workOf(turn),
120
+ "",
121
+ "## Final reply",
122
+ "",
123
+ turn.final || "_No final reply: the run was interrupted or is still going._",
124
+ "",
125
+ ])
126
+ return [`# Subagent of turn ${parent} · "${record.run.description}"`, `This is the full record of a subagent launched from turn ${parent}; that turn only holds its final report.`, "", ...body].join("\n")
127
+ }
128
+
129
+ export function renderSubagentXml(record: SubagentRecord) {
130
+ return [`<subagent parent_turn="${record.parentTurn}" description="${attr(record.run.description)}">`, ...record.turns.map(renderXml), "</subagent>", ""].join("\n")
131
+ }
@@ -1,28 +1,49 @@
1
1
  import { existsSync, readdirSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { SESSION_ID_PATTERN } from "../config/constants"
4
3
 
5
4
  const found = new Map<string, string>()
6
5
 
6
+ export const isSessionId = (value: string) => /^[a-zA-Z0-9-]+$/.test(value)
7
+
8
+ export const projectFolderName = (cwd: string) => cwd.replace(/[^a-zA-Z0-9]/g, "-")
9
+
10
+ export function listEntries(dir: string): string[] {
11
+ try {
12
+ return readdirSync(dir)
13
+ } catch {
14
+ return []
15
+ }
16
+ }
17
+
18
+ export function projectFolders(configDirs: string[]): string[] {
19
+ return configDirs.flatMap((configDir) => {
20
+ const projects = path.join(configDir, "projects")
21
+ return listEntries(projects).map((name) => path.join(projects, name))
22
+ })
23
+ }
24
+
25
+ export function findSessionByPrefix(prefix: string, configDirs: string[], exclude?: string): string | null {
26
+ if (!isSessionId(prefix)) return null
27
+ const hits = new Map<string, string>()
28
+ for (const folder of projectFolders(configDirs)) {
29
+ for (const name of listEntries(folder)) {
30
+ if (!name.endsWith(".jsonl") || !name.startsWith(prefix)) continue
31
+ const id = name.slice(0, -".jsonl".length)
32
+ if (id !== exclude && !hits.has(id)) hits.set(id, path.join(folder, name))
33
+ }
34
+ }
35
+ return hits.size === 1 ? [...hits.values()][0] : null
36
+ }
37
+
7
38
  export function findSessionFile(sessionId: string, configDirs: string[]): string | null {
8
39
  const hit = found.get(sessionId)
9
40
  if (hit && existsSync(hit)) return hit
10
- if (!SESSION_ID_PATTERN.test(sessionId)) return null
11
- for (const configDir of configDirs) {
12
- const projects = path.join(configDir, "projects")
13
- let dirs: string[]
14
- try {
15
- dirs = readdirSync(projects)
16
- } catch {
17
- continue
18
- }
19
- for (const dir of dirs) {
20
- const file = path.join(projects, dir, `${sessionId}.jsonl`)
21
- if (existsSync(file)) {
22
- found.set(sessionId, file)
23
- return file
24
- }
25
- }
41
+ if (!isSessionId(sessionId)) return null
42
+ for (const folder of projectFolders(configDirs)) {
43
+ const file = path.join(folder, `${sessionId}.jsonl`)
44
+ if (!existsSync(file)) continue
45
+ found.set(sessionId, file)
46
+ return file
26
47
  }
27
48
  return null
28
49
  }
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, statSync } from "node:fs"
2
- import type { Message } from "../context/types"
3
- import type { SessionEntry } from "./types"
2
+ import type { Message } from "../context/content"
3
+ import { entryMessages, parseJsonl } from "./parse"
4
4
 
5
5
  let cached: { file: string; mtimeMs: number; messages: Message[] } | null = null
6
6
 
@@ -12,18 +12,7 @@ export function sessionMessages(file: string): Message[] {
12
12
  return []
13
13
  }
14
14
  if (cached?.file === file && cached.mtimeMs === mtimeMs) return cached.messages
15
- const messages: Message[] = []
16
- for (const line of readFileSync(file, "utf8").split("\n")) {
17
- if (!line) continue
18
- let entry: SessionEntry
19
- try {
20
- entry = JSON.parse(line)
21
- } catch {
22
- continue
23
- }
24
- if (entry.isSidechain || entry.isCompactSummary || (entry.type !== "user" && entry.type !== "assistant") || !entry.message) continue
25
- messages.push({ role: entry.message.role, content: entry.message.content })
26
- }
15
+ const messages = entryMessages(parseJsonl(readFileSync(file, "utf8")).filter((entry) => !entry.isSidechain))
27
16
  cached = { file, mtimeMs, messages }
28
17
  return messages
29
18
  }
@@ -1,31 +1,89 @@
1
- import { contentBlocks } from "../context/content"
2
- import type { Block } from "../context/types"
3
- import { CALL_COMMAND_CHARS, CALL_DESCRIPTION_CHARS, CALL_INPUT_CHARS, CALL_PATTERN_CHARS, OUTPUT_CAP } from "./constants"
4
- import type { SessionEntry, ToolPart, Turn } from "./types"
1
+ import { type Block, contentBlocks, type Message } from "../context/content"
5
2
 
6
- function stripReminders(text: string) {
7
- return text
3
+ export interface SessionEntry {
4
+ type?: string
5
+ subtype?: string
6
+ isSidechain?: boolean
7
+ isMeta?: boolean
8
+ isCompactSummary?: boolean
9
+ agentId?: string
10
+ timestamp?: string
11
+ cwd?: string
12
+ aiTitle?: string
13
+ message?: { role: string; content: string | Block[] }
14
+ }
15
+
16
+ export type TranscriptFormat = "markdown" | "xml"
17
+
18
+ export interface ToolPart {
19
+ kind: "tool"
20
+ n: number
21
+ id: string
22
+ name: string
23
+ prompt?: string
24
+ record?: string
25
+ call: string
26
+ body: string
27
+ output: string
28
+ error: boolean
29
+ }
30
+
31
+ type TurnPart = { kind: "say"; text: string } | ToolPart
32
+
33
+ export interface Turn {
34
+ number: number
35
+ time: string
36
+ prompt: string
37
+ parts: TurnPart[]
38
+ final: string
39
+ calls: number
40
+ }
41
+
42
+ const OUTPUT_CAP = 40_000
43
+
44
+ export function parseJsonl<T = SessionEntry>(text: string): T[] {
45
+ return text.split("\n").flatMap((line) => {
46
+ if (!line) return []
47
+ try {
48
+ return [JSON.parse(line) as T]
49
+ } catch {
50
+ return []
51
+ }
52
+ })
53
+ }
54
+
55
+ export function entryMessages(entries: SessionEntry[]): Message[] {
56
+ return entries.flatMap((entry): Message[] => {
57
+ if (entry.isCompactSummary || (entry.type !== "user" && entry.type !== "assistant") || !entry.message) return []
58
+ return [{ role: entry.message.role, content: entry.message.content }]
59
+ })
60
+ }
61
+
62
+ function promptText(content: Block[]) {
63
+ return content
64
+ .filter((block) => block.type === "text")
65
+ .map((block) => block.text ?? "")
66
+ .join("\n")
8
67
  .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
9
68
  .replace(/<system-message>[\s\S]*?<\/system-message>/g, "")
10
69
  .trim()
11
70
  }
12
71
 
13
72
  function isPrompt(entry: SessionEntry) {
14
- if (entry.type !== "user" || entry.isMeta || entry.isCompactSummary || entry.isSidechain) return false
73
+ if (entry.type !== "user" || entry.isMeta || entry.isCompactSummary) return false
15
74
  const content = contentBlocks(entry.message?.content)
16
75
  if (content.some((block) => block.type === "tool_result")) return false
17
- const text = stripReminders(content.filter((block) => block.type === "text").map((block) => block.text ?? "").join("\n"))
18
- return Boolean(text) || content.some((block) => block.type === "image")
76
+ return Boolean(promptText(content)) || content.some((block) => block.type === "image")
19
77
  }
20
78
 
21
79
  function describeCall(block: Block): string {
22
80
  const input = block.input ?? {}
23
- if (typeof input.command === "string") return `${block.name}: \`${input.command.replace(/\s+/g, " ").slice(0, CALL_COMMAND_CHARS)}\``
24
- if (typeof input.file_path === "string") return `${block.name}: ${input.file_path}`
25
- if (typeof input.pattern === "string") return `${block.name}: "${input.pattern.slice(0, CALL_PATTERN_CHARS)}"${input.path ? ` in ${input.path}` : ""}`
26
- if (typeof input.url === "string") return `${block.name}: ${input.url}`
27
- if (typeof input.description === "string") return `${block.name}: ${input.description.slice(0, CALL_DESCRIPTION_CHARS)}`
28
- return `${block.name}: ${JSON.stringify(input).slice(0, CALL_INPUT_CHARS)}`
81
+ if (typeof input.command === "string") return `\`${input.command.replace(/\s+/g, " ").slice(0, 300)}\``
82
+ if (typeof input.file_path === "string") return input.file_path
83
+ if (typeof input.pattern === "string") return `"${input.pattern.slice(0, 120)}"${input.path ? ` in ${input.path}` : ""}`
84
+ if (typeof input.url === "string") return input.url
85
+ if (typeof input.description === "string") return input.description.slice(0, 160)
86
+ return JSON.stringify(input).slice(0, 200)
29
87
  }
30
88
 
31
89
  function callBody(block: Block): string {
@@ -35,59 +93,82 @@ function callBody(block: Block): string {
35
93
  return ""
36
94
  }
37
95
 
38
- function resultText(block: Block): string {
39
- const content = typeof block.content === "string"
40
- ? block.content
41
- : contentBlocks(block.content).map((part) => (part.type === "text" ? part.text : `[${part.type}]`)).join("\n")
96
+ function outputOf(block: Block): string {
97
+ const content =
98
+ typeof block.content === "string"
99
+ ? block.content
100
+ : contentBlocks(block.content)
101
+ .map((part) => (part.type === "text" ? part.text : `[${part.type}]`))
102
+ .join("\n")
42
103
  return content.length > OUTPUT_CAP ? `${content.slice(0, OUTPUT_CAP)}\n… [${content.length - OUTPUT_CAP} more characters]` : content
43
104
  }
44
105
 
45
- export function parseTurns(entries: SessionEntry[]): Turn[] {
106
+ function promptTurn(entry: SessionEntry, number: number): Turn {
107
+ const text = promptText(contentBlocks(entry.message?.content))
108
+ const time = String(entry.timestamp ?? "")
109
+ .slice(0, 16)
110
+ .replace("T", " ")
111
+ return { number, time, prompt: text || "[image]", parts: [], final: "", calls: 0 }
112
+ }
113
+
114
+ function recordAssistant(turn: Turn, content: Block[], pending: Map<string, ToolPart>, narration: string): string {
115
+ let said = narration
116
+ for (const block of content) {
117
+ if (block.type === "text" && String(block.text).trim()) {
118
+ said = String(block.text).trim()
119
+ turn.final = said
120
+ }
121
+ if (block.type !== "tool_use") continue
122
+ turn.calls += 1
123
+ if (said) {
124
+ turn.parts.push({ kind: "say", text: said })
125
+ said = ""
126
+ }
127
+ turn.final = ""
128
+ const call: ToolPart = {
129
+ kind: "tool",
130
+ n: turn.calls,
131
+ id: String(block.id ?? ""),
132
+ name: block.name,
133
+ prompt: typeof block.input?.prompt === "string" ? block.input.prompt : undefined,
134
+ call: describeCall(block),
135
+ body: callBody(block),
136
+ output: "",
137
+ error: false,
138
+ }
139
+ pending.set(block.id, call)
140
+ turn.parts.push(call)
141
+ }
142
+ return said
143
+ }
144
+
145
+ function recordResults(content: Block[], pending: Map<string, ToolPart>) {
146
+ for (const block of content) {
147
+ if (block.type !== "tool_result") continue
148
+ const call = pending.get(block.tool_use_id)
149
+ if (!call) continue
150
+ pending.delete(block.tool_use_id)
151
+ call.output = outputOf(block).trim()
152
+ call.error = Boolean(block.is_error)
153
+ }
154
+ }
155
+
156
+ export function parseTurns(entries: SessionEntry[], sidechain: boolean | "any" = false): Turn[] {
46
157
  const turns: Turn[] = []
47
158
  const pending = new Map<string, ToolPart>()
48
159
  let narration = ""
49
160
  for (const entry of entries) {
50
- if (entry.isSidechain) continue
161
+ if (sidechain !== "any" && Boolean(entry.isSidechain) !== sidechain) continue
51
162
  if (isPrompt(entry)) {
52
- const text = stripReminders(contentBlocks(entry.message.content).filter((block) => block.type === "text").map((block) => block.text).join("\n"))
53
- turns.push({ number: turns.length + 1, time: String(entry.timestamp ?? "").slice(0, 16).replace("T", " "), prompt: text || "[image]", parts: [], final: "", calls: 0 })
163
+ turns.push(promptTurn(entry, turns.length + 1))
54
164
  continue
55
165
  }
56
166
  const turn = turns.at(-1)
57
167
  if (!turn) continue
58
- if (entry.type === "system" && entry.subtype === "compact_boundary") {
59
- turn.parts.push({ kind: "say", text: "(Claude Code compacted the context here.)" })
60
- continue
61
- }
62
- if (entry.type === "assistant") {
63
- for (const block of contentBlocks(entry.message?.content)) {
64
- if (block.type === "text" && String(block.text).trim()) {
65
- narration = String(block.text).trim()
66
- turn.final = narration
67
- }
68
- if (block.type === "tool_use") {
69
- turn.calls += 1
70
- if (narration) {
71
- turn.parts.push({ kind: "say", text: narration })
72
- narration = ""
73
- }
74
- turn.final = ""
75
- const call: ToolPart = { kind: "tool", n: turn.calls, name: block.name, call: describeCall(block).slice(block.name.length + 2), body: callBody(block), output: "", error: false }
76
- pending.set(block.id, call)
77
- turn.parts.push(call)
78
- }
79
- }
80
- }
81
- if (entry.type === "user") {
82
- for (const block of contentBlocks(entry.message?.content)) {
83
- if (block.type !== "tool_result") continue
84
- const call = pending.get(block.tool_use_id)
85
- if (!call) continue
86
- pending.delete(block.tool_use_id)
87
- call.output = resultText(block).trim()
88
- call.error = Boolean(block.is_error)
89
- }
90
- }
168
+ const content = contentBlocks(entry.message?.content)
169
+ if (entry.type === "system" && entry.subtype === "compact_boundary") turn.parts.push({ kind: "say", text: "(Claude Code compacted the context here.)" })
170
+ else if (entry.type === "assistant") narration = recordAssistant(turn, content, pending, narration)
171
+ else if (entry.type === "user") recordResults(content, pending)
91
172
  }
92
173
  return turns
93
174
  }