SLOPSHOPPER

spacenotes-agent

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

newguardpromptmodelprocessnetwork
★ 7v0.5.0GPL-3.0updated 2026-10-25mikaelwills/SpaceNotes/spacenotes-agent/plugin
A shopper browsing a rack in a slop shop
README

<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.

Desktop Notes View

<img src="assets/screenshots/mobile-notes.png" width="45%" alt="Mobile Notes View" />

How it compares

FeatureSpaceNotesObsidian SyncNotionNotesnookBasic Memory
Self-hostedYesNoNoYesNo
Real-time syncYesYesYesYesYes
Mobile + WebYesMobile onlyYesYesWeb only
AI integrationMCPNoneBuilt-inNoneMCP
Plain filesYesYesNoPartialYes
Data ownershipFullPartialNoneFullPartial
CostFree$8/moFree/$10/moFree/$5/moPaid

Requirements:

  • A server or laptop.
  • Comfort with Docker and basic command line
  • A private network setup (Tailscale, WireGuard, or similar)

Current limitations:

  • No hosted option - you must run your own server
  • No E2E encryption for notes - security comes from self-hosting on a private network (passwords are GPG-encrypted at rest)
  • No multi-user collaboration yet
  • Early-stage software - expect rough edges

Architecture

One Docker container on your server runs everything:

  • SpacetimeDB holds the notes: file metadata and markdown content.
  • The sync daemon keeps the vault folder and SpacetimeDB in step in both directions, and serves every file byte over HTTP.
  • The MCP server gives AI assistants read/write access to the vault.
  • nginx serves the web client and proxies /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.

Components

  • SpacetimeDB - Real-time database holding the notes. Clients connect once and receive instant updates.
  • Filesystem sync daemon - Watches the vault and syncs bidirectionally with SpacetimeDB. Also the file server: ranged downloads, resumable uploads, and video/image thumbnails (ffmpeg).
  • MCP server - Lets Claude Code, Cursor and other assistants search, read, write and organise the vault, and hand large files in and out.
  • SpaceNotesAgent - Links a running Claude Code session to the app's chat: a Claude Code plugin plus a small bridge binary. Messages, the agent's progress notes, tool calls, permission prompts and questions all flow through SpacetimeDB.
  • Flutter client - Native apps for iOS, Android, macOS, Windows, Linux, and web.

Standard Ports

  • 5050 - SpacetimeDB (WebSocket/HTTP). The Flutter client connects here.
  • 5051 - HTTP: the web client, plus /files/ (downloads, whole-file uploads), /uploads (resumable uploads) and /thumbnails/.
  • 5052 - MCP server (HTTP), /mcp.

All ports are configurable via docker-compose.yml.

Files, downloads and uploads

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.

  • Large files never go through the database. Binaries under 20KB are stored inline; anything bigger is a few hundred bytes of metadata in SpacetimeDB, with the bytes on disk and served over HTTP. An 80MB video doesn't touch the commitlog.
  • Downloads are on demand and resumable. Opening a file downloads it to the device and caches it; an interrupted download resumes from where it stopped via HTTP 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.
  • Offloading. Downloaded files can be offloaded from the device (Settings → downloaded files) and fetched again when needed.
  • Uploads are resumable. Files over 8MB go up in 4MB chunks over a tus-style protocol (POST/HEAD/PATCH /uploads). An upload survives the app being backgrounded or killed and continues on next launch from the server's offset.
  • No silent overwrites. Uploading onto an existing name is refused (HTTP 409) by the server, not the client, so it holds even before the app has synced.
  • Thumbnails are generated server-side for images and video.
  • Multiple files at once. Multi-select in the file grid to move or delete several files together.

The web client is notes-only: downloading and uploading binary files needs a native app.

Password manager

SpaceNotes can hold a pass-compatible password store: GPG-encrypted .gpg files under .password-store/ in the vault, synced like any other file.

  • Import your GPG private key on each device (Settings → password manager) to reveal passwords. The key stays on that device; the server only ever sees ciphertext.
  • Browse and search credentials from the key icon in the nav, view/copy fields, and create new entries with a built-in password generator.
  • The whole feature can be switched off per device (Settings → preferences), which removes it from the nav, the desktop sidebar and settings.

