Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions changelogs/v2.1.9.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions electron/db/graph-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function getKnowledgeGraph(
meta: {
tagIds: parseJson(n.tag_ids),
isPinned: n.is_pinned === 1,
snippet: (n.content_text as string || "").slice(0, 120),
snippet: (n.content_text as string || "").slice(0, 600),
},
});
}
Expand Down Expand Up @@ -232,7 +232,7 @@ export function getKnowledgeGraph(
priority: c.priority,
assignee: c.assignee,
tagIds: parseJson(c.tag_ids),
snippet: (c.description as string || "").slice(0, 120),
snippet: (c.description as string || "").slice(0, 600),
isArchived: !!(c.archived_at),
},
});
Expand Down
4 changes: 3 additions & 1 deletion electron/ipc/chat-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { callLLM, type LLMConfig } from "../lib/llm";
import { TOOL_LABELS, type ChatRequest, type ToolArgs } from "../lib/tools";
import { aiWriteLock } from "../lib/ai-write-lock";
import { executeTool as executeMcpTool } from "../mcp/tools";
import { normalizeNoteTitle } from "../shared/text-utils";

// ── Static reference constants (returned by get_dashboard_constants / get_idea_flow_rules) ──

Expand Down Expand Up @@ -143,8 +144,9 @@ export async function executeTool(
return executeMcpTool(db, workspacePath, name, args);
}
case "ensure_note": {
const matchTitle = normalizeNoteTitle(args.title as string);
const existing = snap.notes.find(
(n) => !n.archivedAt && n.projectId === args.projectId && n.title === args.title
(n) => !n.archivedAt && n.projectId === args.projectId && normalizeNoteTitle(n.title as string) === matchTitle
);
const ensureNoteId = existing?.id ?? newId();
const win = getWin?.() ?? null;
Expand Down
106 changes: 65 additions & 41 deletions electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptB
import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools";
import { executeTool } from "./chat-executor";
import { saveCachedConfig, getCachedConfig } from "../lib/config-cache";
import { iterSseData } from "../lib/sse";
import { traceTool } from "../lib/tool-trace";

// Track one AbortController per renderer webContents ID
const abortControllers = new Map<number, AbortController>();
Expand Down Expand Up @@ -171,52 +173,47 @@ async function runToolLoop(
const reader = response.body?.getReader();
if (!reader) return { exhausted: true, content: "No response stream" };

const decoder = new TextDecoder();
let contentBuffer = "";
const toolCallBuffers: Map<number, { id: string; name: string; args: string }> = new Map();
let streamDone = false;

while (!streamDone) {
if (signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value, { stream: true }).split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const jsonStr = trimmed.slice(5).trim();
if (jsonStr === "[DONE]") {
streamDone = true;
break;

for await (const jsonStr of iterSseData(reader, signal ?? undefined)) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunk = JSON.parse(jsonStr) as any;
if (chunk.usage && onUsage) {
onUsage(chunk.usage.prompt_tokens ?? 0, chunk.usage.completion_tokens ?? 0);
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunk = JSON.parse(jsonStr) as any;
if (chunk.usage && onUsage) {
onUsage(chunk.usage.prompt_tokens ?? 0, chunk.usage.completion_tokens ?? 0);
}
const delta = chunk.choices?.[0]?.delta;
if (!delta) continue;
const delta = chunk.choices?.[0]?.delta;
if (!delta) continue;

if (delta.content) {
contentBuffer += delta.content;
accumulatedContent += delta.content;
if (onToken) onToken(delta.content);
}
if (delta.content) {
contentBuffer += delta.content;
accumulatedContent += delta.content;
if (onToken) onToken(delta.content);
}

if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
if (!toolCallBuffers.has(idx)) {
toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" });
}
const buf = toolCallBuffers.get(idx)!;
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
if (!toolCallBuffers.has(idx)) {
toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" });
}
const buf = toolCallBuffers.get(idx)!;
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;
}
} catch { /* skip malformed SSE lines */ }
}
}
} catch { /* skip malformed SSE JSON line */ }
}

