subcortex 0.3.0__py3-none-any.whl
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.
- subcortex/__init__.py +3 -0
- subcortex/__main__.py +3 -0
- subcortex/adapters/__init__.py +48 -0
- subcortex/adapters/base.py +230 -0
- subcortex/adapters/claude_family.py +133 -0
- subcortex/adapters/codex.py +87 -0
- subcortex/adapters/copilot.py +60 -0
- subcortex/adapters/cursor.py +36 -0
- subcortex/adapters/docker_agent.py +115 -0
- subcortex/adapters/gemini_family.py +60 -0
- subcortex/adapters/grok.py +98 -0
- subcortex/adapters/kimi_code.py +138 -0
- subcortex/adapters/letta_vibe.py +96 -0
- subcortex/adapters/openhands.py +153 -0
- subcortex/auth.py +59 -0
- subcortex/backends/__init__.py +23 -0
- subcortex/backends/base.py +22 -0
- subcortex/backends/jev.py +460 -0
- subcortex/backends/laya.py +149 -0
- subcortex/cli.py +809 -0
- subcortex/client.py +77 -0
- subcortex/config.py +263 -0
- subcortex/daemon.py +502 -0
- subcortex/evalset.py +241 -0
- subcortex/hook.py +254 -0
- subcortex/installers/__init__.py +62 -0
- subcortex/installers/amp.py +39 -0
- subcortex/installers/base.py +874 -0
- subcortex/installers/claude_family.py +229 -0
- subcortex/installers/codex.py +110 -0
- subcortex/installers/copilot.py +65 -0
- subcortex/installers/crush.py +36 -0
- subcortex/installers/cursor.py +79 -0
- subcortex/installers/gemini_family.py +83 -0
- subcortex/installers/goose.py +186 -0
- subcortex/installers/kimi_code.py +71 -0
- subcortex/installers/mcp_only.py +111 -0
- subcortex/installers/more_hooks.py +184 -0
- subcortex/installers/opencode.py +66 -0
- subcortex/installers/openhands.py +84 -0
- subcortex/installers/pi_cline.py +53 -0
- subcortex/ledger.py +92 -0
- subcortex/localhttp.py +59 -0
- subcortex/mcp_server.py +187 -0
- subcortex/metrics.py +56 -0
- subcortex/plugins/amp/subcortex.ts +258 -0
- subcortex/plugins/cline/subcortex.ts +340 -0
- subcortex/plugins/opencode/subcortex.ts +265 -0
- subcortex/plugins/pi/subcortex.ts +292 -0
- subcortex/policy.py +341 -0
- subcortex/presets.py +163 -0
- subcortex/provision.py +188 -0
- subcortex/service.py +149 -0
- subcortex/state.py +137 -0
- subcortex/transcript.py +211 -0
- subcortex/tuis.py +51 -0
- subcortex/ui.py +319 -0
- subcortex/verdicts.py +233 -0
- subcortex/wizard.py +474 -0
- subcortex-0.3.0.dist-info/METADATA +287 -0
- subcortex-0.3.0.dist-info/RECORD +64 -0
- subcortex-0.3.0.dist-info/WHEEL +5 -0
- subcortex-0.3.0.dist-info/entry_points.txt +3 -0
- subcortex-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// subcortex plugin for Amp (ampcode.com, Neo plugin API).
|
|
2
|
+
//
|
|
3
|
+
// Installed by `subcortex install amp` to ~/.config/amp/plugins/subcortex.ts.
|
|
4
|
+
// All decisions come from the local subcortex daemon's policy endpoints — this
|
|
5
|
+
// file holds no thresholds or heuristics of its own:
|
|
6
|
+
//
|
|
7
|
+
// agent.start -> POST /v1/prompt-hint (+ /v1/restore after a compaction);
|
|
8
|
+
// the text is appended to the user message, hidden from the UI
|
|
9
|
+
// tool.result -> POST /v1/tool-output for shell results; a replacement keeps
|
|
10
|
+
// the original status, only the output changes
|
|
11
|
+
// agent.end -> POST /v1/snapshot (rolling: Amp has no compaction event)
|
|
12
|
+
// session.start-> primes compaction detection (the first message a new turn
|
|
13
|
+
// would see changes identity after each compaction)
|
|
14
|
+
//
|
|
15
|
+
// Fail-open everywhere: every handler catches everything, every request has a
|
|
16
|
+
// timeout, and nothing here ever cancels, rejects, continues or blocks a turn.
|
|
17
|
+
// `tool.call` is deliberately not registered.
|
|
18
|
+
|
|
19
|
+
import type { PluginAPI } from "@ampcode/plugin"
|
|
20
|
+
|
|
21
|
+
export const description = "subcortex: prompt hints, large-output trimming, compaction snapshots"
|
|
22
|
+
|
|
23
|
+
const TEMPLATED_URL = "__SUBCORTEX_URL__" // replaced by the installer
|
|
24
|
+
const BASE = (
|
|
25
|
+
process.env.SUBCORTEX_URL || (TEMPLATED_URL.startsWith("http") ? TEMPLATED_URL : "http://127.0.0.1:7707")
|
|
26
|
+
).replace(/\/+$/, "")
|
|
27
|
+
|
|
28
|
+
const PROMPT_TIMEOUT_MS = 1500
|
|
29
|
+
const OUTPUT_TIMEOUT_MS = 3000
|
|
30
|
+
const LOCAL_MIN_OUTPUT_CHARS = 2000 // cheap pre-filter; the daemon applies the real threshold
|
|
31
|
+
|
|
32
|
+
const PLUGIN_TUI = "amp" // keeps sessions of different TUIs apart in the daemon
|
|
33
|
+
|
|
34
|
+
// >>> subcortex transport (identical in every subcortex plugin) >>>
|
|
35
|
+
// The daemon is reached over a raw TCP socket, one HTTP/1.0 request per
|
|
36
|
+
// connection. Not fetch: Bun sends even loopback requests through HTTP_PROXY,
|
|
37
|
+
// which breaks every call or hands prompts and output to a proxy. Our side is
|
|
38
|
+
// never half-closed before the reply (Bun then drops the response). node:net is
|
|
39
|
+
// imported lazily: a static import that fails to resolve would stop the host
|
|
40
|
+
// from starting. Every failure, timeout or abort resolves to null.
|
|
41
|
+
const DAEMON = (() => {
|
|
42
|
+
try {
|
|
43
|
+
const url = new URL(BASE)
|
|
44
|
+
return { host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port) || 80 }
|
|
45
|
+
} catch {
|
|
46
|
+
return { host: "127.0.0.1", port: 7707 }
|
|
47
|
+
}
|
|
48
|
+
})()
|
|
49
|
+
const MAX_RESPONSE_BYTES = 8_000_000
|
|
50
|
+
const TEMPLATED_TOKEN_FILE = "__SUBCORTEX_TOKEN_FILE__" // replaced by the installer
|
|
51
|
+
let net: any = undefined // undefined: not loaded yet; null: unavailable, use fetch
|
|
52
|
+
let token: string | undefined // the daemon's per-user token (a 0600 file in its data dir)
|
|
53
|
+
|
|
54
|
+
async function daemonToken(): Promise<string> {
|
|
55
|
+
if (token !== undefined) return token
|
|
56
|
+
try {
|
|
57
|
+
const fs = await import("node:fs")
|
|
58
|
+
const dir = process.env.SUBCORTEX_DATA_DIR
|
|
59
|
+
const file = dir
|
|
60
|
+
? dir.replace(/\/+$/, "") + "/token"
|
|
61
|
+
: TEMPLATED_TOKEN_FILE.startsWith("/")
|
|
62
|
+
? TEMPLATED_TOKEN_FILE
|
|
63
|
+
: (process.env.HOME ?? "") + "/.local/share/subcortex/token"
|
|
64
|
+
token = String(fs.readFileSync(file, "utf8")).trim()
|
|
65
|
+
} catch {
|
|
66
|
+
token = ""
|
|
67
|
+
}
|
|
68
|
+
if (!/^[0-9a-f]*$/.test(token)) token = ""
|
|
69
|
+
return token
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function post(path: string, body: unknown, ms: number, outer?: AbortSignal): Promise<any> {
|
|
73
|
+
try {
|
|
74
|
+
if (outer?.aborted) return null
|
|
75
|
+
if (net === undefined) {
|
|
76
|
+
try {
|
|
77
|
+
net = await import("node:net")
|
|
78
|
+
} catch {
|
|
79
|
+
net = null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const payload = JSON.stringify({ tui: PLUGIN_TUI, ...(body as object) })
|
|
83
|
+
const secret = await daemonToken()
|
|
84
|
+
const reply = net ? await viaSocket(path, payload, secret, ms, outer) : await viaFetch(path, payload, secret, ms, outer)
|
|
85
|
+
if (reply === null) token = undefined // re-read next time: the daemon may have made a new one
|
|
86
|
+
return reply
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function viaSocket(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
const chunks: any[] = []
|
|
95
|
+
let size = 0
|
|
96
|
+
let settled = false
|
|
97
|
+
let sock: any
|
|
98
|
+
const finish = (value: any) => {
|
|
99
|
+
if (settled) return
|
|
100
|
+
settled = true
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
outer?.removeEventListener("abort", abort)
|
|
103
|
+
try {
|
|
104
|
+
sock?.destroy()
|
|
105
|
+
} catch {}
|
|
106
|
+
resolve(value)
|
|
107
|
+
}
|
|
108
|
+
const abort = () => finish(null)
|
|
109
|
+
const timer = setTimeout(abort, ms)
|
|
110
|
+
outer?.addEventListener("abort", abort, { once: true })
|
|
111
|
+
try {
|
|
112
|
+
const bytes = Buffer.from(payload, "utf8")
|
|
113
|
+
sock = net.connect({ host: DAEMON.host, port: DAEMON.port })
|
|
114
|
+
sock.on("connect", () => {
|
|
115
|
+
sock.write(`POST ${path} HTTP/1.0\r\nHost: ${DAEMON.host}\r\nContent-Type: application/json\r\n` +
|
|
116
|
+
`Content-Length: ${bytes.length}\r\nX-Subcortex-Token: ${secret}\r\nX-Subcortex-Timeout-Ms: ${ms}\r\n\r\n`)
|
|
117
|
+
sock.write(bytes)
|
|
118
|
+
})
|
|
119
|
+
sock.on("data", (chunk: any) => {
|
|
120
|
+
size += chunk.length
|
|
121
|
+
if (size > MAX_RESPONSE_BYTES) finish(null)
|
|
122
|
+
else chunks.push(chunk)
|
|
123
|
+
})
|
|
124
|
+
sock.on("error", abort)
|
|
125
|
+
sock.on("close", () => finish(parseReply(Buffer.concat(chunks).toString("utf8"))))
|
|
126
|
+
} catch {
|
|
127
|
+
finish(null)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function viaFetch(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
|
|
133
|
+
const controller = new AbortController()
|
|
134
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
135
|
+
const abort = () => controller.abort()
|
|
136
|
+
outer?.addEventListener("abort", abort, { once: true })
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(BASE + path, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "content-type": "application/json", "x-subcortex-token": secret, "x-subcortex-timeout-ms": String(ms) },
|
|
141
|
+
body: payload,
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
})
|
|
144
|
+
return parseReply(`HTTP/1.0 ${res.status} -\r\n\r\n${await res.text()}`)
|
|
145
|
+
} catch {
|
|
146
|
+
return null
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer)
|
|
149
|
+
outer?.removeEventListener("abort", abort)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseReply(raw: string): any {
|
|
154
|
+
const split = raw.indexOf("\r\n\r\n")
|
|
155
|
+
if (split < 0 || !/^HTTP\/1\.[01] 200 /.test(raw)) return null
|
|
156
|
+
try {
|
|
157
|
+
const data = JSON.parse(raw.slice(split + 4))
|
|
158
|
+
return data && typeof data === "object" && data.success !== false ? data : null
|
|
159
|
+
} catch {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// <<< subcortex transport <<<
|
|
164
|
+
|
|
165
|
+
function outputText(output: unknown): string | null {
|
|
166
|
+
if (typeof output === "string") return output
|
|
167
|
+
if (output && typeof output === "object" && typeof (output as any).output === "string") {
|
|
168
|
+
return (output as any).output
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default function (amp: PluginAPI) {
|
|
174
|
+
// threadID -> id of the first message a new turn would see
|
|
175
|
+
const firstSeen = new Map<string, string>()
|
|
176
|
+
|
|
177
|
+
async function compactedSince(threadID: string, ctx: any): Promise<boolean> {
|
|
178
|
+
try {
|
|
179
|
+
const head = await ctx.thread.messages({ from: "start", limit: 1 })
|
|
180
|
+
const id = head && head[0] ? String(head[0].id) : ""
|
|
181
|
+
const prev = firstSeen.get(threadID)
|
|
182
|
+
if (id) {
|
|
183
|
+
firstSeen.delete(threadID) // re-insert: Map order is least recently used first
|
|
184
|
+
firstSeen.set(threadID, id)
|
|
185
|
+
if (firstSeen.size > 256) firstSeen.delete(firstSeen.keys().next().value as string)
|
|
186
|
+
}
|
|
187
|
+
return prev !== undefined && id !== "" && id !== prev
|
|
188
|
+
} catch {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const local = () => {
|
|
194
|
+
try {
|
|
195
|
+
return amp.system.executor.kind === "local"
|
|
196
|
+
} catch {
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
amp.on("session.start", async (event: any, ctx: any) => {
|
|
202
|
+
try {
|
|
203
|
+
await compactedSince(event.thread.id, ctx)
|
|
204
|
+
} catch {}
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
amp.on("agent.start", async (event: any, ctx: any) => {
|
|
208
|
+
try {
|
|
209
|
+
if (!local()) return {}
|
|
210
|
+
const thread = String(event.thread.id)
|
|
211
|
+
const parts: string[] = []
|
|
212
|
+
if (await compactedSince(thread, ctx)) {
|
|
213
|
+
const restored = await post("/v1/restore", { session_id: thread }, PROMPT_TIMEOUT_MS)
|
|
214
|
+
if (typeof restored?.context === "string") parts.push(restored.context)
|
|
215
|
+
}
|
|
216
|
+
const hint = await post("/v1/prompt-hint", { prompt: String(event.message ?? ""), session_id: thread }, PROMPT_TIMEOUT_MS)
|
|
217
|
+
if (typeof hint?.hint === "string") parts.push(hint.hint)
|
|
218
|
+
return parts.length ? { message: { content: parts.join("\n\n"), display: false } } : {}
|
|
219
|
+
} catch {
|
|
220
|
+
return {}
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
amp.on("tool.result", async (event: any) => {
|
|
225
|
+
try {
|
|
226
|
+
if (event.status !== "done" || !local()) return
|
|
227
|
+
const shell = amp.helpers.shellCommandFromToolCall(event)
|
|
228
|
+
if (!shell) return
|
|
229
|
+
const text = outputText(event.output)
|
|
230
|
+
if (text === null || text.length < LOCAL_MIN_OUTPUT_CHARS) return
|
|
231
|
+
const exit = (event.output as any)?.exitCode
|
|
232
|
+
if (typeof exit === "number" && exit !== 0) return // failures stay whole: Amp reports them as status "done"
|
|
233
|
+
const res = await post(
|
|
234
|
+
"/v1/tool-output",
|
|
235
|
+
{ output: text, tool: String(event.tool ?? "Bash"), input: { command: shell.command },
|
|
236
|
+
session_id: String(event?.thread?.id ?? "") },
|
|
237
|
+
OUTPUT_TIMEOUT_MS,
|
|
238
|
+
)
|
|
239
|
+
const replacement = res?.replacement
|
|
240
|
+
if (typeof replacement !== "string") return
|
|
241
|
+
const output = typeof event.output === "string" ? replacement : { ...(event.output as object), output: replacement }
|
|
242
|
+
return { status: "done", output }
|
|
243
|
+
} catch {
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
amp.on("agent.end", async (event: any, ctx: any) => {
|
|
249
|
+
try {
|
|
250
|
+
let messages = event.messages
|
|
251
|
+
try {
|
|
252
|
+
messages = await ctx.thread.messages({ full: true, from: "end", limit: 20 })
|
|
253
|
+
} catch {}
|
|
254
|
+
void post("/v1/snapshot", { session_id: String(event.thread.id), messages }, PROMPT_TIMEOUT_MS)
|
|
255
|
+
} catch {}
|
|
256
|
+
return // never {action: "continue"}
|
|
257
|
+
})
|
|
258
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// subcortex plugin for Cline CLI (cline >= 3.0.62, SDK @cline/core >= 0.0.83).
|
|
2
|
+
//
|
|
3
|
+
// Installed by `subcortex install cline` as ONE file at <CLINE_DIR>/plugins/subcortex.ts
|
|
4
|
+
// (default ~/.cline/plugins/subcortex.ts). Cline discovers every .ts/.js file under
|
|
5
|
+
// that directory. Do not put helper files next to it, or they load as plugins too.
|
|
6
|
+
// Cline runs plugins in a sandbox subprocess (Node or Bun, loaded through jiti)
|
|
7
|
+
// and proxies hooks over JSON IPC. All decisions come from the local subcortex
|
|
8
|
+
// daemon's policy endpoints; this file holds no thresholds of its own:
|
|
9
|
+
//
|
|
10
|
+
// beforeModel -> B1: POST /v1/prompt-hint once per new user prompt (root agent
|
|
11
|
+
// only); the hint is appended as an extra text part to that
|
|
12
|
+
// user message in every later provider request (request-only,
|
|
13
|
+
// never persisted).
|
|
14
|
+
// B3+B4: Cline compacts inside prepareTurn, just before this
|
|
15
|
+
// hook runs, and only changes the request (the transcript in
|
|
16
|
+
// snapshot.messages stays full). When a new compaction summary
|
|
17
|
+
// (metadata.kind === "compaction_summary") shows up: POST
|
|
18
|
+
// /v1/snapshot with the transcript tail, then POST /v1/restore,
|
|
19
|
+
// and append the restored text to that summary message on every
|
|
20
|
+
// request while that summary is live.
|
|
21
|
+
// afterTool -> B2: POST /v1/tool-output for each successful run_commands entry
|
|
22
|
+
// (success === true); only `result` of that entry changes.
|
|
23
|
+
//
|
|
24
|
+
// FAIL-OPEN IS ON US: Cline does NOT catch plugin hook errors. A throw, a
|
|
25
|
+
// rejection, or a sandbox call over 3000 ms (the host kills the sandbox process)
|
|
26
|
+
// fails the whole run. So every hook is raced against a 2300 ms deadline and
|
|
27
|
+
// resolves to undefined on anything unexpected. We never return stop, skip,
|
|
28
|
+
// policy, input, tools, options or appendContext. `onEvent` is deliberately not
|
|
29
|
+
// registered: it would be awaited over IPC for every streamed token.
|
|
30
|
+
|
|
31
|
+
import type { AgentPlugin } from "@cline/core"
|
|
32
|
+
|
|
33
|
+
const TEMPLATED_URL = "__SUBCORTEX_URL__" // replaced by the installer
|
|
34
|
+
const BASE = (
|
|
35
|
+
process.env.SUBCORTEX_URL || (TEMPLATED_URL.startsWith("http") ? TEMPLATED_URL : "http://127.0.0.1:7707")
|
|
36
|
+
).replace(/\/+$/, "")
|
|
37
|
+
|
|
38
|
+
const HOOK_DEADLINE_MS = 2300 // Cline's sandbox hook timeout is a hard 3000 ms (not configurable)
|
|
39
|
+
const PROMPT_TIMEOUT_MS = 1200
|
|
40
|
+
const SNAPSHOT_TIMEOUT_MS = 800
|
|
41
|
+
const RESTORE_TIMEOUT_MS = 1000
|
|
42
|
+
const OUTPUT_TIMEOUT_MS = 2000
|
|
43
|
+
const LOCAL_MIN_OUTPUT_CHARS = 2000 // cheap pre-filter; the daemon applies the real threshold
|
|
44
|
+
const SNAPSHOT_MESSAGES = 20
|
|
45
|
+
const MAX_ENTRIES = 256
|
|
46
|
+
|
|
47
|
+
const PLUGIN_TUI = "cline" // keeps sessions of different TUIs apart in the daemon
|
|
48
|
+
|
|
49
|
+
// >>> subcortex transport (identical in every subcortex plugin) >>>
|
|
50
|
+
// The daemon is reached over a raw TCP socket, one HTTP/1.0 request per
|
|
51
|
+
// connection. Not fetch: Bun sends even loopback requests through HTTP_PROXY,
|
|
52
|
+
// which breaks every call or hands prompts and output to a proxy. Our side is
|
|
53
|
+
// never half-closed before the reply (Bun then drops the response). node:net is
|
|
54
|
+
// imported lazily: a static import that fails to resolve would stop the host
|
|
55
|
+
// from starting. Every failure, timeout or abort resolves to null.
|
|
56
|
+
const DAEMON = (() => {
|
|
57
|
+
try {
|
|
58
|
+
const url = new URL(BASE)
|
|
59
|
+
return { host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port) || 80 }
|
|
60
|
+
} catch {
|
|
61
|
+
return { host: "127.0.0.1", port: 7707 }
|
|
62
|
+
}
|
|
63
|
+
})()
|
|
64
|
+
const MAX_RESPONSE_BYTES = 8_000_000
|
|
65
|
+
const TEMPLATED_TOKEN_FILE = "__SUBCORTEX_TOKEN_FILE__" // replaced by the installer
|
|
66
|
+
let net: any = undefined // undefined: not loaded yet; null: unavailable, use fetch
|
|
67
|
+
let token: string | undefined // the daemon's per-user token (a 0600 file in its data dir)
|
|
68
|
+
|
|
69
|
+
async function daemonToken(): Promise<string> {
|
|
70
|
+
if (token !== undefined) return token
|
|
71
|
+
try {
|
|
72
|
+
const fs = await import("node:fs")
|
|
73
|
+
const dir = process.env.SUBCORTEX_DATA_DIR
|
|
74
|
+
const file = dir
|
|
75
|
+
? dir.replace(/\/+$/, "") + "/token"
|
|
76
|
+
: TEMPLATED_TOKEN_FILE.startsWith("/")
|
|
77
|
+
? TEMPLATED_TOKEN_FILE
|
|
78
|
+
: (process.env.HOME ?? "") + "/.local/share/subcortex/token"
|
|
79
|
+
token = String(fs.readFileSync(file, "utf8")).trim()
|
|
80
|
+
} catch {
|
|
81
|
+
token = ""
|
|
82
|
+
}
|
|
83
|
+
if (!/^[0-9a-f]*$/.test(token)) token = ""
|
|
84
|
+
return token
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function post(path: string, body: unknown, ms: number, outer?: AbortSignal): Promise<any> {
|
|
88
|
+
try {
|
|
89
|
+
if (outer?.aborted) return null
|
|
90
|
+
if (net === undefined) {
|
|
91
|
+
try {
|
|
92
|
+
net = await import("node:net")
|
|
93
|
+
} catch {
|
|
94
|
+
net = null
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const payload = JSON.stringify({ tui: PLUGIN_TUI, ...(body as object) })
|
|
98
|
+
const secret = await daemonToken()
|
|
99
|
+
const reply = net ? await viaSocket(path, payload, secret, ms, outer) : await viaFetch(path, payload, secret, ms, outer)
|
|
100
|
+
if (reply === null) token = undefined // re-read next time: the daemon may have made a new one
|
|
101
|
+
return reply
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function viaSocket(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
|
|
108
|
+
return new Promise((resolve) => {
|
|
109
|
+
const chunks: any[] = []
|
|
110
|
+
let size = 0
|
|
111
|
+
let settled = false
|
|
112
|
+
let sock: any
|
|
113
|
+
const finish = (value: any) => {
|
|
114
|
+
if (settled) return
|
|
115
|
+
settled = true
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
outer?.removeEventListener("abort", abort)
|
|
118
|
+
try {
|
|
119
|
+
sock?.destroy()
|
|
120
|
+
} catch {}
|
|
121
|
+
resolve(value)
|
|
122
|
+
}
|
|
123
|
+
const abort = () => finish(null)
|
|
124
|
+
const timer = setTimeout(abort, ms)
|
|
125
|
+
outer?.addEventListener("abort", abort, { once: true })
|
|
126
|
+
try {
|
|
127
|
+
const bytes = Buffer.from(payload, "utf8")
|
|
128
|
+
sock = net.connect({ host: DAEMON.host, port: DAEMON.port })
|
|
129
|
+
sock.on("connect", () => {
|
|
130
|
+
sock.write(`POST ${path} HTTP/1.0\r\nHost: ${DAEMON.host}\r\nContent-Type: application/json\r\n` +
|
|
131
|
+
`Content-Length: ${bytes.length}\r\nX-Subcortex-Token: ${secret}\r\nX-Subcortex-Timeout-Ms: ${ms}\r\n\r\n`)
|
|
132
|
+
sock.write(bytes)
|
|
133
|
+
})
|
|
134
|
+
sock.on("data", (chunk: any) => {
|
|
135
|
+
size += chunk.length
|
|
136
|
+
if (size > MAX_RESPONSE_BYTES) finish(null)
|
|
137
|
+
else chunks.push(chunk)
|
|
138
|
+
})
|
|
139
|
+
sock.on("error", abort)
|
|
140
|
+
sock.on("close", () => finish(parseReply(Buffer.concat(chunks).toString("utf8"))))
|
|
141
|
+
} catch {
|
|
142
|
+
finish(null)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function viaFetch(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
|
|
148
|
+
const controller = new AbortController()
|
|
149
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
150
|
+
const abort = () => controller.abort()
|
|
151
|
+
outer?.addEventListener("abort", abort, { once: true })
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(BASE + path, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/json", "x-subcortex-token": secret, "x-subcortex-timeout-ms": String(ms) },
|
|
156
|
+
body: payload,
|
|
157
|
+
signal: controller.signal,
|
|
158
|
+
})
|
|
159
|
+
return parseReply(`HTTP/1.0 ${res.status} -\r\n\r\n${await res.text()}`)
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
} finally {
|
|
163
|
+
clearTimeout(timer)
|
|
164
|
+
outer?.removeEventListener("abort", abort)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseReply(raw: string): any {
|
|
169
|
+
const split = raw.indexOf("\r\n\r\n")
|
|
170
|
+
if (split < 0 || !/^HTTP\/1\.[01] 200 /.test(raw)) return null
|
|
171
|
+
try {
|
|
172
|
+
const data = JSON.parse(raw.slice(split + 4))
|
|
173
|
+
return data && typeof data === "object" && data.success !== false ? data : null
|
|
174
|
+
} catch {
|
|
175
|
+
return null
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// <<< subcortex transport <<<
|
|
179
|
+
|
|
180
|
+
// Resolve to `fallback` on error or after `ms`, whichever comes first. Never rejects.
|
|
181
|
+
function withDeadline<T>(work: () => Promise<T>, ms: number, fallback: T): Promise<T> {
|
|
182
|
+
return new Promise<T>((resolve) => {
|
|
183
|
+
const timer = setTimeout(() => resolve(fallback), ms)
|
|
184
|
+
let promise: Promise<T>
|
|
185
|
+
try {
|
|
186
|
+
promise = work()
|
|
187
|
+
} catch {
|
|
188
|
+
promise = Promise.resolve(fallback)
|
|
189
|
+
}
|
|
190
|
+
promise.then(
|
|
191
|
+
(value) => {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
resolve(value)
|
|
194
|
+
},
|
|
195
|
+
() => {
|
|
196
|
+
clearTimeout(timer)
|
|
197
|
+
resolve(fallback)
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function remember<V>(map: Map<string, V>, key: string, value: V) {
|
|
204
|
+
map.delete(key)
|
|
205
|
+
map.set(key, value)
|
|
206
|
+
while (map.size > MAX_ENTRIES) map.delete(map.keys().next().value as string)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const meta = (m: any): Record<string, any> => (m && m.metadata && typeof m.metadata === "object" ? m.metadata : {})
|
|
210
|
+
const isSummary = (m: any) => meta(m).kind === "compaction_summary"
|
|
211
|
+
|
|
212
|
+
// A prompt the user typed. Excludes <hook_context> blocks (displayRole "system"),
|
|
213
|
+
// runtime reminders (userRunSpan 0) and compaction summaries.
|
|
214
|
+
function isUserPrompt(m: any): boolean {
|
|
215
|
+
if (!m || m.role !== "user" || !Array.isArray(m.content)) return false
|
|
216
|
+
const md = meta(m)
|
|
217
|
+
if (md.kind === "compaction_summary" || md.displayRole === "system" || md.userRunSpan === 0) return false
|
|
218
|
+
return m.content.some((p: any) => p && p.type === "text" && typeof p.text === "string")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Cline wraps typed input as <user_input mode="act">…</user_input> and may add <mode_notice> blocks.
|
|
222
|
+
function promptText(m: any): string {
|
|
223
|
+
return m.content
|
|
224
|
+
.filter((p: any) => p && p.type === "text" && typeof p.text === "string")
|
|
225
|
+
.map((p: any) => p.text)
|
|
226
|
+
.join("\n")
|
|
227
|
+
.replace(/<mode_notice>[\s\S]*?<\/mode_notice>/g, "")
|
|
228
|
+
.replace(/<\/?user_(?:input|command)\b[^>]*>/g, "")
|
|
229
|
+
.trim()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function summaryKey(m: any): string {
|
|
233
|
+
const md = meta(m)
|
|
234
|
+
const text = Array.isArray(m?.content) ? m.content.map((p: any) => (p && typeof p.text === "string" ? p.text : "")).join("") : ""
|
|
235
|
+
return `${md.generatedAt ?? ""}:${md.tokensBefore ?? ""}:${text.length}:${text.slice(0, 64)}`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const hints = new Map<string, string | null>() // prompt text -> hint (null = none / pending)
|
|
239
|
+
const restores = new Map<string, string | null>() // summary key -> restored context
|
|
240
|
+
const lastSummary = new Map<string, string>() // conversation -> summary key seen on the previous request
|
|
241
|
+
|
|
242
|
+
async function beforeModel(ctx: any): Promise<any> {
|
|
243
|
+
const messages = ctx?.request?.messages
|
|
244
|
+
if (!Array.isArray(messages) || messages.length === 0) return undefined
|
|
245
|
+
const snapshot = ctx?.snapshot ?? {}
|
|
246
|
+
const conversation = String(snapshot.conversationId ?? snapshot.agentId ?? "")
|
|
247
|
+
const rootAgent = snapshot.parentAgentId == null
|
|
248
|
+
const work: Promise<unknown>[] = []
|
|
249
|
+
|
|
250
|
+
// B1: one daemon call per new user prompt.
|
|
251
|
+
if (rootAgent) {
|
|
252
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
253
|
+
if (!isUserPrompt(messages[i])) continue
|
|
254
|
+
const text = promptText(messages[i])
|
|
255
|
+
if (text && !hints.has(text)) {
|
|
256
|
+
remember(hints, text, null)
|
|
257
|
+
work.push(
|
|
258
|
+
post("/v1/prompt-hint", { prompt: text, session_id: conversation }, PROMPT_TIMEOUT_MS).then((res) => {
|
|
259
|
+
if (typeof res?.hint === "string" && res.hint.trim()) remember(hints, text, res.hint)
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// B3 + B4: detect a compaction that happened in this request's prepareTurn.
|
|
268
|
+
let summaryIndex = -1
|
|
269
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
270
|
+
if (isSummary(messages[i])) {
|
|
271
|
+
summaryIndex = i
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const key = summaryIndex >= 0 ? summaryKey(messages[summaryIndex]) : ""
|
|
276
|
+
const previous = lastSummary.get(conversation)
|
|
277
|
+
if (conversation) remember(lastSummary, conversation, key)
|
|
278
|
+
// `previous === undefined` means the first request we see for this conversation
|
|
279
|
+
// (a new session, a resume, or a respawned sandbox). Record the baseline without
|
|
280
|
+
// restoring, so an old summary does not look like a fresh compaction.
|
|
281
|
+
if (conversation && key && previous !== undefined && previous !== key && !restores.has(key)) {
|
|
282
|
+
remember(restores, key, null)
|
|
283
|
+
const recent = Array.isArray(snapshot.messages) ? snapshot.messages.slice(-SNAPSHOT_MESSAGES) : []
|
|
284
|
+
work.push(
|
|
285
|
+
(async () => {
|
|
286
|
+
await post("/v1/snapshot", { session_id: conversation, messages: recent }, SNAPSHOT_TIMEOUT_MS)
|
|
287
|
+
const res = await post("/v1/restore", { session_id: conversation }, RESTORE_TIMEOUT_MS)
|
|
288
|
+
if (typeof res?.context === "string" && res.context.trim()) remember(restores, key, res.context)
|
|
289
|
+
})().catch(() => {}),
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await Promise.all(work)
|
|
294
|
+
|
|
295
|
+
let changed = false
|
|
296
|
+
const out = messages.map((m: any, i: number) => {
|
|
297
|
+
let extra: string | null | undefined
|
|
298
|
+
if (i === summaryIndex) extra = restores.get(key)
|
|
299
|
+
else if (rootAgent && isUserPrompt(m)) extra = hints.get(promptText(m))
|
|
300
|
+
if (!extra) return m
|
|
301
|
+
changed = true
|
|
302
|
+
return { ...m, content: [...m.content, { type: "text", text: extra }] }
|
|
303
|
+
})
|
|
304
|
+
return changed ? { messages: out } : undefined // full list; replaces the request's messages
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function afterTool(ctx: any): Promise<any> {
|
|
308
|
+
const name = ctx?.toolCall?.toolName ?? ctx?.tool?.name
|
|
309
|
+
const result = ctx?.result
|
|
310
|
+
if (name !== "run_commands" || !result || result.isError === true || !Array.isArray(result.output)) return undefined
|
|
311
|
+
let changed = false
|
|
312
|
+
const output = await Promise.all(
|
|
313
|
+
result.output.map(async (entry: any) => {
|
|
314
|
+
// ToolOperationResult {query, result, error?, success}; success=false means non-zero exit/timeout.
|
|
315
|
+
if (!entry || entry.success !== true || typeof entry.result !== "string") return entry
|
|
316
|
+
if (entry.result.length < LOCAL_MIN_OUTPUT_CHARS) return entry
|
|
317
|
+
const res = await post(
|
|
318
|
+
"/v1/tool-output",
|
|
319
|
+
{ output: entry.result, tool: "run_commands", input: { command: String(entry.query ?? "") },
|
|
320
|
+
session_id: String(ctx?.snapshot?.conversationId ?? ctx?.snapshot?.agentId ?? "") },
|
|
321
|
+
OUTPUT_TIMEOUT_MS,
|
|
322
|
+
)
|
|
323
|
+
if (typeof res?.replacement !== "string") return entry
|
|
324
|
+
changed = true
|
|
325
|
+
return { ...entry, result: res.replacement }
|
|
326
|
+
}),
|
|
327
|
+
)
|
|
328
|
+
return changed ? { result: { ...result, output } } : undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const plugin: AgentPlugin = {
|
|
332
|
+
name: "subcortex",
|
|
333
|
+
manifest: { capabilities: ["hooks"] },
|
|
334
|
+
hooks: {
|
|
335
|
+
beforeModel: (ctx: any) => withDeadline(() => beforeModel(ctx), HOOK_DEADLINE_MS, undefined),
|
|
336
|
+
afterTool: (ctx: any) => withDeadline(() => afterTool(ctx), HOOK_DEADLINE_MS, undefined),
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default plugin
|