Flutter Client Features

Notes and files:

  • Real-time sync across all devices via SpacetimeDB, with an offline cache
  • Recents page: Recently Viewed (what this device opened, tracked locally) and Recently Updated (what changed in the vault)
  • Fuzzy search, folders, favourite folders, masonry grid of file cards
  • Markdown editing; generative-UI "dashboard" notes (KPIs, charts, editable fields)
  • Viewers for images and video; PDFs and other files download to the device
  • Audio player with a native parametric EQ, scrolling waveform scrubber, background/lock-screen playback and a persistent mini player

Agent chat:

  • Chat with a Claude Code session from the app; the agent's short progress notes appear between its tool calls as it works
  • Tool calls show in the chat, with Edit and Write rendered as a red/green diff
  • Approve or deny tool permissions and answer the agent's questions from the app
  • Stop a running turn, or background a long-running tool

Mobile (iOS/Android):

  • Recents, folders and passwords one tap apart in the nav bar

Desktop (macOS/Windows/Linux/Web):

  • Finder-style layout: sidebar and tabbed notes with back navigation
  • Drag and drop file organisation, keyboard navigation (Shift+Tab cycles screens)

Quick Start

  1. Download docker-compose.yml: ``bash curl -O https://raw.githubusercontent.com/mikaelwills/SpaceNotes/master/docker-compose.yml ``
  1. Edit it - set your notes folder and the address clients reach the server on: ```yaml volumes:
  2. /path/to/your/notes:/vault environment:
  3. DATA_DIR=/data
  4. SPACENOTES_FILES_HOST=http://<your-server-ip>:5051 `` 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.
  1. Start: ``bash docker-compose up -d `` Docker pulls the pre-built image. First run takes a minute to download.
  1. Verify it's running: ``bash docker logs spacenotes `` You should see "Watcher started on /vault" when ready.
  1. Access SpaceNotes:
  2. Web client: http://<your-server-ip>:5051
  3. Mobile/desktop app: enter <your-server-ip> in Settings → server
  4. MCP server: http://<your-server-ip>:5052/mcp

Updating

docker-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.

MCP Integration (Claude Code)

Configure Claude Code

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"
    }
  }
}

Available MCP Tools

Find and read:

  • search_files - search by title, path or content
  • search_files_content - search and return excerpts around matches
  • get_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 folder
  • vault_index - every folder path, plus files under chosen folders
  • get_backlinks / get_outbound_links - link graph for a note

Write:

  • create_file - create a note
  • edit_file - find-and-replace, one or many edits in one commit
  • regex_replace, replace_across_files - pattern replace in one note or across many
  • append_to_file / prepend_to_file

Organise:

  • move_file, move_files_to_folder
  • delete_file, delete_files
  • create_folder, move_folder, delete_folder, empty_folder

Binary 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 call

Configuration

Environment variables (set in docker-compose.yml):

  • SPACENOTES_FILES_HOST - required. Externally reachable base URL of port 5051, used in upload_file/download_file URLs
  • DATA_DIR - daemon state (note identity journal); point at the /data volume
  • VAULT_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)

License

GPL-3.0 - This project is free software. Any derivative work must also be open source under the same license.

Source 25 files
hooks/register.ts 228 lines
1import 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}
228
hooks/bridge-client.ts 177 lines
1import 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}
177
hooks/inbound.ts 128 lines
1import { 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}
128
hooks/live-row.ts 47 lines
1import { 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}
47
hooks/own-tools.ts 10 lines
1import { 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}
10
hooks/agent-tools.ts 83 lines
1import { 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}
83
hooks/questions.ts 72 lines
1import { 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}
72
hooks/state.ts 33 lines
1import 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}
33
hooks/turn-notify.ts 71 lines
1import { 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}
71
hooks/hook-types.ts 2 lines
1export type Next = (e: any) => any
2
hooks/onload.ts 161 lines
1import { 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}
161
hooks/selfimprove.ts 110 lines
1import { 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