// Dev trace: per-tool assembled arguments.
for (const [idx, buf] of toolCallBuffers.entries()) {
traceTool("sse-args", {
toolIndex: idx,
toolName: buf.name,
arguments: buf.args,
});
}

if (signal?.aborted) return { exhausted: true, content: "" };
Expand Down Expand Up @@ -246,8 +243,35 @@ async function runToolLoop(

messages.push(assistantMsg);
for (const call of assistantMsg.tool_calls) {
let args: Record<string, unknown> = {};
try { args = JSON.parse(call.function.arguments); } catch { args = {}; }
let args: Record<string, unknown>;
let parseError: string | null = null;
try {
args = JSON.parse(call.function.arguments) as Record<string, unknown>;
traceTool("parse", {
toolName: call.function.name,
title: typeof args.title === "string" ? args.title : "",
content: typeof args.content === "string" ? args.content : "",
rawArguments: call.function.arguments,
});
} catch (err) {
parseError = `Malformed tool-call arguments JSON from model: ${(err as Error).message}`;
args = {};
}

// Surface the chip so the UI shows the tool happening, then fail
// with a descriptive error so the model can re-issue — never run
// a tool with destructured args.
if (parseError) {
emitToolCall({ tool: call.function.name, label: call.function.name, args: {} });
emitToolCallDone?.({ tool: call.function.name });
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify({ error: parseError }),
});
continue;
}

let result: unknown;
try {
result = await executeTool(db, req, workspacePath, { baseUrl, model, apiKey, provider: provider as "openai" | "localllm" }, call.function.name, args, emitToolCall, getWin, emitToolCallDone);
Expand Down
143 changes: 81 additions & 62 deletions electron/lib/pi-agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
import { executeTool } from "../ipc/chat-executor";
import type { ChatRequest, ToolArgs } from "./tools";
import type { SkillMeta } from "./skills";
import { iterSseData } from "./sse";
import { traceTool } from "./tool-trace";

// ── LLM config ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -542,78 +544,74 @@ export async function runAgentLoop(
const reader = response.body?.getReader();
if (!reader) { callbacks.onError("No response stream"); return; }

const decoder = new TextDecoder();
let contentBuffer = "";
const toolCallBuffers: Map<number, { id: string; name: string; args: string }> = new Map();
// callId assigned per tool during streaming — reused at execution time
const streamCallIds: Map<number, string> = new Map();
let toolsReadyFired = false;
let streamDone = false;

while (!streamDone) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value, { stream: true }).split("\n")) {
const trimmed = line.trim();
if (!trimmed.startsWith("data:")) continue;
const jsonStr = trimmed.slice(5).trim();
if (jsonStr === "[DONE]") {
streamDone = true;
break;
}
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunk = JSON.parse(jsonStr) as any;

// Usage chunk — sent as the final SSE chunk when stream_options.include_usage is set.
if (chunk.usage) {
const pt = chunk.usage.prompt_tokens ?? 0;
const ct = chunk.usage.completion_tokens ?? 0;
session.lastPromptTokens = pt;
let breakdown: TokenBreakdown | undefined;
try {
const rawBreakdown = calculatePromptBreakdown(systemPrompt, contextMessages, allTools);
breakdown = scaleBreakdown(rawBreakdown, pt);
} catch (err) {
console.error("[pi-agent] failed to calculate breakdown:", err);
}
callbacks.onUsage(pt, ct, breakdown);

for await (const jsonStr of iterSseData(reader, signal)) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chunk = JSON.parse(jsonStr) as any;

// Usage chunk — sent as the final SSE chunk when stream_options.include_usage is set.
if (chunk.usage) {
const pt = chunk.usage.prompt_tokens ?? 0;
const ct = chunk.usage.completion_tokens ?? 0;
session.lastPromptTokens = pt;
let breakdown: TokenBreakdown | undefined;
try {
const rawBreakdown = calculatePromptBreakdown(systemPrompt, contextMessages, allTools);
breakdown = scaleBreakdown(rawBreakdown, pt);
} catch (err) {
console.error("[pi-agent] failed to calculate breakdown:", err);
}
callbacks.onUsage(pt, ct, breakdown);
}

const delta = chunk.choices?.[0]?.delta;
if (!delta) continue;
const delta = chunk.choices?.[0]?.delta;
if (!delta) continue;

if (delta.content) {
contentBuffer += delta.content;
callbacks.onToken(delta.content);
}
if (delta.content) {
contentBuffer += delta.content;
callbacks.onToken(delta.content);
}

if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
const isNew = !toolCallBuffers.has(idx);
if (isNew) {
toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" });
}
const buf = toolCallBuffers.get(idx)!;
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;

// Fire pending chip as soon as we see the tool name during streaming
if (isNew && buf.name) {
if (!toolsReadyFired) {
callbacks.onToolsReady();
toolsReadyFired = true;
}
const callId = `${buf.name}:${Date.now()}:${idx}`;
streamCallIds.set(idx, callId);
callbacks.onToolPending(buf.name, callId);
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
const idx: number = tc.index ?? 0;
const isNew = !toolCallBuffers.has(idx);
if (isNew) {
toolCallBuffers.set(idx, { id: tc.id ?? "", name: tc.function?.name ?? "", args: "" });
}
const buf = toolCallBuffers.get(idx)!;
if (tc.id) buf.id = tc.id;
if (tc.function?.name) buf.name = tc.function.name;
if (tc.function?.arguments) buf.args += tc.function.arguments;

// Fire pending chip as soon as we see the tool name during streaming
if (isNew && buf.name) {
if (!toolsReadyFired) {
callbacks.onToolsReady();
toolsReadyFired = true;
}
const callId = `${buf.name}:${Date.now()}:${idx}`;
streamCallIds.set(idx, callId);
callbacks.onToolPending(buf.name, callId);
}
}
} catch { /* skip malformed SSE lines */ }
}
}
} catch { /* skip malformed SSE JSON line */ }
}

