summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-02 15:00:44 -0600
committert <t@tjp.lol>2026-06-02 16:37:32 -0600
commit8b88b886346460b1ab29edb03ad85bc3bb565a45 (patch)
tree13b9ab56061a722641389b5fc8ac1113d09b66e5 /src
parent7268c0b8e8bcf4b325581fabe7476a394f167738 (diff)
compaction
Diffstat (limited to 'src')
-rw-r--r--src/command.zig176
-rw-r--r--src/compaction.zig48
-rw-r--r--src/config_file.zig112
-rw-r--r--src/main.zig241
-rw-r--r--src/session_persist.zig153
-rw-r--r--src/system_prompt.zig89
6 files changed, 678 insertions, 141 deletions
diff --git a/src/command.zig b/src/command.zig
new file mode 100644
index 0000000..f10592f
--- /dev/null
+++ b/src/command.zig
@@ -0,0 +1,176 @@
+//! Slash-command framework for the panto CLI.
+//!
+//! The REPL treats any input line beginning with `/` as a *slash
+//! command* rather than a prompt for the model. This module provides the
+//! structure panto uses to understand those commands: a `Registry` of
+//! `Command`s, lookup by name, and a `list()` accessor (useful later for
+//! tab-completion). Command handlers are plain functions that *do stuff*
+//! directly — they print to the REPL's writer, mutate the conversation,
+//! persist to the session, etc. — and return `!void`.
+//!
+//! Dispatch contract (see `Registry.dispatch`):
+//! - The caller only invokes `dispatch` for lines starting with `/`.
+//! - `dispatch` splits the leading word (sans `/`) as the command name
+//! and passes the remainder (trimmed) as `args`.
+//! - An unknown command yields `error.CommandNotFound`; the REPL turns
+//! that into a user-facing error message. Slash lines never reach the
+//! model regardless.
+//!
+//! Builtin commands (e.g. `/compact`) register themselves from their own
+//! modules; future Lua extensions will append commands here too via a
+//! `panto.register_command` bridge.
+
+const std = @import("std");
+const panto = @import("panto");
+
+/// The mutable REPL state a command handler may touch. Bundles pointers
+/// so handler signatures stay stable as the REPL grows. All pointers are
+/// borrowed for the duration of the call; handlers must not retain them.
+pub const Context = struct {
+ allocator: std.mem.Allocator,
+
+ /// In-memory conversation the agent is driving.
+ conv: *panto.conversation.Conversation,
+ /// The active agent (model/provider/tooling snapshot).
+ agent: *panto.agent.Agent,
+ /// Session log to persist any changes a command makes.
+ session_mgr: *panto.session_manager.SessionManager,
+
+ /// REPL output writer and its backing file writer (for flushing).
+ stdout: *std.Io.Writer,
+ stdout_file: *std.Io.File.Writer,
+
+ /// Resolved compaction system prompt (owned elsewhere; borrowed here).
+ compaction_prompt: []const u8,
+
+ /// Banner identity strings used when stamping persisted entries.
+ provider_name: []const u8,
+ model_name: []const u8,
+};
+
+/// A single slash command.
+pub const Command = struct {
+ /// Command name *without* the leading `/` (e.g. `"compact"`).
+ name: []const u8,
+ /// One-line description, shown in listings/completion.
+ description: []const u8,
+ /// Handler. `args` is the remainder of the line after the command
+ /// name, trimmed of surrounding whitespace (empty string if none).
+ run: *const fn (args: []const u8, ctx: *Context) anyerror!void,
+};
+
+pub const Error = error{
+ /// The line's command word matched no registered command.
+ CommandNotFound,
+};
+
+/// A registry of slash commands. Owns its backing list; call `deinit`.
+pub const Registry = struct {
+ allocator: std.mem.Allocator,
+ commands: std.ArrayList(Command) = .empty,
+
+ pub fn init(allocator: std.mem.Allocator) Registry {
+ return .{ .allocator = allocator };
+ }
+
+ pub fn deinit(self: *Registry) void {
+ self.commands.deinit(self.allocator);
+ }
+
+ /// Register a command. Returns `error.DuplicateCommand` if a command
+ /// with the same name is already present.
+ pub fn register(self: *Registry, command: Command) !void {
+ if (self.find(command.name) != null) return error.DuplicateCommand;
+ try self.commands.append(self.allocator, command);
+ }
+
+ /// Find a command by name (without leading `/`). Null if absent.
+ pub fn find(self: *const Registry, name: []const u8) ?*const Command {
+ for (self.commands.items) |*cmd| {
+ if (std.mem.eql(u8, cmd.name, name)) return cmd;
+ }
+ return null;
+ }
+
+ /// All registered commands, in registration order. Useful for
+ /// listing and (later) completion.
+ pub fn list(self: *const Registry) []const Command {
+ return self.commands.items;
+ }
+
+ /// Parse and run a slash-command line. `line` must begin with `/`.
+ ///
+ /// Splits the first whitespace-delimited word (after the `/`) as the
+ /// command name and passes the trimmed remainder as `args`. Returns
+ /// `error.CommandNotFound` if no command matches; otherwise returns
+ /// whatever the handler returns.
+ pub fn dispatch(self: *const Registry, line: []const u8, ctx: *Context) !void {
+ std.debug.assert(line.len > 0 and line[0] == '/');
+ const body = line[1..];
+
+ // Split into name + rest on the first run of whitespace.
+ const name_end = std.mem.indexOfAny(u8, body, " \t") orelse body.len;
+ const name = body[0..name_end];
+ const args = std.mem.trim(u8, body[name_end..], " \t");
+
+ const cmd = self.find(name) orelse return Error.CommandNotFound;
+ try cmd.run(args, ctx);
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+const testing = std.testing;
+
+var test_calls: usize = 0;
+var test_last_args: []const u8 = "";
+
+fn testHandler(args: []const u8, ctx: *Context) anyerror!void {
+ _ = ctx;
+ test_calls += 1;
+ test_last_args = args;
+}
+
+test "register, find, list" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+
+ try reg.register(.{ .name = "compact", .description = "d", .run = testHandler });
+ try reg.register(.{ .name = "help", .description = "d", .run = testHandler });
+
+ try testing.expect(reg.find("compact") != null);
+ try testing.expect(reg.find("nope") == null);
+ try testing.expectEqual(@as(usize, 2), reg.list().len);
+
+ try testing.expectError(error.DuplicateCommand, reg.register(.{
+ .name = "compact",
+ .description = "d",
+ .run = testHandler,
+ }));
+}
+
+test "dispatch splits name and args" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+ try reg.register(.{ .name = "compact", .description = "d", .run = testHandler });
+
+ var ctx: Context = undefined;
+
+ test_calls = 0;
+ try reg.dispatch("/compact", &ctx);
+ try testing.expectEqual(@as(usize, 1), test_calls);
+ try testing.expectEqualStrings("", test_last_args);
+
+ try reg.dispatch("/compact extra words ", &ctx);
+ try testing.expectEqual(@as(usize, 2), test_calls);
+ try testing.expectEqualStrings("extra words", test_last_args);
+}
+
+test "dispatch on unknown command yields CommandNotFound" {
+ var reg = Registry.init(testing.allocator);
+ defer reg.deinit();
+ var ctx: Context = undefined;
+ try testing.expectError(Error.CommandNotFound, reg.dispatch("/nope", &ctx));
+}
diff --git a/src/compaction.zig b/src/compaction.zig
new file mode 100644
index 0000000..a1f6e89
--- /dev/null
+++ b/src/compaction.zig
@@ -0,0 +1,48 @@
+//! The builtin `/compact` slash command.
+//!
+//! Folds the conversation's older turns into a summary (via
+//! `Agent.compact`), persists the result to the session log, and reports
+//! the outcome to the user. Registered into the CLI's command registry by
+//! `register`.
+
+const std = @import("std");
+const panto = @import("panto");
+const command = @import("command.zig");
+const session_persist = @import("session_persist.zig");
+
+/// Install the `/compact` command into `registry`.
+pub fn register(registry: *command.Registry) !void {
+ try registry.register(.{
+ .name = "compact",
+ .description = "Summarize older turns to free up context. Optional args become extra compaction instructions.",
+ .run = run,
+ });
+}
+
+/// `/compact [extra instructions]` — compact the conversation now.
+fn run(args: []const u8, ctx: *command.Context) anyerror!void {
+ const extra: ?[]const u8 = if (args.len > 0) args else null;
+
+ const res = ctx.agent.compact(ctx.conv, ctx.compaction_prompt, extra) catch |err| {
+ try ctx.stdout.print("\n[compaction failed: {s}]\n> ", .{@errorName(err)});
+ try ctx.stdout_file.flush();
+ return;
+ };
+
+ if (res.compacted) {
+ try session_persist.persistCompaction(
+ ctx.allocator,
+ ctx.session_mgr,
+ ctx.conv,
+ ctx.provider_name,
+ ctx.model_name,
+ );
+ try ctx.stdout.print(
+ "\n[compacted: summarized {d} message(s), kept {d} recent turn(s)]\n> ",
+ .{ res.summarized_messages, res.kept_turns },
+ );
+ } else {
+ try ctx.stdout.writeAll("\n[nothing to compact: conversation already fits]\n> ");
+ }
+ try ctx.stdout_file.flush();
+}
diff --git a/src/config_file.zig b/src/config_file.zig
index f7589fe..f1ae65f 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -158,11 +158,19 @@ pub const Config = struct {
default_model_ref: ?ModelRef,
tools: Policy,
extensions: Policy,
+ /// `[compaction]` settings. `compaction_keep_verbatim` is the kept-
+ /// suffix token budget (null = use libpanto's default). `compaction_model`
+ /// is an owned `<provider>:<alias>` reference string for the optional
+ /// compaction-model override; `compaction_model_ref` slices into it.
+ compaction_keep_verbatim: ?u32 = null,
+ compaction_model: ?[]const u8 = null,
+ compaction_model_ref: ?ModelRef = null,
pub fn deinit(self: *Config) void {
for (self.providers) |p| p.deinit(self.allocator);
self.allocator.free(self.providers);
if (self.default_model) |m| self.allocator.free(m);
+ if (self.compaction_model) |m| self.allocator.free(m);
self.tools.deinit(self.allocator);
self.extensions.deinit(self.allocator);
}
@@ -440,6 +448,25 @@ fn resolve(
}
}
+ // `[compaction]` settings.
+ var compaction_keep_verbatim: ?u32 = null;
+ var compaction_model: ?[]const u8 = null;
+ errdefer if (compaction_model) |m| allocator.free(m);
+ var compaction_model_ref: ?ModelRef = null;
+ if (root.get("compaction")) |compaction_tbl| {
+ if (compaction_tbl.get("keep_verbatim")) |kv| {
+ if (kv.* == .integer and kv.integer > 0) {
+ compaction_keep_verbatim = @intCast(kv.integer);
+ }
+ }
+ if (compaction_tbl.get("model")) |model_v| {
+ if (model_v.* == .string) {
+ compaction_model = try allocator.dupe(u8, model_v.string);
+ compaction_model_ref = try parseModelRef(compaction_model.?);
+ }
+ }
+ }
+
const providers_slice = try providers.toOwnedSlice(allocator);
errdefer {
for (providers_slice) |p| p.deinit(allocator);
@@ -458,6 +485,18 @@ fn resolve(
if (!found) return error.UnknownDefaultProvider;
}
+ // Validate the compaction-model override names a provider that resolved.
+ if (compaction_model_ref) |ref| {
+ var found = false;
+ for (providers_slice) |p| {
+ if (std.mem.eql(u8, p.name, ref.provider)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return error.UnknownDefaultProvider;
+ }
+
return .{
.allocator = allocator,
.providers = providers_slice,
@@ -465,6 +504,9 @@ fn resolve(
.default_model_ref = default_model_ref,
.tools = tools,
.extensions = extensions,
+ .compaction_keep_verbatim = compaction_keep_verbatim,
+ .compaction_model = compaction_model,
+ .compaction_model_ref = compaction_model_ref,
};
}
@@ -977,3 +1019,73 @@ test "loadFromPaths: missing files are skipped" {
try testing.expectEqual(@as(usize, 0), cfg.providers.len);
try testing.expect(cfg.default_model == null);
}
+
+test "resolve: [compaction] keep_verbatim and model parse" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\
+ \\[compaction]
+ \\keep_verbatim = 12345
+ \\model = "anthropic:haiku"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expectEqual(@as(?u32, 12345), cfg.compaction_keep_verbatim);
+ try testing.expectEqualStrings("anthropic:haiku", cfg.compaction_model.?);
+ try testing.expectEqualStrings("anthropic", cfg.compaction_model_ref.?.provider);
+ try testing.expectEqualStrings("haiku", cfg.compaction_model_ref.?.model);
+}
+
+test "resolve: [compaction] absent leaves defaults null" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ var cfg = try resolve(a, &env, doc.root);
+ defer cfg.deinit();
+ try testing.expect(cfg.compaction_keep_verbatim == null);
+ try testing.expect(cfg.compaction_model == null);
+ try testing.expect(cfg.compaction_model_ref == null);
+}
+
+test "resolve: [compaction] model naming an unresolved provider errors" {
+ const a = testing.allocator;
+ var env = emptyEnv(a);
+ defer env.deinit();
+ try env.put("ANTHROPIC_API_KEY", "sk-ant-xyz");
+
+ const src =
+ \\[providers.anthropic]
+ \\style = "anthropic_messages"
+ \\base_url = "https://api.anthropic.com"
+ \\api_key_env_var = "ANTHROPIC_API_KEY"
+ \\
+ \\[compaction]
+ \\model = "openai:gpt-4o"
+ ;
+ const doc = try parseDoc(a, src);
+ defer doc.deinit();
+
+ try testing.expectError(error.UnknownDefaultProvider, resolve(a, &env, doc.root));
+}
diff --git a/src/main.zig b/src/main.zig
index 28f1144..14a14b6 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -12,6 +12,9 @@ const models_toml = @import("models_toml.zig");
const config_file = @import("config_file.zig");
const glob = @import("glob.zig");
const system_prompt = @import("system_prompt.zig");
+const command = @import("command.zig");
+const command_compaction = @import("compaction.zig");
+const session_persist = @import("session_persist.zig");
// Shorthand alias for the Lua C API. The bridge module owns the actual
// `@cImport`; we re-use it here so the smoke check uses identical types.
@@ -39,6 +42,9 @@ test {
_ = config_file;
_ = glob;
_ = system_prompt;
+ _ = command;
+ _ = command_compaction;
+ _ = session_persist;
}
const Receiver = panto.provider.Receiver;
@@ -335,6 +341,21 @@ pub fn main(init: std.process.Init) !void {
std.process.exit(1);
};
+ // Resolve the optional compaction-model override into a ProviderConfig.
+ // On any resolution failure we log and fall back to no override (the
+ // active chat model is used for compaction).
+ const compaction_provider_config: ?panto.config.ProviderConfig = blk: {
+ const ref = app_config.compaction_model_ref orelse break :blk null;
+ break :blk config_file.buildProviderConfig(&app_config, &models.defs, ref) catch |err| {
+ std.log.warn("compaction_model resolution failed ({t}); using active model", .{err});
+ break :blk null;
+ };
+ };
+ const compaction_cfg: panto.config.CompactionConfig = .{
+ .keep_verbatim = app_config.compaction_keep_verbatim orelse 20_000,
+ .model = compaction_provider_config,
+ };
+
// Process-global HTTP client: one connection pool for every provider /
// base_url the agent may switch to. Torn down at shutdown.
panto.config.initHttp(alloc, io);
@@ -471,10 +492,22 @@ pub fn main(init: std.process.Init) !void {
const active_config: panto.config.Config = .{
.provider = provider_config,
.registry = &registry,
+ .compaction = compaction_cfg,
};
var agent = panto.agent.Agent.init(alloc, io, &active_config);
defer agent.deinit();
+ // Resolve the compaction system prompt (COMPACTION.md across layers,
+ // last wins; built-in default otherwise) and arm automatic compaction.
+ // Owned by `sp_arena`, which lives for the whole REPL.
+ const compaction_prompt = try system_prompt.resolveCompaction(
+ sp_arena.allocator(),
+ io,
+ init.environ_map,
+ luarocks_rt.layout.agent_dir,
+ );
+ agent.compaction_system_prompt = compaction_prompt;
+
const banner_base: []const u8 = switch (provider_config) {
inline else => |c| c.base_url,
};
@@ -493,6 +526,24 @@ pub fn main(init: std.process.Init) !void {
defer cli_recv.deinit();
var recv = cli_recv.receiver();
+ // Build the slash-command registry and register builtins. Future Lua
+ // extensions will append commands here too.
+ var cmd_registry = command.Registry.init(alloc);
+ defer cmd_registry.deinit();
+ try command_compaction.register(&cmd_registry);
+
+ var cmd_ctx: command.Context = .{
+ .allocator = alloc,
+ .conv = &conv,
+ .agent = &agent,
+ .session_mgr = &session_mgr,
+ .stdout = stdout,
+ .stdout_file = &stdout_file,
+ .compaction_prompt = compaction_prompt,
+ .provider_name = banner_provider_initial,
+ .model_name = banner_model_initial,
+ };
+
while (true) {
const maybe_line = stdin.takeDelimiter('\n') catch |err| {
std.debug.print("read error: {}\n", .{err});
@@ -510,8 +561,25 @@ pub fn main(init: std.process.Init) !void {
continue;
}
+ // Slash commands are handled by the CLI, not sent to the model.
+ // Any line starting with `/` is a command; an unrecognized one
+ // prints an error rather than reaching the model.
+ if (std.mem.startsWith(u8, line, "/")) {
+ cmd_registry.dispatch(line, &cmd_ctx) catch |err| switch (err) {
+ command.Error.CommandNotFound => {
+ try stdout.print("\n[unknown command: {s}]\n> ", .{line});
+ try stdout_file.flush();
+ },
+ else => {
+ try stdout.print("\n[command error: {s}]\n> ", .{@errorName(err)});
+ try stdout_file.flush();
+ },
+ };
+ continue;
+ }
+
try conv.addUserMessage(line);
- try appendUserPromptToSession(
+ try session_persist.appendUserPromptToSession(
alloc,
&session_mgr,
line,
@@ -526,22 +594,37 @@ pub fn main(init: std.process.Init) !void {
try stdout.print("\n[error: {s}]\n", .{@errorName(err)});
};
- // Persist whatever new entries the agent produced this turn. The
- // agent loop may have appended:
- // - assistant message(s) (one per provider response)
- // - user messages containing ToolResult blocks (one per tool round)
- // Each assistant message gets paired with the Usage that the
- // receiver captured at its onMessageComplete time (or null if
- // the provider didn't emit usage that round).
- try persistTurn(
- alloc,
- &session_mgr,
- &conv,
- entries_before_step,
- banner_provider_initial,
- banner_model_initial,
- cli_recv.per_message_usage.items,
- );
+ if (agent.auto_compacted) {
+ // Automatic compaction rewrote the in-memory conversation, so
+ // message indices no longer align with `entries_before_step`.
+ // Persist the whole post-compaction window (summary + duplicated
+ // kept suffix + the retried turn) as fresh entries; the
+ // superseded prefix stays on disk and is ignored on replay.
+ try session_persist.persistCompaction(
+ alloc,
+ &session_mgr,
+ &conv,
+ banner_provider_initial,
+ banner_model_initial,
+ );
+ } else {
+ // Persist whatever new entries the agent produced this turn. The
+ // agent loop may have appended:
+ // - assistant message(s) (one per provider response)
+ // - user messages containing ToolResult blocks (one per tool round)
+ // Each assistant message gets paired with the Usage that the
+ // receiver captured at its onMessageComplete time (or null if
+ // the provider didn't emit usage that round).
+ try session_persist.persistTurn(
+ alloc,
+ &session_mgr,
+ &conv,
+ entries_before_step,
+ banner_provider_initial,
+ banner_model_initial,
+ cli_recv.per_message_usage.items,
+ );
+ }
try stdout.writeAll("\n> ");
try stdout_file.flush();
@@ -663,127 +746,3 @@ fn openSession(
}
}
-// -----------------------------------------------------------------------------
-// Session append helpers — bridge in-memory ContentBlocks to on-disk entries.
-// -----------------------------------------------------------------------------
-
-fn appendUserPromptToSession(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- text: []const u8,
- provider: []const u8,
- model: []const u8,
-) !void {
- const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1);
- blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } };
- _ = try mgr.appendMessage(
- .{ .role = .user, .content = blocks },
- provider,
- model,
- );
-}
-
-/// After the agent loop has driven a turn to completion, persist every
-/// new in-memory message at index `>= start_index` to the session log.
-///
-/// Each in-memory message is mapped to disk:
-/// - assistant → assistant entry with provider/model/stop_reason metadata
-/// and Usage (from `per_message_usage`, one slot per assistant message in
-/// completion order).
-/// - user (with ToolResult blocks) → user entry stamped with provider/model.
-///
-/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire
-/// value requires plumbing it through the Receiver vtable (future work,
-/// separate from token plumbing).
-fn persistTurn(
- alloc: std.mem.Allocator,
- mgr: *panto.session_manager.SessionManager,
- conv: *const panto.conversation.Conversation,
- start_index: usize,
- provider: []const u8,
- model: []const u8,
- per_message_usage: []const ?panto.session_manager.Usage,
-) !void {
- var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty;
- defer batch_messages.deinit(alloc);
- var batch_providers: std.ArrayList(?[]const u8) = .empty;
- defer batch_providers.deinit(alloc);
- var batch_models: std.ArrayList(?[]const u8) = .empty;
- defer batch_models.deinit(alloc);
-
- var i = start_index;
- var assistant_seen: usize = 0;
- while (i < conv.messages.items.len) : (i += 1) {
- const msg = conv.messages.items[i];
- const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len);
- var allocated: usize = 0;
- errdefer {
- for (blocks[0..allocated]) |b| b.deinit(alloc);
- alloc.free(blocks);
- }
- for (msg.content.items) |block| {
- blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block);
- allocated += 1;
- }
- if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
- for (blocks[0..allocated]) |b| b.deinit(alloc);
- alloc.free(blocks);
- continue;
- }
- switch (msg.role) {
- .system => {
- // Mid-turn system messages aren't a thing in pantograph today.
- // Treat as harmless and persist verbatim, sans stamps.
- try batch_messages.append(alloc, .{ .role = .system, .content = blocks });
- try batch_providers.append(alloc, null);
- try batch_models.append(alloc, null);
- },
- .user => {
- try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
- try batch_providers.append(alloc, provider);
- try batch_models.append(alloc, model);
- },
- .assistant => {
- // Pair this assistant message with its usage, if the
- // receiver captured one for this position. (A turn
- // ending in a stream error can have fewer usage entries
- // than assistant messages; treat as null and move on.)
- const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len)
- per_message_usage[assistant_seen]
- else
- null;
- assistant_seen += 1;
- try batch_messages.append(alloc, .{
- .role = .assistant,
- .content = blocks,
- .provider = try alloc.dupe(u8, provider),
- .model = try alloc.dupe(u8, model),
- .stop_reason = try alloc.dupe(u8, "stop"),
- .usage = usage,
- });
- try batch_providers.append(alloc, null);
- try batch_models.append(alloc, null);
- },
- }
- }
- try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items);
-}
-
-fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool {
- const msg = conv.messages.items[index];
- for (msg.content.items) |block| {
- if (block != .ToolUse) continue;
- if (index + 1 >= conv.messages.items.len) return true;
- const next = conv.messages.items[index + 1];
- if (next.role != .user) return true;
- var found = false;
- for (next.content.items) |next_block| {
- if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
- found = true;
- break;
- }
- }
- if (!found) return true;
- }
- return false;
-}
diff --git a/src/session_persist.zig b/src/session_persist.zig
new file mode 100644
index 0000000..d5349bc
--- /dev/null
+++ b/src/session_persist.zig
@@ -0,0 +1,153 @@
+//! Session-log persistence helpers shared between the REPL loop
+//! (`main.zig`) and slash-command handlers (e.g. `compaction.zig`).
+//!
+//! These were previously private to `main.zig`. They were lifted into
+//! their own module so that command handlers living outside the root
+//! source file can reuse them without depending on `main.zig` (which,
+//! being the executable root, can't be imported as a normal module).
+
+const std = @import("std");
+const panto = @import("panto");
+
+/// Append a single user-prompt message to the session log.
+pub fn appendUserPromptToSession(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ text: []const u8,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, 1);
+ blocks[0] = .{ .text = .{ .text = try alloc.dupe(u8, text) } };
+ _ = try mgr.appendMessage(
+ .{ .role = .user, .content = blocks },
+ provider,
+ model,
+ );
+}
+
+/// After the agent loop has driven a turn to completion, persist every
+/// new in-memory message at index `>= start_index` to the session log.
+///
+/// Each in-memory message is mapped to disk:
+/// - assistant → assistant entry with provider/model/stop_reason metadata
+/// and Usage (from `per_message_usage`, one slot per assistant message in
+/// completion order).
+/// - user (with ToolResult blocks) → user entry stamped with provider/model.
+///
+/// `stop_reason` is recorded as `"stop"` for now; surfacing the real wire
+/// value requires plumbing it through the Receiver vtable (future work,
+/// separate from token plumbing).
+pub fn persistTurn(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ conv: *const panto.conversation.Conversation,
+ start_index: usize,
+ provider: []const u8,
+ model: []const u8,
+ per_message_usage: []const ?panto.session_manager.Usage,
+) !void {
+ var batch_messages: std.ArrayList(panto.session_manager.DiskMessage) = .empty;
+ defer batch_messages.deinit(alloc);
+ var batch_providers: std.ArrayList(?[]const u8) = .empty;
+ defer batch_providers.deinit(alloc);
+ var batch_models: std.ArrayList(?[]const u8) = .empty;
+ defer batch_models.deinit(alloc);
+
+ var i = start_index;
+ var assistant_seen: usize = 0;
+ while (i < conv.messages.items.len) : (i += 1) {
+ const msg = conv.messages.items[i];
+ const blocks = try alloc.alloc(panto.session_manager.DiskContentBlock, msg.content.items.len);
+ var allocated: usize = 0;
+ errdefer {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ }
+ for (msg.content.items) |block| {
+ blocks[allocated] = try panto.session.contentBlockToDisk(alloc, block);
+ allocated += 1;
+ }
+ if (msg.role == .assistant and hasToolUseWithoutFollowingResults(conv, i)) {
+ for (blocks[0..allocated]) |b| b.deinit(alloc);
+ alloc.free(blocks);
+ continue;
+ }
+ switch (msg.role) {
+ .system => {
+ // Mid-turn system messages aren't a thing in pantograph today.
+ // Treat as harmless and persist verbatim, sans stamps.
+ try batch_messages.append(alloc, .{ .role = .system, .content = blocks });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ .user => {
+ try batch_messages.append(alloc, .{ .role = .user, .content = blocks });
+ try batch_providers.append(alloc, provider);
+ try batch_models.append(alloc, model);
+ },
+ .assistant => {
+ // Pair this assistant message with its usage, if the
+ // receiver captured one for this position. (A turn
+ // ending in a stream error can have fewer usage entries
+ // than assistant messages; treat as null and move on.)
+ const usage: ?panto.session_manager.Usage = if (assistant_seen < per_message_usage.len)
+ per_message_usage[assistant_seen]
+ else
+ null;
+ assistant_seen += 1;
+ try batch_messages.append(alloc, .{
+ .role = .assistant,
+ .content = blocks,
+ .provider = try alloc.dupe(u8, provider),
+ .model = try alloc.dupe(u8, model),
+ .stop_reason = try alloc.dupe(u8, "stop"),
+ .usage = usage,
+ });
+ try batch_providers.append(alloc, null);
+ try batch_models.append(alloc, null);
+ },
+ }
+ }
+ try mgr.appendMessagesAtomic(batch_messages.items, batch_providers.items, batch_models.items);
+}
+
+/// Persist a compaction to the session log. The agent rewrote the
+/// in-memory conversation to `[system..., summary, kept-suffix...]`; the
+/// superseded prefix stays on disk untouched. We append everything from
+/// the compaction summary onward as fresh entries: the summary message
+/// (carrying the `compactionSummary` block) followed by the duplicated
+/// kept-verbatim suffix. On replay, the latest summary resets effective
+/// context, so the duplicated suffix is what survives.
+///
+/// Usage is not threaded through here: the duplicated entries are replays
+/// of already-sized turns, and the summary itself isn't an assistant turn.
+pub fn persistCompaction(
+ alloc: std.mem.Allocator,
+ mgr: *panto.session_manager.SessionManager,
+ conv: *const panto.conversation.Conversation,
+ provider: []const u8,
+ model: []const u8,
+) !void {
+ const start = panto.conversation.latestCompactionIndex(conv.messages.items) orelse return;
+ try persistTurn(alloc, mgr, conv, start, provider, model, &.{});
+}
+
+pub fn hasToolUseWithoutFollowingResults(conv: *const panto.conversation.Conversation, index: usize) bool {
+ const msg = conv.messages.items[index];
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ if (index + 1 >= conv.messages.items.len) return true;
+ const next = conv.messages.items[index + 1];
+ if (next.role != .user) return true;
+ var found = false;
+ for (next.content.items) |next_block| {
+ if (next_block == .ToolResult and std.mem.eql(u8, next_block.ToolResult.tool_use_id, block.ToolUse.id)) {
+ found = true;
+ break;
+ }
+ }
+ if (!found) return true;
+ }
+ return false;
+}
diff --git a/src/system_prompt.zig b/src/system_prompt.zig
index b796394..0b449dc 100644
--- a/src/system_prompt.zig
+++ b/src/system_prompt.zig
@@ -37,6 +37,31 @@ pub const default_seed = "You are a helpful assistant.";
const seed_filename = "SYSTEM.md";
const append_filename = "APPEND_SYSTEM.md";
+const compaction_filename = "COMPACTION.md";
+
+/// Last-resort fallback compaction prompt when no `COMPACTION.md` exists at
+/// any layer. In normal operation the base layer ships a bundled
+/// `agent/COMPACTION.md`, so this is only used if that staged file is
+/// missing or unreadable.
+pub const default_compaction_prompt =
+ \\You are compacting a long conversation to reduce its context size.
+ \\
+ \\You will be given a transcript of an earlier portion of a
+ \\conversation between a user and an AI coding assistant, and possibly a
+ \\previous summary of even older history. Produce a single, dense
+ \\summary that preserves everything needed to continue the work
+ \\coherently:
+ \\
+ \\- the user's goals, decisions, and stated preferences
+ \\- the current state of the task and what remains to be done
+ \\- key facts discovered about the codebase or problem
+ \\- important file paths, identifiers, and code structures
+ \\- unresolved questions, bugs, and their understanding
+ \\
+ \\Write the summary as factual notes, not as a chat reply. Do not
+ \\address the user. Do not include pleasantries. Be comprehensive about
+ \\specifics but omit redundant back-and-forth.
+;
/// A resolved set of system blocks for a fresh session: the seed followed
/// by zero or more appends, already in emission order. All slices
@@ -185,6 +210,39 @@ pub fn reconcileResumeLayers(
}
}
+/// Resolve the compaction system prompt across the three config layers.
+/// Like `SYSTEM.md` (not `APPEND_SYSTEM.md`): the highest layer present
+/// wins (project > user > base); falls back to a built-in default if no
+/// `COMPACTION.md` exists anywhere. The result is owned by `arena`.
+pub fn resolveCompaction(
+ arena: Allocator,
+ io: Io,
+ environ_map: *const std.process.Environ.Map,
+ base_dir: []const u8,
+) ![]const u8 {
+ const user_dir = try userLayerDir(arena, environ_map);
+ const project_dir = try projectLayerDir(arena, io);
+ return resolveCompactionLayers(arena, io, base_dir, user_dir, project_dir);
+}
+
+/// Layer-explicit variant of `resolveCompaction` (see `resolveLayers`).
+pub fn resolveCompactionLayers(
+ arena: Allocator,
+ io: Io,
+ base_dir: ?[]const u8,
+ user_dir: ?[]const u8,
+ project_dir: ?[]const u8,
+) ![]const u8 {
+ var prompt: []const u8 = default_compaction_prompt;
+ for ([_]?[]const u8{ base_dir, user_dir, project_dir }) |maybe_dir| {
+ const dir = maybe_dir orelse continue;
+ if (try readLayerFile(arena, io, dir, compaction_filename)) |text| {
+ prompt = text;
+ }
+ }
+ return prompt;
+}
+
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
@@ -539,3 +597,34 @@ test "Resolved.blocks - seed leads appends" {
try testing.expectEqualStrings("U", blocks[2]);
try testing.expectEqualStrings("B", blocks[3]);
}
+
+test "resolveCompactionLayers - highest COMPACTION.md wins, default otherwise" {
+ var tmp = testing.tmpDir(.{ .iterate = true });
+ defer tmp.cleanup();
+
+ var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const root_len = try tmp.dir.realPathFile(testing.io, ".", &buf);
+ const root = buf[0..root_len];
+
+ // No files anywhere -> default.
+ {
+ const p = try resolveCompactionLayers(arena, testing.io, root, null, null);
+ try testing.expectEqualStrings(default_compaction_prompt, p);
+ }
+
+ // base + project; project wins.
+ try tmp.dir.createDirPath(testing.io, "base");
+ try tmp.dir.createDirPath(testing.io, "project");
+ try writeTmpFile(tmp.dir, "base/COMPACTION.md", "base compaction");
+ try writeTmpFile(tmp.dir, "project/COMPACTION.md", "project compaction");
+ const base_dir = try std.fs.path.join(arena, &.{ root, "base" });
+ const project_dir = try std.fs.path.join(arena, &.{ root, "project" });
+ {
+ const p = try resolveCompactionLayers(arena, testing.io, base_dir, null, project_dir);
+ try testing.expectEqualStrings("project compaction", p);
+ }
+}