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,265 @@
1
+ // subcortex plugin for OpenCode (>= 1.1.62) and Kilo Code CLI (an OpenCode fork).
2
+ //
3
+ // Installed by `subcortex install opencode` / `subcortex install kilo`. All
4
+ // decisions come from the local subcortex daemon's policy endpoints — no
5
+ // thresholds or heuristics live here:
6
+ //
7
+ // chat.message -> POST /v1/prompt-hint; pushes one fully
8
+ // formed synthetic text part (id/sessionID/
9
+ // messageID set — a malformed part can fail
10
+ // the user's prompt)
11
+ // tool.execute.after -> POST /v1/tool-output for `bash` only, exit 0,
12
+ // not already spilled to a file by OpenCode;
13
+ // a replacement is assigned to output.output
14
+ // experimental.text.complete -> records assistant text (never mutated)
15
+ // experimental.session.compacting -> pushes the last few messages into the
16
+ // compaction prompt so the summary keeps them
17
+ //
18
+ // OpenCode awaits plugin hooks with no timeout and turns any throw into an
19
+ // error for the operation the hook is attached to, so every hook catches
20
+ // everything and every request has a hard timeout.
21
+
22
+ import type { Plugin } from "@opencode-ai/plugin"
23
+
24
+ const TEMPLATED_URL = "__SUBCORTEX_URL__" // replaced by the installer
25
+ const BASE = (
26
+ process.env.SUBCORTEX_URL || (TEMPLATED_URL.startsWith("http") ? TEMPLATED_URL : "http://127.0.0.1:7707")
27
+ ).replace(/\/+$/, "")
28
+
29
+ const PROMPT_TIMEOUT_MS = 1500
30
+ const OUTPUT_TIMEOUT_MS = 3000
31
+ const LOCAL_MIN_OUTPUT_CHARS = 2000 // cheap pre-filter; the daemon applies the real threshold
32
+ const RING_SIZE = 6
33
+ const MAX_SESSIONS = 64 // a long-running server sees many sessions; keep memory bounded
34
+ const RING_TEXT_CHARS = 500
35
+
36
+ const PLUGIN_TUI = "opencode" // keeps sessions of different TUIs apart in the daemon
37
+
38
+ // >>> subcortex transport (identical in every subcortex plugin) >>>
39
+ // The daemon is reached over a raw TCP socket, one HTTP/1.0 request per
40
+ // connection. Not fetch: Bun sends even loopback requests through HTTP_PROXY,
41
+ // which breaks every call or hands prompts and output to a proxy. Our side is
42
+ // never half-closed before the reply (Bun then drops the response). node:net is
43
+ // imported lazily: a static import that fails to resolve would stop the host
44
+ // from starting. Every failure, timeout or abort resolves to null.
45
+ const DAEMON = (() => {
46
+ try {
47
+ const url = new URL(BASE)
48
+ return { host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port) || 80 }
49
+ } catch {
50
+ return { host: "127.0.0.1", port: 7707 }
51
+ }
52
+ })()
53
+ const MAX_RESPONSE_BYTES = 8_000_000
54
+ const TEMPLATED_TOKEN_FILE = "__SUBCORTEX_TOKEN_FILE__" // replaced by the installer
55
+ let net: any = undefined // undefined: not loaded yet; null: unavailable, use fetch
56
+ let token: string | undefined // the daemon's per-user token (a 0600 file in its data dir)
57
+
58
+ async function daemonToken(): Promise<string> {
59
+ if (token !== undefined) return token
60
+ try {
61
+ const fs = await import("node:fs")
62
+ const dir = process.env.SUBCORTEX_DATA_DIR
63
+ const file = dir
64
+ ? dir.replace(/\/+$/, "") + "/token"
65
+ : TEMPLATED_TOKEN_FILE.startsWith("/")
66
+ ? TEMPLATED_TOKEN_FILE
67
+ : (process.env.HOME ?? "") + "/.local/share/subcortex/token"
68
+ token = String(fs.readFileSync(file, "utf8")).trim()
69
+ } catch {
70
+ token = ""
71
+ }
72
+ if (!/^[0-9a-f]*$/.test(token)) token = ""
73
+ return token
74
+ }
75
+
76
+ async function post(path: string, body: unknown, ms: number, outer?: AbortSignal): Promise<any> {
77
+ try {
78
+ if (outer?.aborted) return null
79
+ if (net === undefined) {
80
+ try {
81
+ net = await import("node:net")
82
+ } catch {
83
+ net = null
84
+ }
85
+ }
86
+ const payload = JSON.stringify({ tui: PLUGIN_TUI, ...(body as object) })
87
+ const secret = await daemonToken()
88
+ const reply = net ? await viaSocket(path, payload, secret, ms, outer) : await viaFetch(path, payload, secret, ms, outer)
89
+ if (reply === null) token = undefined // re-read next time: the daemon may have made a new one
90
+ return reply
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ function viaSocket(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
97
+ return new Promise((resolve) => {
98
+ const chunks: any[] = []
99
+ let size = 0
100
+ let settled = false
101
+ let sock: any
102
+ const finish = (value: any) => {
103
+ if (settled) return
104
+ settled = true
105
+ clearTimeout(timer)
106
+ outer?.removeEventListener("abort", abort)
107
+ try {
108
+ sock?.destroy()
109
+ } catch {}
110
+ resolve(value)
111
+ }
112
+ const abort = () => finish(null)
113
+ const timer = setTimeout(abort, ms)
114
+ outer?.addEventListener("abort", abort, { once: true })
115
+ try {
116
+ const bytes = Buffer.from(payload, "utf8")
117
+ sock = net.connect({ host: DAEMON.host, port: DAEMON.port })
118
+ sock.on("connect", () => {
119
+ sock.write(`POST ${path} HTTP/1.0\r\nHost: ${DAEMON.host}\r\nContent-Type: application/json\r\n` +
120
+ `Content-Length: ${bytes.length}\r\nX-Subcortex-Token: ${secret}\r\nX-Subcortex-Timeout-Ms: ${ms}\r\n\r\n`)
121
+ sock.write(bytes)
122
+ })
123
+ sock.on("data", (chunk: any) => {
124
+ size += chunk.length
125
+ if (size > MAX_RESPONSE_BYTES) finish(null)
126
+ else chunks.push(chunk)
127
+ })
128
+ sock.on("error", abort)
129
+ sock.on("close", () => finish(parseReply(Buffer.concat(chunks).toString("utf8"))))
130
+ } catch {
131
+ finish(null)
132
+ }
133
+ })
134
+ }
135
+
136
+ async function viaFetch(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
137
+ const controller = new AbortController()
138
+ const timer = setTimeout(() => controller.abort(), ms)
139
+ const abort = () => controller.abort()
140
+ outer?.addEventListener("abort", abort, { once: true })
141
+ try {
142
+ const res = await fetch(BASE + path, {
143
+ method: "POST",
144
+ headers: { "content-type": "application/json", "x-subcortex-token": secret, "x-subcortex-timeout-ms": String(ms) },
145
+ body: payload,
146
+ signal: controller.signal,
147
+ })
148
+ return parseReply(`HTTP/1.0 ${res.status} -\r\n\r\n${await res.text()}`)
149
+ } catch {
150
+ return null
151
+ } finally {
152
+ clearTimeout(timer)
153
+ outer?.removeEventListener("abort", abort)
154
+ }
155
+ }
156
+
157
+ function parseReply(raw: string): any {
158
+ const split = raw.indexOf("\r\n\r\n")
159
+ if (split < 0 || !/^HTTP\/1\.[01] 200 /.test(raw)) return null
160
+ try {
161
+ const data = JSON.parse(raw.slice(split + 4))
162
+ return data && typeof data === "object" && data.success !== false ? data : null
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+ // <<< subcortex transport <<<
168
+
169
+ // Part ids mirror OpenCode's identifier scheme: "prt_" + 12 hex (ms * 0x1000 +
170
+ // counter) + 14 base62 chars. Never reuse an existing id — it would overwrite
171
+ // that part.
172
+ const B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
173
+ let lastTs = 0
174
+ let counter = 0
175
+ function partId(): string {
176
+ const now = Date.now()
177
+ if (now !== lastTs) {
178
+ lastTs = now
179
+ counter = 0
180
+ }
181
+ counter++
182
+ const value = BigInt(now) * 0x1000n + BigInt(counter)
183
+ let hex = ""
184
+ for (let i = 0; i < 6; i++) hex += Number((value >> BigInt(40 - 8 * i)) & 0xffn).toString(16).padStart(2, "0")
185
+ const random = crypto.getRandomValues(new Uint8Array(14))
186
+ let tail = ""
187
+ for (const b of random) tail += B62[b % 62]
188
+ return "prt_" + hex + tail
189
+ }
190
+
191
+ type Message = { role: "user" | "assistant"; text: string }
192
+ const rings = new Map<string, Message[]>()
193
+
194
+ function remember(sessionID: string, role: Message["role"], text: string) {
195
+ if (!sessionID || !text.trim()) return
196
+ const ring = rings.get(sessionID) ?? []
197
+ ring.push({ role, text: text.trim().slice(0, RING_TEXT_CHARS) })
198
+ if (ring.length > RING_SIZE) ring.splice(0, ring.length - RING_SIZE)
199
+ rings.delete(sessionID) // re-insert: Map order is least recently used first
200
+ rings.set(sessionID, ring)
201
+ if (rings.size > MAX_SESSIONS) rings.delete(rings.keys().next().value as string)
202
+ }
203
+
204
+ function userText(parts: unknown): string {
205
+ if (!Array.isArray(parts)) return ""
206
+ return parts
207
+ .filter((p: any) => p && p.type === "text" && !p.synthetic && typeof p.text === "string")
208
+ .map((p: any) => p.text)
209
+ .join("\n")
210
+ .trim()
211
+ }
212
+
213
+ const server: Plugin = async () => ({
214
+ "chat.message": async (input: any, output: any) => {
215
+ try {
216
+ const prompt = userText(output?.parts)
217
+ if (!prompt) return
218
+ remember(String(input?.sessionID ?? ""), "user", prompt)
219
+ const res = await post("/v1/prompt-hint", { prompt, session_id: String(input?.sessionID ?? "") }, PROMPT_TIMEOUT_MS)
220
+ const hint = res?.hint
221
+ const messageID = output?.message?.id
222
+ const sessionID = input?.sessionID ?? output?.message?.sessionID
223
+ if (typeof hint !== "string" || !messageID || !sessionID || !Array.isArray(output.parts)) return
224
+ output.parts.push({ id: partId(), sessionID, messageID, type: "text", synthetic: true, text: hint })
225
+ } catch {
226
+ // fail open: the user's message goes through untouched
227
+ }
228
+ },
229
+
230
+ "tool.execute.after": async (input: any, output: any) => {
231
+ try {
232
+ if (input?.tool !== "bash" || typeof output?.output !== "string") return
233
+ const meta = output.metadata ?? {}
234
+ if (meta.exit !== 0 || meta.truncated === true) return // failures stay; spilled output stays
235
+ if (output.output.length < LOCAL_MIN_OUTPUT_CHARS) return
236
+ const res = await post(
237
+ "/v1/tool-output",
238
+ { output: output.output, tool: "bash", input: input?.args ?? {}, session_id: String(input?.sessionID ?? "") },
239
+ OUTPUT_TIMEOUT_MS,
240
+ )
241
+ if (typeof res?.replacement === "string") output.output = res.replacement
242
+ } catch {
243
+ // fail open: the full tool output passes through untouched
244
+ }
245
+ },
246
+
247
+ "experimental.text.complete": async (input: any, output: any) => {
248
+ try {
249
+ if (typeof output?.text === "string") remember(String(input?.sessionID ?? ""), "assistant", output.text)
250
+ } catch {}
251
+ },
252
+
253
+ "experimental.session.compacting": async (input: any, output: any) => {
254
+ try {
255
+ const ring = rings.get(String(input?.sessionID ?? ""))
256
+ if (!ring?.length || !Array.isArray(output?.context)) return
257
+ const lines = ring.map((m) => `${m.role}: ${m.text}`).join("\n")
258
+ output.context.push(`[subcortex] Most recent messages, verbatim (keep their key facts):\n${lines}`)
259
+ } catch {}
260
+ },
261
+ })
262
+
263
+ // One export only (any other export breaks OpenCode's plugin loader); the
264
+ // `{id, server}` object form is accepted by OpenCode and required by Kilo.
265
+ export default { id: "subcortex", server }
@@ -0,0 +1,292 @@
1
+ // subcortex plugin for Pi (@earendil-works/pi-coding-agent >= 0.87.0).
2
+ //
3
+ // Installed by `subcortex install pi` to $PI_CODING_AGENT_DIR/extensions/subcortex.ts
4
+ // (default ~/.pi/agent/extensions/subcortex.ts). Pi auto-discovers it, loads it
5
+ // with jiti (no build step) and calls the default-exported factory once per
6
+ // session runtime. All decisions come from the local subcortex daemon's policy
7
+ // endpoints; this file holds no thresholds or heuristics of its own:
8
+ //
9
+ // before_agent_start -> POST /v1/prompt-hint; returns a hidden custom
10
+ // message (display:false). Pi appends it right after
11
+ // the user's prompt and sends it as a user-role message
12
+ // tool_result -> POST /v1/tool-output for bash/powershell results with
13
+ // isError=false; returns {content} only (details,
14
+ // isError and usage keep their values)
15
+ // session_before_compact -> POST /v1/snapshot with the messages about to be
16
+ // summarized (never returns {cancel} or {compaction})
17
+ // session_compact -> POST /v1/restore; the text is kept in memory
18
+ // context -> before every LLM call, inserts the restored text
19
+ // right after the compaction summary it belongs to
20
+ //
21
+ // Fail-open everywhere. Pi awaits every handler with no timeout, so each request
22
+ // has its own abort timer. Pi catches handler throws for these events, but every
23
+ // handler also catches everything. `tool_call`, `input` and `user_bash` are
24
+ // deliberately not registered: those events block or fail closed on error.
25
+ // The only import is `import type`, which jiti erases. A runtime import that
26
+ // fails to resolve makes Pi exit with status 1 at startup.
27
+
28
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
29
+
30
+ const TEMPLATED_URL = "__SUBCORTEX_URL__" // replaced by the installer
31
+ const BASE = (
32
+ process.env.SUBCORTEX_URL || (TEMPLATED_URL.startsWith("http") ? TEMPLATED_URL : "http://127.0.0.1:7707")
33
+ ).replace(/\/+$/, "")
34
+
35
+ const PROMPT_TIMEOUT_MS = 1500
36
+ const OUTPUT_TIMEOUT_MS = 3000
37
+ const LOCAL_MIN_OUTPUT_CHARS = 2000 // cheap pre-filter; the daemon applies the real threshold
38
+ const SNAPSHOT_MESSAGES = 20
39
+ const SHELL_TOOLS = new Set(["bash", "powershell"])
40
+ // Pi's shell tools end truncated output with "\n\n[Showing ... Full output: <path>]".
41
+ const TRUNCATION_NOTICE = /\n\n\[Showing [^\n]*Full output: [^\n]*\]$/
42
+
43
+ const PLUGIN_TUI = "pi" // keeps sessions of different TUIs apart in the daemon
44
+
45
+ // >>> subcortex transport (identical in every subcortex plugin) >>>
46
+ // The daemon is reached over a raw TCP socket, one HTTP/1.0 request per
47
+ // connection. Not fetch: Bun sends even loopback requests through HTTP_PROXY,
48
+ // which breaks every call or hands prompts and output to a proxy. Our side is
49
+ // never half-closed before the reply (Bun then drops the response). node:net is
50
+ // imported lazily: a static import that fails to resolve would stop the host
51
+ // from starting. Every failure, timeout or abort resolves to null.
52
+ const DAEMON = (() => {
53
+ try {
54
+ const url = new URL(BASE)
55
+ return { host: url.hostname.replace(/^\[|\]$/g, ""), port: Number(url.port) || 80 }
56
+ } catch {
57
+ return { host: "127.0.0.1", port: 7707 }
58
+ }
59
+ })()
60
+ const MAX_RESPONSE_BYTES = 8_000_000
61
+ const TEMPLATED_TOKEN_FILE = "__SUBCORTEX_TOKEN_FILE__" // replaced by the installer
62
+ let net: any = undefined // undefined: not loaded yet; null: unavailable, use fetch
63
+ let token: string | undefined // the daemon's per-user token (a 0600 file in its data dir)
64
+
65
+ async function daemonToken(): Promise<string> {
66
+ if (token !== undefined) return token
67
+ try {
68
+ const fs = await import("node:fs")
69
+ const dir = process.env.SUBCORTEX_DATA_DIR
70
+ const file = dir
71
+ ? dir.replace(/\/+$/, "") + "/token"
72
+ : TEMPLATED_TOKEN_FILE.startsWith("/")
73
+ ? TEMPLATED_TOKEN_FILE
74
+ : (process.env.HOME ?? "") + "/.local/share/subcortex/token"
75
+ token = String(fs.readFileSync(file, "utf8")).trim()
76
+ } catch {
77
+ token = ""
78
+ }
79
+ if (!/^[0-9a-f]*$/.test(token)) token = ""
80
+ return token
81
+ }
82
+
83
+ async function post(path: string, body: unknown, ms: number, outer?: AbortSignal): Promise<any> {
84
+ try {
85
+ if (outer?.aborted) return null
86
+ if (net === undefined) {
87
+ try {
88
+ net = await import("node:net")
89
+ } catch {
90
+ net = null
91
+ }
92
+ }
93
+ const payload = JSON.stringify({ tui: PLUGIN_TUI, ...(body as object) })
94
+ const secret = await daemonToken()
95
+ const reply = net ? await viaSocket(path, payload, secret, ms, outer) : await viaFetch(path, payload, secret, ms, outer)
96
+ if (reply === null) token = undefined // re-read next time: the daemon may have made a new one
97
+ return reply
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ function viaSocket(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
104
+ return new Promise((resolve) => {
105
+ const chunks: any[] = []
106
+ let size = 0
107
+ let settled = false
108
+ let sock: any
109
+ const finish = (value: any) => {
110
+ if (settled) return
111
+ settled = true
112
+ clearTimeout(timer)
113
+ outer?.removeEventListener("abort", abort)
114
+ try {
115
+ sock?.destroy()
116
+ } catch {}
117
+ resolve(value)
118
+ }
119
+ const abort = () => finish(null)
120
+ const timer = setTimeout(abort, ms)
121
+ outer?.addEventListener("abort", abort, { once: true })
122
+ try {
123
+ const bytes = Buffer.from(payload, "utf8")
124
+ sock = net.connect({ host: DAEMON.host, port: DAEMON.port })
125
+ sock.on("connect", () => {
126
+ sock.write(`POST ${path} HTTP/1.0\r\nHost: ${DAEMON.host}\r\nContent-Type: application/json\r\n` +
127
+ `Content-Length: ${bytes.length}\r\nX-Subcortex-Token: ${secret}\r\nX-Subcortex-Timeout-Ms: ${ms}\r\n\r\n`)
128
+ sock.write(bytes)
129
+ })
130
+ sock.on("data", (chunk: any) => {
131
+ size += chunk.length
132
+ if (size > MAX_RESPONSE_BYTES) finish(null)
133
+ else chunks.push(chunk)
134
+ })
135
+ sock.on("error", abort)
136
+ sock.on("close", () => finish(parseReply(Buffer.concat(chunks).toString("utf8"))))
137
+ } catch {
138
+ finish(null)
139
+ }
140
+ })
141
+ }
142
+
143
+ async function viaFetch(path: string, payload: string, secret: string, ms: number, outer?: AbortSignal): Promise<any> {
144
+ const controller = new AbortController()
145
+ const timer = setTimeout(() => controller.abort(), ms)
146
+ const abort = () => controller.abort()
147
+ outer?.addEventListener("abort", abort, { once: true })
148
+ try {
149
+ const res = await fetch(BASE + path, {
150
+ method: "POST",
151
+ headers: { "content-type": "application/json", "x-subcortex-token": secret, "x-subcortex-timeout-ms": String(ms) },
152
+ body: payload,
153
+ signal: controller.signal,
154
+ })
155
+ return parseReply(`HTTP/1.0 ${res.status} -\r\n\r\n${await res.text()}`)
156
+ } catch {
157
+ return null
158
+ } finally {
159
+ clearTimeout(timer)
160
+ outer?.removeEventListener("abort", abort)
161
+ }
162
+ }
163
+
164
+ function parseReply(raw: string): any {
165
+ const split = raw.indexOf("\r\n\r\n")
166
+ if (split < 0 || !/^HTTP\/1\.[01] 200 /.test(raw)) return null
167
+ try {
168
+ const data = JSON.parse(raw.slice(split + 4))
169
+ return data && typeof data === "object" && data.success !== false ? data : null
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+ // <<< subcortex transport <<<
175
+
176
+ function sessionId(ctx: any): string {
177
+ try {
178
+ return String(ctx?.sessionManager?.getSessionId?.() ?? "")
179
+ } catch {
180
+ return ""
181
+ }
182
+ }
183
+
184
+ export default function subcortex(pi: ExtensionAPI) {
185
+ // sessionId -> restored context for the compaction whose summary has timestamp `ts`
186
+ const restored = new Map<string, { ts: number; text: string }>()
187
+
188
+ // Behavior 1: hidden hint appended to the user's turn.
189
+ pi.on("before_agent_start", async (event: any, ctx: any) => {
190
+ try {
191
+ const prompt = typeof event?.prompt === "string" ? event.prompt.trim() : ""
192
+ if (!prompt) return
193
+ const res = await post("/v1/prompt-hint", { prompt, session_id: sessionId(ctx) }, PROMPT_TIMEOUT_MS, ctx?.signal)
194
+ const hint = res?.hint
195
+ if (typeof hint !== "string" || !hint.trim()) return
196
+ return { message: { customType: "subcortex-hint", content: hint, display: false } }
197
+ } catch {
198
+ return // fail open: the prompt goes through untouched
199
+ }
200
+ })
201
+
202
+ // Behavior 2: trim large successful shell output before the model sees it.
203
+ pi.on("tool_result", async (event: any, ctx: any) => {
204
+ try {
205
+ if (!SHELL_TOOLS.has(event?.toolName) || event?.isError !== false) return
206
+ const parts = event.content
207
+ if (!Array.isArray(parts) || parts.length === 0) return
208
+ if (!parts.every((p: any) => p && p.type === "text" && typeof p.text === "string")) return // leave images alone
209
+ const text: string = parts.map((p: any) => p.text).join("")
210
+ const notice = event.details?.truncation?.truncated ? (TRUNCATION_NOTICE.exec(text)?.[0] ?? "") : ""
211
+ const output = notice ? text.slice(0, -notice.length) : text
212
+ if (output.length < LOCAL_MIN_OUTPUT_CHARS) return
213
+ const res = await post(
214
+ "/v1/tool-output",
215
+ { output, tool: String(event.toolName), input: event.input ?? {}, session_id: sessionId(ctx) },
216
+ OUTPUT_TIMEOUT_MS,
217
+ ctx?.signal,
218
+ )
219
+ const replacement = res?.replacement
220
+ if (typeof replacement !== "string") return
221
+ // Keep Pi's "Full output: <path>" pointer so the model can still read the spill file.
222
+ return { content: [{ type: "text", text: replacement + notice }] }
223
+ } catch {
224
+ return // fail open: the full tool output passes through untouched
225
+ }
226
+ })
227
+
228
+ // Behavior 3: snapshot what compaction is about to summarize away.
229
+ pi.on("session_before_compact", async (event: any, ctx: any) => {
230
+ try {
231
+ const sid = sessionId(ctx)
232
+ const prep = event?.preparation ?? {}
233
+ const toSummarize = Array.isArray(prep.messagesToSummarize) ? prep.messagesToSummarize : []
234
+ const prefix = Array.isArray(prep.turnPrefixMessages) ? prep.turnPrefixMessages : []
235
+ const messages = [...toSummarize, ...prefix].slice(-SNAPSHOT_MESSAGES)
236
+ if (sid && messages.length) {
237
+ await post("/v1/snapshot", { session_id: sid, messages }, PROMPT_TIMEOUT_MS, event?.signal)
238
+ }
239
+ } catch {}
240
+ return // never {cancel: true} and never {compaction}: Pi keeps its own summary
241
+ })
242
+
243
+ // Behavior 4 (part 1): fetch the context to re-inject once compaction succeeded.
244
+ pi.on("session_compact", async (event: any, ctx: any) => {
245
+ try {
246
+ const sid = sessionId(ctx)
247
+ if (!sid) return
248
+ restored.delete(sid)
249
+ const ts = Date.parse(String(event?.compactionEntry?.timestamp ?? ""))
250
+ const res = await post("/v1/restore", { session_id: sid }, PROMPT_TIMEOUT_MS)
251
+ const text = res?.context
252
+ if (Number.isFinite(ts) && typeof text === "string" && text.trim()) restored.set(sid, { ts, text })
253
+ } catch {}
254
+ })
255
+
256
+ // Behavior 4 (part 2): on every LLM call, place the restored text right after the
257
+ // summary of that compaction. Not persisted; the same insertion on each request
258
+ // keeps the prompt cache stable. Also covers threshold compaction mid-run and
259
+ // overflow retries, where before_agent_start does not fire.
260
+ pi.on("context", (event: any, ctx: any) => {
261
+ try {
262
+ const entry = restored.get(sessionId(ctx))
263
+ const messages = event?.messages
264
+ if (!entry || !Array.isArray(messages)) return
265
+ let index = -1
266
+ for (let i = messages.length - 1; i >= 0; i--) {
267
+ if (messages[i]?.role === "compactionSummary") {
268
+ index = i
269
+ break
270
+ }
271
+ }
272
+ if (index < 0 || messages[index].timestamp !== entry.ts) return // another branch or compaction is active
273
+ const out = messages.slice()
274
+ out.splice(index + 1, 0, {
275
+ role: "custom",
276
+ customType: "subcortex-restore",
277
+ content: entry.text,
278
+ display: false,
279
+ timestamp: entry.ts,
280
+ })
281
+ return { messages: out }
282
+ } catch {
283
+ return
284
+ }
285
+ })
286
+
287
+ pi.on("session_shutdown", () => {
288
+ try {
289
+ restored.clear()
290
+ } catch {}
291
+ })
292
+ }