// Dev trace: per-tool assembled arguments.
for (const [idx, buf] of toolCallBuffers.entries()) {
traceTool("sse-args", {
toolIndex: idx,
toolName: buf.name,
arguments: buf.args,
});
}

// ── No tool calls → turn complete ─────────────────────────────────────
Expand Down Expand Up @@ -650,8 +648,22 @@ export async function runAgentLoop(
type ToolOutcome = { tcIdx: number; tc: ToolCallSpec; ok: boolean; resultContent: string; pendingCallId?: string };

const toolPromises: Promise<ToolOutcome>[] = toolCalls.map(async (tc, tcIdx): Promise<ToolOutcome> => {
let args: ToolArgs = {};
try { args = JSON.parse(tc.function.arguments) as ToolArgs; } catch { args = {}; }
// Parse tool arguments. Never run a tool with destructured args — surface
// the parse error so the model can re-issue the call.
let args: ToolArgs;
let parseError: string | null = null;
try {
args = JSON.parse(tc.function.arguments) as ToolArgs;
traceTool("parse", {
toolName: tc.function.name,
title: typeof (args as Record<string, unknown>).title === "string" ? (args as Record<string, unknown>).title as string : "",
content: typeof (args as Record<string, unknown>).content === "string" ? (args as Record<string, unknown>).content as string : "",
rawArguments: tc.function.arguments,
});
} catch (err) {
parseError = `malformed tool-call arguments JSON from model: ${(err as Error).message}`;
args = {};
}

const label = CODING_LABELS[tc.function.name]?.(args) ?? tc.function.name;
const pendingCallId = streamCallIds.get(tcIdx);
Expand All @@ -667,6 +679,13 @@ export async function runAgentLoop(
let resultContent: string = "";
let ok = true;

if (parseError) {
ok = false;
resultContent = `Error: ${parseError}`;
callbacks.onToolEnd(tc.function.name, label, ok, resultContent, pendingCallId);
return { tcIdx, tc, ok, resultContent, pendingCallId };
}

if (llmConfig.autoApprove === false) {
const callKey = pendingCallId || tc.id;
callbacks.onToolConfirmRequired?.(tc.function.name, label, callKey);
Expand Down
Loading