const std = @import("std");const io_mod = @import("../../core/shared/io.zig");const profile_paths = @import("../../core/shared/profile_paths.zig");const tool_args = @import("../../core/tooling/tool_args.zig");const tool_dispatch = @import("../../core/tooling/tool_dispatch.zig"); const Allocator = std.mem.Allocator; pub const Input = struct { action: []u8, fact: ?[]u8, pub fn deinit(self: *Input, alloc: Allocator) void { alloc.free(self.action); if (self.fact) |fact| alloc.free(fact); self.* = .{ .action = &.{}, .fact = null }; }}; pub fn decode(ctx: tool_dispatch.DispatchContext, args_json: []const u8) tool_dispatch.DispatchError!tool_dispatch.DecodeResult { var parsed = std.json.parseFromSlice(std.json.Value, ctx.allocator, args_json, .{}) catch { return .{ .failure = try ctx.allocator.dupe(u8, "memory arguments must be valid JSON") }; }; defer parsed.deinit(); if (parsed.value != .object) { return .{ .failure = try ctx.allocator.dupe(u8, "memory arguments must be an object") }; } const action_value = parsed.value.object.get("action") orelse { return .{ .failure = try ctx.allocator.dupe(u8, "memory field \"action\" is required") }; }; if (action_value != .string) { return .{ .failure = try ctx.allocator.dupe(u8, "memory field \"action\" must be a string") }; } const fact_value = parsed.value.object.get("fact"); const fact: ?[]u8 = if (fact_value) |value| if (value == .string) try ctx.allocator.dupe(u8, value.string) else null else null; errdefer if (fact) |owned| ctx.allocator.free(owned); const action = try ctx.allocator.dupe(u8, action_value.string); errdefer ctx.allocator.free(action); const input = try ctx.allocator.create(Input); errdefer ctx.allocator.destroy(input); input.* = .{ .action = action, .fact = fact }; return .{ .input = .{ .ptr = input, .deinit_fn = inputDeinit } };} fn inputDeinit(ptr: *anyopaque, alloc: Allocator) void { const input: *Input = @ptrCast(@alignCast(ptr)); input.deinit(alloc); alloc.destroy(input);} pub fn validate(_: tool_dispatch.DispatchContext, _: tool_dispatch.ToolInput) tool_dispatch.DispatchError!?[]u8 { return null;} pub fn call(ctx: tool_dispatch.DispatchContext, erased: tool_dispatch.ToolInput) tool_dispatch.DispatchError!tool_dispatch.ToolResult { const input = erased.as(Input); const output = runMemory(ctx.allocator, input.action, input.fact) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .failure = try std.fmt.allocPrint(ctx.allocator, "memory failed: {s}", .{@errorName(err)}) }, }; return .{ .success = output };} pub fn execute(arena: Allocator, args_json: []const u8) ![]u8 { const args = try tool_args.parseToolArgsObject(arena, args_json); const action = try tool_args.requiredStringArg(args, "action"); const fact = tool_args.optionalStringArg(args, "fact"); return runMemory(arena, action, fact);} fn runMemory(alloc: Allocator, action: []const u8, fact: ?[]const u8) ![]u8 { const home = io_mod.getenv("HOME") orelse return std.fmt.allocPrint(alloc, "memory unavailable: HOME not set", .{}); const memories_path = try profile_paths.memoriesPath(alloc, home); defer alloc.free(memories_path); if (std.mem.eql(u8, action, "save")) { const fact_value = fact orelse return std.fmt.allocPrint(alloc, "no fact provided", .{}); var existing = loadMemories(alloc, memories_path); defer freeMemories(alloc, &existing); for (existing.items) |memory| { if (std.mem.eql(u8, memory, fact_value)) return std.fmt.allocPrint(alloc, "remembered", .{}); } try existing.append(alloc, try alloc.dupe(u8, fact_value)); try saveMemories(alloc, memories_path, existing.items); return std.fmt.allocPrint(alloc, "remembered", .{}); } if (std.mem.eql(u8, action, "list")) { var existing = loadMemories(alloc, memories_path); defer freeMemories(alloc, &existing); if (existing.items.len == 0) return std.fmt.allocPrint(alloc, "No saved memories", .{}); var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); for (existing.items) |memory| { try out.writer.print("- {s}\n", .{memory}); } return try out.toOwnedSlice(); } if (std.mem.eql(u8, action, "clear")) { std.Io.Dir.deleteFileAbsolute(io_mod.getIo(), memories_path) catch {}; return std.fmt.allocPrint(alloc, "memories cleared", .{}); } return std.fmt.allocPrint(alloc, "unknown memory action: {s}", .{action});} fn loadMemories(alloc: Allocator, path: []const u8) std.ArrayList([]u8) { var list: std.ArrayList([]u8) = .empty; var file = std.Io.Dir.openFileAbsolute(io_mod.getIo(), path, .{}) catch return list; defer file.close(io_mod.getIo()); const content = io_mod.readFileToEnd(alloc, &file, 1024 * 1024) catch return list; defer alloc.free(content); const parsed = std.json.parseFromSlice(std.json.Value, alloc, content, .{}) catch return list; defer parsed.deinit(); if (parsed.value != .array) return list; for (parsed.value.array.items) |item| { if (item != .string) continue; list.append(alloc, alloc.dupe(u8, item.string) catch continue) catch continue; } return list;} fn freeMemories(alloc: Allocator, list: *std.ArrayList([]u8)) void { for (list.items) |memory| alloc.free(memory); list.deinit(alloc);} fn saveMemories(alloc: Allocator, path: []const u8, memories: []const []u8) !void { const dir_path = std.fs.path.dirname(path) orelse return error.InvalidPath; std.Io.Dir.createDirAbsolute(io_mod.getIo(), dir_path, .default_dir) catch |err| { if (err != error.PathAlreadyExists) return err; }; var out: std.Io.Writer.Allocating = .init(alloc); defer out.deinit(); try out.writer.writeByte('['); for (memories, 0..) |memory, index| { if (index > 0) try out.writer.writeByte(','); try out.writer.writeByte('\n'); try out.writer.writeAll(" "); try std.json.Stringify.value(memory, .{}, &out.writer); } if (memories.len > 0) try out.writer.writeByte('\n'); try out.writer.writeAll("]\n"); const json = try out.toOwnedSlice(); defer alloc.free(json); io_mod.writeFileAtomic(alloc, path, json) catch { var file = try std.Io.Dir.createFileAbsolute(io_mod.getIo(), path, .{ .truncate = true }); defer file.close(io_mod.getIo()); try file.writeStreamingAll(io_mod.getIo(), json); };} pub fn readsOnly(erased: tool_dispatch.ToolInput) bool { return std.mem.eql(u8, erased.as(Input).action, "list");} pub fn isIrreversible(erased: tool_dispatch.ToolInput) bool { return std.mem.eql(u8, erased.as(Input).action, "clear");} fn expectDecodeFailure(args_json: []const u8, expected: []const u8) !void { const alloc = std.testing.allocator; const decoded = try decode(.{ .allocator = alloc }, args_json); switch (decoded) { .failure => |body| { defer alloc.free(body); try std.testing.expectEqualStrings(expected, body); }, .input => |input| { defer input.deinit(alloc); try std.testing.expect(false); }, }} fn expectMemoryOutput(args_json: []const u8, expected: []const u8) !void { var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); const output = try execute(arena, args_json); try std.testing.expectEqualStrings(expected, output);} fn setTestHome(home: ?[]const u8) !void { const map = try std.heap.c_allocator.create(std.process.Environ.Map); map.* = std.process.Environ.Map.init(std.heap.c_allocator); if (home) |value| try map.put("HOME", value); io_mod.setEnvironMap(map);} test "memory owner rejects invalid JSON and action shape" { try expectDecodeFailure("{", "memory arguments must be valid JSON"); try expectDecodeFailure("[]", "memory arguments must be an object"); try expectDecodeFailure("{}", "memory field \"action\" is required"); try expectDecodeFailure("{\"action\":1}", "memory field \"action\" must be a string");} test "memory owner preserves active output behavior" { const alloc = std.testing.allocator; try setTestHome(null); defer setTestHome(null) catch {}; try expectMemoryOutput("{\"action\":\"list\"}", "memory unavailable: HOME not set"); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io_mod.getIo(), "home"); const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home"); defer alloc.free(home); try setTestHome(home); try expectMemoryOutput("{\"action\":\"list\"}", "No saved memories"); try expectMemoryOutput("{\"action\":\"save\"}", "no fact provided"); try expectMemoryOutput("{\"action\":\"unknown\"}", "unknown memory action: unknown"); try expectMemoryOutput("{\"action\":\"save\",\"fact\":\"likes Zig\"}", "remembered"); try expectMemoryOutput("{\"action\":\"save\",\"fact\":\"likes Zig\"}", "remembered"); try expectMemoryOutput("{\"action\":\"list\"}", "- likes Zig\n"); const memories_path = try profile_paths.memoriesPath(alloc, home); defer alloc.free(memories_path); var file = try std.Io.Dir.openFileAbsolute(io_mod.getIo(), memories_path, .{}); const content = blk: { defer file.close(io_mod.getIo()); break :blk try io_mod.readFileToEnd(alloc, &file, 4096); }; defer alloc.free(content); var parsed = try std.json.parseFromSlice(std.json.Value, alloc, content, .{}); defer parsed.deinit(); try std.testing.expectEqual(@as(usize, 1), parsed.value.array.items.len); try std.testing.expectEqualStrings("likes Zig", parsed.value.array.items[0].string); try expectMemoryOutput("{\"action\":\"clear\"}", "memories cleared"); try expectMemoryOutput("{\"action\":\"list\"}", "No saved memories");}
const std = @import("std");
const io_mod = @import("../../core/shared/io.zig");
const profile_paths = @import("../../core/shared/profile_paths.zig");
const tool_args = @import("../../core/tooling/tool_args.zig");
const tool_dispatch = @import("../../core/tooling/tool_dispatch.zig");
const Allocator = std.mem.Allocator;
pub const Input = struct {
action: []u8,
fact: ?[]u8,
pub fn deinit(self: *Input, alloc: Allocator) void {
alloc.free(self.action);
if (self.fact) |fact| alloc.free(fact);
self.* = .{ .action = &.{}, .fact = null };
}
};
pub fn decode(ctx: tool_dispatch.DispatchContext, args_json: []const u8) tool_dispatch.DispatchError!tool_dispatch.DecodeResult {
var parsed = std.json.parseFromSlice(std.json.Value, ctx.allocator, args_json, .{}) catch {
return .{ .failure = try ctx.allocator.dupe(u8, "memory arguments must be valid JSON") };
};
defer parsed.deinit();
if (parsed.value != .object) {
return .{ .failure = try ctx.allocator.dupe(u8, "memory arguments must be an object") };
}
const action_value = parsed.value.object.get("action") orelse {
return .{ .failure = try ctx.allocator.dupe(u8, "memory field \"action\" is required") };
};
if (action_value != .string) {
return .{ .failure = try ctx.allocator.dupe(u8, "memory field \"action\" must be a string") };
}
const fact_value = parsed.value.object.get("fact");
const fact: ?[]u8 = if (fact_value) |value|
if (value == .string) try ctx.allocator.dupe(u8, value.string) else null
else
null;
errdefer if (fact) |owned| ctx.allocator.free(owned);
const action = try ctx.allocator.dupe(u8, action_value.string);
errdefer ctx.allocator.free(action);
const input = try ctx.allocator.create(Input);
errdefer ctx.allocator.destroy(input);
input.* = .{ .action = action, .fact = fact };
return .{ .input = .{ .ptr = input, .deinit_fn = inputDeinit } };
}
fn inputDeinit(ptr: *anyopaque, alloc: Allocator) void {
const input: *Input = @ptrCast(@alignCast(ptr));
input.deinit(alloc);
alloc.destroy(input);
}
pub fn validate(_: tool_dispatch.DispatchContext, _: tool_dispatch.ToolInput) tool_dispatch.DispatchError!?[]u8 {
return null;
}
pub fn call(ctx: tool_dispatch.DispatchContext, erased: tool_dispatch.ToolInput) tool_dispatch.DispatchError!tool_dispatch.ToolResult {
const input = erased.as(Input);
const output = runMemory(ctx.allocator, input.action, input.fact) catch |err| switch (err) {
error.OutOfMemory => return error.OutOfMemory,
else => return .{ .failure = try std.fmt.allocPrint(ctx.allocator, "memory failed: {s}", .{@errorName(err)}) },
};
return .{ .success = output };
}
pub fn execute(arena: Allocator, args_json: []const u8) ![]u8 {
const args = try tool_args.parseToolArgsObject(arena, args_json);
const action = try tool_args.requiredStringArg(args, "action");
const fact = tool_args.optionalStringArg(args, "fact");
return runMemory(arena, action, fact);
}
fn runMemory(alloc: Allocator, action: []const u8, fact: ?[]const u8) ![]u8 {
const home = io_mod.getenv("HOME") orelse return std.fmt.allocPrint(alloc, "memory unavailable: HOME not set", .{});
const memories_path = try profile_paths.memoriesPath(alloc, home);
defer alloc.free(memories_path);
if (std.mem.eql(u8, action, "save")) {
const fact_value = fact orelse return std.fmt.allocPrint(alloc, "no fact provided", .{});
var existing = loadMemories(alloc, memories_path);
defer freeMemories(alloc, &existing);
for (existing.items) |memory| {
if (std.mem.eql(u8, memory, fact_value)) return std.fmt.allocPrint(alloc, "remembered", .{});
}
try existing.append(alloc, try alloc.dupe(u8, fact_value));
try saveMemories(alloc, memories_path, existing.items);
return std.fmt.allocPrint(alloc, "remembered", .{});
}
if (std.mem.eql(u8, action, "list")) {
var existing = loadMemories(alloc, memories_path);
defer freeMemories(alloc, &existing);
if (existing.items.len == 0) return std.fmt.allocPrint(alloc, "No saved memories", .{});
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
for (existing.items) |memory| {
try out.writer.print("- {s}\n", .{memory});
}
return try out.toOwnedSlice();
}
if (std.mem.eql(u8, action, "clear")) {
std.Io.Dir.deleteFileAbsolute(io_mod.getIo(), memories_path) catch {};
return std.fmt.allocPrint(alloc, "memories cleared", .{});
}
return std.fmt.allocPrint(alloc, "unknown memory action: {s}", .{action});
}
fn loadMemories(alloc: Allocator, path: []const u8) std.ArrayList([]u8) {
var list: std.ArrayList([]u8) = .empty;
var file = std.Io.Dir.openFileAbsolute(io_mod.getIo(), path, .{}) catch return list;
defer file.close(io_mod.getIo());
const content = io_mod.readFileToEnd(alloc, &file, 1024 * 1024) catch return list;
defer alloc.free(content);
const parsed = std.json.parseFromSlice(std.json.Value, alloc, content, .{}) catch return list;
defer parsed.deinit();
if (parsed.value != .array) return list;
for (parsed.value.array.items) |item| {
if (item != .string) continue;
list.append(alloc, alloc.dupe(u8, item.string) catch continue) catch continue;
}
return list;
}
fn freeMemories(alloc: Allocator, list: *std.ArrayList([]u8)) void {
for (list.items) |memory| alloc.free(memory);
list.deinit(alloc);
}
fn saveMemories(alloc: Allocator, path: []const u8, memories: []const []u8) !void {
const dir_path = std.fs.path.dirname(path) orelse return error.InvalidPath;
std.Io.Dir.createDirAbsolute(io_mod.getIo(), dir_path, .default_dir) catch |err| {
if (err != error.PathAlreadyExists) return err;
};
var out: std.Io.Writer.Allocating = .init(alloc);
defer out.deinit();
try out.writer.writeByte('[');
for (memories, 0..) |memory, index| {
if (index > 0) try out.writer.writeByte(',');
try out.writer.writeByte('\n');
try out.writer.writeAll(" ");
try std.json.Stringify.value(memory, .{}, &out.writer);
}
if (memories.len > 0) try out.writer.writeByte('\n');
try out.writer.writeAll("]\n");
const json = try out.toOwnedSlice();
defer alloc.free(json);
io_mod.writeFileAtomic(alloc, path, json) catch {
var file = try std.Io.Dir.createFileAbsolute(io_mod.getIo(), path, .{ .truncate = true });
defer file.close(io_mod.getIo());
try file.writeStreamingAll(io_mod.getIo(), json);
};
}
pub fn readsOnly(erased: tool_dispatch.ToolInput) bool {
return std.mem.eql(u8, erased.as(Input).action, "list");
}
pub fn isIrreversible(erased: tool_dispatch.ToolInput) bool {
return std.mem.eql(u8, erased.as(Input).action, "clear");
}
fn expectDecodeFailure(args_json: []const u8, expected: []const u8) !void {
const alloc = std.testing.allocator;
const decoded = try decode(.{ .allocator = alloc }, args_json);
switch (decoded) {
.failure => |body| {
defer alloc.free(body);
try std.testing.expectEqualStrings(expected, body);
},
.input => |input| {
defer input.deinit(alloc);
try std.testing.expect(false);
},
}
}
fn expectMemoryOutput(args_json: []const u8, expected: []const u8) !void {
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const output = try execute(arena, args_json);
try std.testing.expectEqualStrings(expected, output);
}
fn setTestHome(home: ?[]const u8) !void {
const map = try std.heap.c_allocator.create(std.process.Environ.Map);
map.* = std.process.Environ.Map.init(std.heap.c_allocator);
if (home) |value| try map.put("HOME", value);
io_mod.setEnvironMap(map);
}
test "memory owner rejects invalid JSON and action shape" {
try expectDecodeFailure("{", "memory arguments must be valid JSON");
try expectDecodeFailure("[]", "memory arguments must be an object");
try expectDecodeFailure("{}", "memory field \"action\" is required");
try expectDecodeFailure("{\"action\":1}", "memory field \"action\" must be a string");
}
test "memory owner preserves active output behavior" {
const alloc = std.testing.allocator;
try setTestHome(null);
defer setTestHome(null) catch {};
try expectMemoryOutput("{\"action\":\"list\"}", "memory unavailable: HOME not set");
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(io_mod.getIo(), "home");
const home = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "home");
defer alloc.free(home);
try setTestHome(home);
try expectMemoryOutput("{\"action\":\"list\"}", "No saved memories");
try expectMemoryOutput("{\"action\":\"save\"}", "no fact provided");
try expectMemoryOutput("{\"action\":\"unknown\"}", "unknown memory action: unknown");
try expectMemoryOutput("{\"action\":\"save\",\"fact\":\"likes Zig\"}", "remembered");
try expectMemoryOutput("{\"action\":\"save\",\"fact\":\"likes Zig\"}", "remembered");
try expectMemoryOutput("{\"action\":\"list\"}", "- likes Zig\n");
const memories_path = try profile_paths.memoriesPath(alloc, home);
defer alloc.free(memories_path);
var file = try std.Io.Dir.openFileAbsolute(io_mod.getIo(), memories_path, .{});
const content = blk: {
defer file.close(io_mod.getIo());
break :blk try io_mod.readFileToEnd(alloc, &file, 4096);
};
defer alloc.free(content);
var parsed = try std.json.parseFromSlice(std.json.Value, alloc, content, .{});
defer parsed.deinit();
try std.testing.expectEqual(@as(usize, 1), parsed.value.array.items.len);
try std.testing.expectEqualStrings("likes Zig", parsed.value.array.items[0].string);
try expectMemoryOutput("{\"action\":\"clear\"}", "memories cleared");
try expectMemoryOutput("{\"action\":\"list\"}", "No saved memories");
}