@ai-ecoverse/slicc-agent 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,7 +108,7 @@ app.model = { ...createKernelModel({ kernel, root }), ...createAgentModel(agent,
108
108
 
109
109
  The adapter keeps the replicated transcript and maps it on every update. User, assistant and compaction entries become messages; tool results fill in the tool calls they answer; the generation in flight shows as a streaming message; retries and running compactions show as system messages. A failed generation ends in an error card whose action fits the error: credential errors (an invalid or missing key, 401/403, not authorized) open settings, a model the account can't use offers the model picker, and anything else offers Retry. A content-filter stop is different: every later request resends the content that tripped the filter, so Retry, and any new message, would be stopped again. Content-filter stops are Bedrock's `content_filtered` and `guardrail_intervened`, Anthropic's `sensitive` and refusals, and OpenAI's `content_filter`. Their card leads with "The model's content filter stopped this reply." and offers **Drop the last turn** (spectrum ≥ 1.12.0). `AgentControl.rewind(messageId)` finds the user turn before that message and forks the cone's conversation at the entry before it (`Conversation.fork`, so `pi.agent` comes back as it was there). It points the cone at the fork and returns the turn's prompt, which spectrum puts back into the composer. Forking the very first turn creates a fresh conversation with the same model and thinking level. The filtered branch stays in storage, off the active path. The fork starts with a `slicc.rewound` entry that shows as a "Rewound 1 turn" notice, and clicking the card of an earlier filtered turn rewinds that turn.
110
110
 
111
- Each cone's active conversation is in the session document `slicc.agents` (the root until the first rewind). The host's transcript follows it across forks. Rewinding is refused while the cone itself is running or has queued input, because a fork starts with an empty inbox, but never because its scoops are busy (see Cones and scoops). A background compaction still running on the old branch finishes there. A fork also starts with fresh `pi.usage`. Credential and model errors lead with a plain sentence ("Bedrock rejected the API key.", "This model isn't available with the current account.") and keep the provider's raw error as `detail`, which spectrum (≥ 1.11.0) shows underneath. `busy()` and `queue()` read durable's live and inbox documents. Steering is the default (#4): a send or a steer goes out with `whenBusy: 'steer'`, so while a run is going it joins after the current tool round, and a `queue` send is a follow-up. User messages carry `delivered` from the host's deliveries; entries the host didn't record (from before a reload) count as steered when they follow a tool result. Cones and scoops are below; the freezer and questions come later. Licks show as spectrum's lick cards (below). slicc-spectrum is an optional peer dependency (≥ 1.37.0, which has `ChangesPort` with copyable commands in its empty state), used for types only, so package managers don't install it next to the agent.
111
+ Each cone's active conversation is in the session document `slicc.agents` (the root until the first rewind). The host's transcript follows it across forks. Rewinding is refused while the cone itself is running or has queued input, because a fork starts with an empty inbox, but never because its scoops are busy (see Cones and scoops). A background compaction still running on the old branch finishes there. A fork also starts with fresh `pi.usage`. Credential and model errors lead with a plain sentence ("Bedrock rejected the API key.", "This model isn't available with the current account.") and keep the provider's raw error as `detail`, which spectrum (≥ 1.11.0) shows underneath. `busy()` and `queue()` read durable's live and inbox documents. Steering is the default (#4): a send or a steer goes out with `whenBusy: 'steer'`, so while a run is going it joins after the current tool round, and a `queue` send is a follow-up. User messages carry `delivered` from the host's deliveries; entries the host didn't record (from before a reload) count as steered when they follow a tool result. Cones and scoops are below; the freezer and questions come later. Licks show as spectrum's lick cards (below). slicc-spectrum is an optional peer dependency (≥ 1.39.0, whose Freezer shows every conversation with its kind and Open, Thaw and Remove), used for types only, so package managers don't install it next to the agent.
112
112
 
113
113
  Each tool call shows its input in the way that suits it:
114
114
  - `bash`: its command, with a timeout as the card's `meta`;
@@ -235,7 +235,6 @@ The model can write a short JavaScript program that calls its tools, with the `c
235
235
  - **Isolation.** Seven is cross-origin isolated, so pi-codemode's `SharedArrayBuffer` interrupt works.
236
236
  - **Overrides.** `runAgentWorker` takes `codemodeWasm` and `codemodeWorker` to override both, for bundled hosts.
237
237
 
238
- ||||||| parent of d578592 (wip: sprinkles)
239
238
  ### Memory
240
239
 
241
240
  pi has no memory store or memory tool; its coding agent loads context files, and [pi-subagents](https://github.com/nicobailon/pi-subagents) gives a role a `MEMORY.md`. Both are ported here over `ExecutionEnv`.
@@ -285,24 +284,38 @@ pi core has no browser tooling, and Mario's pi-skills `browser-tools` launches i
285
284
 
286
285
  ### The freezer
287
286
 
288
- pi durable keeps every conversation and entry in the session database, and has no archive flag and no delete. So freezing is bookkeeping in the `slicc.agents` registry.
287
+ pi durable keeps every conversation and entry in the session database, and has no archive flag and no delete (#75). The `slicc.agents` registry is the index of which conversations exist and what they are, so the Freezer lists from it, as Lars put it: the list of sessions in the database.
289
288
 
290
- - **Freeze a cone** (the Freezer's button, or `freeze` in the command palette):
289
+ - **The list** (`frozen` on the transcript service): every cone, frozen cone, scoop and `agent` run the registry knows.
290
+ - Each row has its id, name (names needn't be unique), title (the first user line), model, message count and last activity.
291
+ - It also has `kind` (`cone`, `scoop` or `agent`), `live` and `thawedAs`.
292
+ - Live rows come first, then the newest. A stopped conversation's numbers are read once and cached.
293
+ - **New conversation** (spectrum's `clear` and `/clear`, Lars's W9) gives the same cone, with its id and name, a fresh conversation with the same configuration, when the chat isn't empty. The old conversation becomes a frozen row. On a scoop, `clear` is a plain reset.
294
+ - **Delete cone** (the Freezer's freeze):
291
295
  - its run and its scoops' runs stop;
292
- - it moves from `cones` to `frozen` (`frozen-<n>`, with its name, conversation, title, model, message count and time), and its live scoops are marked frozen, keeping their transcripts and their workspaces;
296
+ - it moves from `cones` to `frozen` with its live scoops marked frozen, keeping their transcripts and workspaces;
293
297
  - if it was active, another cone is selected, and if it was the last one, a fresh cone takes its name.
294
- - Before it moves, 14b's memory extraction is scheduled for anything new.
295
- - Its title starts as the first user line, then one background call (with `memory.extractModel` or the cone's model) replaces it with a short title.
296
- - **New chat** (spectrum's `clear` and `/clear`, Lars's W9) archives the chat the same way, when it isn't empty, and gives the cone a fresh conversation with the same configuration; its scoops stay. On a scoop, `clear` is a plain reset.
297
- - **Thaw** puts a frozen cone back under its old id when that's free (otherwise a new `cone-<n>`), unfreezes its scoops idle with their workspaces, and selects it. A taken name becomes `<name> (earlier)`, then `(earlier 2)` and so on, as in spectrum's dummy. It continues where it stopped.
298
- - **Delete** removes the frozen entry, marks its scoops gone and deletes their folders. The history stays in the session database, which SLICC can't remove from.
298
+ - 14b's memory extraction runs first, and one background call (with `memory.extractModel` or the cone's model) gives it a short title.
299
+ - **Thaw:**
300
+ - **A frozen cone** comes back under its old id when that's free (otherwise a new `cone-<n>`), keeping its name, with its scoops unfrozen.
301
+ - **A live row** just opens: it's selected.
302
+ - **A stopped scoop or `agent` run comes back as a cone, as a fork:** a conversation's task ownership can't change, so `Conversation.fork` at its last entry gives an ownerless conversation with the cone's tools and instructions, cwd `/home`, and the scoop's model and thinking level. It becomes `cone-<n>` with the scoop's name, and the scoop's row records `thawedAs`.
303
+ - The thawed cone's first entry is a `slicc.thawed` line the model sees and spectrum shows as a notice: whose conversation it was, and whether its workspace is still there or was deleted when it stopped.
304
+ - **Remove** (the Freezer's delete, `discard`):
305
+ - on a frozen cone, it removes the record, marks its scoops gone and deletes their folders;
306
+ - on a stopped scoop or `agent` run, it sets `removed` (a tombstone in the registry, so the row never returns) and deletes its folder.
307
+ - The history stays in the session database. Live rows can't be removed.
299
308
  - **While frozen:**
300
- - its scoops aren't listed, and `agent send|wait|stop` on one says it is frozen with its cone;
301
- - cron fires, watches and webhooks aimed at the cone or its scoops are skipped, and the active cone gets one `warn` lick per source naming what was skipped;
309
+ - a frozen cone's scoops aren't listed by `agent list`, and `agent send|wait|stop` on one says it is frozen with its cone;
310
+ - cron fires, watches and webhooks aimed at it are skipped, and the active cone gets one `warn` lick per source naming what was skipped;
302
311
  - sprinkles it owned send to the active cone;
303
312
  - it can't be rewound.
304
- - **The `freezer` command** is the same script as `agent`: `freezer list`, `freezer show <id>` (the chat as Markdown, which replaces v6's `transcript-export`) and `freezer thaw <id>`. The gelatiere's pass reads frozen chats with it.
305
- - **The panel:** the transcript service adds `frozen`, and `AgentControl` adds `freeze`, `newChat`, `thaw` and `discard`.
313
+ - **The `freezer` command** is the same script as `agent`:
314
+ - `freezer list` shows every conversation with its kind, and `(live)` on live ones;
315
+ - `freezer show <id>` shows a conversation as Markdown, which replaces v6's `transcript-export`;
316
+ - `freezer thaw <id>` thaws it.
317
+ - The gelatiere's pass reads frozen chats with it.
318
+ - **The panel:** `AgentControl` has `freeze` (Delete cone), `newChat` (New conversation), `thaw` and `discard` (Remove).
306
319
 
307
320
  ### Changes
308
321
 
@@ -313,7 +326,7 @@ pi has no git integration and no changes list; pi coding-agent leaves that to th
313
326
  - `before` is the index version (`git show :<path>`) and `after` is the working file. Binary files and files over 1 MB get null for both, which spectrum shows as "no diff".
314
327
  - At most 200 files per repo are listed. There's no store of our own, and `agentId` is null, because git doesn't know which agent wrote a file.
315
328
  - **Without git,** `unavailable()` says git isn't installed and how to get it (`pnpm add -g @ai-ecoverse/wasm-git`). **With git but no repo,** it says to `git init` a folder under /home or clone one with slicc-node or the extension connected; cloning over HTTPS through the page alone fails. Seven doesn't preinstall git; the user or the agent installs it with that command.
316
- - **The `system` section** has a Git line. With `git` on PATH, it says local repositories work and that clone, fetch and push need slicc-node or the extension. Without it, it gives the agent `pnpm add -g @ai-ecoverse/wasm-git` to install it when needed. Seven doesn't preinstall git (Lars, 2026-10-09). The install line also names the other tested packages that aren't preinstalled: `@ai-ecoverse/wasi-esbuild` (esbuild), `@ai-ecoverse/wasi-typescript` (tsc) and `@ai-ecoverse/wasi-biome` (biome).
329
+ - **The `system` section** has a Git line. With `git` on PATH, it says local repositories work and that clone, fetch and push need slicc-node or the extension. Without it, it gives the agent `pnpm add -g @ai-ecoverse/wasm-git` to install it when needed. Seven doesn't preinstall git (Lars, 2026-10-09). The install line also names the other tested packages that aren't preinstalled: `@ai-ecoverse/wasi-esbuild` (esbuild), `@ai-ecoverse/wasi-typescript` (tsc), `@ai-ecoverse/wasi-biome` (biome), and for PDFs `@ai-ecoverse/wasm-poppler` (pdftotext and friends) and `@ai-ecoverse/wasm-qpdf` (qpdf).
317
330
  - **Which repos:**
318
331
  - the repo of each live agent's cwd (`git rev-parse --show-toplevel`), with the active cone's first;
319
332
  - plus a scan of /home and each /mnt/<name>, three levels deep and at most 200 directories per level, skipping node_modules and dot-directories.
@@ -354,7 +367,7 @@ The built-in `sprinkles` skill teaches the files, the bridge and the command.
354
367
 
355
368
  ## The kernel
356
369
 
357
- `@ai-ecoverse/slicc-kernel` is a peer dependency (`>=1.23.0 <2`): the agent only calls `attachKernel`, the client end of the page kernel's port, so it must match the kernel the page runs. Seven chooses that version: slicc-bios pins the certified kernel next to the agent in `src/packages/agent/package.json`, and pnpm fills the peer from that pin. The devDependency is the lowest certified kernel (1.23.0), and the unit and integration tests run on it. A second CI job reruns the integration tests on the newest certified kernel (`NEWEST_CERTIFIED_KERNEL` in `.github/workflows/ci.yml`, 1.26.3 today), so both ends of the range are tested; it moves by hand, with each certification. If the port protocol ever breaks within 1.x, a patch release narrows the upper bound. Renovate opens PRs for the kernel, `wasi-pnpm`, the `wasm-*` packages and `slicc-cdp` but never merges them, because they move only after the homescoop coordinator certifies them.
370
+ `@ai-ecoverse/slicc-kernel` is a peer dependency (`>=1.26.3 <2`; older kernels have an IPv6-to-loopback security bug): the agent only calls `attachKernel`, the client end of the page kernel's port, so it must match the kernel the page runs. Seven chooses that version: slicc-bios pins the certified kernel next to the agent in `src/packages/agent/package.json`, and pnpm fills the peer from that pin. The devDependency is the lowest certified kernel the range allows (1.26.3), and the unit and integration tests run on it. A second CI job reruns the integration tests on the newest certified kernel (`NEWEST_CERTIFIED_KERNEL` in `.github/workflows/ci.yml`). Today both are 1.26.3; the second job moves by hand to each newer certified kernel, so both ends of the range are tested. If the port protocol ever breaks within 1.x, a patch release narrows the upper bound. Renovate opens PRs for the kernel, `wasi-pnpm`, the `wasm-*` packages and `slicc-cdp` but never merges them, because they move only after the homescoop coordinator certifies them.
358
371
 
359
372
  ## Patched dependencies
360
373
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/agents.ts"],
4
- "sourcesContent": ["import type { Context } from '@earendil-works/chord';\nimport {\n type Conversation,\n type ConversationId,\n defineDoc,\n type Harness,\n type Tx,\n} from '@earendil-works/pi-durable';\nimport { ConeDoc } from './cone.ts';\nimport type { RoleMemory } from './memory/store.ts';\n\nexport const FIRST_CONE = 'cone';\n\nexport type ConeRecord = { name: string; conversation: number };\n\nexport type Mark = { in: number; at: number };\n\nexport type ScoopRecord = {\n name: string;\n folder: string;\n cone: string;\n conversation: number;\n anchor: number;\n role: string | null;\n memory?: RoleMemory;\n origin?: string;\n context?: { project: boolean; global: boolean };\n kind: 'async' | 'sync';\n parent: string;\n depth: number;\n roots: { write: string[]; read: string[] } | null;\n created: Mark | null;\n dropped: Mark | null;\n gone: boolean;\n frozen?: string;\n};\n\nexport type FrozenRecord = {\n cone: string;\n name: string;\n conversation: number;\n title: string;\n model: string;\n messages: number;\n frozenAt: number;\n scoops: string[];\n};\n\nexport function live(record: ScoopRecord): boolean {\n return record.dropped === null && !record.gone && !record.frozen;\n}\n\nexport type AgentsState = {\n cones: Record<string, ConeRecord>;\n active: string;\n next: number;\n scoops: Record<string, ScoopRecord>;\n frozen?: Record<string, FrozenRecord>;\n frozenNext?: number;\n};\n\nexport const AgentsDoc = defineDoc<AgentsState>({\n kind: 'slicc.agents',\n version: 1,\n scope: 'session',\n initial: () => ({ cones: {}, active: FIRST_CONE, next: 2, scoops: {} }),\n});\n\nexport const SCOOP_PREFIX = 'scoop:';\n\nexport function scoopId(folder: string): string {\n return `${SCOOP_PREFIX}${folder}`;\n}\n\nexport function slug(name: string): string {\n const cleaned = name\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[-._]+|[-._]+$/g, '')\n .slice(0, 48);\n return cleaned || 'scoop';\n}\n\nexport function freeFolder(state: Pick<AgentsState, 'scoops'>, name: string): string {\n const base = slug(name);\n if (!state.scoops[scoopId(base)]) return base;\n for (let n = 2; ; n++) if (!state.scoops[scoopId(`${base}-${n}`)]) return `${base}-${n}`;\n}\n\nexport interface Agents {\n state(): Readonly<AgentsState>;\n cone(): Conversation;\n activeCone(): string;\n conversation(agentId: string, context: Context): Promise<Conversation | undefined>;\n selectCone(id: string, context: Context): Promise<void>;\n createCone(name: string, context: Context): Promise<string>;\n switchCone(conversation: Conversation, context: Context): Promise<void>;\n setConversation(coneId: string, conversation: Conversation, context: Context): Promise<void>;\n update<T>(change: (tx: Tx, state: AgentsState) => T | Promise<T>, context: Context): Promise<T>;\n onChange(listener: (state: Readonly<AgentsState>) => void): () => void;\n onCone(listener: (conversation: Conversation) => void | Promise<void>): () => void;\n}\n\nasync function initial(harness: Harness, root: Conversation, context: Context) {\n return harness.commit(async (tx) => {\n const doc = await tx.doc(AgentsDoc);\n if (!doc.cones[FIRST_CONE]) {\n const legacy = (await tx.doc(ConeDoc)).conversation;\n doc.cones[FIRST_CONE] = { name: 'sliccy', conversation: legacy ?? root.id };\n }\n if (!doc.cones[doc.active]) doc.active = FIRST_CONE;\n return JSON.parse(JSON.stringify(doc)) as AgentsState;\n }, context);\n}\n\nexport async function openAgents(\n harness: Harness,\n root: Conversation,\n context: Context\n): Promise<Agents> {\n let state = await initial(harness, root, context);\n const handles = new Map<number, Conversation>([[root.id, root]]);\n const handle = async (id: number, using: Context) => {\n const known = handles.get(id);\n if (known) return known;\n const found = await harness.conversation(id as ConversationId, using);\n if (found) handles.set(id, found);\n return found;\n };\n let cone =\n (await handle((state.cones[state.active] as ConeRecord).conversation, context)) ?? root;\n const changes = new Set<(state: Readonly<AgentsState>) => void>();\n const cones = new Set<(conversation: Conversation) => void | Promise<void>>();\n const notifyCone = async (next: Conversation) => {\n cone = next;\n await Promise.all([...cones].map((listener) => listener(next)));\n };\n const update: Agents['update'] = async (change, using) => {\n const [result, next] = await harness.commit(async (tx) => {\n const doc = await tx.doc(AgentsDoc);\n const value = await change(tx, doc as AgentsState);\n return [value, JSON.parse(JSON.stringify(doc)) as AgentsState] as const;\n }, using);\n state = next;\n for (const listener of changes) listener(state);\n return result;\n };\n const agents: Agents = {\n state: () => state,\n cone: () => cone,\n activeCone: () => state.active,\n async conversation(agentId, using) {\n const id = state.cones[agentId]?.conversation ?? state.scoops[agentId]?.conversation;\n return id === undefined ? undefined : handle(id, using);\n },\n async selectCone(id, using) {\n const record = state.cones[id];\n if (!record) throw new Error(`no cone ${id}`);\n if (state.active === id) return;\n await update((_tx, doc) => {\n doc.active = id;\n }, using);\n await notifyCone((await handle(record.conversation, using)) as Conversation);\n },\n async createCone(name, using) {\n const wanted = name.trim();\n if (!wanted) throw new Error('A cone needs a name.');\n if (Object.values(state.cones).some((record) => record.name === wanted))\n throw new Error(`A cone named ${wanted} already exists.`);\n const conversation = await harness.createConversation(\n {\n ownership: { kind: 'ownerless' },\n agent: { model: (await cone.agent(using)).model ?? null },\n },\n using\n );\n handles.set(conversation.id, conversation);\n return update((_tx, doc) => {\n const id = `cone-${doc.next}`;\n doc.next += 1;\n doc.cones[id] = { name: wanted, conversation: conversation.id };\n return id;\n }, using);\n },\n switchCone(conversation, using) {\n return agents.setConversation(state.active, conversation, using);\n },\n async setConversation(coneId, conversation, using) {\n handles.set(conversation.id, conversation);\n await update(async (tx, doc) => {\n (doc.cones[coneId] as ConeRecord).conversation = conversation.id;\n if (coneId === doc.active) (await tx.doc(ConeDoc)).conversation = conversation.id;\n }, using);\n if (coneId === state.active) await notifyCone(conversation);\n },\n update,\n onChange(listener) {\n changes.add(listener);\n return () => changes.delete(listener);\n },\n onCone(listener) {\n cones.add(listener);\n return () => cones.delete(listener);\n },\n };\n return agents;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { Context } from '@earendil-works/chord';\nimport {\n type Conversation,\n type ConversationId,\n defineDoc,\n type Harness,\n type Tx,\n} from '@earendil-works/pi-durable';\nimport { ConeDoc } from './cone.ts';\nimport type { RoleMemory } from './memory/store.ts';\n\nexport const FIRST_CONE = 'cone';\n\nexport type ConeRecord = { name: string; conversation: number };\n\nexport type Mark = { in: number; at: number };\n\nexport type ScoopRecord = {\n name: string;\n folder: string;\n cone: string;\n conversation: number;\n anchor: number;\n role: string | null;\n memory?: RoleMemory;\n origin?: string;\n context?: { project: boolean; global: boolean };\n kind: 'async' | 'sync';\n parent: string;\n depth: number;\n roots: { write: string[]; read: string[] } | null;\n created: Mark | null;\n dropped: Mark | null;\n gone: boolean;\n frozen?: string;\n removed?: boolean;\n thawedAs?: string;\n};\n\nexport type FrozenRecord = {\n cone: string;\n name: string;\n conversation: number;\n title: string;\n model: string;\n messages: number;\n frozenAt: number;\n scoops: string[];\n};\n\nexport function live(record: ScoopRecord): boolean {\n return record.dropped === null && !record.gone && !record.frozen;\n}\n\nexport type AgentsState = {\n cones: Record<string, ConeRecord>;\n active: string;\n next: number;\n scoops: Record<string, ScoopRecord>;\n frozen?: Record<string, FrozenRecord>;\n frozenNext?: number;\n};\n\nexport const AgentsDoc = defineDoc<AgentsState>({\n kind: 'slicc.agents',\n version: 1,\n scope: 'session',\n initial: () => ({ cones: {}, active: FIRST_CONE, next: 2, scoops: {} }),\n});\n\nexport const SCOOP_PREFIX = 'scoop:';\n\nexport function scoopId(folder: string): string {\n return `${SCOOP_PREFIX}${folder}`;\n}\n\nexport function slug(name: string): string {\n const cleaned = name\n .toLowerCase()\n .replace(/[^a-z0-9._-]+/g, '-')\n .replace(/^[-._]+|[-._]+$/g, '')\n .slice(0, 48);\n return cleaned || 'scoop';\n}\n\nexport function freeFolder(state: Pick<AgentsState, 'scoops'>, name: string): string {\n const base = slug(name);\n if (!state.scoops[scoopId(base)]) return base;\n for (let n = 2; ; n++) if (!state.scoops[scoopId(`${base}-${n}`)]) return `${base}-${n}`;\n}\n\nexport interface Agents {\n state(): Readonly<AgentsState>;\n cone(): Conversation;\n activeCone(): string;\n conversation(agentId: string, context: Context): Promise<Conversation | undefined>;\n selectCone(id: string, context: Context): Promise<void>;\n createCone(name: string, context: Context): Promise<string>;\n switchCone(conversation: Conversation, context: Context): Promise<void>;\n setConversation(coneId: string, conversation: Conversation, context: Context): Promise<void>;\n update<T>(change: (tx: Tx, state: AgentsState) => T | Promise<T>, context: Context): Promise<T>;\n onChange(listener: (state: Readonly<AgentsState>) => void): () => void;\n onCone(listener: (conversation: Conversation) => void | Promise<void>): () => void;\n}\n\nasync function initial(harness: Harness, root: Conversation, context: Context) {\n return harness.commit(async (tx) => {\n const doc = await tx.doc(AgentsDoc);\n if (!doc.cones[FIRST_CONE]) {\n const legacy = (await tx.doc(ConeDoc)).conversation;\n doc.cones[FIRST_CONE] = { name: 'sliccy', conversation: legacy ?? root.id };\n }\n if (!doc.cones[doc.active]) doc.active = FIRST_CONE;\n return JSON.parse(JSON.stringify(doc)) as AgentsState;\n }, context);\n}\n\nexport async function openAgents(\n harness: Harness,\n root: Conversation,\n context: Context\n): Promise<Agents> {\n let state = await initial(harness, root, context);\n const handles = new Map<number, Conversation>([[root.id, root]]);\n const handle = async (id: number, using: Context) => {\n const known = handles.get(id);\n if (known) return known;\n const found = await harness.conversation(id as ConversationId, using);\n if (found) handles.set(id, found);\n return found;\n };\n let cone =\n (await handle((state.cones[state.active] as ConeRecord).conversation, context)) ?? root;\n const changes = new Set<(state: Readonly<AgentsState>) => void>();\n const cones = new Set<(conversation: Conversation) => void | Promise<void>>();\n const notifyCone = async (next: Conversation) => {\n cone = next;\n await Promise.all([...cones].map((listener) => listener(next)));\n };\n const update: Agents['update'] = async (change, using) => {\n const [result, next] = await harness.commit(async (tx) => {\n const doc = await tx.doc(AgentsDoc);\n const value = await change(tx, doc as AgentsState);\n return [value, JSON.parse(JSON.stringify(doc)) as AgentsState] as const;\n }, using);\n state = next;\n for (const listener of changes) listener(state);\n return result;\n };\n const agents: Agents = {\n state: () => state,\n cone: () => cone,\n activeCone: () => state.active,\n async conversation(agentId, using) {\n const id = state.cones[agentId]?.conversation ?? state.scoops[agentId]?.conversation;\n return id === undefined ? undefined : handle(id, using);\n },\n async selectCone(id, using) {\n const record = state.cones[id];\n if (!record) throw new Error(`no cone ${id}`);\n if (state.active === id) return;\n await update((_tx, doc) => {\n doc.active = id;\n }, using);\n await notifyCone((await handle(record.conversation, using)) as Conversation);\n },\n async createCone(name, using) {\n const wanted = name.trim();\n if (!wanted) throw new Error('A cone needs a name.');\n if (Object.values(state.cones).some((record) => record.name === wanted))\n throw new Error(`A cone named ${wanted} already exists.`);\n const conversation = await harness.createConversation(\n {\n ownership: { kind: 'ownerless' },\n agent: { model: (await cone.agent(using)).model ?? null },\n },\n using\n );\n handles.set(conversation.id, conversation);\n return update((_tx, doc) => {\n const id = `cone-${doc.next}`;\n doc.next += 1;\n doc.cones[id] = { name: wanted, conversation: conversation.id };\n return id;\n }, using);\n },\n switchCone(conversation, using) {\n return agents.setConversation(state.active, conversation, using);\n },\n async setConversation(coneId, conversation, using) {\n handles.set(conversation.id, conversation);\n await update(async (tx, doc) => {\n (doc.cones[coneId] as ConeRecord).conversation = conversation.id;\n if (coneId === doc.active) (await tx.doc(ConeDoc)).conversation = conversation.id;\n }, using);\n if (coneId === state.active) await notifyCone(conversation);\n },\n update,\n onChange(listener) {\n changes.add(listener);\n return () => changes.delete(listener);\n },\n onCone(listener) {\n cones.add(listener);\n return () => cones.delete(listener);\n },\n };\n return agents;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,16 +1,21 @@
1
1
  import { replicatedState } from "@earendil-works/chord";
2
2
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
3
3
  import { answerText } from "../agent.js";
4
- import { live } from "../agents.js";
5
- import { SCOOPS_ROOT } from "../scoops/service.js";
4
+ import {
5
+ live
6
+ } from "../agents.js";
7
+ import { HOME } from "../kernel/env.js";
8
+ import { SCOOPS_ROOT, workspace } from "../scoops/service.js";
9
+ const THAWED_KIND = "slicc.thawed";
6
10
  const USAGE = `usage:
7
- freezer list every frozen chat: id, name, title, messages, when
8
- freezer show <id> a frozen chat as Markdown
9
- freezer thaw <id> bring a frozen chat back as a cone
11
+ freezer list every conversation: id, kind, name, title, messages, when
12
+ freezer show <id> a conversation as Markdown
13
+ freezer thaw <id> continue a stopped conversation as a cone
10
14
 
11
- Freeze a cone, or start a new chat, from the chat toolbar or the command palette.
15
+ Delete a cone, or start a new conversation, from the chat toolbar or the command palette.
12
16
  `;
13
17
  const TITLE_LENGTH = 60;
18
+ const TOUCH_MS = 500;
14
19
  function textOf(entry) {
15
20
  return answerText(entry.model?.[0]?.content).trim();
16
21
  }
@@ -32,7 +37,7 @@ function markdown(record, entries) {
32
37
  const out = [
33
38
  `# ${record.title}`,
34
39
  "",
35
- `${record.name} \xB7 ${record.model} \xB7 frozen ${new Date(record.frozenAt).toISOString()}`
40
+ `${record.name} \xB7 ${record.model} \xB7 ${new Date(record.frozenAt).toISOString()}`
36
41
  ];
37
42
  for (const entry of entries) {
38
43
  if (entry.kind === "pi.user" && textOf(entry)) out.push("", "## User", "", textOf(entry));
@@ -47,22 +52,79 @@ function markdown(record, entries) {
47
52
  return `${out.join("\n")}
48
53
  `;
49
54
  }
50
- function list(state) {
51
- return Object.entries(state.frozen ?? {}).map(([id, record]) => ({
52
- id,
53
- name: record.name,
54
- title: record.title,
55
- model: record.model,
56
- messages: record.messages,
57
- frozenAt: record.frozenAt
58
- })).sort((a, b) => b.frozenAt - a.frozenAt);
55
+ function counted(entries) {
56
+ return entries.filter((entry) => entry.kind === "pi.user" || entry.kind === "pi.assistant").length;
59
57
  }
60
- function freeName(state, wanted) {
61
- const taken = new Set(Object.values(state.cones).map((cone) => cone.name));
62
- if (!taken.has(wanted)) return wanted;
63
- if (!taken.has(`${wanted} (earlier)`)) return `${wanted} (earlier)`;
64
- for (let n = 2; ; n++)
65
- if (!taken.has(`${wanted} (earlier ${n})`)) return `${wanted} (earlier ${n})`;
58
+ function lastAt(entries) {
59
+ let at = 0;
60
+ for (const entry of entries)
61
+ for (const message of entry.model ?? []) {
62
+ const stamp = message.timestamp;
63
+ if (typeof stamp === "number" && stamp > at) at = stamp;
64
+ }
65
+ return at;
66
+ }
67
+ async function statsOf(harness, id, context) {
68
+ const conversation = await harness.conversation(id, context);
69
+ if (!conversation) return { title: firstLine([]), model: "", messages: 0, frozenAt: 0 };
70
+ const view = await conversation.context(context);
71
+ const agent = await conversation.agent(context);
72
+ return {
73
+ title: firstLine(view.entries),
74
+ model: modelName(agent.model),
75
+ messages: counted(view.entries),
76
+ frozenAt: lastAt(view.entries)
77
+ };
78
+ }
79
+ const kindOf = (scoop) => scoop.kind === "sync" ? "agent" : "scoop";
80
+ async function rows(harness, state, cache, context) {
81
+ const stats = async (conversation, stopped) => {
82
+ const cached = stopped ? cache.get(conversation) : void 0;
83
+ if (cached) return cached;
84
+ const found = await statsOf(harness, conversation, context);
85
+ if (stopped) cache.set(conversation, found);
86
+ return found;
87
+ };
88
+ const out = [];
89
+ for (const [id, cone] of Object.entries(state.cones))
90
+ out.push({
91
+ id,
92
+ name: cone.name,
93
+ ...await stats(cone.conversation, false),
94
+ kind: "cone",
95
+ live: true,
96
+ thawedAs: null
97
+ });
98
+ for (const [id, record] of Object.entries(state.frozen ?? {}))
99
+ out.push({
100
+ id,
101
+ name: record.name,
102
+ title: record.title,
103
+ model: record.model,
104
+ messages: record.messages,
105
+ frozenAt: record.frozenAt,
106
+ kind: "cone",
107
+ live: false,
108
+ thawedAs: null
109
+ });
110
+ for (const [id, scoop] of Object.entries(state.scoops)) {
111
+ if (scoop.removed) continue;
112
+ const running = live(scoop);
113
+ out.push({
114
+ id,
115
+ name: scoop.name,
116
+ ...await stats(scoop.conversation, !running),
117
+ kind: kindOf(scoop),
118
+ live: running,
119
+ thawedAs: scoop.thawedAs ?? null
120
+ });
121
+ }
122
+ return out.sort((a, b) => Number(b.live) - Number(a.live) || b.frozenAt - a.frozenAt);
123
+ }
124
+ function thawedNote(kind, name, folder, kept) {
125
+ const what = kind === "agent" ? "agent run" : kind;
126
+ const place = kept ? `Its workspace ${folder} is still there.` : `Its workspace ${folder} was deleted when it stopped.`;
127
+ return `This was ${what} ${name}'s conversation, thawed as a cone. ${place} You now work in ${HOME} as a cone, with a cone's tools.`;
66
128
  }
67
129
  async function snapshot(conversation, name, now, context) {
68
130
  const view = await conversation.context(context);
@@ -74,9 +136,7 @@ async function snapshot(conversation, name, now, context) {
74
136
  conversation: conversation.id,
75
137
  title: firstLine(view.entries),
76
138
  model: modelName(agent.model),
77
- messages: view.entries.filter(
78
- (entry) => entry.kind === "pi.user" || entry.kind === "pi.assistant"
79
- ).length,
139
+ messages: counted(view.entries),
80
140
  frozenAt: now
81
141
  }
82
142
  };
@@ -95,25 +155,34 @@ function frozenTarget(state, target) {
95
155
  }
96
156
  async function command(options, runtime, argv, context) {
97
157
  const verb = argv[0] ?? "help";
158
+ const listed = await runtime.refresh();
98
159
  if (verb === "list") {
99
- const rows = list(options.agents.state()).map(
100
- (item) => `${item.id} ${item.name} ${item.title} ${item.messages} messages ${new Date(item.frozenAt).toISOString()}`
160
+ const lines = listed.map(
161
+ (item2) => `${item2.id} ${item2.kind}${item2.live ? " (live)" : ""} ${item2.name} ${item2.title} ${item2.messages} messages ${new Date(item2.frozenAt).toISOString()}`
101
162
  );
102
- return { code: 0, out: rows.length ? `${rows.join("\n")}
103
- ` : "nothing is frozen\n" };
163
+ return { code: 0, out: lines.length ? `${lines.join("\n")}
164
+ ` : "no conversations\n" };
104
165
  }
105
166
  if (verb !== "show" && verb !== "thaw")
106
167
  return { code: verb === "help" || verb === "--help" ? 0 : 2, out: USAGE };
107
168
  const id = argv[1] ?? "";
108
- const record = options.agents.state().frozen?.[id];
109
- if (!record) return { code: 1, out: `freezer: there is no frozen chat ${id}
169
+ const item = listed.find((row) => row.id === id);
170
+ if (!item) return { code: 1, out: `freezer: there is no conversation ${id}
171
+ ` };
172
+ if (verb === "thaw") {
173
+ if (item.live) return { code: 1, out: `freezer: ${item.name} is live; it needs no thawing
110
174
  ` };
111
- if (verb === "thaw")
112
175
  return { code: 0, out: `thawed ${id} as ${await runtime.thaw(id, context)}
113
176
  ` };
114
- const conversation = await options.harness.conversation(record.conversation, context);
177
+ }
178
+ const state = options.agents.state();
179
+ const conversationId = state.frozen?.[id]?.conversation ?? state.cones[id]?.conversation ?? state.scoops[id]?.conversation;
180
+ const conversation = await options.harness.conversation(
181
+ conversationId,
182
+ context
183
+ );
115
184
  const entries = conversation ? (await conversation.context(context)).entries : [];
116
- return { code: 0, out: markdown(record, entries) };
185
+ return { code: 0, out: markdown(item, entries) };
117
186
  }
118
187
  async function freshLike(harness, conversation, context) {
119
188
  const agent = await conversation.agent(context);
@@ -132,11 +201,140 @@ async function freshLike(harness, conversation, context) {
132
201
  context
133
202
  );
134
203
  }
204
+ async function activeCone(agents, context) {
205
+ const conversation = await agents.conversation(agents.state().active, context);
206
+ if (!conversation) throw new Error("There is no active cone.");
207
+ return conversation;
208
+ }
209
+ async function thawScoop(options, id, scoop, context) {
210
+ const { harness, agents, env, now } = options;
211
+ if (scoop.removed) throw new Error(`There is no conversation ${id}.`);
212
+ if (live(scoop)) throw new Error(`${scoop.name} is still running; open it instead.`);
213
+ const conversation = await harness.conversation(scoop.conversation, context);
214
+ if (!conversation) throw new Error(`There is no conversation ${id}.`);
215
+ const last = (await conversation.context(context)).entries.at(-1);
216
+ const agent = await conversation.agent(context);
217
+ const cone = await (await activeCone(agents, context)).agent(context);
218
+ const created = {
219
+ ownership: { kind: "ownerless" },
220
+ agent: {
221
+ model: agent.model,
222
+ thinkingLevel: agent.thinkingLevel,
223
+ extensions: cone.extensions,
224
+ tools: cone.tools,
225
+ instructions: cone.instructions ?? null,
226
+ cwd: HOME
227
+ }
228
+ };
229
+ const fork = last ? await conversation.fork(last.id, created, context) : await harness.createConversation(created, context);
230
+ const folder = workspace(scoop.folder);
231
+ const exists = await env.exists(folder, context);
232
+ const kept = exists.ok && exists.value;
233
+ await fork.submit(
234
+ {
235
+ type: "write",
236
+ entry: {
237
+ kind: THAWED_KIND,
238
+ data: { scoop: id, name: scoop.name, kind: kindOf(scoop), workspace: folder, kept },
239
+ model: [
240
+ {
241
+ role: "user",
242
+ content: thawedNote(kindOf(scoop), scoop.name, folder, kept),
243
+ timestamp: now()
244
+ }
245
+ ]
246
+ }
247
+ },
248
+ context
249
+ );
250
+ const coneId = await agents.update((_tx, doc) => {
251
+ const target = `cone-${doc.next}`;
252
+ doc.next += 1;
253
+ doc.cones[target] = { name: scoop.name, conversation: fork.id };
254
+ doc.scoops[id].thawedAs = target;
255
+ return target;
256
+ }, context);
257
+ await agents.selectCone(coneId, context);
258
+ return coneId;
259
+ }
260
+ async function removeScoop(options, id, scoop, context) {
261
+ const { agents, env } = options;
262
+ if (live(scoop)) throw new Error(`${scoop.name} is still running; stop it first.`);
263
+ await agents.update((_tx, doc) => {
264
+ const entry = doc.scoops[id];
265
+ entry.removed = true;
266
+ entry.gone = true;
267
+ delete entry.frozen;
268
+ }, context);
269
+ await env.remove(`${SCOOPS_ROOT}/${scoop.folder}`, { recursive: true, force: true }, context);
270
+ }
271
+ function listing(harness, agents) {
272
+ const frozen = replicatedState([]);
273
+ const cache = /* @__PURE__ */ new Map();
274
+ let lock = Promise.resolve([]);
275
+ const refresh = () => {
276
+ lock = lock.catch(() => []).then(async () => {
277
+ const listed = await rows(harness, agents.state(), cache, BACKGROUND_CONTEXT);
278
+ frozen.replace(BACKGROUND_CONTEXT, listed);
279
+ return listed;
280
+ });
281
+ return lock;
282
+ };
283
+ const quiet = () => void refresh().catch(() => void 0);
284
+ quiet();
285
+ const stop = agents.onChange(quiet);
286
+ let timer;
287
+ const touch = () => {
288
+ clearTimeout(timer);
289
+ timer = setTimeout(quiet, TOUCH_MS);
290
+ };
291
+ const off = () => {
292
+ clearTimeout(timer);
293
+ stop();
294
+ };
295
+ return { frozen, refresh, touch, off };
296
+ }
297
+ async function thawFrozen(agents, id, context) {
298
+ const coneId = await agents.update((_tx, doc) => {
299
+ const record = doc.frozen?.[id];
300
+ if (!record) throw new Error(`There is no frozen chat ${id}.`);
301
+ const reuse = !doc.cones[record.cone];
302
+ const target = reuse ? record.cone : `cone-${doc.next}`;
303
+ if (!reuse) doc.next += 1;
304
+ doc.cones[target] = { name: record.name, conversation: record.conversation };
305
+ for (const scoop of record.scoops) {
306
+ const entry = doc.scoops[scoop];
307
+ if (!entry) continue;
308
+ delete entry.frozen;
309
+ entry.cone = target;
310
+ }
311
+ delete doc.frozen?.[id];
312
+ return target;
313
+ }, context);
314
+ await agents.selectCone(coneId, context);
315
+ return coneId;
316
+ }
317
+ async function discardFrozen(options, id, context) {
318
+ const { agents, env } = options;
319
+ const record = agents.state().frozen?.[id];
320
+ if (!record) throw new Error(`There is no frozen chat ${id}.`);
321
+ const folders = record.scoops.map((scoop) => agents.state().scoops[scoop]?.folder).filter((folder) => Boolean(folder));
322
+ await agents.update((_tx, doc) => {
323
+ for (const scoop of record.scoops) {
324
+ const entry = doc.scoops[scoop];
325
+ if (!entry) continue;
326
+ delete entry.frozen;
327
+ entry.gone = true;
328
+ }
329
+ delete doc.frozen?.[id];
330
+ }, context);
331
+ for (const folder of folders)
332
+ await env.remove(`${SCOOPS_ROOT}/${folder}`, { recursive: true, force: true }, context);
333
+ }
135
334
  function attachFreezer(options) {
136
- const { harness, agents, env } = options;
335
+ const { harness, agents } = options;
137
336
  const now = options.now ?? Date.now;
138
- const frozen = replicatedState(list(agents.state()));
139
- const off = agents.onChange((state) => frozen.replace(BACKGROUND_CONTEXT, list(state)));
337
+ const { frozen, refresh, touch, off } = listing(harness, agents);
140
338
  const conversationOf = async (agentId, context) => {
141
339
  const conversation = await agents.conversation(agentId, context);
142
340
  if (!conversation) throw new Error(`There is no cone ${agentId}.`);
@@ -230,41 +428,25 @@ function attachFreezer(options) {
230
428
  return id;
231
429
  },
232
430
  async thaw(id, context) {
233
- const coneId = await agents.update((_tx, doc) => {
234
- const record = doc.frozen?.[id];
235
- if (!record) throw new Error(`There is no frozen chat ${id}.`);
236
- const reuse = !doc.cones[record.cone];
237
- const target = reuse ? record.cone : `cone-${doc.next}`;
238
- if (!reuse) doc.next += 1;
239
- doc.cones[target] = { name: freeName(doc, record.name), conversation: record.conversation };
240
- for (const scoop of record.scoops) {
241
- const entry = doc.scoops[scoop];
242
- if (!entry) continue;
243
- delete entry.frozen;
244
- entry.cone = target;
245
- }
246
- delete doc.frozen?.[id];
247
- return target;
248
- }, context);
249
- await agents.selectCone(coneId, context);
250
- return coneId;
431
+ const state = agents.state();
432
+ if (state.cones[id]) {
433
+ await agents.selectCone(id, context);
434
+ return id;
435
+ }
436
+ const scoop = state.scoops[id];
437
+ if (scoop) return thawScoop({ ...options, now }, id, scoop, context);
438
+ return thawFrozen(agents, id, context);
251
439
  },
252
440
  async discard(id, context) {
253
- const record = agents.state().frozen?.[id];
254
- if (!record) throw new Error(`There is no frozen chat ${id}.`);
255
- const folders = record.scoops.map((scoop) => agents.state().scoops[scoop]?.folder).filter((folder) => Boolean(folder));
256
- await agents.update((_tx, doc) => {
257
- for (const scoop of record.scoops) {
258
- const entry = doc.scoops[scoop];
259
- if (!entry) continue;
260
- delete entry.frozen;
261
- entry.gone = true;
262
- }
263
- delete doc.frozen?.[id];
264
- }, context);
265
- for (const folder of folders)
266
- await env.remove(`${SCOOPS_ROOT}/${folder}`, { recursive: true, force: true }, context);
441
+ const state = agents.state();
442
+ const scoop = state.scoops[id];
443
+ if (scoop) return removeScoop(options, id, scoop, context);
444
+ const cone = state.cones[id];
445
+ if (cone) throw new Error(`${cone.name} is live; delete the cone first.`);
446
+ return discardFrozen(options, id, context);
267
447
  },
448
+ refresh,
449
+ touch,
268
450
  frozenTarget: (target) => frozenTarget(agents.state(), target),
269
451
  command: (argv, _caller, context) => command(options, runtime, argv, context),
270
452
  close: () => off()
@@ -272,12 +454,14 @@ function attachFreezer(options) {
272
454
  return runtime;
273
455
  }
274
456
  export {
457
+ THAWED_KIND,
458
+ TOUCH_MS,
275
459
  USAGE,
276
460
  attachFreezer,
277
461
  firstLine,
278
- freeName,
279
462
  frozenTarget,
280
463
  markdown,
281
- modelName
464
+ modelName,
465
+ thawedNote
282
466
  };
283
467
  //# sourceMappingURL=index.js.map