@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.
- package/README.md +68 -9
- package/package.json +18 -2
- package/src/cli/{types.ts → command.ts} +0 -4
- package/src/cli/commands/index.ts +13 -1
- package/src/cli/commands/load.ts +44 -0
- package/src/cli/commands/mode.ts +41 -33
- package/src/cli/commands/peek.ts +22 -0
- package/src/cli/commands/replay.ts +245 -0
- package/src/cli/commands/serve.ts +22 -7
- package/src/cli/commands/sessions.ts +70 -0
- package/src/cli/commands/settings.ts +79 -60
- package/src/cli/commands/start.ts +56 -21
- package/src/cli/commands/stats.ts +233 -0
- package/src/cli/commands/status.ts +98 -55
- package/src/cli/commands/stop.ts +8 -6
- package/src/cli/commands/uninstall.ts +10 -12
- package/src/cli/commands/watch.ts +16 -0
- package/src/cli/logo/art.ts +99 -0
- package/src/cli/logo/index.ts +12 -18
- package/src/cli/main.ts +3 -3
- package/src/cli/proxy/claude-settings.ts +69 -0
- package/src/cli/proxy/inspect.ts +67 -0
- package/src/cli/proxy/launchd.ts +53 -0
- package/src/cli/proxy/live.ts +57 -0
- package/src/cli/proxy/service.ts +53 -0
- package/src/cli/{stats.ts → proxy/stats.ts} +29 -16
- package/src/cli/proxy/watch.ts +60 -0
- package/src/cli/replay/index.ts +146 -0
- package/src/cli/replay/worker.ts +20 -0
- package/src/cli/sessions/browse.ts +88 -0
- package/src/cli/sessions/index.ts +76 -0
- package/src/cli/sessions/source.ts +74 -0
- package/src/cli/{help.ts → terminal/help.ts} +24 -8
- package/src/cli/terminal/prompt.ts +114 -0
- package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
- package/src/cli/usage/index.ts +151 -0
- package/src/config/index.ts +99 -34
- package/src/config/paths.ts +17 -0
- package/src/context/content.ts +40 -14
- package/src/context/fold/evidence.ts +145 -0
- package/src/context/fold/index.ts +65 -0
- package/src/context/fold/strategies.ts +94 -0
- package/src/context/fold/turns.ts +76 -0
- package/src/context/prompts.ts +141 -22
- package/src/context/recall/index.ts +214 -0
- package/src/context/recall/rerank.ts +71 -0
- package/src/proxy/canonical.ts +17 -9
- package/src/proxy/compaction.ts +106 -53
- package/src/proxy/estimate.ts +14 -9
- package/src/proxy/fingerprint.ts +23 -9
- package/src/proxy/handoff.ts +71 -0
- package/src/proxy/log.ts +2 -2
- package/src/proxy/recall.ts +58 -0
- package/src/proxy/rewrite.ts +141 -119
- package/src/proxy/server.ts +108 -51
- package/src/proxy/sessions.ts +32 -14
- package/src/proxy/state.ts +17 -3
- package/src/proxy/usage.ts +50 -34
- package/src/proxy/window.ts +32 -34
- package/src/transcript/format.ts +88 -25
- package/src/transcript/locate.ts +38 -17
- package/src/transcript/messages.ts +3 -14
- package/src/transcript/parse.ts +137 -56
- package/src/transcript/render.ts +44 -17
- package/src/transcript/search.ts +20 -56
- package/src/transcript/subagents.ts +150 -0
- package/src/cli/claude-settings.ts +0 -42
- package/src/cli/constants.ts +0 -52
- package/src/cli/inspect.ts +0 -41
- package/src/cli/launchd.ts +0 -39
- package/src/cli/logo/pixels.ts +0 -34
- package/src/cli/prompt.ts +0 -91
- package/src/cli/service.ts +0 -33
- package/src/cli/sessions.ts +0 -25
- package/src/config/constants.ts +0 -39
- package/src/config/types.ts +0 -50
- package/src/context/constants.ts +0 -42
- package/src/context/evidence.ts +0 -92
- package/src/context/recall.ts +0 -155
- package/src/context/rerank.ts +0 -44
- package/src/context/strategies.ts +0 -89
- package/src/context/turns.ts +0 -87
- package/src/context/types.ts +0 -66
- package/src/proxy/constants.ts +0 -29
- package/src/proxy/types.ts +0 -82
- package/src/transcript/constants.ts +0 -15
- package/src/transcript/types.ts +0 -43
- /package/src/cli/{args.ts → terminal/args.ts} +0 -0
package/src/proxy/usage.ts
CHANGED
|
@@ -1,41 +1,57 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
package/src/proxy/window.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
-
import { EUNOE_DIR, paths } from "../config/
|
|
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(
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
const
|
|
24
|
-
if (!
|
|
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
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
}
|
package/src/transcript/format.ts
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
import { clip } from "../context/content"
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
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-${
|
|
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 (${
|
|
48
|
+
`## Work (${calls(turn.calls)})`,
|
|
25
49
|
"",
|
|
26
|
-
|
|
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, "&").replace(/"/g, """).replace(/</g, "<").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
|
-
...
|
|
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
|
-
|
|
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.${
|
|
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.
|
|
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
|
+
}
|
package/src/transcript/locate.ts
CHANGED
|
@@ -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 (!
|
|
11
|
-
for (const
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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/
|
|
3
|
-
import
|
|
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
|
|
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
|
}
|
package/src/transcript/parse.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
24
|
-
if (typeof input.file_path === "string") return
|
|
25
|
-
if (typeof input.pattern === "string") return
|
|
26
|
-
if (typeof input.url === "string") return
|
|
27
|
-
if (typeof input.description === "string") return
|
|
28
|
-
return
|
|
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
|
|
39
|
-
const content =
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
}
|