From 2fdc812a13d053df32a6835765f2369496438596 Mon Sep 17 00:00:00 2001 From: Pierre De Lancre Date: Tue, 1 Sep 2026 23:15:58 +0300 Subject: [PATCH] Agent + conversation pickers, prefs persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tray submenus: Agent (from letta agents list, radio-style) and Conversation (from local backend storage, CLI has no listing surface) - conversation entries show preview: summary or last-message text - conversation menu refreshes on aboutToShow (KDE DBus menu syncs a step behind eager mutation) - CLI rule honored: --agent only with --conversation default; named conversations imply the agent - plain-text fallback parsing for non-stream-json output - empty-reply debug: raw stream tail dump - prefs: ~/.local/inferon/prefs.json, saved on pick, loaded at startup, HOME-resolved paths (std.c.getenv), owned buffers (no alias double-free) - leak fixes: agent name fallback, quit-path drains for menu entries 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- src/letta.zig | 216 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/main.zig | 209 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 416 insertions(+), 9 deletions(-) diff --git a/src/letta.zig b/src/letta.zig index 8873479..52dc264 100644 --- a/src/letta.zig +++ b/src/letta.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Io = std.Io; const LETTA_PATH = "/home/pierre/.bun/bin/letta"; @@ -61,13 +62,39 @@ pub fn streamInfer( ctx: ?*anyopaque, onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void, ) ![]u8 { + return streamInferConv(io, allocator, agent, "default", prompt, ctx, onEvent); +} + +pub fn streamInferConv( + io: std.Io, + allocator: std.mem.Allocator, + agent: []const u8, + conversation: []const u8, + prompt: []const u8, + ctx: ?*anyopaque, + onEvent: *const fn (ctx: ?*anyopaque, ev: Event) void, +) ![]u8 { + // CLI rule: --agent only pairs with --conversation default; for named + // conversations the agent is implied, so we omit it. + const is_default = std.mem.eql(u8, conversation, "default"); + var argv: [11][]const u8 = undefined; + var ai: usize = 0; + argv[ai] = LETTA_PATH; ai += 1; + if (is_default) { + argv[ai] = "--agent"; ai += 1; + argv[ai] = agent; ai += 1; + } + argv[ai] = "-p"; ai += 1; + argv[ai] = prompt; ai += 1; + argv[ai] = "--conversation"; ai += 1; + argv[ai] = conversation; ai += 1; + argv[ai] = "--toolset"; ai += 1; + argv[ai] = "default"; ai += 1; + argv[ai] = "--output-format"; ai += 1; + argv[ai] = "stream-json"; ai += 1; + var child = try std.process.spawn(io, .{ - .argv = &.{ - LETTA_PATH, "--agent", agent, - "-p", prompt, "--conversation", - "default", "--toolset", "default", - "--output-format", "stream-json", - }, + .argv = argv[0..ai], .stdout = .pipe, .stderr = .inherit, }); @@ -75,6 +102,8 @@ pub fn streamInfer( var reply: std.ArrayList(u8) = .empty; errdefer reply.deinit(allocator); + var raw_tail: std.ArrayList(u8) = .empty; // last bytes, debug on empty reply + defer raw_tail.deinit(allocator); var line_buf: [16 * 1024]u8 = undefined; var line_len: usize = 0; @@ -83,6 +112,12 @@ pub fn streamInfer( while (true) { const n = stdout_file.readStreaming(io, &.{&buf}) catch break; if (n == 0) break; + raw_tail.appendSlice(allocator, buf[0..n]) catch {}; + if (raw_tail.items.len > 4096) { + const excess = raw_tail.items.len - 4096; + std.mem.copyForwards(u8, raw_tail.items[0..4096], raw_tail.items[excess..]); + raw_tail.shrinkRetainingCapacity(4096); + } for (buf[0..n]) |ch| { if (ch == '\n') { if (line_len > 0) { @@ -97,6 +132,9 @@ pub fn streamInfer( } _ = child.wait(io) catch {}; + if (reply.items.len == 0 and raw_tail.items.len > 0) { + std.debug.print("inferon/letta: EMPTY reply, raw stream tail:\n{s}\n", .{raw_tail.items}); + } return reply.toOwnedSlice(allocator); } @@ -109,12 +147,21 @@ fn handleLine( reply: *std.ArrayList(u8), ) void { const mt = dupeStr(allocator, line, "message_type") orelse { - // Non-message line; the "result" line holds the full text as backup. + // The "result" line holds the full text as backup. if (std.mem.indexOf(u8, line, "\"type\":\"result\"") != null and reply.items.len == 0) { if (dupeStr(allocator, line, "result")) |r| { defer allocator.free(r); reply.appendSlice(allocator, r) catch {}; } + return; + } + // Conversation-mode (no --agent) ignores --output-format and emits + // PLAIN TEXT — treat non-JSON lines as the reply itself. + if (line.len > 0 and line[0] != '{') { + reply.appendSlice(allocator, line) catch {}; + reply.append(allocator, ' ') catch {}; + const dup = allocator.dupe(u8, line) catch return; + onEvent(ctx, .{ .chunk = dup }); } return; }; @@ -144,7 +191,7 @@ fn handleLine( } /// Extract "key":"value" (with escape handling), allocated with `allocator`. -fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u8 { +pub fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u8 { var pat_buf: [64]u8 = undefined; const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\":\"", .{key}) catch return null; const start = std.mem.indexOf(u8, line, pat) orelse return null; @@ -168,3 +215,156 @@ fn dupeStr(allocator: std.mem.Allocator, line: []const u8, key: []const u8) ?[]u } return out.toOwnedSlice(allocator) catch null; } + +// -------------------------------------------------------------- listings --- + +pub const Agent = struct { id: []u8, name: []u8 }; // caller frees both + +fn readAllStdout(io: std.Io, alloc: std.mem.Allocator, argv: []const []const u8) ![]u8 { + var child = try std.process.spawn(io, .{ + .argv = argv, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .ignore, + }); + const out = child.stdout.?; + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(alloc); + var tmp: [16 * 1024]u8 = undefined; + while (true) { + const n = out.readStreaming(io, &.{&tmp}) catch break; + if (n == 0) break; + try buf.appendSlice(alloc, tmp[0..n]); + } + _ = child.wait(io) catch {}; + return buf.toOwnedSlice(alloc); +} + +/// Parse `letta agents list` JSON into id/name pairs. +pub fn listAgents(io: std.Io, alloc: std.mem.Allocator) ![]Agent { + const json = try readAllStdout(io, alloc, &.{ LETTA_PATH, "agents", "list" }); + defer alloc.free(json); + + var out: std.ArrayList(Agent) = .empty; + errdefer out.deinit(alloc); + var i: usize = 0; + while (jsonStr(alloc, json, i, "id")) |r| { + defer alloc.free(r.val); + if (!std.mem.startsWith(u8, r.val, "agent-")) { + i = r.end; + continue; + } + const id = try alloc.dupe(u8, r.val); + errdefer alloc.free(id); + // name is the next field after id in each item — no fallback leak. + const name: []u8 = if (jsonStr(alloc, json, r.end, "name")) |nr| nr.val else try alloc.dupe(u8, "?"); + try out.append(alloc, .{ .id = id, .name = name }); + i = r.end; + } + return out.toOwnedSlice(alloc); +} + +pub const Convo = struct { id: []u8, preview: []u8 }; // caller frees both + +/// Conversations for an agent, straight from the local backend's storage +/// (the CLI has no list-conversations surface; messages list only exports +/// "default"). Reads /conversations/*/conversation.json. +var conv_dir_buf: [512]u8 = undefined; +pub var CONVERSATIONS_DIR: []const u8 = ""; + +pub fn initConversationsDir() void { + const home = std.c.getenv("HOME") orelse return; + CONVERSATIONS_DIR = std.fmt.bufPrint(&conv_dir_buf, "{s}/.letta/lc-local-backend/conversations", .{std.mem.span(home)}) catch return; +} + +pub fn listConversations(io: std.Io, alloc: std.mem.Allocator, agent: []const u8) ![]Convo { + var out: std.ArrayList(Convo) = .empty; + errdefer out.deinit(alloc); + try out.append(alloc, .{ .id = try alloc.dupe(u8, "default"), .preview = try alloc.dupe(u8, "") }); + + var dir = try Io.Dir.openDirAbsolute(io, CONVERSATIONS_DIR, .{ .iterate = true }); + defer dir.close(io); + var it = dir.iterate(); + while (try it.next(io)) |entry| { + if (entry.kind != .directory) continue; + var buf: [4096]u8 = undefined; + const sub = try std.fmt.bufPrint(&buf, "{s}/conversation.json", .{entry.name}); + const json = dir.readFileAlloc(io, sub, alloc, .limited(64 * 1024)) catch continue; + defer alloc.free(json); + + const aid = jsonStr(alloc, json, 0, "agent_id") orelse continue; + defer alloc.free(aid.val); + if (!std.mem.eql(u8, aid.val, agent)) continue; + + // skip archived + if (std.mem.indexOf(u8, json, "\"archived\": true") != null) continue; + + const cid = jsonStr(alloc, json, 0, "id") orelse continue; + var dup = false; + for (out.items) |existing| { + if (std.mem.eql(u8, existing.id, cid.val)) dup = true; + } + if (dup) { + alloc.free(cid.val); + continue; + } + + // Preview: summary if set, else the last message text. + var preview: []u8 = try alloc.dupe(u8, ""); + if (jsonStr(alloc, json, 0, "summary")) |s| { + if (s.val.len > 0) { + alloc.free(preview); + preview = s.val; + } else { + alloc.free(s.val); + } + } + if (preview.len == 0) { + var mbuf: [4096]u8 = undefined; + const msub = try std.fmt.bufPrint(&mbuf, "{s}/messages.jsonl", .{entry.name}); + const msgs = dir.readFileAlloc(io, msub, alloc, .limited(16 * 1024 * 1024)) catch null; + if (msgs) |m| { + defer alloc.free(m); + if (std.mem.lastIndexOfScalar(u8, m, '\n')) |nl| { + const last = m[nl + 1 ..]; + if (jsonStr(alloc, last, 0, "text")) |t| { + alloc.free(preview); + preview = t.val; + } + } + } + } + try out.append(alloc, .{ .id = cid.val, .preview = preview }); + } + return out.toOwnedSlice(alloc); +} + +/// Whitespace-tolerant JSON string-field extractor: finds "key" (with or +/// without space after the colon), returns the unescaped value. +pub fn jsonStr(alloc: std.mem.Allocator, json: []const u8, from: usize, key: []const u8) ?struct { val: []u8, end: usize } { + var pat_buf: [64]u8 = undefined; + const pat = std.fmt.bufPrint(&pat_buf, "\"{s}\"", .{key}) catch return null; + const ks = std.mem.indexOfPos(u8, json, from, pat) orelse return null; + var i = ks + pat.len; + while (i < json.len and (json[i] == ' ' or json[i] == ':')) i += 1; + if (i >= json.len or json[i] != '"') return null; + i += 1; + + var out: std.ArrayList(u8) = .empty; + while (i < json.len and json[i] != '"') { + if (json[i] == '\\' and i + 1 < json.len) { + const esc: u8 = switch (json[i + 1]) { + 'n' => '\n', + 't' => '\t', + else => json[i + 1], + }; + out.append(alloc, esc) catch return null; + i += 2; + } else { + out.append(alloc, json[i]) catch return null; + i += 1; + } + } + if (i >= json.len) return null; + return .{ .val = out.toOwnedSlice(alloc) catch return null, .end = i + 1 }; +} diff --git a/src/main.zig b/src/main.zig index 97dff40..a18d9ac 100644 --- a/src/main.zig +++ b/src/main.zig @@ -70,6 +70,78 @@ var tray_icon: QSystemTrayIcon = undefined; var allocator: std.mem.Allocator = undefined; var conversation_active: bool = false; +var agent_menu: QMenu = undefined; +var conv_menu: QMenu = undefined; +var current_agent: []const u8 = LETTA_AGENT_ID; +var current_conversation: []const u8 = "default"; + +// ---------------------------------------------------------------- prefs --- + +var prefs_dir_buf: [512]u8 = undefined; +var prefs_path_buf: [576]u8 = undefined; +var PREFS_DIR: []const u8 = ""; +var PREFS_PATH: []const u8 = ""; + +/// Resolve prefs paths from $HOME once at startup. +fn initPrefsPaths() void { + const home = std.c.getenv("HOME") orelse return; + const home_s = std.mem.span(home); + PREFS_DIR = std.fmt.bufPrint(&prefs_dir_buf, "{s}/.local/inferon", .{home_s}) catch return; + PREFS_PATH = std.fmt.bufPrint(&prefs_path_buf, "{s}/prefs.json", .{PREFS_DIR}) catch return; +} + +var prefs_agent_buf: ?[]u8 = null; +var prefs_convo_buf: ?[]u8 = null; + +fn setPrefsAgent(id: []const u8) void { + if (allocator.dupe(u8, id)) |d| { + if (prefs_agent_buf) |old| allocator.free(old); + prefs_agent_buf = d; + current_agent = d; + } else |_| {} +} + +fn setPrefsConvo(id: []const u8) void { + if (allocator.dupe(u8, id)) |d| { + if (prefs_convo_buf) |old| allocator.free(old); + prefs_convo_buf = d; + current_conversation = d; + } else |_| {} +} + +fn savePrefs() void { + const io = io_ctx orelse return; + Io.Dir.createDirAbsolute(io, PREFS_DIR, .default_dir) catch {}; + const f = Io.Dir.createFileAbsolute(io, PREFS_PATH, .{ .truncate = true }) catch return; + var buf: [1024]u8 = undefined; + const body = std.fmt.bufPrint(&buf, + "{{\"agent\": \"{s}\", \"conversation\": \"{s}\"}}\n", + .{ current_agent, current_conversation }, + ) catch return; + var wbuf: [1024]u8 = undefined; + var w = f.writer(io, &wbuf); + w.interface.writeAll(body) catch {}; + w.interface.flush() catch {}; + f.close(io); +} + +fn loadPrefs() void { + const io = io_ctx orelse return; + var f = Io.Dir.openFileAbsolute(io, PREFS_PATH, .{}) catch return; + defer f.close(io); + var buf: [1024]u8 = undefined; + var rbuf: [1024]u8 = undefined; + var r = f.reader(io, &rbuf); + const n = r.interface.readSliceShort(&buf) catch return; + if (letta.jsonStr(allocator, buf[0..n], 0, "agent")) |a| { + setPrefsAgent(a.val); + allocator.free(a.val); + } + if (letta.jsonStr(allocator, buf[0..n], 0, "conversation")) |c| { + setPrefsConvo(c.val); + allocator.free(c.val); + } +} var last_user_text: ?[]u8 = null; fn setLastUserText(text: []u8) void { @@ -324,7 +396,7 @@ fn processUtterance() void { pushOverlay(.begin, null); if (allocator.dupe(u8, text) catch null) |user_dup| pushOverlay(.user, user_dup); - const response = letta.streamInfer(io, allocator, LETTA_AGENT_ID, text, null, onLettaEvent) catch |err| { + const response = letta.streamInferConv(io, allocator, current_agent, current_conversation, text, null, onLettaEvent) catch |err| { std.log.err("letta failed: {s}", .{@errorName(err)}); setState(.idle); return; @@ -370,6 +442,110 @@ fn onTrayActivated(_: QSystemTrayIcon, reason: i32) callconv(.c) void { } } +fn onToggleWindow(a: QAction) callconv(.c) void { + if (a.isChecked()) { + if (overlay) |*ov| ov.window.show(); + } else { + if (overlay) |*ov| ov.hide(); + } +} + +const AgentEntry = struct { action: QAction, id: []u8 }; +var agent_entries: std.ArrayList(AgentEntry) = .empty; + +fn onPickAgent(a: QAction) callconv(.c) void { + for (agent_entries.items) |e| { + e.action.setChecked(e.action.ptr == a.ptr); + if (e.action.ptr == a.ptr) setPrefsAgent(e.id); + } + setPrefsConvo("default"); + populateConvMenu(); + savePrefs(); + std.debug.print("inferon: agent -> {s}\n", .{current_agent}); +} + +fn onConvMenuShow(_: QMenu) callconv(.c) void { + populateConvMenu(); +} + +fn populateAgentMenu() void { + for (agent_entries.items) |e| allocator.free(e.id); + agent_entries.clearRetainingCapacity(); + agent_menu.clear(); + + const agents = letta.listAgents(io_ctx.?, allocator) catch return; + defer { + for (agents) |ag| { + allocator.free(ag.id); + allocator.free(ag.name); + } + allocator.free(agents); + } + for (agents) |ag| { + const label = std.fmt.allocPrint(allocator, "{s}", .{ag.name}) catch continue; + defer allocator.free(label); + const a = QAction.new5(label, QWidget{ .ptr = null }); + a.setCheckable(true); + a.setChecked(std.mem.eql(u8, ag.id, current_agent)); + a.onTriggered(onPickAgent); + agent_menu.addAction(a); + const id_dup = allocator.dupe(u8, ag.id) catch continue; + agent_entries.append(allocator, .{ .action = a, .id = id_dup }) catch allocator.free(id_dup); + } +} + +const ConvEntry = struct { action: QAction, id: []u8, preview: []u8 }; +var conv_entries: std.ArrayList(ConvEntry) = .empty; + +fn onPickConv(a: QAction) callconv(.c) void { + for (conv_entries.items) |e| { + e.action.setChecked(e.action.ptr == a.ptr); + if (e.action.ptr == a.ptr) setPrefsConvo(e.id); + } + savePrefs(); + std.debug.print("inferon: conversation -> {s}\n", .{current_conversation}); +} + +fn populateConvMenu() void { + for (conv_entries.items) |e| { + allocator.free(e.id); + allocator.free(e.preview); + } + conv_entries.clearRetainingCapacity(); + conv_menu.clear(); + + const convs = letta.listConversations(io_ctx.?, allocator, current_agent) catch return; + defer { + for (convs) |c| { + allocator.free(c.id); + allocator.free(c.preview); + } + allocator.free(convs); + } + for (convs) |c| { + const trunc = if (c.preview.len > 60) c.preview[0..60] else c.preview; + const label = if (trunc.len > 0) + std.fmt.allocPrint(allocator, "{s} — {s}", .{ c.id, trunc }) catch continue + else + allocator.dupe(u8, c.id) catch continue; + defer allocator.free(label); + const a = QAction.new5(label, QWidget{ .ptr = null }); + a.setCheckable(true); + a.setChecked(std.mem.eql(u8, c.id, current_conversation)); + a.onTriggered(onPickConv); + conv_menu.addAction(a); + const id_dup = allocator.dupe(u8, c.id) catch continue; + const prev_dup = allocator.dupe(u8, c.preview) catch { + allocator.free(id_dup); + continue; + }; + conv_entries.append(allocator, .{ .id = id_dup, .action = a, .preview = prev_dup }) catch { + allocator.free(id_dup); + allocator.free(prev_dup); + }; + } +} + fn onEndConversation(_: QAction) callconv(.c) void { if (recorder != null) stopRecording(); conversation_active = false; @@ -444,7 +620,28 @@ pub fn main(init: std.process.Init) !void { const end_action = QAction.new5("&End conversation", QWidget{ .ptr = null }); end_action.onTriggered(onEndConversation); + // Floating-window toggle + const win_action = QAction.new5("&Floating window", QWidget{ .ptr = null }); + win_action.setCheckable(true); + win_action.setChecked(true); + win_action.onTriggered(onToggleWindow); + + initPrefsPaths(); + letta.initConversationsDir(); + loadPrefs(); + agent_menu = QMenu.new(QWidget{ .ptr = null }); + agent_menu.setTitle("Agent"); + conv_menu = QMenu.new(QWidget{ .ptr = null }); + conv_menu.setTitle("Conversation"); + populateAgentMenu(); + populateConvMenu(); + conv_menu.onAboutToShow(onConvMenuShow); + const tray_menu = QMenu.new(QWidget{ .ptr = null }); + _ = tray_menu.addMenu(agent_menu); + _ = tray_menu.addMenu(conv_menu); + _ = tray_menu.addSeparator(); + tray_menu.addAction(win_action); tray_menu.addAction(end_action); _ = tray_menu.addSeparator(); tray_menu.addAction(quit_action); @@ -459,6 +656,7 @@ pub fn main(init: std.process.Init) !void { var ov = overlay_mod.Overlay.create(allocator); overlay = ov; + ov.window.show(); // visible from startup per Pierre _ = &ov; // Own the whisper-server lifecycle: spawn AFTER the tray exists (setState @@ -491,6 +689,15 @@ pub fn main(init: std.process.Init) !void { if (last_user_text) |t| allocator.free(t); last_user_text = null; sentence_buf.deinit(allocator); + if (prefs_agent_buf) |b| allocator.free(b); + if (prefs_convo_buf) |b| allocator.free(b); + for (agent_entries.items) |e| allocator.free(e.id); + agent_entries.deinit(allocator); + for (conv_entries.items) |e| { + allocator.free(e.id); + allocator.free(e.preview); + } + conv_entries.deinit(allocator); overlay_mod.Overlay.shutdownMem(); // static, no instance needed playback.shutdown();