@chroxify/eunoe 0.1.0 → 1.0.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 +315 -236
- package/package.json +3 -2
- package/src/cli/args.ts +1 -1
- package/src/cli/commands/index.ts +16 -2
- package/src/cli/commands/mode.ts +53 -17
- package/src/cli/commands/serve.ts +2 -1
- package/src/cli/commands/settings.ts +100 -0
- package/src/cli/commands/start.ts +64 -0
- package/src/cli/commands/status.ts +50 -16
- package/src/cli/commands/stop.ts +33 -11
- package/src/cli/commands/uninstall.ts +11 -9
- package/src/cli/constants.ts +28 -5
- package/src/cli/help.ts +2 -2
- package/src/cli/launchd.ts +8 -3
- package/src/cli/prompt.ts +91 -0
- package/src/cli/service.ts +33 -0
- package/src/cli/sessions.ts +25 -0
- package/src/cli/stats.ts +73 -0
- package/src/cli/types.ts +1 -0
- package/src/cli/ui.ts +15 -0
- package/src/config/constants.ts +15 -3
- package/src/config/index.ts +55 -6
- package/src/config/types.ts +34 -5
- package/src/context/constants.ts +35 -0
- package/src/context/content.ts +23 -1
- package/src/context/evidence.ts +92 -0
- package/src/context/recall.ts +155 -0
- package/src/context/rerank.ts +44 -0
- package/src/context/strategies.ts +6 -6
- package/src/context/turns.ts +19 -14
- package/src/context/types.ts +43 -1
- package/src/proxy/canonical.ts +32 -0
- package/src/proxy/compaction.ts +29 -12
- package/src/proxy/constants.ts +10 -0
- package/src/proxy/fingerprint.ts +15 -0
- package/src/proxy/rewrite.ts +84 -10
- package/src/proxy/server.ts +30 -12
- package/src/proxy/sessions.ts +46 -0
- package/src/proxy/types.ts +24 -0
- package/src/transcript/constants.ts +0 -1
- package/src/transcript/locate.ts +1 -1
- package/src/transcript/messages.ts +29 -0
- package/src/cli/commands/install.ts +0 -44
package/src/proxy/server.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { claudeConfigDirs, loadConfig } from "../config"
|
|
2
|
+
import { claudeConfigDirs, loadConfig, settingsFor } from "../config"
|
|
3
3
|
import type { Config } from "../config/types"
|
|
4
|
+
import { wellFormed } from "../context/content"
|
|
4
5
|
import type { Body } from "../context/types"
|
|
5
6
|
import { findSessionFile } from "../transcript/locate"
|
|
6
7
|
import { prepareTranscript } from "../transcript/search"
|
|
7
8
|
import { VERSION } from "../version"
|
|
8
|
-
import { HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER } from "./constants"
|
|
9
|
+
import { CANONICAL_HEADER, HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER, WINDOW_HEADER } from "./constants"
|
|
10
|
+
import { applyCanonical } from "./canonical"
|
|
9
11
|
import { sizeOf } from "./estimate"
|
|
12
|
+
import { contextFingerprint } from "./fingerprint"
|
|
10
13
|
import { logRequest } from "./log"
|
|
11
|
-
import {
|
|
14
|
+
import { rewriteAsync } from "./rewrite"
|
|
15
|
+
import { recordSession } from "./sessions"
|
|
12
16
|
import { loadState, saveState } from "./state"
|
|
13
|
-
import type { Health, Note } from "./types"
|
|
17
|
+
import type { ContextFingerprint, Health, Note } from "./types"
|
|
14
18
|
import { inputTokens, watchUsage } from "./usage"
|
|
15
19
|
import { resolveWindow } from "./window"
|
|
16
20
|
|
|
@@ -31,17 +35,21 @@ function forwardableHeaders(headers: Headers) {
|
|
|
31
35
|
return out
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
async function prepare(request: Request, body: Body,
|
|
38
|
+
async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
|
|
35
39
|
const sessionId = sessionIdOf(request.headers, body)
|
|
40
|
+
const config = settingsFor(base, sessionId)
|
|
36
41
|
const trusted = config.eval?.trustHeaders === true
|
|
37
42
|
const transcriptOverride = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
|
|
38
43
|
const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
|
|
44
|
+
if (sessionId && !trusted) recordSession(sessionId, config.mode, sessionFile)
|
|
45
|
+
if (config.mode === "off") return rewriteAsync({ body, sessionId, config, transcript: null, window: null, mayCompact: false })
|
|
39
46
|
const transcript = sessionFile && sessionId
|
|
40
47
|
? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
|
|
41
48
|
: null
|
|
42
|
-
const needsWindow = config.
|
|
43
|
-
const
|
|
44
|
-
|
|
49
|
+
const needsWindow = !config.eval?.strategy || config.eval.strategy === "tail"
|
|
50
|
+
const windowOverride = trusted ? Number(request.headers.get(WINDOW_HEADER)) || null : null
|
|
51
|
+
const window = windowOverride ?? config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
|
|
52
|
+
return rewriteAsync({
|
|
45
53
|
body,
|
|
46
54
|
sessionId,
|
|
47
55
|
config,
|
|
@@ -67,15 +75,22 @@ export async function handle(request: Request): Promise<Response> {
|
|
|
67
75
|
let note: Note = { action: "pass" }
|
|
68
76
|
let threadKey: string | null = null
|
|
69
77
|
let sentSize = 0
|
|
78
|
+
let givenSize = 0
|
|
79
|
+
let sent: (ContextFingerprint & { session: string | null }) | null = null
|
|
70
80
|
|
|
71
81
|
if (bodyText && isMessages) {
|
|
72
82
|
try {
|
|
73
|
-
const
|
|
83
|
+
const given = JSON.parse(bodyText) as Body
|
|
84
|
+
const canonicalFile = config.eval?.trustHeaders ? request.headers.get(CANONICAL_HEADER) : null
|
|
85
|
+
const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
|
|
86
|
+
const body = canonical?.body ?? given
|
|
74
87
|
const result = await prepare(request, body, config, isCountTokens)
|
|
75
|
-
note = result.note
|
|
88
|
+
note = canonical ? { ...result.note, canonical: canonical.status } : result.note
|
|
76
89
|
threadKey = result.threadKey
|
|
77
|
-
if (result.body !==
|
|
90
|
+
if (result.body !== given) bodyText = JSON.stringify(result.body, wellFormed)
|
|
78
91
|
sentSize = sizeOf(result.body)
|
|
92
|
+
givenSize = result.body === given ? sentSize : sizeOf(given)
|
|
93
|
+
if (config.eval) sent = { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) }
|
|
79
94
|
} catch (error) {
|
|
80
95
|
note = { action: "error-passthrough", error: String(error) }
|
|
81
96
|
}
|
|
@@ -91,7 +106,7 @@ export async function handle(request: Request): Promise<Response> {
|
|
|
91
106
|
|
|
92
107
|
const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
|
|
93
108
|
if (note.action === "pass" || !isMessages || isCountTokens) {
|
|
94
|
-
if (note.action !== "pass") logRequest({ path: url.pathname, status: upstream.status, ...note })
|
|
109
|
+
if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
|
|
95
110
|
return response
|
|
96
111
|
}
|
|
97
112
|
|
|
@@ -106,6 +121,9 @@ export async function handle(request: Request): Promise<Response> {
|
|
|
106
121
|
cacheRead: usage.cache_read_input_tokens ?? 0,
|
|
107
122
|
cacheWrite: usage.cache_creation_input_tokens ?? 0,
|
|
108
123
|
uncached: usage.input_tokens ?? 0,
|
|
124
|
+
charsIn: givenSize,
|
|
125
|
+
charsOut: sentSize,
|
|
126
|
+
...(sent && { context: sent }),
|
|
109
127
|
})
|
|
110
128
|
if (threadKey && input > 0 && sentSize > 0) {
|
|
111
129
|
const state = loadState(threadKey)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { EUNOE_DIR, paths } from "../config/constants"
|
|
3
|
+
import type { Mode } from "../config/types"
|
|
4
|
+
import { SESSION_HEAD_BYTES, SESSION_SEEN_INTERVAL_MS, SESSIONS_KEPT } from "./constants"
|
|
5
|
+
import type { SeenSession } from "./types"
|
|
6
|
+
|
|
7
|
+
let seen: Record<string, SeenSession> | null = null
|
|
8
|
+
|
|
9
|
+
function load(): Record<string, SeenSession> {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(paths.sessions, "utf8"))
|
|
12
|
+
} catch {
|
|
13
|
+
return {}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cwdOf(sessionFile: string): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
const fd = openSync(sessionFile, "r")
|
|
20
|
+
const buffer = Buffer.alloc(SESSION_HEAD_BYTES)
|
|
21
|
+
const read = readSync(fd, buffer, 0, SESSION_HEAD_BYTES, 0)
|
|
22
|
+
closeSync(fd)
|
|
23
|
+
return buffer.subarray(0, read).toString("utf8").match(/"cwd":"((?:[^"\\]|\\.)*)"/)?.[1]
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function recordSession(sessionId: string, mode: Mode, sessionFile: string | null) {
|
|
30
|
+
seen ??= load()
|
|
31
|
+
const previous = seen[sessionId]
|
|
32
|
+
const now = Date.now()
|
|
33
|
+
const needsCwd = !previous?.cwd && sessionFile !== null
|
|
34
|
+
if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) < SESSION_SEEN_INTERVAL_MS) return
|
|
35
|
+
seen[sessionId] = { lastSeen: new Date(now).toISOString(), mode, cwd: previous?.cwd ?? (sessionFile ? cwdOf(sessionFile) : undefined) }
|
|
36
|
+
const kept = Object.entries(seen).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen)).slice(0, SESSIONS_KEPT)
|
|
37
|
+
seen = Object.fromEntries(kept)
|
|
38
|
+
try {
|
|
39
|
+
mkdirSync(EUNOE_DIR, { recursive: true })
|
|
40
|
+
writeFileSync(paths.sessions, JSON.stringify(seen, null, 2))
|
|
41
|
+
} catch {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function seenSessions(): Array<[string, SeenSession]> {
|
|
45
|
+
return Object.entries(load()).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen))
|
|
46
|
+
}
|
package/src/proxy/types.ts
CHANGED
|
@@ -7,10 +7,28 @@ export interface ThreadState {
|
|
|
7
7
|
keep?: number
|
|
8
8
|
cleared?: number
|
|
9
9
|
ratio?: number
|
|
10
|
+
recalls?: Record<string, string>
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export type Note = Record<string, unknown>
|
|
13
14
|
|
|
15
|
+
export interface ContextFingerprint {
|
|
16
|
+
system: string
|
|
17
|
+
tools: string
|
|
18
|
+
skills: string[]
|
|
19
|
+
given?: ContextFingerprint
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Canonical {
|
|
23
|
+
system: Body["system"]
|
|
24
|
+
tools: Body["tools"]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CanonicalResult {
|
|
28
|
+
body: Body
|
|
29
|
+
status: "applied" | "captured" | "skipped"
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export interface RewriteInput {
|
|
15
33
|
body: Body
|
|
16
34
|
sessionId: string | null
|
|
@@ -50,6 +68,12 @@ export interface Estimator {
|
|
|
50
68
|
|
|
51
69
|
export type Usage = Record<string, number>
|
|
52
70
|
|
|
71
|
+
export interface SeenSession {
|
|
72
|
+
lastSeen: string
|
|
73
|
+
mode: Config["mode"]
|
|
74
|
+
cwd?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
export interface Health {
|
|
54
78
|
ok: boolean
|
|
55
79
|
version: string
|
package/src/transcript/locate.ts
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs"
|
|
2
|
+
import type { Message } from "../context/types"
|
|
3
|
+
import type { SessionEntry } from "./types"
|
|
4
|
+
|
|
5
|
+
let cached: { file: string; mtimeMs: number; messages: Message[] } | null = null
|
|
6
|
+
|
|
7
|
+
export function sessionMessages(file: string): Message[] {
|
|
8
|
+
let mtimeMs: number
|
|
9
|
+
try {
|
|
10
|
+
mtimeMs = statSync(file).mtimeMs
|
|
11
|
+
} catch {
|
|
12
|
+
return []
|
|
13
|
+
}
|
|
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
|
+
}
|
|
27
|
+
cached = { file, mtimeMs, messages }
|
|
28
|
+
return messages
|
|
29
|
+
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
|
-
import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
|
|
4
|
-
import { paths } from "../../config/constants"
|
|
5
|
-
import { flagValues } from "../args"
|
|
6
|
-
import { editSettings, proxyUrl, withEunoe } from "../claude-settings"
|
|
7
|
-
import { MODE_DESCRIPTIONS } from "../constants"
|
|
8
|
-
import { routedDirs } from "../inspect"
|
|
9
|
-
import * as launchd from "../launchd"
|
|
10
|
-
import type { Command } from "../types"
|
|
11
|
-
import { c, fail, line, listFiles, note, ok, rows, tilde, warn } from "../ui"
|
|
12
|
-
|
|
13
|
-
export const install: Command = {
|
|
14
|
-
summary: "Route Claude Code through eunoe and keep it running",
|
|
15
|
-
usage: "eunoe install [--config-dir <dir>]",
|
|
16
|
-
details: [
|
|
17
|
-
"Starts the proxy in the background and points Claude Code at it by setting",
|
|
18
|
-
"ANTHROPIC_BASE_URL in settings.json. New sessions pick it up; restart running ones.",
|
|
19
|
-
],
|
|
20
|
-
options: [["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"]],
|
|
21
|
-
run(args) {
|
|
22
|
-
const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
|
|
23
|
-
for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
|
|
24
|
-
if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
|
|
25
|
-
const config = loadConfig()
|
|
26
|
-
|
|
27
|
-
line()
|
|
28
|
-
if (launchd.supported) {
|
|
29
|
-
launchd.installAgent([process.execPath, path.join(import.meta.dir, "..", "main.ts"), "serve"], paths.proxyLog)
|
|
30
|
-
ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
|
|
31
|
-
} else {
|
|
32
|
-
warn("No launchd here, so keep the proxy running yourself:")
|
|
33
|
-
line(` ${c.cyan("eunoe serve")}`)
|
|
34
|
-
}
|
|
35
|
-
const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, config.mode))
|
|
36
|
-
ok("Claude Code routed through eunoe")
|
|
37
|
-
listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
|
|
38
|
-
line()
|
|
39
|
-
rows([["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`]])
|
|
40
|
-
line()
|
|
41
|
-
note(`New sessions go through eunoe. Restart any that are already running.`)
|
|
42
|
-
line()
|
|
43
|
-
},
|
|
44
|
-
}
|