volute 0.16.0 → 0.18.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.
Files changed (49) hide show
  1. package/dist/chunk-AYB7XAWO.js +812 -0
  2. package/dist/{chunk-3FD4ZZUL.js → chunk-FW5API7X.js} +116 -10
  3. package/dist/{chunk-3FC42ZBM.js → chunk-GK4E7LM7.js} +3 -0
  4. package/dist/cli.js +18 -6
  5. package/dist/connectors/discord.js +1 -1
  6. package/dist/connectors/slack.js +1 -1
  7. package/dist/connectors/telegram.js +1 -1
  8. package/dist/{daemon-restart-MS5FI44G.js → daemon-restart-2HVTHZAT.js} +1 -1
  9. package/dist/daemon.js +1443 -592
  10. package/dist/history-YUEKTJ2N.js +108 -0
  11. package/dist/{mind-manager-PN5SUDJ4.js → mind-manager-Z7O7PN2O.js} +1 -1
  12. package/dist/{package-3QGV3KX6.js → package-OKLFO7UY.js} +8 -9
  13. package/dist/{send-KBBZNYG6.js → send-BNDTLUPM.js} +41 -9
  14. package/dist/skill-2Y42P4JY.js +287 -0
  15. package/dist/{up-GZLWZAQE.js → up-7B3BWF2U.js} +1 -1
  16. package/dist/web-assets/assets/index-CtiimdWK.css +1 -0
  17. package/dist/web-assets/assets/index-kt1_EcuO.js +63 -0
  18. package/dist/web-assets/index.html +2 -1
  19. package/drizzle/0006_mind_history.sql +20 -0
  20. package/drizzle/0007_system_prompts.sql +5 -0
  21. package/drizzle/0008_volute_channels.sql +24 -0
  22. package/drizzle/0009_shared_skills.sql +9 -0
  23. package/drizzle/meta/0006_snapshot.json +7 -0
  24. package/drizzle/meta/0007_snapshot.json +7 -0
  25. package/drizzle/meta/0008_snapshot.json +7 -0
  26. package/drizzle/meta/0009_snapshot.json +7 -0
  27. package/drizzle/meta/_journal.json +28 -0
  28. package/package.json +8 -9
  29. package/templates/_base/.init/.config/prompts.json +5 -0
  30. package/templates/_base/_skills/volute-mind/SKILL.md +19 -5
  31. package/templates/_base/src/lib/daemon-client.ts +45 -0
  32. package/templates/_base/src/lib/logger.ts +19 -0
  33. package/templates/_base/src/lib/router.ts +48 -41
  34. package/templates/_base/src/lib/routing.ts +5 -8
  35. package/templates/_base/src/lib/startup.ts +43 -0
  36. package/templates/_base/src/lib/transparency.ts +89 -0
  37. package/templates/_base/src/lib/types.ts +0 -1
  38. package/templates/_base/src/lib/volute-server.ts +3 -35
  39. package/templates/claude/src/agent.ts +9 -22
  40. package/templates/claude/src/lib/hooks/reply-instructions.ts +6 -9
  41. package/templates/claude/src/lib/stream-consumer.ts +39 -12
  42. package/templates/pi/src/agent.ts +9 -22
  43. package/templates/pi/src/lib/event-handler.ts +58 -7
  44. package/templates/pi/src/lib/reply-instructions-extension.ts +6 -9
  45. package/dist/chunk-J52CJCVI.js +0 -447
  46. package/dist/history-LKCJJMUV.js +0 -50
  47. package/dist/web-assets/assets/index-B1XIIGCh.js +0 -307
  48. package/templates/_base/src/lib/auto-reply.ts +0 -38
  49. /package/dist/{chunk-LLBBVTEY.js → chunk-6DVBMLVN.js} +0 -0
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import type { DaemonEvent, EventType } from "./daemon-client.js";
4
+
5
+ export type TransparencyPreset = "transparent" | "standard" | "private" | "silent";
6
+
7
+ type FilterableEventType = Exclude<EventType, "inbound" | "outbound">;
8
+
9
+ const PRESET_RULES: Record<
10
+ TransparencyPreset,
11
+ Record<FilterableEventType, "yes" | "name_only" | "no">
12
+ > = {
13
+ transparent: {
14
+ thinking: "yes",
15
+ text: "yes",
16
+ tool_use: "yes",
17
+ tool_result: "yes",
18
+ log: "yes",
19
+ usage: "yes",
20
+ session_start: "yes",
21
+ done: "yes",
22
+ },
23
+ standard: {
24
+ thinking: "no",
25
+ text: "yes",
26
+ tool_use: "name_only",
27
+ tool_result: "no",
28
+ log: "yes",
29
+ usage: "yes",
30
+ session_start: "yes",
31
+ done: "yes",
32
+ },
33
+ private: {
34
+ thinking: "no",
35
+ text: "no",
36
+ tool_use: "no",
37
+ tool_result: "no",
38
+ log: "no",
39
+ usage: "yes",
40
+ session_start: "yes",
41
+ done: "yes",
42
+ },
43
+ silent: {
44
+ thinking: "no",
45
+ text: "no",
46
+ tool_use: "no",
47
+ tool_result: "no",
48
+ log: "no",
49
+ usage: "no",
50
+ session_start: "no",
51
+ done: "no",
52
+ },
53
+ };
54
+
55
+ // Communication records are always emitted (bypass transparency filtering)
56
+ const ALWAYS_ALLOWED: ReadonlySet<string> = new Set(["inbound", "outbound"]);
57
+
58
+ export function loadTransparencyPreset(): TransparencyPreset {
59
+ for (const file of ["home/.config/config.json", "home/.config/volute.json"]) {
60
+ try {
61
+ const config = JSON.parse(readFileSync(resolve(file), "utf-8"));
62
+ if (config.transparency && config.transparency in PRESET_RULES) {
63
+ return config.transparency as TransparencyPreset;
64
+ }
65
+ } catch {
66
+ // try next
67
+ }
68
+ }
69
+ return "standard";
70
+ }
71
+
72
+ export function filterEvent(preset: TransparencyPreset, event: DaemonEvent): DaemonEvent | null {
73
+ if (ALWAYS_ALLOWED.has(event.type)) return event;
74
+
75
+ const rules = PRESET_RULES[preset];
76
+ const rule = rules[event.type as FilterableEventType];
77
+
78
+ if (!rule) {
79
+ // Unknown event types: pass through in transparent mode, drop otherwise
80
+ return preset === "transparent" ? event : null;
81
+ }
82
+ if (rule === "no") return null;
83
+
84
+ if (rule === "name_only" && event.type === "tool_use") {
85
+ return { ...event, content: undefined };
86
+ }
87
+
88
+ return event;
89
+ }
@@ -19,7 +19,6 @@ export type ChannelMeta = {
19
19
  export type HandlerMeta = ChannelMeta & {
20
20
  messageId: string;
21
21
  interrupt?: boolean;
22
- autoReply: boolean;
23
22
  };
24
23
 
25
24
  export type VoluteRequest = {
@@ -32,41 +32,9 @@ export function createVoluteServer(options: {
32
32
  if (req.method === "POST" && url.pathname === "/message") {
33
33
  try {
34
34
  const body = JSON.parse(await readBody(req)) as VoluteRequest;
35
-
36
- let usage: { input_tokens: number; output_tokens: number } | undefined;
37
- let done = false;
38
-
39
- const { unsubscribe } = router.route(body.content, body, (event) => {
40
- if (event.type === "usage") {
41
- usage = { input_tokens: event.input_tokens, output_tokens: event.output_tokens };
42
- }
43
- if (event.type === "done") {
44
- done = true;
45
- clearTimeout(timeout);
46
- const response: { ok: true; usage?: { input_tokens: number; output_tokens: number } } =
47
- { ok: true };
48
- if (usage) response.usage = usage;
49
- res.writeHead(200, { "Content-Type": "application/json" });
50
- res.end(JSON.stringify(response));
51
- }
52
- });
53
-
54
- const timeout = setTimeout(
55
- () => {
56
- if (!done) {
57
- done = true;
58
- unsubscribe();
59
- res.writeHead(504, { "Content-Type": "application/json" });
60
- res.end(JSON.stringify({ ok: false, error: "Mind processing timed out" }));
61
- }
62
- },
63
- 5 * 60 * 1000,
64
- );
65
-
66
- res.on("close", () => {
67
- clearTimeout(timeout);
68
- if (!done) unsubscribe();
69
- });
35
+ router.route(body.content, body);
36
+ res.writeHead(200, { "Content-Type": "application/json" });
37
+ res.end(JSON.stringify({ ok: true }));
70
38
  } catch (err) {
71
39
  if (err instanceof SyntaxError) {
72
40
  res.writeHead(400);
@@ -1,10 +1,5 @@
1
1
  import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { query } from "@anthropic-ai/claude-agent-sdk";
3
- import {
4
- type AutoReplyTracker,
5
- createAutoReplyTracker,
6
- type MessageChannelInfo,
7
- } from "./lib/auto-reply.js";
8
3
  import { toSDKContent } from "./lib/content.js";
9
4
  import { createAutoCommitHook } from "./lib/hooks/auto-commit.js";
10
5
  import { createIdentityReloadHook } from "./lib/hooks/identity-reload.js";
@@ -14,6 +9,7 @@ import { createSessionContextHook } from "./lib/hooks/session-context.js";
14
9
  import { log } from "./lib/logger.js";
15
10
  import { createMessageChannel } from "./lib/message-channel.js";
16
11
  import { createSessionStore } from "./lib/session-store.js";
12
+ import { loadPrompts } from "./lib/startup.js";
17
13
  import { consumeStream } from "./lib/stream-consumer.js";
18
14
  import type {
19
15
  HandlerMeta,
@@ -31,8 +27,7 @@ type Session = {
31
27
  messageIds: (string | undefined)[];
32
28
  currentMessageId?: string;
33
29
  currentQuery?: ReturnType<typeof query>;
34
- messageChannels: Map<string, MessageChannelInfo>;
35
- autoReply: AutoReplyTracker;
30
+ messageChannels: Map<string, string>;
36
31
  };
37
32
 
38
33
  export function createMind(options: {
@@ -53,10 +48,10 @@ export function createMind(options: {
53
48
  ];
54
49
 
55
50
  const sessions = new Map<string, Session>();
56
- const today = new Date().toISOString().slice(0, 10);
51
+ const prompts = loadPrompts();
52
+ const today = new Date().toLocaleDateString("en-CA");
57
53
  const compactionMessage =
58
- options.compactionMessage ??
59
- `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
54
+ options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
60
55
 
61
56
  // --- Event broadcasting ---
62
57
 
@@ -133,15 +128,13 @@ export function createMind(options: {
133
128
  const q = createStream(session, savedSessionId);
134
129
  session.currentQuery = q;
135
130
  await consumeStream(q, session, callbacks);
136
- // Stream ended — flush any pending auto-reply and broadcast done if no result was emitted
137
- session.autoReply.flush(session.currentMessageId);
131
+ // Stream ended — broadcast done if no result was emitted
138
132
  if (session.currentMessageId !== undefined) {
139
133
  session.messageChannels.delete(session.currentMessageId);
140
134
  broadcastToSession(session, { type: "done" });
141
135
  session.currentMessageId = undefined;
142
136
  }
143
137
  } catch (err) {
144
- session.autoReply.reset();
145
138
  session.messageChannels.clear();
146
139
  if (savedSessionId) {
147
140
  log("mind", `session "${session.name}": resume failed, starting fresh:`, err);
@@ -150,7 +143,6 @@ export function createMind(options: {
150
143
  const q = createStream(session);
151
144
  session.currentQuery = q;
152
145
  await consumeStream(q, session, callbacks);
153
- session.autoReply.flush(session.currentMessageId);
154
146
  if (session.currentMessageId !== undefined) {
155
147
  session.messageChannels.delete(session.currentMessageId);
156
148
  broadcastToSession(session, { type: "done" });
@@ -175,14 +167,12 @@ export function createMind(options: {
175
167
  const existing = sessions.get(name);
176
168
  if (existing) return existing;
177
169
 
178
- const messageChannels = new Map<string, MessageChannelInfo>();
179
170
  const session: Session = {
180
171
  name,
181
172
  channel: createMessageChannel(),
182
173
  listeners: new Set(),
183
174
  messageIds: [],
184
- messageChannels,
185
- autoReply: createAutoReplyTracker(messageChannels),
175
+ messageChannels: new Map(),
186
176
  };
187
177
  sessions.set(name, session);
188
178
 
@@ -211,12 +201,9 @@ export function createMind(options: {
211
201
  };
212
202
  session.listeners.add(filteredListener);
213
203
 
214
- // Track channel for auto-reply
204
+ // Track channel for reply instructions
215
205
  if (meta.channel) {
216
- session.messageChannels.set(meta.messageId, {
217
- channel: meta.channel,
218
- autoReply: meta.autoReply,
219
- });
206
+ session.messageChannels.set(meta.messageId, meta.channel);
220
207
  }
221
208
 
222
209
  // Interrupt if requested and session is mid-turn
@@ -1,25 +1,22 @@
1
1
  import type { HookCallback } from "@anthropic-ai/claude-agent-sdk";
2
- import type { MessageChannelInfo } from "../auto-reply.js";
2
+ import { loadPrompts } from "../startup.js";
3
3
 
4
- export function createReplyInstructionsHook(messageChannels: Map<string, MessageChannelInfo>) {
4
+ export function createReplyInstructionsHook(messageChannels: Map<string, string>) {
5
5
  let fired = false;
6
+ const prompts = loadPrompts();
6
7
 
7
8
  const hook: HookCallback = async () => {
8
9
  if (fired) return {};
9
10
 
10
- const entry = messageChannels.values().next().value;
11
- if (!entry?.channel) return {};
11
+ const channel = messageChannels.values().next().value;
12
+ if (!channel) return {};
12
13
 
13
14
  fired = true;
14
15
 
15
- const context = entry.autoReply
16
- ? `Auto-reply is enabled for this session — your text output will automatically be sent back to ${entry.channel}. To send to a different channel: volute send <channel> "message"`
17
- : `To reply to this message, use: volute send ${entry.channel} "your message"`;
18
-
19
16
  return {
20
17
  hookSpecificOutput: {
21
18
  hookEventName: "UserPromptSubmit" as const,
22
- additionalContext: context,
19
+ additionalContext: prompts.reply_instructions.replace(/\$\{channel\}/g, channel),
23
20
  },
24
21
  };
25
22
  };
@@ -1,14 +1,14 @@
1
1
  import type { query } from "@anthropic-ai/claude-agent-sdk";
2
- import type { AutoReplyTracker, MessageChannelInfo } from "./auto-reply.js";
2
+ import { daemonEmit, type EventType } from "./daemon-client.js";
3
3
  import { log, logText, logThinking, logToolUse } from "./logger.js";
4
+ import { filterEvent, loadTransparencyPreset } from "./transparency.js";
4
5
  import type { VoluteEvent } from "./types.js";
5
6
 
6
7
  export type StreamSession = {
7
8
  name: string;
8
9
  messageIds: (string | undefined)[];
9
10
  currentMessageId?: string;
10
- messageChannels: Map<string, MessageChannelInfo>;
11
- autoReply: AutoReplyTracker;
11
+ messageChannels: Map<string, string>;
12
12
  };
13
13
 
14
14
  export type StreamCallbacks = {
@@ -17,15 +17,34 @@ export type StreamCallbacks = {
17
17
  onTurnEnd?: () => void;
18
18
  };
19
19
 
20
+ // Loaded once at startup — mind restarts on config changes
21
+ const preset = loadTransparencyPreset();
22
+
23
+ function emit(
24
+ session: StreamSession,
25
+ event: { type: EventType; content?: string; metadata?: Record<string, unknown> },
26
+ ) {
27
+ const channel = session.currentMessageId
28
+ ? session.messageChannels.get(session.currentMessageId)
29
+ : undefined;
30
+ const filtered = filterEvent(preset, {
31
+ ...event,
32
+ session: session.name,
33
+ channel,
34
+ messageId: session.currentMessageId,
35
+ });
36
+ if (filtered) daemonEmit(filtered);
37
+ }
38
+
20
39
  export async function consumeStream(
21
40
  stream: ReturnType<typeof query>,
22
41
  session: StreamSession,
23
42
  callbacks: StreamCallbacks,
24
43
  ) {
44
+ emit(session, { type: "session_start" });
25
45
  for await (const msg of stream) {
26
46
  if (session.currentMessageId === undefined) {
27
47
  session.currentMessageId = session.messageIds.shift();
28
- session.autoReply.reset();
29
48
  }
30
49
  if ("session_id" in msg && msg.session_id) {
31
50
  callbacks.onSessionId?.(msg.session_id as string);
@@ -33,32 +52,40 @@ export async function consumeStream(
33
52
  if (msg.type === "assistant") {
34
53
  for (const b of msg.message.content) {
35
54
  if (b.type === "thinking" && "thinking" in b && b.thinking) {
36
- logThinking(b.thinking as string);
55
+ const text = b.thinking as string;
56
+ logThinking(text);
57
+ emit(session, { type: "thinking", content: text });
37
58
  } else if (b.type === "text") {
38
- logText((b as { text: string }).text);
39
- session.autoReply.accumulate((b as { text: string }).text);
59
+ const text = (b as { text: string }).text;
60
+ logText(text);
61
+ emit(session, { type: "text", content: text });
40
62
  } else if (b.type === "tool_use") {
41
- session.autoReply.flush(session.currentMessageId);
42
63
  const tb = b as { name: string; input: unknown };
43
64
  logToolUse(tb.name, tb.input);
65
+ emit(session, {
66
+ type: "tool_use",
67
+ content: JSON.stringify(tb.input),
68
+ metadata: { name: tb.name },
69
+ });
44
70
  }
45
71
  }
46
72
  }
47
73
  if (msg.type === "result") {
48
- session.autoReply.flush(session.currentMessageId);
49
74
  if (session.currentMessageId) {
50
75
  session.messageChannels.delete(session.currentMessageId);
51
76
  }
52
77
  log("mind", `session "${session.name}": turn done`);
53
78
  const result = msg as { usage?: { input_tokens?: number; output_tokens?: number } };
54
79
  if (result.usage) {
55
- callbacks.broadcast({
56
- type: "usage",
80
+ const usage = {
57
81
  input_tokens: result.usage.input_tokens ?? 0,
58
82
  output_tokens: result.usage.output_tokens ?? 0,
59
- });
83
+ };
84
+ callbacks.broadcast({ type: "usage", ...usage });
85
+ emit(session, { type: "usage", metadata: usage });
60
86
  }
61
87
  callbacks.broadcast({ type: "done" });
88
+ emit(session, { type: "done" });
62
89
  session.currentMessageId = undefined;
63
90
  callbacks.onTurnEnd?.();
64
91
  }
@@ -7,17 +7,13 @@ import {
7
7
  SessionManager,
8
8
  SettingsManager,
9
9
  } from "@mariozechner/pi-coding-agent";
10
- import {
11
- type AutoReplyTracker,
12
- createAutoReplyTracker,
13
- type MessageChannelInfo,
14
- } from "./lib/auto-reply.js";
15
10
  import { extractImages, extractText } from "./lib/content.js";
16
11
  import { createEventHandler } from "./lib/event-handler.js";
17
12
  import { log } from "./lib/logger.js";
18
13
  import { createReplyInstructionsExtension } from "./lib/reply-instructions-extension.js";
19
14
  import { resolveModel } from "./lib/resolve-model.js";
20
15
  import { createSessionContextExtension } from "./lib/session-context-extension.js";
16
+ import { loadPrompts } from "./lib/startup.js";
21
17
  import type {
22
18
  HandlerMeta,
23
19
  HandlerResolver,
@@ -37,15 +33,9 @@ type PiSession = {
37
33
  unsubscribe?: () => void;
38
34
  messageIds: (string | undefined)[];
39
35
  currentMessageId?: string;
40
- messageChannels: Map<string, MessageChannelInfo>;
41
- autoReply: AutoReplyTracker;
36
+ messageChannels: Map<string, string>;
42
37
  };
43
38
 
44
- function defaultCompactionMessage(): string {
45
- const today = new Date().toISOString().slice(0, 10);
46
- return `Context is getting long — compaction is about to summarize this conversation. Before that happens, save anything important to files (MEMORY.md, memory/journal/${today}.md, etc.) since those survive compaction. Focus on: decisions made, open tasks, and anything you'd need to pick up where you left off.`;
47
- }
48
-
49
39
  export function createMind(options: {
50
40
  systemPrompt: string;
51
41
  cwd: string;
@@ -54,7 +44,10 @@ export function createMind(options: {
54
44
  compactionMessage?: string;
55
45
  }): { resolve: HandlerResolver } {
56
46
  const sessions = new Map<string, PiSession>();
57
- const compactionMessage = options.compactionMessage ?? defaultCompactionMessage();
47
+ const prompts = loadPrompts();
48
+ const today = new Date().toLocaleDateString("en-CA");
49
+ const compactionMessage =
50
+ options.compactionMessage ?? prompts.compaction_warning.replace("${date}", today);
58
51
 
59
52
  // Shared setup (created once)
60
53
  const modelStr = options.model || process.env.PI_MODEL || "anthropic:claude-sonnet-4-20250514";
@@ -68,20 +61,17 @@ export function createMind(options: {
68
61
  const existing = sessions.get(name);
69
62
  if (existing) return existing;
70
63
 
71
- const messageChannels = new Map<string, MessageChannelInfo>();
72
64
  const session: PiSession = {
73
65
  name,
74
66
  agentSession: null,
75
67
  ready: Promise.resolve(),
76
68
  listeners: new Set(),
77
69
  messageIds: [],
78
- messageChannels,
79
- autoReply: createAutoReplyTracker(messageChannels),
70
+ messageChannels: new Map(),
80
71
  };
81
72
  sessions.set(name, session);
82
73
 
83
74
  session.ready = initSession(session).catch((err) => {
84
- session.autoReply.reset();
85
75
  session.messageChannels.clear();
86
76
  log("mind", `session "${session.name}": init failed:`, err);
87
77
  });
@@ -197,12 +187,9 @@ export function createMind(options: {
197
187
  };
198
188
  session.listeners.add(filteredListener);
199
189
 
200
- // Track channel for auto-reply
190
+ // Track channel for reply instructions
201
191
  if (meta.channel) {
202
- session.messageChannels.set(meta.messageId, {
203
- channel: meta.channel,
204
- autoReply: meta.autoReply,
205
- });
192
+ session.messageChannels.set(meta.messageId, meta.channel);
206
193
  }
207
194
 
208
195
  // Track messageId (must be pushed before prompt)
@@ -1,14 +1,14 @@
1
1
  import { commitFileChange } from "./auto-commit.js";
2
- import type { AutoReplyTracker, MessageChannelInfo } from "./auto-reply.js";
2
+ import { daemonEmit, type EventType } from "./daemon-client.js";
3
3
  import { log, logText, logThinking, logToolResult, logToolUse } from "./logger.js";
4
+ import { filterEvent, loadTransparencyPreset } from "./transparency.js";
4
5
  import type { VoluteEvent } from "./types.js";
5
6
 
6
7
  export type EventSession = {
7
8
  name: string;
8
9
  messageIds: (string | undefined)[];
9
10
  currentMessageId?: string;
10
- messageChannels: Map<string, MessageChannelInfo>;
11
- autoReply: AutoReplyTracker;
11
+ messageChannels: Map<string, string>;
12
12
  };
13
13
 
14
14
  export type EventHandlerOptions = {
@@ -16,6 +16,25 @@ export type EventHandlerOptions = {
16
16
  broadcast: (event: VoluteEvent) => void;
17
17
  };
18
18
 
19
+ // Loaded once at startup — mind restarts on config changes
20
+ const preset = loadTransparencyPreset();
21
+
22
+ function emit(
23
+ session: EventSession,
24
+ event: { type: EventType; content?: string; metadata?: Record<string, unknown> },
25
+ ) {
26
+ const channel = session.currentMessageId
27
+ ? session.messageChannels.get(session.currentMessageId)
28
+ : undefined;
29
+ const filtered = filterEvent(preset, {
30
+ ...event,
31
+ session: session.name,
32
+ channel,
33
+ messageId: session.currentMessageId,
34
+ });
35
+ if (filtered) daemonEmit(filtered);
36
+ }
37
+
19
38
  export function createEventHandler(session: EventSession, options: EventHandlerOptions) {
20
39
  const toolArgs = new Map<string, any>();
21
40
  let textBuf = "";
@@ -24,6 +43,7 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
24
43
  function flushText() {
25
44
  if (textBuf) {
26
45
  logText(textBuf);
46
+ emit(session, { type: "text", content: textBuf });
27
47
  textBuf = "";
28
48
  }
29
49
  }
@@ -31,6 +51,7 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
31
51
  function flushThinking() {
32
52
  if (thinkingBuf) {
33
53
  logThinking(thinkingBuf);
54
+ emit(session, { type: "thinking", content: thinkingBuf });
34
55
  thinkingBuf = "";
35
56
  }
36
57
  }
@@ -40,12 +61,18 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
40
61
  flushText();
41
62
  }
42
63
 
64
+ let sessionStarted = false;
65
+
43
66
  return (event: any) => {
44
67
  try {
68
+ if (!sessionStarted && event.type === "agent_start") {
69
+ sessionStarted = true;
70
+ emit(session, { type: "session_start" });
71
+ }
72
+
45
73
  if (session.currentMessageId === undefined) {
46
74
  flushBuffers(); // flush any leftover from a turn that ended without agent_end
47
75
  session.currentMessageId = session.messageIds.shift();
48
- session.autoReply.reset();
49
76
  }
50
77
 
51
78
  if (event.type === "message_update") {
@@ -53,7 +80,6 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
53
80
  if (ae.type === "text_delta") {
54
81
  if (thinkingBuf) flushThinking();
55
82
  textBuf += ae.delta;
56
- session.autoReply.accumulate(ae.delta);
57
83
  // Log complete lines as they arrive
58
84
  for (let nl = textBuf.indexOf("\n"); nl !== -1; nl = textBuf.indexOf("\n")) {
59
85
  logText(textBuf.slice(0, nl + 1));
@@ -71,15 +97,24 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
71
97
 
72
98
  if (event.type === "tool_execution_start") {
73
99
  flushBuffers();
74
- session.autoReply.flush(session.currentMessageId);
75
100
  toolArgs.set(event.toolCallId, event.args);
76
101
  logToolUse(event.toolName, event.args);
102
+ emit(session, {
103
+ type: "tool_use",
104
+ content: JSON.stringify(event.args),
105
+ metadata: { name: event.toolName },
106
+ });
77
107
  }
78
108
 
79
109
  if (event.type === "tool_execution_end") {
80
110
  const output =
81
111
  typeof event.result === "string" ? event.result : JSON.stringify(event.result);
82
112
  logToolResult(event.toolName, output, event.isError);
113
+ emit(session, {
114
+ type: "tool_result",
115
+ content: output,
116
+ metadata: { name: event.toolName, is_error: event.isError },
117
+ });
83
118
 
84
119
  // Auto-commit file changes in home/
85
120
  if ((event.toolName === "edit" || event.toolName === "write") && !event.isError) {
@@ -94,12 +129,28 @@ export function createEventHandler(session: EventSession, options: EventHandlerO
94
129
 
95
130
  if (event.type === "agent_end") {
96
131
  flushBuffers();
97
- session.autoReply.flush(session.currentMessageId);
98
132
  if (session.currentMessageId) {
99
133
  session.messageChannels.delete(session.currentMessageId);
100
134
  }
101
135
  log("mind", `session "${session.name}": turn done`);
136
+ // Sum usage from assistant messages
137
+ if (event.messages) {
138
+ let inputTokens = 0;
139
+ let outputTokens = 0;
140
+ for (const msg of event.messages as any[]) {
141
+ if (msg.role === "assistant" && msg.usage) {
142
+ inputTokens += msg.usage.input ?? 0;
143
+ outputTokens += msg.usage.output ?? 0;
144
+ }
145
+ }
146
+ if (inputTokens > 0 || outputTokens > 0) {
147
+ const usage = { input_tokens: inputTokens, output_tokens: outputTokens };
148
+ options.broadcast({ type: "usage", ...usage });
149
+ emit(session, { type: "usage", metadata: usage });
150
+ }
151
+ }
102
152
  options.broadcast({ type: "done" });
153
+ emit(session, { type: "done" });
103
154
  session.currentMessageId = undefined;
104
155
  }
105
156
  } catch (err) {
@@ -1,27 +1,24 @@
1
1
  import type { ExtensionFactory } from "@mariozechner/pi-coding-agent";
2
- import type { MessageChannelInfo } from "./auto-reply.js";
2
+ import { loadPrompts } from "./startup.js";
3
3
 
4
4
  export function createReplyInstructionsExtension(
5
- messageChannels: Map<string, MessageChannelInfo>,
5
+ messageChannels: Map<string, string>,
6
6
  ): ExtensionFactory {
7
+ const prompts = loadPrompts();
7
8
  return (pi) => {
8
9
  let fired = false;
9
10
  pi.on("before_agent_start", () => {
10
11
  if (fired) return {};
11
12
 
12
- const entry = messageChannels.values().next().value;
13
- if (!entry?.channel) return {};
13
+ const channel = messageChannels.values().next().value;
14
+ if (!channel) return {};
14
15
 
15
16
  fired = true;
16
17
 
17
- const content = entry.autoReply
18
- ? `Auto-reply is enabled for this session — your text output will automatically be sent back to ${entry.channel}. To send to a different channel: volute send <channel> "message"`
19
- : `To reply to this message, use: volute send ${entry.channel} "your message"`;
20
-
21
18
  return {
22
19
  message: {
23
20
  customType: "reply-instructions",
24
- content,
21
+ content: prompts.reply_instructions.replace(/\$\{channel\}/g, channel),
25
22
  display: true,
26
23
  },
27
24
  };