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.
Files changed (64) hide show
  1. subcortex/__init__.py +3 -0
  2. subcortex/__main__.py +3 -0
  3. subcortex/adapters/__init__.py +48 -0
  4. subcortex/adapters/base.py +230 -0
  5. subcortex/adapters/claude_family.py +133 -0
  6. subcortex/adapters/codex.py +87 -0
  7. subcortex/adapters/copilot.py +60 -0
  8. subcortex/adapters/cursor.py +36 -0
  9. subcortex/adapters/docker_agent.py +115 -0
  10. subcortex/adapters/gemini_family.py +60 -0
  11. subcortex/adapters/grok.py +98 -0
  12. subcortex/adapters/kimi_code.py +138 -0
  13. subcortex/adapters/letta_vibe.py +96 -0
  14. subcortex/adapters/openhands.py +153 -0
  15. subcortex/auth.py +59 -0
  16. subcortex/backends/__init__.py +23 -0
  17. subcortex/backends/base.py +22 -0
  18. subcortex/backends/jev.py +460 -0
  19. subcortex/backends/laya.py +149 -0
  20. subcortex/cli.py +809 -0
  21. subcortex/client.py +77 -0
  22. subcortex/config.py +263 -0
  23. subcortex/daemon.py +502 -0
  24. subcortex/evalset.py +241 -0
  25. subcortex/hook.py +254 -0
  26. subcortex/installers/__init__.py +62 -0
  27. subcortex/installers/amp.py +39 -0
  28. subcortex/installers/base.py +874 -0
  29. subcortex/installers/claude_family.py +229 -0
  30. subcortex/installers/codex.py +110 -0
  31. subcortex/installers/copilot.py +65 -0
  32. subcortex/installers/crush.py +36 -0
  33. subcortex/installers/cursor.py +79 -0
  34. subcortex/installers/gemini_family.py +83 -0
  35. subcortex/installers/goose.py +186 -0
  36. subcortex/installers/kimi_code.py +71 -0
  37. subcortex/installers/mcp_only.py +111 -0
  38. subcortex/installers/more_hooks.py +184 -0
  39. subcortex/installers/opencode.py +66 -0
  40. subcortex/installers/openhands.py +84 -0
  41. subcortex/installers/pi_cline.py +53 -0
  42. subcortex/ledger.py +92 -0
  43. subcortex/localhttp.py +59 -0
  44. subcortex/mcp_server.py +187 -0
  45. subcortex/metrics.py +56 -0
  46. subcortex/plugins/amp/subcortex.ts +258 -0
  47. subcortex/plugins/cline/subcortex.ts +340 -0
  48. subcortex/plugins/opencode/subcortex.ts +265 -0
  49. subcortex/plugins/pi/subcortex.ts +292 -0
  50. subcortex/policy.py +341 -0
  51. subcortex/presets.py +163 -0
  52. subcortex/provision.py +188 -0
  53. subcortex/service.py +149 -0
  54. subcortex/state.py +137 -0
  55. subcortex/transcript.py +211 -0
  56. subcortex/tuis.py +51 -0
  57. subcortex/ui.py +319 -0
  58. subcortex/verdicts.py +233 -0
  59. subcortex/wizard.py +474 -0
  60. subcortex-0.3.0.dist-info/METADATA +287 -0
  61. subcortex-0.3.0.dist-info/RECORD +64 -0
  62. subcortex-0.3.0.dist-info/WHEEL +5 -0
  63. subcortex-0.3.0.dist-info/entry_points.txt +3 -0
  64. 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