@chroxify/eunoe 1.2.1 → 1.3.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 +143 -5
- package/dist/core/index.js +2235 -0
- package/dist/types/context/content.d.ts +31 -0
- package/dist/types/context/fold/evidence.d.ts +20 -0
- package/dist/types/context/fold/index.d.ts +11 -0
- package/dist/types/context/fold/strategies.d.ts +20 -0
- package/dist/types/context/fold/turns.d.ts +14 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +44 -0
- package/dist/types/context/recall/rerank.d.ts +3 -0
- package/dist/types/core/compaction.d.ts +32 -0
- package/dist/types/core/config.d.ts +33 -0
- package/dist/types/core/estimate.d.ts +9 -0
- package/dist/types/core/fingerprint.d.ts +10 -0
- package/dist/types/core/fold.d.ts +18 -0
- package/dist/types/core/forward.d.ts +24 -0
- package/dist/types/core/handoff.d.ts +10 -0
- package/dist/types/core/index.d.ts +14 -0
- package/dist/types/core/recall.d.ts +8 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +22 -0
- package/dist/types/core/subagents.d.ts +10 -0
- package/dist/types/core/transcript.d.ts +9 -0
- package/dist/types/core/usage.d.ts +7 -0
- package/dist/types/core/window.d.ts +5 -0
- package/dist/types/transcript/format.d.ts +16 -0
- package/dist/types/transcript/parse.d.ts +45 -0
- package/package.json +15 -2
- package/src/cli/commands/mode.ts +2 -1
- package/src/cli/commands/serve.ts +2 -0
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/proxy/stats.ts +1 -1
- package/src/cli/replay/index.ts +3 -3
- package/src/cli/sessions/index.ts +2 -2
- package/src/config/index.ts +4 -27
- package/src/context/content.ts +64 -1
- package/src/context/fold/strategies.ts +55 -10
- package/src/context/fold/turns.ts +22 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +259 -35
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +74 -24
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +69 -0
- package/src/core/forward.ts +192 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/{proxy → core}/recall.ts +18 -20
- package/src/core/rewrite.ts +189 -0
- package/src/core/state.ts +40 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +34 -1
- package/src/core/window.ts +39 -0
- package/src/proxy/rewrite.ts +22 -139
- package/src/proxy/server.ts +24 -137
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +32 -21
- package/src/proxy/warm.ts +68 -0
- package/src/proxy/window.ts +3 -33
- package/src/transcript/entries.ts +114 -0
- package/src/transcript/format.ts +1 -2
- package/src/transcript/messages.ts +14 -11
- package/src/transcript/render-worker.ts +24 -0
- package/src/transcript/render.ts +12 -3
- package/src/transcript/search.ts +46 -5
- package/src/transcript/subagents.ts +55 -34
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
package/src/proxy/server.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { type Config, claudeConfigDirs, readConfig, settingsFor } from "../config"
|
|
3
|
-
import {
|
|
3
|
+
import type { Body } from "../context/content"
|
|
4
|
+
import { contextFingerprint } from "../core/fingerprint"
|
|
5
|
+
import { claudeCodeSessionId, createForwarder, type Prepared } from "../core/forward"
|
|
4
6
|
import { findSessionFile } from "../transcript/locate"
|
|
5
7
|
import { prepareTranscript } from "../transcript/search"
|
|
6
8
|
import { VERSION } from "../version"
|
|
7
9
|
import { applyCanonical } from "./canonical"
|
|
8
|
-
import { sizeOf } from "./estimate"
|
|
9
|
-
import { type ContextFingerprint, contextFingerprint } from "./fingerprint"
|
|
10
10
|
import { logRequest } from "./log"
|
|
11
11
|
import { rewriteAsync } from "./rewrite"
|
|
12
12
|
import { recordSession } from "./sessions"
|
|
13
|
-
import {
|
|
14
|
-
import { inputTokens, watchUsage } from "./usage"
|
|
13
|
+
import { fileStore } from "./state"
|
|
15
14
|
import { resolveWindow } from "./window"
|
|
16
15
|
|
|
17
16
|
export interface Health {
|
|
@@ -22,48 +21,14 @@ export interface Health {
|
|
|
22
21
|
startedAt?: number
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
interface Outgoing {
|
|
26
|
-
note: Record<string, unknown>
|
|
27
|
-
threadKey: string | null
|
|
28
|
-
bodyText: string | undefined
|
|
29
|
-
sentSize: number
|
|
30
|
-
givenSize: number
|
|
31
|
-
fixedSize: number
|
|
32
|
-
sent: (ContextFingerprint & { session: string | null }) | null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
24
|
export const HEALTH_PATH = "/_eunoe/health"
|
|
36
25
|
|
|
37
26
|
const startedAt = Date.now()
|
|
38
27
|
|
|
39
28
|
const trustedHeader = (request: Request, config: Config, name: string) => (config.eval?.trustHeaders === true ? request.headers.get(`x-eunoe-${name}`) : null)
|
|
40
29
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
function sessionIdOf(headers: Headers, body: Body): string | null {
|
|
44
|
-
const header = headers.get("x-claude-code-session-id")
|
|
45
|
-
if (header) return header
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(body.metadata?.user_id ?? "{}").session_id ?? null
|
|
48
|
-
} catch {
|
|
49
|
-
return null
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function forwardableHeaders(headers: Headers) {
|
|
54
|
-
const out = new Headers(headers)
|
|
55
|
-
out.delete("content-encoding")
|
|
56
|
-
out.delete("content-length")
|
|
57
|
-
return out
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function overflowTokens(errorText: string): number | null {
|
|
61
|
-
const tokens = Number(errorText.match(/prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i)?.[1])
|
|
62
|
-
return Number.isFinite(tokens) && tokens > 0 ? tokens : null
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
66
|
-
const sessionId = sessionIdOf(request.headers, body)
|
|
30
|
+
async function prepareSession(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
31
|
+
const sessionId = claudeCodeSessionId(request.headers, body)
|
|
67
32
|
const config = settingsFor(base, sessionId)
|
|
68
33
|
const trusted = config.eval?.trustHeaders === true
|
|
69
34
|
const transcriptOverride = trustedHeader(request, config, "transcript")
|
|
@@ -87,109 +52,31 @@ async function prepare(request: Request, body: Body, base: Config, isCountTokens
|
|
|
87
52
|
})
|
|
88
53
|
}
|
|
89
54
|
|
|
90
|
-
function
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
55
|
+
async function prepare(request: Request, given: Body, config: Config, mayCompact: boolean): Promise<Prepared> {
|
|
56
|
+
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
57
|
+
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
58
|
+
const result = await prepareSession(request, canonical?.body ?? given, config, !mayCompact)
|
|
59
|
+
const context = config.eval ? { context: { session: claudeCodeSessionId(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) } } : {}
|
|
60
|
+
return { ...result, note: { ...result.note, ...(canonical && { canonical: canonical.status }), ...context } }
|
|
94
61
|
}
|
|
95
62
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return await route(request)
|
|
99
|
-
} catch (error) {
|
|
100
|
-
logRequest({ path: new URL(request.url).pathname, status: 502, action: "upstream-unreachable", error: clipped(String(error)) })
|
|
101
|
-
return Response.json({ type: "error", error: { type: "api_error", message: `eunoe could not reach the API: ${String(error)}` } }, { status: 502 })
|
|
102
|
-
}
|
|
63
|
+
interface Timing {
|
|
64
|
+
countTokensTimeoutMs: number
|
|
103
65
|
}
|
|
104
66
|
|
|
105
|
-
|
|
67
|
+
export function handle(request: Request, timing: Timing = { countTokensTimeoutMs: 10_000 }): Promise<Response> {
|
|
106
68
|
const config = readConfig()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const isMessages = request.method === "POST" && url.pathname.startsWith("/v1/messages")
|
|
115
|
-
const isCountTokens = url.pathname.includes("count_tokens")
|
|
116
|
-
|
|
117
|
-
const build = async (): Promise<Outgoing> => {
|
|
118
|
-
const passthrough: Outgoing = { note: { action: "pass" }, threadKey: null, bodyText: givenText, sentSize: 0, givenSize: 0, fixedSize: 0, sent: null }
|
|
119
|
-
if (!givenText || !isMessages) return passthrough
|
|
120
|
-
try {
|
|
121
|
-
const given = JSON.parse(givenText) as Body
|
|
122
|
-
const canonicalFile = trustedHeader(request, config, "canonical")
|
|
123
|
-
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
124
|
-
const result = await prepare(request, canonical?.body ?? given, config, isCountTokens)
|
|
125
|
-
const sentSize = sizeOf(result.body)
|
|
126
|
-
return {
|
|
127
|
-
note: canonical ? { ...result.note, canonical: canonical.status } : result.note,
|
|
128
|
-
threadKey: result.threadKey,
|
|
129
|
-
bodyText: result.body !== given ? JSON.stringify(result.body, wellFormed) : givenText,
|
|
130
|
-
sentSize,
|
|
131
|
-
givenSize: result.body === given ? sentSize : sizeOf(given),
|
|
132
|
-
fixedSize: sizeOf({ ...result.body, messages: [] }),
|
|
133
|
-
sent: config.eval ? { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) } : null,
|
|
134
|
-
}
|
|
135
|
-
} catch (error) {
|
|
136
|
-
return { ...passthrough, note: { action: "error-passthrough", error: String(error) } }
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
const send = (body: string | undefined) => fetch(config.upstream + url.pathname + url.search, { method: request.method, headers, body })
|
|
140
|
-
|
|
141
|
-
let out = await build()
|
|
142
|
-
let upstream = await send(out.bodyText)
|
|
143
|
-
const errorResponse = (text: string) => new Response(text, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
144
|
-
|
|
145
|
-
if (upstream.status >= 400 && out.note.action !== "pass") {
|
|
146
|
-
const errorText = await upstream.text()
|
|
147
|
-
const overflow = overflowTokens(errorText)
|
|
148
|
-
const logged = { path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note }
|
|
149
|
-
if (!overflow || !out.threadKey || out.sentSize === 0 || isCountTokens) {
|
|
150
|
-
logRequest({ ...logged, error: clipped(errorText) })
|
|
151
|
-
return errorResponse(errorText)
|
|
152
|
-
}
|
|
153
|
-
const sentSize = out.sentSize
|
|
154
|
-
saveRatio(out.threadKey, (previous) => Math.max(previous ?? 0, overflow / sentSize) * 1.1)
|
|
155
|
-
logRequest({ ...logged, overflow, error: clipped(errorText) })
|
|
156
|
-
out = await build()
|
|
157
|
-
out = { ...out, note: { ...out.note, healed: overflow } }
|
|
158
|
-
upstream = await send(out.bodyText)
|
|
159
|
-
if (upstream.status >= 400) {
|
|
160
|
-
const retryError = await upstream.text()
|
|
161
|
-
logRequest({ path: url.pathname, status: upstream.status, thread: out.threadKey, ...out.note, error: clipped(retryError) })
|
|
162
|
-
return errorResponse(retryError)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const { note, threadKey, sentSize, givenSize, fixedSize, sent } = out
|
|
167
|
-
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
168
|
-
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
169
|
-
if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
|
|
170
|
-
return response
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return watchUsage(response, (usage) => {
|
|
174
|
-
const input = inputTokens(usage)
|
|
175
|
-
logRequest({
|
|
176
|
-
path: url.pathname,
|
|
177
|
-
status: upstream.status,
|
|
178
|
-
thread: threadKey,
|
|
179
|
-
...note,
|
|
180
|
-
input,
|
|
181
|
-
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
182
|
-
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
183
|
-
uncached: usage.input_tokens ?? 0,
|
|
184
|
-
charsIn: givenSize,
|
|
185
|
-
charsOut: sentSize,
|
|
186
|
-
charsFixed: fixedSize,
|
|
187
|
-
...(sent && { context: sent }),
|
|
188
|
-
})
|
|
189
|
-
if (threadKey && input > 0 && sentSize > 0) saveRatio(threadKey, () => input / sentSize)
|
|
69
|
+
if (new URL(request.url).pathname === HEALTH_PATH) return Promise.resolve(Response.json({ ok: true, version: VERSION, mode: config.mode, pid: process.pid, startedAt } satisfies Health))
|
|
70
|
+
const forward = createForwarder({
|
|
71
|
+
store: fileStore,
|
|
72
|
+
upstream: config.upstream,
|
|
73
|
+
prepare: (incoming, body, mayCompact) => prepare(incoming, body, config, mayCompact),
|
|
74
|
+
record: logRequest,
|
|
75
|
+
countTokensTimeoutMs: timing.countTokensTimeoutMs,
|
|
190
76
|
})
|
|
77
|
+
return forward(request)
|
|
191
78
|
}
|
|
192
79
|
|
|
193
80
|
export function serve(port: number) {
|
|
194
|
-
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: handle })
|
|
81
|
+
return Bun.serve({ port, hostname: "127.0.0.1", idleTimeout: 255, fetch: (request) => handle(request) })
|
|
195
82
|
}
|
package/src/proxy/sessions.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
|
|
2
|
-
import type { Mode } from "../config"
|
|
3
2
|
import { EUNOE_DIR, paths } from "../config/paths"
|
|
3
|
+
import type { Mode } from "../core/config"
|
|
4
4
|
|
|
5
5
|
interface SeenSession {
|
|
6
6
|
lastSeen: string
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Config, claudeConfigDirs } from "../config"
|
|
2
|
+
import type { Message } from "../context/content"
|
|
3
|
+
import { searchGuide } from "../context/prompts"
|
|
4
|
+
import type { ThreadSources } from "../core/rewrite"
|
|
5
|
+
import { findSessionByPrefix } from "../transcript/locate"
|
|
6
|
+
import { sessionMessages } from "../transcript/messages"
|
|
7
|
+
import type { TranscriptRef } from "../transcript/search"
|
|
8
|
+
import { subagentChunks } from "../transcript/subagents"
|
|
9
|
+
import { titleOf } from "./sessions"
|
|
10
|
+
|
|
11
|
+
function readSession(file: string): Message[] {
|
|
12
|
+
try {
|
|
13
|
+
return sessionMessages(file)
|
|
14
|
+
} catch {
|
|
15
|
+
return []
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sessionSources(transcript: TranscriptRef | null, config: Config, sessionId: string | null): ThreadSources {
|
|
20
|
+
const handoffs = {
|
|
21
|
+
findHandoff: (id: string) => {
|
|
22
|
+
const file = findSessionByPrefix(id, claudeConfigDirs(config), sessionId ?? undefined)
|
|
23
|
+
return file ? { label: titleOf(file) ?? id, source: file } : null
|
|
24
|
+
},
|
|
25
|
+
readHandoff: readSession,
|
|
26
|
+
}
|
|
27
|
+
if (!transcript) return handoffs
|
|
28
|
+
return {
|
|
29
|
+
...handoffs,
|
|
30
|
+
guide: searchGuide(transcript),
|
|
31
|
+
transcriptDir: transcript.mode !== "jsonl" ? transcript.dir : null,
|
|
32
|
+
readHistory: () => sessionMessages(transcript.jsonl),
|
|
33
|
+
listSubagentChunks: () => subagentChunks(transcript.jsonl),
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/proxy/state.ts
CHANGED
|
@@ -1,29 +1,41 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
1
|
+
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { paths } from "../config/paths"
|
|
4
|
+
import type { ThreadState, ThreadStore } from "../core/state"
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
source: string
|
|
8
|
-
text: string
|
|
6
|
+
function stateFile(key: string) {
|
|
7
|
+
return path.join(paths.threads, `${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`)
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
const KEPT_THREADS = 64
|
|
11
|
+
|
|
12
|
+
const known = new Map<string, { version: string; state: ThreadState }>()
|
|
13
|
+
|
|
14
|
+
function versionOf(file: string): string | null {
|
|
15
|
+
try {
|
|
16
|
+
const { mtimeMs, size } = statSync(file)
|
|
17
|
+
return `${mtimeMs}:${size}`
|
|
18
|
+
} catch {
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
function
|
|
21
|
-
|
|
23
|
+
function remember(file: string, version: string, state: ThreadState) {
|
|
24
|
+
known.delete(file)
|
|
25
|
+
known.set(file, { version, state })
|
|
26
|
+
if (known.size > KEPT_THREADS) known.delete(known.keys().next().value as string)
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function readState(key: string): ThreadState {
|
|
30
|
+
const file = stateFile(key)
|
|
31
|
+
const version = versionOf(file)
|
|
32
|
+
if (version === null) return {}
|
|
33
|
+
const hit = known.get(file)
|
|
34
|
+
if (hit?.version === version) return structuredClone(hit.state)
|
|
25
35
|
try {
|
|
26
|
-
|
|
36
|
+
const state = JSON.parse(readFileSync(file, "utf8")) as ThreadState
|
|
37
|
+
remember(file, version, state)
|
|
38
|
+
return structuredClone(state)
|
|
27
39
|
} catch {
|
|
28
40
|
return {}
|
|
29
41
|
}
|
|
@@ -31,11 +43,10 @@ export function readState(key: string): ThreadState {
|
|
|
31
43
|
|
|
32
44
|
export function saveState(key: string, state: ThreadState) {
|
|
33
45
|
mkdirSync(paths.threads, { recursive: true })
|
|
34
|
-
|
|
46
|
+
const file = stateFile(key)
|
|
47
|
+
writeFileSync(file, JSON.stringify(state))
|
|
48
|
+
const version = versionOf(file)
|
|
49
|
+
if (version !== null) remember(file, version, structuredClone(state))
|
|
35
50
|
}
|
|
36
51
|
|
|
37
|
-
export
|
|
38
|
-
delete state.cut
|
|
39
|
-
delete state.keep
|
|
40
|
-
delete state.cleared
|
|
41
|
-
}
|
|
52
|
+
export const fileStore: ThreadStore = { read: readState, save: saveState }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import { type Config, claudeConfigDirs } from "../config"
|
|
3
|
+
import type { Message } from "../context/content"
|
|
4
|
+
import { splitTurns } from "../context/fold/turns"
|
|
5
|
+
import { recallChunks, warmIndex } from "../context/recall"
|
|
6
|
+
import { readSessionSlice } from "../transcript/entries"
|
|
7
|
+
import { findSessionFile, listEntries } from "../transcript/locate"
|
|
8
|
+
import { sessionMessages } from "../transcript/messages"
|
|
9
|
+
import { subagentChunks, subagentMessages, subagentRuns, subagentsDir } from "../transcript/subagents"
|
|
10
|
+
import { seenSessions } from "./sessions"
|
|
11
|
+
|
|
12
|
+
const SLICE_MS = 8
|
|
13
|
+
const SLICE_BYTES = 2 * 1024 * 1024
|
|
14
|
+
const RECENT_MS = 24 * 60 * 60 * 1000
|
|
15
|
+
const MAX_SESSIONS = 16
|
|
16
|
+
const TURNS_PER_SLICE = 40
|
|
17
|
+
|
|
18
|
+
let deadline = 0
|
|
19
|
+
|
|
20
|
+
async function breathe() {
|
|
21
|
+
if (performance.now() < deadline) return
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 1))
|
|
23
|
+
deadline = performance.now() + SLICE_MS
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const closingTurn: Message = { role: "user", content: "" }
|
|
27
|
+
|
|
28
|
+
async function warmSession(file: string) {
|
|
29
|
+
while (!readSessionSlice(file, SLICE_BYTES)) await breathe()
|
|
30
|
+
await breathe()
|
|
31
|
+
for (const name of listEntries(subagentsDir(file))) {
|
|
32
|
+
if (!name.endsWith(".jsonl")) continue
|
|
33
|
+
while (!readSessionSlice(path.join(subagentsDir(file), name), SLICE_BYTES)) await breathe()
|
|
34
|
+
await breathe()
|
|
35
|
+
}
|
|
36
|
+
const messages = sessionMessages(file)
|
|
37
|
+
await breathe()
|
|
38
|
+
const { preamble, turns } = splitTurns(messages)
|
|
39
|
+
let end = preamble.length
|
|
40
|
+
for (let i = 0; i < turns.length; i += 1) {
|
|
41
|
+
end += turns[i].length
|
|
42
|
+
if ((i + 1) % TURNS_PER_SLICE !== 0 && i !== turns.length - 1) continue
|
|
43
|
+
recallChunks([...messages.slice(0, end), closingTurn], new Map())
|
|
44
|
+
await breathe()
|
|
45
|
+
}
|
|
46
|
+
for (const run of subagentRuns(file)) {
|
|
47
|
+
recallChunks([...subagentMessages(run), closingTurn], new Map())
|
|
48
|
+
await breathe()
|
|
49
|
+
}
|
|
50
|
+
const corpus = [...recallChunks([...messages, closingTurn], new Map()), ...subagentChunks(file)]
|
|
51
|
+
await breathe()
|
|
52
|
+
while (!warmIndex(corpus, performance.now() + SLICE_MS)) await breathe()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function warmRecentSessions(config: Config, now = Date.now()) {
|
|
56
|
+
deadline = performance.now() + SLICE_MS
|
|
57
|
+
const recent = seenSessions()
|
|
58
|
+
.filter(([, seen]) => seen.mode !== "off" && now - Date.parse(seen.lastSeen) < RECENT_MS)
|
|
59
|
+
.slice(0, MAX_SESSIONS)
|
|
60
|
+
for (const [id] of recent) {
|
|
61
|
+
const file = findSessionFile(id, claudeConfigDirs(config))
|
|
62
|
+
if (!file) continue
|
|
63
|
+
try {
|
|
64
|
+
await warmSession(file)
|
|
65
|
+
} catch {}
|
|
66
|
+
await breathe()
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/proxy/window.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { EUNOE_DIR, paths } from "../config/paths"
|
|
3
|
+
import { createWindowLookup } from "../core/window"
|
|
3
4
|
|
|
4
5
|
function readCache(): Map<string, number> {
|
|
5
6
|
try {
|
|
@@ -9,40 +10,9 @@ function readCache(): Map<string, number> {
|
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
const lookups = new Map<string, Promise<number | null>>()
|
|
14
|
-
|
|
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
|
|
23
|
-
}
|
|
24
|
-
|
|
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)
|
|
13
|
+
function saveCache(windows: Map<string, number>) {
|
|
31
14
|
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
32
15
|
writeFileSync(paths.models, JSON.stringify(Object.fromEntries(windows), null, 2))
|
|
33
|
-
return info.max_input_tokens
|
|
34
16
|
}
|
|
35
17
|
|
|
36
|
-
export
|
|
37
|
-
const id = String(model ?? "").replace(/\[[^\]]*\]$/, "")
|
|
38
|
-
if (!id) return Promise.resolve(null)
|
|
39
|
-
const known = windows.get(id)
|
|
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
|
|
48
|
-
}
|
|
18
|
+
export const resolveWindow = createWindowLookup({ known: readCache(), onLearn: saveCache })
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs"
|
|
2
|
+
import type { SessionEntry } from "./parse"
|
|
3
|
+
|
|
4
|
+
interface Parsed {
|
|
5
|
+
size: number
|
|
6
|
+
mtimeMs: number
|
|
7
|
+
offset: number
|
|
8
|
+
seam: Buffer
|
|
9
|
+
committed: SessionEntry[]
|
|
10
|
+
entries: SessionEntry[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BUDGET_BYTES = 768 * 1024 * 1024
|
|
14
|
+
const NEWLINE = 0x0a
|
|
15
|
+
const SEAM_BYTES = 64
|
|
16
|
+
|
|
17
|
+
const parsed = new Map<string, Parsed>()
|
|
18
|
+
let heldBytes = 0
|
|
19
|
+
|
|
20
|
+
function parseLines(text: string): SessionEntry[] {
|
|
21
|
+
const out: SessionEntry[] = []
|
|
22
|
+
for (const line of text.split("\n")) {
|
|
23
|
+
if (!line) continue
|
|
24
|
+
try {
|
|
25
|
+
out.push(JSON.parse(line) as SessionEntry)
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readRange(file: string, from: number, to: number): Buffer {
|
|
32
|
+
const buffer = Buffer.allocUnsafe(Math.max(0, to - from))
|
|
33
|
+
const fd = openSync(file, "r")
|
|
34
|
+
try {
|
|
35
|
+
let read = 0
|
|
36
|
+
while (read < buffer.length) {
|
|
37
|
+
const n = readSync(fd, buffer, read, buffer.length - read, from + read)
|
|
38
|
+
if (n === 0) break
|
|
39
|
+
read += n
|
|
40
|
+
}
|
|
41
|
+
return buffer.subarray(0, read)
|
|
42
|
+
} finally {
|
|
43
|
+
closeSync(fd)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function remember(file: string, next: Parsed) {
|
|
48
|
+
heldBytes += next.size - (parsed.get(file)?.size ?? 0)
|
|
49
|
+
parsed.delete(file)
|
|
50
|
+
parsed.set(file, next)
|
|
51
|
+
for (const [key, value] of parsed) {
|
|
52
|
+
if (heldBytes <= BUDGET_BYTES || key === file) break
|
|
53
|
+
parsed.delete(key)
|
|
54
|
+
heldBytes -= value.size
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appendable(file: string, known: Parsed | undefined, size: number): known is Parsed {
|
|
59
|
+
if (!known || size < known.size || known.offset === 0) return false
|
|
60
|
+
return readRange(file, known.offset - known.seam.length, known.offset).equals(known.seam)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function readSessionSlice(file: string, maxBytes: number): boolean {
|
|
64
|
+
let size: number
|
|
65
|
+
let mtimeMs: number
|
|
66
|
+
try {
|
|
67
|
+
;({ size, mtimeMs } = statSync(file))
|
|
68
|
+
} catch {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
const known = parsed.get(file)
|
|
72
|
+
if (known && known.size === size && known.mtimeMs === mtimeMs) return true
|
|
73
|
+
const appended = appendable(file, known, size)
|
|
74
|
+
const from = appended ? known.offset : 0
|
|
75
|
+
const bytes = from + maxBytes < size ? readRange(file, from, from + maxBytes) : null
|
|
76
|
+
const complete = bytes ? bytes.lastIndexOf(NEWLINE) + 1 : 0
|
|
77
|
+
if (!bytes || complete === 0) {
|
|
78
|
+
readSessionEntries(file)
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
const added = parseLines(bytes.subarray(0, complete).toString("utf8"))
|
|
82
|
+
const committed = appended ? known.committed.concat(added) : added
|
|
83
|
+
const offset = from + complete
|
|
84
|
+
remember(file, { size: offset, mtimeMs: 0, offset, seam: readRange(file, Math.max(0, offset - SEAM_BYTES), offset), committed, entries: committed })
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function readSessionEntries(file: string): SessionEntry[] {
|
|
89
|
+
let size: number
|
|
90
|
+
let mtimeMs: number
|
|
91
|
+
try {
|
|
92
|
+
;({ size, mtimeMs } = statSync(file))
|
|
93
|
+
} catch {
|
|
94
|
+
return []
|
|
95
|
+
}
|
|
96
|
+
const known = parsed.get(file)
|
|
97
|
+
if (known && known.size === size && known.mtimeMs === mtimeMs) {
|
|
98
|
+
parsed.delete(file)
|
|
99
|
+
parsed.set(file, known)
|
|
100
|
+
return known.entries
|
|
101
|
+
}
|
|
102
|
+
const appended = appendable(file, known, size)
|
|
103
|
+
const from = appended ? known.offset : 0
|
|
104
|
+
const bytes = readRange(file, from, size)
|
|
105
|
+
const complete = bytes.lastIndexOf(NEWLINE) + 1
|
|
106
|
+
const added = parseLines(bytes.subarray(0, complete).toString("utf8"))
|
|
107
|
+
const committed = appended ? (added.length > 0 ? known.committed.concat(added) : known.committed) : added
|
|
108
|
+
const tail = parseLines(bytes.subarray(complete).toString("utf8"))
|
|
109
|
+
const entries = tail.length > 0 ? committed.concat(tail) : committed
|
|
110
|
+
const offset = from + complete
|
|
111
|
+
const seam = readRange(file, Math.max(0, offset - SEAM_BYTES), offset)
|
|
112
|
+
remember(file, { size, mtimeMs, offset, seam, committed, entries })
|
|
113
|
+
return entries
|
|
114
|
+
}
|
package/src/transcript/format.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { clip } from "../context/content"
|
|
2
2
|
import type { ToolPart, TranscriptFormat, Turn } from "./parse"
|
|
3
|
-
import type { SubagentRun } from "./subagents"
|
|
4
3
|
|
|
5
4
|
export interface SubagentRecord {
|
|
6
|
-
run:
|
|
5
|
+
run: { description: string }
|
|
7
6
|
turns: Turn[]
|
|
8
7
|
parentTurn: number
|
|
9
8
|
file: string
|
|
@@ -1,18 +1,21 @@
|
|
|
1
|
-
import { readFileSync, statSync } from "node:fs"
|
|
2
1
|
import type { Message } from "../context/content"
|
|
3
|
-
import {
|
|
2
|
+
import { readSessionEntries } from "./entries"
|
|
3
|
+
import { entryMessages, type SessionEntry } from "./parse"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
const KEPT_SESSIONS = 32
|
|
6
|
+
|
|
7
|
+
const cached = new Map<string, { entries: SessionEntry[]; messages: Message[] }>()
|
|
6
8
|
|
|
7
9
|
export function sessionMessages(file: string): Message[] {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
const entries = readSessionEntries(file)
|
|
11
|
+
const known = cached.get(file)
|
|
12
|
+
cached.delete(file)
|
|
13
|
+
if (known?.entries === entries) {
|
|
14
|
+
cached.set(file, known)
|
|
15
|
+
return known.messages
|
|
13
16
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
cached
|
|
17
|
+
const messages = entryMessages(entries.filter((entry) => !entry.isSidechain))
|
|
18
|
+
cached.set(file, { entries, messages })
|
|
19
|
+
if (cached.size > KEPT_SESSIONS) cached.delete(cached.keys().next().value as string)
|
|
17
20
|
return messages
|
|
18
21
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from "node:path"
|
|
2
|
+
import type { TranscriptFormat } from "./parse"
|
|
3
|
+
import { renderTranscript } from "./render"
|
|
4
|
+
|
|
5
|
+
declare const self: Worker
|
|
6
|
+
|
|
7
|
+
export interface RenderJob {
|
|
8
|
+
dir: string
|
|
9
|
+
sessionFile: string
|
|
10
|
+
format: TranscriptFormat
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RenderDone {
|
|
14
|
+
dir: string
|
|
15
|
+
changed: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
self.onmessage = ({ data }: MessageEvent<RenderJob>) => {
|
|
19
|
+
let changed = false
|
|
20
|
+
try {
|
|
21
|
+
changed = renderTranscript(data.sessionFile, path.basename(data.dir), data.dir, data.format).changed
|
|
22
|
+
} catch {}
|
|
23
|
+
self.postMessage({ dir: data.dir, changed } satisfies RenderDone)
|
|
24
|
+
}
|
package/src/transcript/render.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import path from "node:path"
|
|
3
3
|
import { paths } from "../config/paths"
|
|
4
|
+
import { readSessionEntries } from "./entries"
|
|
4
5
|
import { renderIndex, renderMarkdown, renderSubagentMarkdown, renderSubagentXml, renderXml, type SubagentRecord, subagentFile, turnFile } from "./format"
|
|
5
|
-
import {
|
|
6
|
+
import { parseTurns, type ToolPart, type TranscriptFormat, type Turn } from "./parse"
|
|
6
7
|
import { type SubagentRun, subagentEntries, subagentRuns } from "./subagents"
|
|
7
8
|
|
|
8
9
|
interface RenderResult {
|
|
@@ -11,6 +12,8 @@ interface RenderResult {
|
|
|
11
12
|
turns: number
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
const written = new Map<string, number | bigint>()
|
|
16
|
+
|
|
14
17
|
export function transcriptDir(sessionId: string) {
|
|
15
18
|
return path.join(paths.transcripts, sessionId)
|
|
16
19
|
}
|
|
@@ -45,13 +48,19 @@ export function renderTranscript(sessionFile: string, sessionId: string, outDir
|
|
|
45
48
|
const stamp = [statSync(sessionFile).mtimeMs, ...runs.map((run) => `${run.id}:${run.mtimeMs}`)].join("|")
|
|
46
49
|
if (existsSync(stampFile) && readFileSync(stampFile, "utf8") === stamp) return { dir: outDir, changed: false, turns: Number(readFileSync(countFile, "utf8") || 0) }
|
|
47
50
|
|
|
48
|
-
const turns = parseTurns(
|
|
51
|
+
const turns = parseTurns(readSessionEntries(sessionFile))
|
|
49
52
|
const records = subagentRecords(turns, runs, format)
|
|
50
53
|
let changed = false
|
|
51
54
|
const write = (file: string, content: string) => {
|
|
52
55
|
const target = path.join(outDir, file)
|
|
53
|
-
|
|
56
|
+
const hash = Bun.hash(content)
|
|
57
|
+
if (written.get(target) === hash && existsSync(target)) return
|
|
58
|
+
if (!written.has(target) && existsSync(target) && readFileSync(target, "utf8") === content) {
|
|
59
|
+
written.set(target, hash)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
54
62
|
writeFileSync(target, content)
|
|
63
|
+
written.set(target, hash)
|
|
55
64
|
changed = true
|
|
56
65
|
}
|
|
57
66
|
for (const turn of turns) write(turnFile(turn, format), format === "xml" ? renderXml(turn) : renderMarkdown(turn))
|