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,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
|
+
}
|