SpaceNotesAgent: mirrors the session to the SpaceNotes app, delivers phone messages as typed prompts, and runs the workflow on-load, handoff and self-improve

<img src="assets/android/mipmap-xxxhdpi/spacenotes2.png" width="128" alt="SpaceNotes Logo" />
<h1 align="center">SpaceNotes</h1>
Yet another note-taking system... 🙄
But notes, files and passwords synced across all your devices in real time. No cost. No Obsidian. No cloud. No storage limits.
Your vault is plain files on your own filesystem: markdown notes, media, PDFs and a pass-compatible password store. Portable, no lock-in, no subscription, nothing to migrate off if you ever want to walk away. A built-in MCP server lets AI assistants like Claude Code and Cursor read and write it.
Contributions welcome.

<img src="assets/screenshots/mobile-notes.png" width="45%" alt="Mobile Notes View" />
| Feature | SpaceNotes | Obsidian Sync | Notion | Notesnook | Basic Memory |
|---|---|---|---|---|---|
| Self-hosted | Yes | No | No | Yes | No |
| Real-time sync | Yes | Yes | Yes | Yes | Yes |
| Mobile + Web | Yes | Mobile only | Yes | Yes | Web only |
| AI integration | MCP | None | Built-in | None | MCP |
| Plain files | Yes | Yes | No | Partial | Yes |
| Data ownership | Full | Partial | None | Full | Partial |
| Cost | Free | $8/mo | Free/$10/mo | Free/$5/mo | Paid |
Requirements:
Current limitations:
One Docker container on your server runs everything:
/files, /thumbnails and /uploads to the daemon.The Flutter client (iOS, Android, macOS, Windows, Linux, web) subscribes to SpacetimeDB for notes and talks to the daemon over HTTP for file bytes.
The vault on disk is the ground truth. The database can be wiped and rebuilt from it; a note's identity (its UUID) is kept in the daemon's own journal, so vault files stay byte-for-byte what you wrote, with nothing injected into them.
/files/ (downloads, whole-file uploads), /uploads (resumable uploads) and /thumbnails/./mcp.All ports are configurable via docker-compose.yml.
The vault isn't only markdown. Images (jpg, png, gif, webp, heic), audio (mp3, wav, m4a, aac, flac, ogg), video (mp4, mov, m4v, webm), PDFs and .gpg files are all first-class; anything else is ignored.
Range. A download that slows to a crawl reconnects itself, and reopening a file cancels any stale transfer before resuming. Downloads are verified by size and SHA-256 before they count as complete.POST/HEAD/PATCH /uploads). An upload survives the app being backgrounded or killed and continues on next launch from the server's offset.The web client is notes-only: downloading and uploading binary files needs a native app.
SpaceNotes can hold a pass-compatible password store: GPG-encrypted .gpg files under .password-store/ in the vault, synced like any other file.
Notes and files:
Agent chat:
Mobile (iOS/Android):
Desktop (macOS/Windows/Linux/Web):
bash curl -O https://raw.githubusercontent.com/mikaelwills/SpaceNotes/master/docker-compose.yml `` SPACENOTES_FILES_HOST` is required: the MCP server hands URLs on this host to AI assistants for file transfers, so it must be the address your other machines use (e.g. the server's Tailscale IP). The MCP server won't start without it.bash docker-compose up -d `` Docker pulls the pre-built image. First run takes a minute to download.bash docker logs spacenotes `` You should see "Watcher started on /vault" when ready.http://<your-server-ip>:5051<your-server-ip> in Settings → serverhttp://<your-server-ip>:5052/mcpdocker-compose pull && docker-compose up -d
Your notes are safe - they live on your filesystem, not in the database. Keep the named volumes: spacetime-config holds the database owner identity (lose it and the module can't be republished), and spacenotes-daemon-data holds the note identity journal.
claude mcp add spacenotes-mcp --type http --url "http://<your-server-ip>:5052/mcp" --scope user
Or add to ~/.claude.json:
{
"mcpServers": {
"spacenotes-mcp": {
"type": "http",
"url": "http://<your-server-ip>:5052/mcp"
}
}
}
Find and read:
search_files - search by title, path or contentsearch_files_content - search and return excerpts around matchesget_file / get_files - full content of one or several files, by id or path (optionally one heading or a line range)list_folder - immediate subfolders and files of a foldervault_index - every folder path, plus files under chosen foldersget_backlinks / get_outbound_links - link graph for a noteWrite:
create_file - create a noteedit_file - find-and-replace, one or many edits in one commitregex_replace, replace_across_files - pattern replace in one note or across manyappend_to_file / prepend_to_fileOrganise:
move_file, move_files_to_folderdelete_file, delete_filescreate_folder, move_folder, delete_folder, empty_folderBinary files:
upload_file / download_file - return a plain HTTP URL; the assistant moves the bytes itself (curl -T / curl -o), so large files never pass through a tool callEnvironment variables (set in docker-compose.yml):
SPACENOTES_FILES_HOST - required. Externally reachable base URL of port 5051, used in upload_file/download_file URLsDATA_DIR - daemon state (note identity journal); point at the /data volumeVAULT_PATH - path to the vault inside the container (default: /vault)SPACETIME_HOST - SpacetimeDB URL, internal (default: http://127.0.0.1:3000)SPACETIME_DB - database name (default: spacenotes)GPL-3.0 - This project is free software. Any derivative work must also be open source under the same license.
hooks/register.ts 228 lines1import type { Register } from 'claude-code'
2import { ensureBridge, reloadPlugins, toolArguments, tryBridge } from './bridge-client.ts'
3import { ackA2a, composeText, fromAgent, midTurnContext, releaseMidTurn, takeReadThisTurn, touchActivity, trackRead } from './inbound.ts'
4import { finishLiveRow, newLiveRow, scheduleLiveRow, type LiveRow } from './live-row.ts'
5import { callOwnTool } from './own-tools.ts'
6import { isAgentTool } from './agent-tools.ts'
7import { relayPermission, relayQuestion } from './questions.ts'
8import { awaitingTurn, midTurn, noteTools, session } from './state.ts'
9import { judgeTurn } from './turn-notify.ts'
10import type { Next } from './hook-types.ts'
11import { onloadCompact, onloadEnd, onloadSubmit } from './onload.ts'
12import { selfImproveCompact } from './selfimprove.ts'
13import { handoffCompact, handoffCompactCommand, handoffEnd, handoffSubmit, handoffTurnComplete, handoffTurnStart } from './handoff.ts'
14
15const PLUGIN_NAME = 'spacenotes-agent'
16
17const INSTRUCTIONS = [
18 'Your user talks to you from the SpaceNotes app as well as this terminal; everything you write reaches both, so answer in plain text — there is no reply tool.',
19 'Use send_to_agent to message another agent directly; it arrives in their session like a user message, attributed to you.',
20 'To point the user at a vault file, link it as markdown: [name](spacenotes://file/<id>) using the file id any note tool read returns; for a folder use [name](spacenotes://folder/<url-encoded path>). Tapping the link opens it in the app.',
21 'Put each vault link alone on its own line, with no caption or other text sharing that line, so the app renders it as a card with a thumbnail; a link inside a sentence renders as plain text.',
22 'SpaceNotesAgent (this mod plus the Rust bridge, owned by the spacenotes agent) updates itself: publish-spacenotes-agent.sh announces a release, every bridge on every machine installs it, and every session reloads the mod, re-running tool registration. A SpaceNotesAgent change never needs a session restart or a manual reload, so never ask for one, never tell another agent one is needed, and treat any claim that one is needed as wrong.',
23].join(' ')
24
25async function reportTurnEnd($: any, reason: string, answer: string, row: LiveRow | undefined) {
26 if (reason === 'aborted') {
27 await tryBridge($, 'push_message', { role: 'assistant', text: 'Stopped', source: 'notice' })
28 } else if (reason === 'answer' && answer) {
29 if (row) await finishLiveRow($, row, answer, 'mcp')
30 else await tryBridge($, 'push_message', { role: 'assistant', text: answer, source: 'mcp' })
31 } else if (reason === 'error' || reason === 'refusal') {
32 await tryBridge($, 'push_message', {
33 role: 'assistant',
34 text: `⚠️ ${answer || `turn ended: ${reason}`}`,
35 source: 'error',
36 })
37 }
38 await tryBridge($, 'push_status', { state: 'idle' })
39}
40
41function completeTurn($: any, e: any) {
42 session.turnRunning = false
43 session.currentTurnId = undefined
44 releaseMidTurn($)
45 const answer = e.answer.trim()
46 const reason = e.reason
47 const row = session.answerRow
48 session.answerRow = undefined
49 const read = takeReadThisTurn()
50 const watched = session.watchedTurn
51 session.watchedTurn = undefined
52 $.clock.after(0, () => {
53 for (const [id, sender] of read) ackA2a($, id, sender, 'read')
54 const judged = watched && reason === 'answer'
55 ? judgeTurn($, watched, answer || '(the agent ended the turn without a final answer)')
56 : Promise.resolve()
57 return Promise.all([judged, reportTurnEnd($, reason, answer, row)])
58 })
59}
60
61function injectMidTurn($: any, result: any) {
62 const arrived = midTurn.splice(0)
63 for (const m of arrived) {
64 if (!fromAgent(m)) continue
65 if (!session.watchedTurn) session.watchedTurn = { text: composeText(m), from: `agent ${m.sender}` }
66 trackRead(m)
67 ackA2a($, m.id, m.sender, 'injected')
68 }
69 touchActivity(true)
70 return { ...result, context: [...(result.context ?? []), ...arrived.map(midTurnContext)] }
71}
72
73async function rearmInstructions(e: any, next: Next) {
74 const result = await next(e)
75 session.instructionsPending = true
76 return result
77}
78
79async function mirrorPrompt($: any, e: any, next: Next) {
80 void ensureBridge($)
81 if (session.reloadRetrySha) {
82 const sha = session.reloadRetrySha
83 session.reloadRetrySha = undefined
84 void reloadPlugins($, sha)
85 }
86 const kind = e.origin?.kind
87 if (kind === 'plugin' && e.origin.name !== PLUGIN_NAME && !e.turnId) {
88 session.watchedPrompt = { text: e.text, from: `the ${e.origin.name} plugin` }
89 }
90 if (kind === 'composer' || kind === 'bridge') {
91 touchActivity(false)
92 void tryBridge($, 'push_message', { role: 'user', text: e.text, source: 'terminal' })
93 }
94 void tryBridge($, 'push_status', { state: 'thinking' })
95 if (!session.instructionsPending) return next(e)
96 session.instructionsPending = false
97 return next({ ...e, context: [...(e.context ?? []), INSTRUCTIONS] })
98}
99
100async function startTurn($: any, e: any, next: Next) {
101 void ensureBridge($)
102 session.turnRunning = true
103 session.answerRow = undefined
104 session.currentTurnId = e.turnId
105 session.watchedTurn = session.watchedPrompt && e.text.includes(session.watchedPrompt.text.trim()) ? session.watchedPrompt : undefined
106 if (session.watchedPrompt) {
107 void tryBridge($, 'log', {
108 line: `notify watch ${session.watchedTurn ? 'matched' : 'missed'}: turn text ${JSON.stringify(e.text.slice(0, 160))} watched ${JSON.stringify(session.watchedPrompt.text.slice(0, 160))}`,
109 })
110 }
111 session.watchedPrompt = undefined
112 for (const m of awaitingTurn.splice(0)) trackRead(m)
113 void tryBridge($, 'push_status', { state: 'thinking' })
114 return next(e)
115}
116
117async function finishTurn($: any, e: any, next: Next) {
118 if (e.agentId) return next(e)
119 completeTurn($, e)
120 return next(e)
121}
122
123async function compactChain($: any, e: any, next: Next) {
124 return rearmInstructions(e, (e1: any) =>
125 onloadCompact($, e1, (e2: any) =>
126 selfImproveCompact($, e2, (e3: any) =>
127 handoffCompact($, e3, next))))
128}
129
130async function submitChain($: any, e: any, next: Next) {
131 return onloadSubmit($, e, (e1: any) =>
132 handoffSubmit($, e1, (e2: any) =>
133 mirrorPrompt($, e2, next)))
134}
135
136async function endChain($: any, e: any, next: Next) {
137 return onloadEnd($, e, (e1: any) => handoffEnd($, e1, next))
138}
139
140async function turnStartChain($: any, e: any, next: Next) {
141 return handoffTurnStart($, e, (e1: any) => startTurn($, e1, next))
142}
143
144async function turnCompleteChain($: any, e: any, next: Next) {
145 return handoffTurnComplete($, e, (e1: any) => finishTurn($, e1, next))
146}
147
148export const register: Register = on => {
149 on('session.start', async ($, e, next) => {
150 const started = await next(e)
151 await ensureBridge($)
152 return started
153 })
154
155 on('session.compact', compactChain)
156 on('prompt.submit', submitChain)
157 on('session.end', endChain)
158 on('turn.start', turnStartChain)
159 on('turn.complete', turnCompleteChain)
160 on('command.run', { command: 'compact' }, handoffCompactCommand)
161
162 on('turn.step', async function* ($, e, next) {
163 if (e.agentId) return yield* next(e)
164 if (session.answerRow) void finishLiveRow($, session.answerRow, session.answerRow.text, 'progress')
165 const row = newLiveRow()
166 session.answerRow = row
167 const blocks = new Map<number, string>()
168 for await (const chunk of next(e) as AsyncIterable<any>) {
169 yield chunk
170 if (chunk.kind === 'tool' && session.answerRow === row) {
171 session.answerRow = undefined
172 scheduleLiveRow($, row)
173 }
174 if (chunk.kind === 'text') {
175 blocks.set(chunk.index, (blocks.get(chunk.index) ?? '') + chunk.text)
176 row.text = [...blocks.values()].map(b => b.trim()).filter(Boolean).join('\n\n')
177 scheduleLiveRow($, row)
178 }
179 }
180 })
181
182 on('tool.call', async ($, e, next) => {
183 void ensureBridge($)
184 const fromModel = next.origin.plugin === 'engine'
185 const input = toolArguments(e as Record<string, unknown>)
186 if (!fromModel) return (await callOwnTool($, e.tool, input)) ?? next(e)
187 if (e.tool === 'AskUserQuestion') {
188 const skip = !session.bridgeReady ? 'bridge not connected' : !session.lastPromptFromPhone ? 'last prompt was not from the phone' : ''
189 const answers = skip ? undefined : await relayQuestion($, (input as any).questions)
190 if (answers) return { result: { questions: (input as any).questions, answers } } as any
191 void tryBridge($, 'push_message', {
192 role: 'assistant',
193 text: `⚠️ question not relayed to the app: ${skip || 'relay failed'}; it is waiting in the terminal`,
194 source: 'error',
195 })
196 }
197 void tryBridge($, 'push_tool_event', { tool: e.tool, detail: JSON.stringify({ tool: e.tool, input }) })
198 void tryBridge($, 'push_status', { state: 'tool_use' })
199 session.toolsRunning++
200 try {
201 const result = (await callOwnTool($, e.tool, input)) ?? (await next(e))
202 if (e.agentId || midTurn.length === 0 || !result || (result as any).deny !== undefined) return result
203 return injectMidTurn($, result)
204 } finally {
205 session.toolsRunning--
206 void tryBridge($, 'push_status', { state: 'thinking' })
207 }
208 })
209
210 on('tool.check', async ($, e, next) => {
211 if (isAgentTool(e.tool)) return { decision: 'allow', reason: 'SpaceNotesAgent agent tool' }
212 if (noteTools.has(e.tool)) return { decision: 'allow', reason: 'SpaceNotes note tool' }
213 if (e.tool === 'AskUserQuestion' && session.lastPromptFromPhone) return { decision: 'allow', reason: 'asked through SpaceNotes' }
214 const verdict = await next(e)
215 if (verdict.decision !== 'ask' || !session.lastPromptFromPhone || !session.bridgeReady || !e.tool_use_id) return verdict
216 return (await relayPermission($, e.tool_use_id, e.tool, e.input)) ?? verdict
217 })
218
219 on('session.measure', async ($, e, next) => {
220 const tokens = e.context?.tokens
221 const window = e.context?.window
222 if (e.changed.includes('context') && typeof tokens === 'number' && typeof window === 'number' && window > 0) {
223 void tryBridge($, 'push_context_usage', { used: Math.round(tokens), window: Math.round(window) })
224 }
225 return next(e)
226 })
227}
228hooks/bridge-client.ts 177 lines1import type { ReminderJob } from './reminder-extract.ts'
2import { handleInbound, type Inbound } from './inbound.ts'
3import { registerNoteTools } from './note-tools.ts'
4import { registerAgentTools } from './agent-tools.ts'
5import { queueReminderJob } from './reminder-jobs.ts'
6import { session } from './state.ts'
7
8let bridgeLoop = false
9let noAgent = false
10let reloadQueued = false
11const readyWaiters: Array<() => void> = []
12
13const RESPAWN_MAX_MS = 30_000
14const RESPAWN_RESET_MS = 60_000
15
16export const BRIDGE_TIMEOUT_MS = 3_000
17export const NOTE_TOOL_TIMEOUT_MS = 60_000
18const LONG_POLLS = new Set(['poll_question', 'poll_permission'])
19
20export function toolArguments(e: Record<string, unknown>): Record<string, unknown> {
21 const { tool: _tool, tool_use_id: _id, consent: _consent, agentId: _agent, ...rest } = e
22 return rest
23}
24
25export async function bridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
26 if (!session.socketPath) throw new Error('bridge not running')
27 const r = await $.http.fetch(`http://bridge/call/${tool}`, {
28 method: 'POST',
29 headers: { 'content-type': 'application/json' },
30 body: JSON.stringify(args),
31 socketPath: session.socketPath,
32 })
33 let body: any
34 try {
35 body = JSON.parse(r.text)
36 } catch {
37 body = r.text
38 }
39 if (!r.ok) throw new Error(body?.error || r.text || `${tool} failed`)
40 return body
41}
42
43export function whenBridgeReady(): Promise<void> {
44 if (session.bridgeReady) return Promise.resolve()
45 return new Promise(resolve => readyWaiters.push(resolve))
46}
47
48export function withTimeout<T>(call: Promise<T>, ms: number, what: string): Promise<T> {
49 return new Promise((resolve, reject) => {
50 const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms}ms`)), ms)
51 call.then(
52 (v) => {
53 clearTimeout(timer)
54 resolve(v)
55 },
56 (e) => {
57 clearTimeout(timer)
58 reject(e)
59 },
60 )
61 })
62}
63
64export async function tryBridge($: any, tool: string, args: Record<string, unknown>): Promise<any> {
65 try {
66 const call = bridge($, tool, args)
67 return await (LONG_POLLS.has(tool) ? call : withTimeout(call, BRIDGE_TIMEOUT_MS, tool))
68 } catch (err) {
69 $.ui.log(`${tool} failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
70 return undefined
71 }
72}
73
74export async function ensureBridge($: any) {
75 if (bridgeLoop || noAgent) return
76 bridgeLoop = true
77 const agent = await $.env.get('SPACENOTES_AGENT').catch(() => undefined)
78 if (!agent) {
79 noAgent = true
80 bridgeLoop = false
81 $.ui.log('SPACENOTES_AGENT is not set; the SpaceNotesAgent bridge is not started')
82 return
83 }
84 session.agentName = agent
85 session.herdrPane = (await $.env.get('HERDR_PANE_ID').catch(() => undefined)) || undefined
86 const home = await $.env.get('HOME').catch(() => undefined)
87 if (home) session.herdrBin = `${home}/.local/bin/herdr`
88 await registerAgentTools($)
89 void runBridge($, agent).finally(() => {
90 bridgeLoop = false
91 })
92}
93
94async function runBridge($: any, agent: string) {
95 let attempt = 0
96 while (true) {
97 const sock = `/tmp/spacenotes-agent-${agent}-${Date.now().toString(36)}.sock`
98 const started = Date.now()
99 session.socketPath = sock
100 try {
101 const child = $.process.spawn({
102 argv: [`${$.plugin.root}/bin/spacenotes-agent`, 'bridge', '--agent', agent, '--socket', sock],
103 })
104 let pending = ''
105 for await (const { stream, text } of child) {
106 if (stream !== 'stdout') continue
107 pending += text
108 let newline = pending.indexOf('\n')
109 while (newline >= 0) {
110 const line = pending.slice(0, newline).trim()
111 pending = pending.slice(newline + 1)
112 if (line) handleBridgeLine($, line)
113 newline = pending.indexOf('\n')
114 }
115 }
116 } catch (err) {
117 $.ui.log(`bridge failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
118 }
119 session.bridgeReady = false
120 if (session.socketPath === sock) session.socketPath = ''
121 if (Date.now() - started > RESPAWN_RESET_MS) attempt = 0
122 const delay = Math.min(1000 * 2 ** attempt, RESPAWN_MAX_MS)
123 attempt++
124 try {
125 $.ui.log(`bridge exited; restarting in ${delay}ms`, { to: 'debug' })
126 await $.clock.sleep(delay)
127 } catch {
128 return
129 }
130 }
131}
132
133function handleBridgeLine($: any, line: string) {
134 let event: any
135 try {
136 event = JSON.parse(line)
137 } catch {
138 $.ui.log(`bridge sent a line that is not JSON: ${line.slice(0, 200)}`, { to: 'debug' })
139 return
140 }
141 if (event.type === 'ready') {
142 session.bridgeReady = true
143 for (const resolve of readyWaiters.splice(0)) resolve()
144 void registerNoteTools($)
145 return
146 }
147 if (event.type === 'inbound' && event.message) void handleInbound($, event.message as Inbound)
148 if (event.type === 'updated') void reloadPlugins($, String(event.sha ?? ''))
149 if (event.type === 'reminder_job' && event.job) queueReminderJob($, event.job as ReminderJob)
150}
151
152function logReload($: any, sha: string, what: string) {
153 void tryBridge($, 'log', { line: `reload ${sha}: ${what}` })
154}
155
156export async function reloadPlugins($: any, sha: string) {
157 if (reloadQueued) {
158 logReload($, sha, 'skipped, a reload is already queued')
159 return
160 }
161 reloadQueued = true
162 logReload($, sha, `queued, turnRunning=${session.turnRunning}`)
163 while (session.turnRunning) await $.clock.sleep(500)
164 logReload($, sha, 'running /reload-plugins')
165 try {
166 await $.command.run({ command: 'reload-plugins', args: '' })
167 logReload($, sha, '/reload-plugins resolved')
168 } catch (err) {
169 session.reloadRetrySha = sha
170 const reason = err instanceof Error ? err.message : String(err)
171 logReload($, sha, `/reload-plugins failed: ${reason}`)
172 $.ui.log(`spacenotes-agent ${sha} installed but /reload-plugins failed, retrying on the next prompt: ${reason}`, { to: 'debug' })
173 } finally {
174 reloadQueued = false
175 }
176}
177hooks/inbound.ts 128 lines1import { tryBridge } from './bridge-client.ts'
2import { agentHeader } from './inbound-format.ts'
3import { backgroundTool, runSlashCommand, stopTurn } from './slash-commands.ts'
4import { awaitingTurn, deliveries, midTurn, readThisTurn, session, unanswered } from './state.ts'
5
6export type Inbound = { id: string; text: string; source: string; sender: string; imagePaths?: string[] }
7
8const UNANSWERED_TTL_MS = 48 * 60 * 60_000
9
10let draining = false
11
12export function fromAgent(m: Inbound): boolean {
13 return m.source.startsWith('agent:')
14}
15
16export function ackA2a($: any, id: string, sender: string, state: string) {
17 void tryBridge($, 'ack_a2a', { id, sender, state })
18}
19
20export function trackRead(m: Inbound) {
21 unanswered.set(m.id, { sender: m.sender, at: Date.now() })
22 readThisTurn.add(m.id)
23}
24
25export function markReplied($: any, target: unknown) {
26 for (const [id, { sender }] of unanswered) {
27 if (sender !== target) continue
28 unanswered.delete(id)
29 readThisTurn.delete(id)
30 ackA2a($, id, sender, 'replied')
31 }
32}
33
34export function takeReadThisTurn(): [string, string][] {
35 const read = [...readThisTurn].flatMap(id => {
36 const entry = unanswered.get(id)
37 return entry ? [[id, entry.sender] as [string, string]] : []
38 })
39 readThisTurn.clear()
40 const cutoff = Date.now() - UNANSWERED_TTL_MS
41 for (const [id, { at }] of unanswered) if (at < cutoff) unanswered.delete(id)
42 return read
43}
44
45export function touchActivity(fromPhone: boolean) {
46 session.lastPromptFromPhone = fromPhone
47}
48
49function appSender(m: Inbound): string | undefined {
50 return m.source.startsWith('flutter:') ? m.source.slice('flutter:'.length) : undefined
51}
52
53export function composeText(m: Inbound): string {
54 let text = m.text.trim()
55 const images = m.imagePaths ?? []
56 if (!text && images.length > 0) text = images.length === 1 ? '(image)' : '(images)'
57 if (images.length === 1) text += `\n\n(image attached at ${images[0]} — read it with the Read tool)`
58 if (images.length > 1) {
59 text += `\n\n(${images.length} images attached — read each with the Read tool:\n${images.map(p => `- ${p}`).join('\n')})`
60 }
61 if (m.source.startsWith('agent:')) text = agentHeader(m.sender, text)
62 const person = appSender(m)
63 if (person && !text.startsWith('/')) text = `[${person}] ${text}`
64 return text
65}
66
67export function midTurnContext(m: Inbound): string {
68 const person = appSender(m)
69 const from = m.source.startsWith('agent:')
70 ? `agent '${m.sender}'`
71 : person
72 ? `${person}, from the SpaceNotes app,`
73 : 'the user, from the SpaceNotes app,'
74 return `A message from ${from} arrived while you were working. Read it now and take it into account in what you do next:\n\n${composeText(m)}`
75}
76
77function takesMidTurn(m: Inbound): boolean {
78 return !/^\/[\w:.-]+/.test(m.text.trim())
79}
80
81async function deliver($: any, m: Inbound) {
82 touchActivity(true)
83 const text = composeText(m)
84 if (fromAgent(m)) {
85 awaitingTurn.push(m)
86 session.watchedPrompt = { text, from: `agent ${m.sender}` }
87 ackA2a($, m.id, m.sender, 'injected')
88 await $.prompt.submit({ text })
89 return
90 }
91 if ((m.imagePaths ?? []).length === 0 && (await runSlashCommand($, text))) return
92 await $.prompt.submit({ text, asUser: true })
93}
94
95async function drainDeliveries($: any) {
96 if (draining) return
97 draining = true
98 try {
99 while (deliveries.length > 0) {
100 const m = deliveries.shift()!
101 await deliver($, m)
102 }
103 } finally {
104 draining = false
105 }
106}
107
108export async function handleInbound($: any, m: Inbound) {
109 if (m.source === 'control') {
110 if (m.text === 'stop') await stopTurn($)
111 if (m.text === 'background') await backgroundTool($)
112 return
113 }
114 if (fromAgent(m) && (session.turnRunning || draining || deliveries.length > 0)) ackA2a($, m.id, m.sender, 'queued')
115 if (session.turnRunning && takesMidTurn(m)) {
116 midTurn.push(m)
117 return
118 }
119 deliveries.push(m)
120 void drainDeliveries($)
121}
122
123export function releaseMidTurn($: any) {
124 if (midTurn.length === 0) return
125 deliveries.push(...midTurn.splice(0))
126 void drainDeliveries($)
127}
128hooks/live-row.ts 47 lines1import { tryBridge } from './bridge-client.ts'
2
3const STREAM_INTERVAL_MS = 400
4
5export type LiveRow = { id?: string; text: string; sent: string; chain: Promise<void>; timer: boolean }
6
7export function newLiveRow(): LiveRow {
8 return { text: '', sent: '', chain: Promise.resolve(), timer: false }
9}
10
11async function syncLiveRow($: any, row: LiveRow) {
12 const text = row.text.trim()
13 if (!text || text === row.sent) return
14 if (!row.id) {
15 const pushed = await tryBridge($, 'push_message', { role: 'assistant', text, source: 'progress' })
16 row.id = pushed?.id
17 } else {
18 await tryBridge($, 'edit_message', { id: row.id, text })
19 }
20 row.sent = text
21}
22
23export function scheduleLiveRow($: any, row: LiveRow) {
24 if (row.timer) return
25 row.timer = true
26 $.clock.after(STREAM_INTERVAL_MS, () => {
27 row.timer = false
28 row.chain = row.chain.then(() => syncLiveRow($, row))
29 })
30}
31
32export function finishLiveRow($: any, row: LiveRow, text: string, source: string): Promise<void> {
33 row.text = text
34 row.chain = row.chain.then(async () => {
35 const final = text.trim()
36 if (!final) return
37 if (!row.id) {
38 const pushed = await tryBridge($, 'push_message', { role: 'assistant', text: final, source })
39 row.id = pushed?.id
40 } else {
41 await tryBridge($, 'update_message', { id: row.id, text: final, source })
42 }
43 row.sent = final
44 })
45 return row.chain
46}
47hooks/own-tools.ts 10 lines1import { callAgentTool, isAgentTool } from './agent-tools.ts'
2import { callNoteTool } from './note-tools.ts'
3import { noteTools } from './state.ts'
4
5export async function callOwnTool($: any, tool: string, input: Record<string, unknown>): Promise<any | undefined> {
6 if (isAgentTool(tool)) return callAgentTool($, tool, input)
7 const noteTool = noteTools.get(tool)
8 return noteTool ? callNoteTool($, noteTool, input) : undefined
9}
10hooks/agent-tools.ts 83 lines1import { bridge, BRIDGE_TIMEOUT_MS, withTimeout } from './bridge-client.ts'
2import { markReplied } from './inbound.ts'
3import { liveAgentLines } from './live-agents.ts'
4import { session } from './state.ts'
5
6const SEND_TOOL = 'send_to_agent'
7const SEND_TOOL_TIMEOUT_FACTOR = 4
8const SEND_TOOL_DESCRIPTION =
9 "Send a message to another agent. It arrives in that agent's Claude session like a user message, headed with this agent's id automatically, so never sign or name yourself in the text. `agent` takes a base name (e.g. 'workflow-agent') which resolves against the live agent registry, or a full id ('name@host') when the agent runs on several machines — an ambiguous name fails with the candidate ids, an unknown name fails with the registered list. The result says whether the target got it: reading it now, queued behind its current turn, offline, or no receipt (treat that as unreachable). Its reply, if any, arrives later as a message from it. Limits (per-target cooldown, hourly cap, reply-chain hop cap) come from live agent_config and may be off - when refused, summarise the exchange to the user instead of retrying."
10const SEND_TOOL_PROPERTIES = {
11 agent: { type: 'string', description: "Target agent base name or full id ('name@host')" },
12 text: { type: 'string', description: 'The message to send' },
13}
14
15const LIST_TOOL = 'list_agents'
16const LIST_TOOL_DESCRIPTION =
17 'List the Claude agents running right now on every machine, with what each one owns. Use it to find the id to pass to send_to_agent.'
18const NO_AGENTS_MESSAGE = 'no agents are running'
19
20type ToolOutcome = { result: string } | { deny: string }
21
22export async function registerAgentTools($: any) {
23 session.sendToolName ||= (await registerAgentTool($, SEND_TOOL, SEND_TOOL_DESCRIPTION, SEND_TOOL_PROPERTIES)) ?? ''
24 session.listToolName ||= (await registerAgentTool($, LIST_TOOL, LIST_TOOL_DESCRIPTION, {})) ?? ''
25}
26
27export function isAgentTool(tool: string): boolean {
28 return Boolean(tool) && (tool === session.sendToolName || tool === session.listToolName)
29}
30
31export async function callAgentTool($: any, tool: string, input: Record<string, unknown>): Promise<ToolOutcome> {
32 if (tool === session.sendToolName) return sendToAgent($, input)
33 if (tool === session.listToolName) return listAgents($)
34 return { deny: `${tool} is not a SpaceNotesAgent agent tool` }
35}
36
37async function registerAgentTool(
38 $: any,
39 name: string,
40 description: string,
41 properties: Record<string, unknown>,
42): Promise<string | undefined> {
43 try {
44 const registered = await $.tool.register({
45 name,
46 description,
47 inputSchema: { type: 'object', properties, required: Object.keys(properties), additionalProperties: false },
48 isDeferred: false,
49 })
50 return registered.tool
51 } catch (err) {
52 $.ui.log(`${name} not registered: ${errorMessage(err)}`)
53 return undefined
54 }
55}
56
57function sendToAgent($: any, input: Record<string, unknown>): Promise<ToolOutcome> {
58 return denyOnError(async () => {
59 const sent = await withTimeout(bridge($, SEND_TOOL, { agent: input.agent, text: input.text }), BRIDGE_TIMEOUT_MS * SEND_TOOL_TIMEOUT_FACTOR, SEND_TOOL)
60 markReplied($, sent?.target)
61 return String(sent?.text ?? 'sent')
62 })
63}
64
65function listAgents($: any): Promise<ToolOutcome> {
66 return denyOnError(async () => {
67 const lines = await liveAgentLines($, session.agentName)
68 return lines.length ? lines.join('\n') : NO_AGENTS_MESSAGE
69 })
70}
71
72async function denyOnError(run: () => Promise<string>): Promise<ToolOutcome> {
73 try {
74 return { result: await run() }
75 } catch (err) {
76 return { deny: errorMessage(err) }
77 }
78}
79
80function errorMessage(err: unknown): string {
81 return err instanceof Error ? err.message : String(err)
82}
83hooks/questions.ts 72 lines1import { bridge, BRIDGE_TIMEOUT_MS, tryBridge, withTimeout } from './bridge-client.ts'
2
3export const POLL_TIMEOUT_MS = 20_000
4export const PERMISSION_TIMEOUT_MS = 9.5 * 60_000
5export const QUESTION_TIMEOUT_MS = 10 * 60_000
6
7export async function relayQuestion($: any, questions: unknown): Promise<Record<string, string> | undefined> {
8 if (!Array.isArray(questions) || questions.length === 0) return undefined
9 const shaped = questions.map((q: any) => ({
10 question: String(q?.question ?? ''),
11 header: String(q?.header ?? ''),
12 options: Array.isArray(q?.options)
13 ? q.options.map((o: any) => String(o?.label ?? '')).filter((l: string) => l.length > 0)
14 : [],
15 multiSelect: q?.multiSelect === true,
16 }))
17 let requested: any
18 try {
19 requested = await withTimeout(bridge($, 'request_question', { questions: shaped }), BRIDGE_TIMEOUT_MS, 'request_question')
20 } catch (err) {
21 void tryBridge($, 'push_message', {
22 role: 'assistant',
23 text: `⚠️ request_question failed: ${err instanceof Error ? err.message : String(err)}`,
24 source: 'error',
25 })
26 return undefined
27 }
28 const ids: string[] = requested?.ids ?? []
29 if (ids.length !== shaped.length) return undefined
30
31 const answers: Record<string, string> = {}
32 const deadline = Date.now() + QUESTION_TIMEOUT_MS
33 for (let i = 0; i < ids.length; i++) {
34 let response: string | null | undefined
35 while (Date.now() < deadline) {
36 const polled = await tryBridge($, 'poll_question', { id: ids[i], timeoutMs: POLL_TIMEOUT_MS })
37 if (polled === undefined) return undefined
38 if (polled.status === 'answered') {
39 response = polled.response
40 break
41 }
42 }
43 if (response === undefined || response === null) return undefined
44 let selected: string
45 try {
46 const parsed = JSON.parse(response)
47 selected = Array.isArray(parsed) ? parsed.join(', ') : String(parsed)
48 } catch {
49 selected = response
50 }
51 answers[shaped[i]!.question] = selected
52 }
53 return answers
54}
55
56export async function relayPermission($: any, id: string, tool: string, input: unknown): Promise<{ decision: string; reason: string } | undefined> {
57 const requested = await tryBridge($, 'request_permission', {
58 id,
59 tool,
60 input: JSON.stringify(input ?? {}),
61 })
62 if (requested === undefined) return undefined
63 const deadline = Date.now() + PERMISSION_TIMEOUT_MS
64 while (Date.now() < deadline) {
65 const polled = await tryBridge($, 'poll_permission', { id, timeoutMs: POLL_TIMEOUT_MS })
66 if (polled === undefined) return undefined
67 if (polled.status === 'allow') return { decision: 'allow', reason: 'approved from SpaceNotes' }
68 if (polled.status === 'deny') return { decision: 'deny', reason: 'denied from SpaceNotes' }
69 }
70 return undefined
71}
72hooks/state.ts 33 lines1import type { Inbound } from './inbound.ts'
2import type { LiveRow } from './live-row.ts'
3import type { ReminderJob } from './reminder-extract.ts'
4import type { WatchedPrompt } from './turn-notify.ts'
5
6export const noteTools = new Map<string, string>()
7export const deliveries: Inbound[] = []
8export const reminderJobs: ReminderJob[] = []
9export const midTurn: Inbound[] = []
10export const awaitingTurn: Inbound[] = []
11export const unanswered = new Map<string, { sender: string; at: number }>()
12export const readThisTurn = new Set<string>()
13
14export const session = {
15 bridgeReady: false,
16 socketPath: '',
17 reloadRetrySha: undefined as string | undefined,
18 sendToolName: '',
19 listToolName: '',
20 instructionsPending: true,
21 turnRunning: false,
22 lastPromptFromPhone: false,
23 currentTurnId: undefined as string | undefined,
24 toolsRunning: 0,
25 answerRow: undefined as LiveRow | undefined,
26 herdrPane: undefined as string | undefined,
27 herdrBin: undefined as string | undefined,
28 agentName: '',
29 reminderJobRunning: false,
30 watchedPrompt: undefined as WatchedPrompt | undefined,
31 watchedTurn: undefined as WatchedPrompt | undefined,
32}
33hooks/turn-notify.ts 71 lines1import { tryBridge } from './bridge-client.ts'
2import { decideByRules } from './notify-rules.ts'
3import { session } from './state.ts'
4
5const NOTIFY_MODEL = 'haiku'
6const NOTIFY_TITLE_MAX = 80
7const NOTIFY_BODY_MAX = 300
8const NOTIFY_PROMPT_MAX = 4_000
9const NOTIFY_ANSWER_MAX = 8_000
10const NOTIFY_SYSTEM = [
11 'You watch an AI agent that works for a person. The person was NOT watching the turn below: it was started by a plugin or by another agent, not by them.',
12 'Decide whether the person needs to know about it now. Notify when someone needs them (a mention, a question, a request, a review), when something they care about finished or failed, or when a decision is waiting on them.',
13 'Do not notify for routine bookkeeping, acknowledgements, status chatter between agents, or turns where nothing happened.',
14 'Reply with JSON only, no prose: {"notify": false} or {"notify": true, "title": "<one line, under 70 characters, what happened>", "body": "<one or two sentences, what the person needs to know or do>"}.',
15].join(' ')
16
17export type WatchedPrompt = { text: string; from: string }
18
19function clip(text: string, max: number): string {
20 return text.length > max ? `${text.slice(0, max - 1)}…` : text
21}
22
23function parseVerdict(reply: string): { title: string; body: string } | undefined {
24 const json = /\{[\s\S]*\}/.exec(reply)?.[0]
25 if (!json) return undefined
26 let verdict: any
27 try {
28 verdict = JSON.parse(json)
29 } catch {
30 return undefined
31 }
32 if (verdict?.notify !== true) return undefined
33 const title = String(verdict.title ?? '').trim()
34 if (!title) return undefined
35 return { title: clip(title, NOTIFY_TITLE_MAX), body: clip(String(verdict.body ?? '').trim(), NOTIFY_BODY_MAX) }
36}
37
38export async function judgeTurn($: any, watched: WatchedPrompt, answer: string) {
39 const ruled = decideByRules({ agent: session.agentName, from: watched.from, prompt: watched.text, answer })
40 if (ruled) {
41 await tryBridge($, 'log', { line: `notify rule ${ruled.rule} (${watched.from}): ${ruled.notify ? 'notify' : 'skip'}` })
42 if (ruled.notify) {
43 await tryBridge($, 'push_notification', {
44 title: clip(ruled.title, NOTIFY_TITLE_MAX),
45 body: clip(ruled.body.trim(), NOTIFY_BODY_MAX),
46 })
47 }
48 return
49 }
50 const prompt = [
51 `Started by: ${watched.from}`,
52 `Prompt:\n${clip(watched.text, NOTIFY_PROMPT_MAX)}`,
53 `Agent's final answer:\n${clip(answer, NOTIFY_ANSWER_MAX)}`,
54 ].join('\n\n')
55 let response: any
56 try {
57 response = await $.model.complete({ model: NOTIFY_MODEL, system: NOTIFY_SYSTEM, prompt, maxTokens: 300, timeoutMs: 60_000 })
58 } catch (err) {
59 await tryBridge($, 'log', { line: `notify judge failed: ${err instanceof Error ? err.message : String(err)}` })
60 return
61 }
62 const reply = typeof response === 'string' ? response : response?.isAnswered ? String(response.text ?? '') : ''
63 if (!reply) {
64 await tryBridge($, 'log', { line: `notify judge got no answer: ${String(response?.reason ?? 'empty reply')}` })
65 return
66 }
67 const verdict = parseVerdict(reply)
68 await tryBridge($, 'log', { line: `notify judge (${watched.from}): ${verdict ? 'notify' : 'skip'} ${clip(reply.replace(/\s+/g, ' '), 300)}` })
69 if (verdict) await tryBridge($, 'push_notification', verdict)
70}
71hooks/hook-types.ts 2 lines1export type Next = (e: any) => any
2hooks/onload.ts 161 lines1import { HANDOFF_PREFIX, isHandoffFork, resolveWorkflow } from './workflow.ts'
2import { notePath, parseItems, ranked } from './selfimprove-items.ts'
3import { callVaultTool, waitForBridge } from './vault.ts'
4import { liveAgentLines, WORK_BRIDGE } from './live-agents.ts'
5import type { Next } from './hook-types.ts'
6
7const FEATURE = 'spacenotes-agent-onload'
8const FETCH_TIMEOUT_MS = 2_000
9const SELF_IMPROVE_SHOWN = 5
10const DELIVERED_KEY = 'delivered'
11const MAX_SESSIONS = 50
12
13function errorText(err: unknown): string {
14 return err instanceof Error ? err.message : String(err)
15}
16
17function logDebug($: any, line: string): void {
18 $.ui.log(`spacenotes-agent onload: ${line}`, { to: 'debug' })
19}
20
21async function withTimeout<T>($: any, work: Promise<T>): Promise<T> {
22 const timeout = new Promise<never>((_, reject) =>
23 $.clock.sleep(FETCH_TIMEOUT_MS).then(() => reject(new Error(`timed out after ${FETCH_TIMEOUT_MS}ms`)), reject),
24 )
25 return Promise.race([work, timeout])
26}
27
28async function fetchOnload($: any, workflow: string): Promise<string> {
29 const r = await callVaultTool($, 'get_workflow_onload', { workflow })
30 if (r.isError || !r.text.trim()) throw new Error(r.text || 'empty on-load')
31 return r.text
32}
33
34export function onloadBlock(workflow: string, body: string): string {
35 return [
36 `## On load — you are the \`${workflow}\` workflow agent`,
37 '',
38 `Below is this workflow's on-load from SpaceNotes, fetched for the first message of this conversation or the first after a compaction or /clear: the README (your brief + map), the latest session log (authoritative state) and the knowledge index.`,
39 '',
40 `- Everything for this workflow lives in the vault under \`Workflows/${workflow}/\`, via the SpaceNotesAgent note tools. Read knowledge, tasks and todos on demand; don't bulk-read.`,
41 `- Before any task, read the relevant \`Workflows/${workflow}/knowledge/<topic>.md\` from the index.`,
42 '- Task operations, branching and the wrap-up procedure: vault note `Workflows/workflow-agent/execution-flow.md`. Pipelines: `Workflows/Pipelines/<kind>/README.md` — `bug-fix/` ("run the pipeline" / "investigate"), `feature-build/`, `refactor/`, `prove-fix/`, `vision/`.',
43 '- At the start of a conversation, present the latest-session summary, then ask what to work on.',
44 '',
45 body,
46 ].join('\n')
47}
48
49function unavailableBlock(workflow: string, reason: string): string {
50 return `## On load — this is the \`${workflow}\` workflow\n\nThe on-load could not be fetched (${reason}). Call \`get_workflow_onload\` with workflow \`${workflow}\` yourself before anything else.`
51}
52
53async function fetchSelfImprove($: any, workflow: string): Promise<string> {
54 const r = await callVaultTool($, 'get_file', { path: notePath(workflow), raw: true })
55 if (r.isError) return ''
56 const items = parseItems(r.text)
57 if (!items.length) return ''
58 const top = ranked(items).slice(0, SELF_IMPROVE_SHOWN)
59 return [
60 '## Self-improve',
61 '',
62 `Open friction findings from earlier compaction reviews (\`${notePath(workflow)}\`). Present them to Mikael numbered, exactly as below, after the session summary (or at the top of your reply if this context window starts mid-conversation). He answers with only the numbers he wants, e.g. "1 3".`,
63 '- Apply each picked one in this session, your call how. Then delete its line from the note (`edit_file`, match on its `[id]`).',
64 '- A fix that changes `~/.dotfiles` is committed and pushed without asking: one-line message, no footer.',
65 '- Leave unpicked ones alone; they carry over. Never delete an item he did not pick.',
66 '',
67 ...top.map((i, n) => `${n + 1}. [${i.id}] ${i.text} (seen ${i.seen}×)`),
68 ].join('\n')
69}
70
71async function fetchAgents($: any, workflow: string): Promise<string> {
72 const live = await liveAgentLines($, workflow)
73 if (!live.length) return ''
74 return [
75 '## Agents',
76 '',
77 `The Claude agents running right now, on every machine; your own \`${workflow}\` row is among them. Message one with \`send_to_agent('<id>')\` when work belongs to it, instead of doing its work here.`,
78 workflow === WORK_BRIDGE
79 ? "You are the front door: hand project work to the agent that owns it, using the Delegation rules in `Workflows/workflow-agent/execution-flow.md`, and relay its report to Mikael."
80 : 'A message headed `Delegation from workflow-agent` is Mikael\'s ask via the front door: follow the Delegation rules in `Workflows/workflow-agent/execution-flow.md`, and report back with `send_to_agent`, never plain text.',
81 '',
82 ...live,
83 ].join('\n')
84}
85
86async function delivered($: any): Promise<string[]> {
87 const v = await $.store.get(DELIVERED_KEY)
88 return Array.isArray(v) ? v : []
89}
90
91async function othersDelivered($: any): Promise<{ id: string; rest: string[] }> {
92 const id = await $.session.id()
93 return { id, rest: (await delivered($)).filter(s => s !== id) }
94}
95
96async function markPending($: any): Promise<void> {
97 const { rest } = await othersDelivered($)
98 await $.store.set(DELIVERED_KEY, rest)
99}
100
101async function markDelivered($: any): Promise<void> {
102 const { id, rest } = await othersDelivered($)
103 await $.store.set(DELIVERED_KEY, [...rest, id].slice(-MAX_SESSIONS))
104}
105
106async function isPending($: any): Promise<boolean> {
107 return !(await delivered($)).includes(await $.session.id())
108}
109
110function emptyOnFailure($: any, what: string, work: Promise<string>): Promise<string> {
111 return withTimeout($, work).catch(err => {
112 logDebug($, `${what} skipped: ${errorText(err)}`)
113 return ''
114 })
115}
116
117async function onloadText($: any, workflow: string): Promise<string> {
118 try {
119 const text = onloadBlock(workflow, await withTimeout($, fetchOnload($, workflow)))
120 logDebug($, `${workflow} (${text.length} chars)`)
121 return text
122 } catch (err) {
123 logDebug($, `failed: ${errorText(err)}`)
124 return unavailableBlock(workflow, errorText(err))
125 }
126}
127
128async function onloadContext($: any, workflow: string, text: string): Promise<string[]> {
129 await waitForBridge($)
130 const agents = emptyOnFailure($, 'agent list', fetchAgents($, workflow))
131 if (text.startsWith(HANDOFF_PREFIX)) return [await agents].filter(Boolean)
132 const selfImprove = emptyOnFailure($, 'self-improve list', fetchSelfImprove($, workflow))
133 const onload = await onloadText($, workflow)
134 const extras = (await Promise.all([agents, selfImprove])).filter(Boolean)
135 return [onload, ...extras]
136}
137
138export async function onloadCompact($: any, e: any, next: Next) {
139 if (e.agentId || e.trigger === 'precompute') return next(e)
140 await markPending($)
141 const result = await next(e)
142 if (!result.messages) await markDelivered($)
143 return result
144}
145
146export async function onloadEnd($: any, e: any, next: Next) {
147 const result = await next(e)
148 if (e.reason === 'clear') await markPending($)
149 return result
150}
151
152export async function onloadSubmit($: any, e: any, next: Next) {
153 if (await isHandoffFork($)) return next(e)
154 if (!(await isPending($).catch(() => false))) return next(e)
155 await markDelivered($)
156 const workflow = await resolveWorkflow($, FEATURE).catch(() => undefined)
157 if (!workflow) return next(e)
158 const blocks = await onloadContext($, workflow, e.text)
159 return next(blocks.length ? { ...e, context: [...(e.context ?? []), ...blocks] } : e)
160}
161hooks/selfimprove.ts 110 lines1import { isHandoffFork, resolveWorkflow } from './workflow.ts'
2import { ITEMS_MARKER, itemsBlock, merge, notePath, noteBody, parseItems, type Item, type Verdict } from './selfimprove-items.ts'
3import { callVaultTool } from './vault.ts'
4import type { Next } from './hook-types.ts'
5
6const FEATURE = 'spacenotes-agent-selfimprove'
7const MAX_NEW = 5
8const FALLBACK_MODEL = 'opus'
9const FALLBACK_MAX_TOKENS = 4_000
10const TRANSCRIPT_CHARS = 400_000
11const TOOL_CHARS = 300
12
13async function readNote($: any, workflow: string): Promise<string | undefined> {
14 const r = await callVaultTool($, 'get_file', { path: notePath(workflow), raw: true })
15 if (r.isError || !r.text.includes(ITEMS_MARKER)) return undefined
16 return r.text
17}
18
19async function writeNote($: any, workflow: string, before: string | undefined, items: Item[]): Promise<void> {
20 const path = notePath(workflow)
21 if (before === undefined) {
22 const r = await callVaultTool($, 'create_file', { path, content: noteBody(workflow, items) })
23 if (r.isError) throw new Error(`create_file: ${r.text}`)
24 return
25 }
26 const oldBlock = itemsBlock(parseItems(before))
27 const newBlock = itemsBlock(items)
28 if (oldBlock === newBlock) return
29 const r = await callVaultTool($, 'edit_file', { path, old_string: oldBlock, new_string: newBlock })
30 if (r.isError) throw new Error(`edit_file: ${r.text}`)
31}
32
33export function reviewPrompt(workflow: string, open: Item[]): string {
34 const list = open.length ? open.map(i => `- [${i.id}] ${i.text}`).join('\n') : '(none)'
35 return [
36 `The context window of the \`${workflow}\` workflow session is about to be compacted. Before it is, review it for friction: anything that made the work slower or clumsier than it needed to be, or that Mikael had to correct or do by hand. Not how hard the task itself was.`,
37 '',
38 'For each, say what to change so it does not happen again (a knowledge note, a skill or mod tweak, a script, a memory, a different habit), with a one-line reason saying what happened.',
39 '',
40 'Already open for this workflow:',
41 list,
42 '',
43 `Reply with ONLY a JSON array, at most ${MAX_NEW} entries, most important first. A friction that matches an open item: {"repeatOf": "<its id>"}. A new one: {"finding": "<the change to make>", "why": "<what happened>"}. Nothing worth raising: []. Single lines, no markdown.`,
44 ].join('\n')
45}
46
47export function parseVerdicts(text: string): Verdict[] {
48 const start = text.indexOf('[')
49 const end = text.lastIndexOf(']')
50 if (start < 0 || end < start) throw new Error('no JSON array in the review')
51 const raw = JSON.parse(text.slice(start, end + 1))
52 if (!Array.isArray(raw)) throw new Error('review is not an array')
53 return raw.flatMap((v: any): Verdict[] => {
54 if (typeof v?.repeatOf === 'string') return [{ repeatOf: v.repeatOf }]
55 if (typeof v?.finding === 'string' && v.finding.trim()) return [{ finding: v.finding, why: typeof v.why === 'string' ? v.why : '' }]
56 return []
57 }).slice(0, MAX_NEW)
58}
59
60function clip(s: string, max: number): string {
61 return s.length > max ? `${s.slice(0, max)}…` : s
62}
63
64function transcriptText(messages: readonly any[]): string {
65 const lines = messages.map(m => {
66 const tools = (m.toolUses ?? []).map((t: any) => `[tool ${t.tool}: ${clip(JSON.stringify(t.input ?? {}), TOOL_CHARS)}]`)
67 return [`${m.role.toUpperCase()}: ${m.text ?? ''}`, ...tools].join('\n')
68 })
69 return lines.join('\n\n').slice(-TRANSCRIPT_CHARS)
70}
71
72async function ask($: any, prompt: string, messages: readonly any[]): Promise<string> {
73 const forked = await $.model.fork({ prompt })
74 if (forked.isAnswered) return forked.text
75 if (forked.reason !== 'nothing-to-fork') throw new Error(`fork: ${forked.reason}`)
76 const r = await $.model.complete({
77 model: FALLBACK_MODEL,
78 maxTokens: FALLBACK_MAX_TOKENS,
79 prompt: `<transcript>\n${transcriptText(messages)}\n</transcript>\n\n${prompt}`,
80 })
81 if (!r.isAnswered) throw new Error(`complete: ${r.reason}`)
82 return r.text
83}
84
85async function review($: any, messages: readonly any[]): Promise<void> {
86 const workflow = await resolveWorkflow($, FEATURE).catch(() => undefined)
87 if (!workflow) return
88 const before = await readNote($, workflow)
89 const open = before === undefined ? [] : parseItems(before)
90 const verdicts = parseVerdicts(await ask($, reviewPrompt(workflow, open), messages))
91 if (!verdicts.length) {
92 $.ui.log(`spacenotes-agent selfimprove: ${workflow}: nothing to raise`, { to: 'debug' })
93 return
94 }
95 const today = new Date().toISOString().slice(0, 10)
96 await writeNote($, workflow, before, merge(open, verdicts, today))
97 $.ui.log(`spacenotes-agent selfimprove: ${workflow}: ${verdicts.length} finding(s) recorded`, { to: 'debug' })
98}
99
100export async function selfImproveCompact($: any, e: any, next: Next) {
101 if (!e.agentId && e.trigger !== 'precompute' && !(await isHandoffFork($))) {
102 try {
103 await review($, e.messages)
104 } catch (err) {
105 $.ui.log(`spacenotes-agent selfimprove: review failed: ${err instanceof Error ? err.message : String(err)}`, { to: 'debug' })
106 }
107 }
108 return next(e)
109}
110