Skip to content

Commit 2a8d9b7

Browse files
committed
Add conversation organization and Agent mode power-ups
Conversation organization: - Full-text search: the sidebar search box now matches message content, not just chat titles (data was already loaded client-side, no new IPC). - Chat tags: freeform labels per chat with a sidebar filter row, lighter-weight than Projects for ad-hoc grouping. - Message pinning: bookmark any message and jump to it from a "Pinned" panel in the chat toolbar. - Conversation forking: branch a new chat from any earlier message, copying everything up to that point and continuing independently. Agent mode power-ups: - run_code: a Python/JavaScript execution tool - a convenience over shell-quoting multi-line code through run_command, not a new capability or risk tier (same approval flow, same blocklist applied to the source text too). - git_status/git_diff/git_log (read-only, auto-approvable) and git_commit (mutating, requires approval) as dedicated tools so the model doesn't need to guess git flag syntax. Scheduled/automated agent tasks (the third agent-power-up item) deliberately not built - it needs a persistent background scheduler and a headless version of the tool-calling loop that currently only lives in the renderer, which is a materially different feature from the rest of this batch.
1 parent 195b4df commit 2a8d9b7

9 files changed

Lines changed: 441 additions & 12 deletions

File tree

‎README.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ Beyond chat, Modelforge includes an **agentic mode** — the model can read/writ
3636
- **Per-session and per-project overrides** — pin a specific prompt, model, temperature, seed, top-K/top-P, repeat penalty, context length, GPU offload, or stop sequences to a single chat or an entire project, falling back to sane defaults. Provider-specific parameters (e.g. seed isn't supported by Claude, top-K isn't supported by ChatGPT) are automatically disabled when they don't apply to the selected model.
3737
- **Prompt library** — save and reuse system prompts across chats. Prompts can include `{{variables}}` (e.g. `{{topic}}`) that you fill in each time you apply one, and edits keep version history so a bad change can be restored.
3838
- **Command palette** (`Ctrl/Cmd+K`) — jump between chats, projects, and settings without touching the mouse.
39+
- **Full-text search** — the sidebar search box matches message content across every chat, not just titles.
40+
- **Tags** — label chats with freeform tags and filter the sidebar by them; lighter-weight than Projects for ad-hoc organization.
41+
- **Pin messages** — bookmark any message in a chat and jump straight to it from a "Pinned" panel in the toolbar.
42+
- **Fork a conversation** — branch a new chat from any earlier message, keeping everything up to that point and continuing independently from there.
3943

4044
**Files & retrieval**
4145
- Attach files, folders, images, video, and PDFs directly into a conversation.
@@ -134,11 +138,14 @@ Click **Agent** in the chat toolbar and pick a folder — that becomes the model
134138
| `list_dir` | List files and subdirectories |
135139
| `search_files` | Search for a text string across the workspace |
136140
| `run_command` | Execute a shell command in the workspace (or a subfolder), with a 60s timeout |
141+
| `run_code` | Run a Python or JavaScript snippet — a convenience over shell-quoting multi-line code through `run_command`, not a new capability |
142+
| `git_status`, `git_diff`, `git_log` | Read-only git helpers (auto-approvable, like the file tools) so the model doesn't need to guess flag syntax |
143+
| `git_commit` | Stage everything and commit — requires approval, like `write_file` |
137144

138145
**Safety model:**
139146
- `read_file`, `write_file`, `list_dir`, and `search_files` are genuinely confined to the chosen workspace folder — path-traversal attempts (`../../etc`, absolute paths elsewhere on disk) are rejected before anything runs.
140-
- `run_command` is different: a shell command is opaque text that can reference any path on the system regardless of its working directory, so it isn't sandboxed the way the file tools are. As a safety net, commands matching destructive or system-level patterns — deleting outside the workspace, formatting a drive, shutting down the machine, registry deletion, `sudo`/`runas`, piping a remote script into a shell — are **rejected outright**, even if already approved. This blocklist catches the common catastrophic cases, not everything a shell can do — only approve a command you actually understand.
141-
- Every call (including ones the blocklist doesn't catch) shows an **Allow / Deny** card before it executes — nothing runs without an explicit click. Read-only tools (`read_file`, `list_dir`, `search_files`) can be marked "always allow this session" to cut down on repetitive approvals; `write_file` and `run_command` always require a fresh click, since they have real, potentially irreversible effects.
147+
- `run_command` and `run_code` are different: a shell command (or a script `run_code` hands to `python3`/`node`) is opaque text that can reference any path on the system regardless of its working directory, so neither is sandboxed the way the file tools are. As a safety net, commands (and `run_code`'s source text) matching destructive or system-level patterns — deleting outside the workspace, formatting a drive, shutting down the machine, registry deletion, `sudo`/`runas`, piping a remote script into a shell — are **rejected outright**, even if already approved. This blocklist catches the common catastrophic cases, not everything a shell or script can do — only approve a command or snippet you actually understand.
148+
- Every call (including ones the blocklist doesn't catch) shows an **Allow / Deny** card before it executes — nothing runs without an explicit click. Read-only tools (`read_file`, `list_dir`, `search_files`, `git_status`, `git_diff`, `git_log`) can be marked "always allow this session" to cut down on repetitive approvals; `write_file`, `run_command`, `run_code`, and `git_commit` always require a fresh click, since they have real, potentially irreversible effects.
142149
- A per-turn step limit (25 tool-result → model-continuation round trips) stops a model from looping indefinitely without producing a final answer.
143150
- The trust list for "always allow" is in-memory only — closing and reopening a chat resets it.
144151

‎app/src/agent-tools.test.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import * as fs from "node:fs";
33
import * as os from "node:os";
44
import * as path from "node:path";
5+
import { execSync } from "node:child_process";
56
import {
67
readFile,
78
writeFile,
89
listDir,
910
searchFiles,
1011
executeTool,
1112
runCommand,
13+
runCode,
1214
rollbackLastWrite,
1315
detectProjectScripts,
16+
gitStatus,
17+
gitDiff,
18+
gitLog,
19+
gitCommit,
1420
} from "./agent-tools";
1521

1622
describe("agent-tools", () => {
@@ -195,6 +201,64 @@ describe("agent-tools", () => {
195201
});
196202
});
197203

204+
describe("run_code", () => {
205+
it("runs a JavaScript snippet via node", async () => {
206+
const output = await runCode(workspace, "javascript", "console.log('hi from js')");
207+
expect(output).toContain("Exit code: 0");
208+
expect(output).toContain("hi from js");
209+
});
210+
211+
it("runs a Python snippet via python3", async () => {
212+
const output = await runCode(workspace, "python", "print('hi from py')");
213+
expect(output).toContain("Exit code: 0");
214+
expect(output).toContain("hi from py");
215+
});
216+
217+
it("blocks code whose text matches the dangerous-command blocklist", async () => {
218+
await expect(runCode(workspace, "python", "import os\nos.system('sudo rm -rf /')")).rejects.toThrow(
219+
/blocked/
220+
);
221+
});
222+
223+
it("cleans up its temp file after running", async () => {
224+
const before = fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith("modelforge-code-"));
225+
await runCode(workspace, "javascript", "1+1");
226+
const after = fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith("modelforge-code-"));
227+
expect(after.length).toBe(before.length);
228+
});
229+
});
230+
231+
describe("git tools", () => {
232+
beforeEach(() => {
233+
execSync("git init -q", { cwd: workspace });
234+
execSync('git -c user.email=test@test.com -c user.name=Test config commit.gpgsign false', { cwd: workspace });
235+
fs.writeFileSync(path.join(workspace, "a.txt"), "hello");
236+
});
237+
238+
it("git_status reports an untracked file", async () => {
239+
const output = await gitStatus(workspace);
240+
expect(output).toContain("a.txt");
241+
});
242+
243+
it("git_diff shows staged changes when staged=true", async () => {
244+
execSync("git add a.txt", { cwd: workspace });
245+
const output = await gitDiff(workspace, true);
246+
expect(output).toContain("a.txt");
247+
});
248+
249+
it("git_commit stages and commits everything", async () => {
250+
const output = await gitCommit(workspace, "initial commit");
251+
expect(output).toContain("Exit code: 0");
252+
const log = await gitLog(workspace, 5);
253+
expect(log).toContain("initial commit");
254+
});
255+
256+
it("git_log returns nothing unusual with no commits yet", async () => {
257+
const output = await gitLog(workspace);
258+
expect(output).toContain("Exit code:");
259+
});
260+
});
261+
198262
describe("dangerous command blocking", () => {
199263
it.each([
200264
"rm -rf /",

‎app/src/agent-tools.ts‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import * as fs from "node:fs";
2+
import * as os from "node:os";
23
import * as path from "node:path";
34
import { exec } from "node:child_process";
45
import { promisify } from "node:util";
6+
import { randomUUID } from "node:crypto";
57
import type { ToolDefinition } from "./providers/types";
68

79
const execAsync = promisify(exec);
@@ -66,6 +68,59 @@ export const AGENT_TOOLS: ToolDefinition[] = [
6668
required: ["command"],
6769
},
6870
},
71+
{
72+
name: "run_code",
73+
description:
74+
"Run a Python or JavaScript code snippet in the workspace and return its stdout/stderr/exit code. A convenience over run_command for multi-line code (no shell-quoting to worry about) — it is not a sandbox: the code runs with the same permissions as run_command and is subject to the same safety checks.",
75+
parameters: {
76+
type: "object",
77+
properties: {
78+
language: { type: "string", enum: ["python", "javascript"], description: "Which interpreter to run the code with." },
79+
code: { type: "string", description: "The full source code to execute." },
80+
cwd: { type: "string", description: 'Working directory, relative to the workspace root. Defaults to "."' },
81+
},
82+
required: ["language", "code"],
83+
},
84+
},
85+
{
86+
name: "git_status",
87+
description: "Show the working tree status (git status) for the workspace.",
88+
parameters: { type: "object", properties: {}, required: [] },
89+
},
90+
{
91+
name: "git_diff",
92+
description: "Show unstaged (or, if staged=true, staged) changes in the workspace as a unified diff.",
93+
parameters: {
94+
type: "object",
95+
properties: {
96+
staged: { type: "boolean", description: "Show staged changes (git diff --staged) instead of unstaged." },
97+
path: { type: "string", description: "Limit the diff to this file or directory, relative to the workspace root." },
98+
},
99+
required: [],
100+
},
101+
},
102+
{
103+
name: "git_log",
104+
description: "Show recent commit history for the workspace.",
105+
parameters: {
106+
type: "object",
107+
properties: {
108+
count: { type: "number", description: "How many commits to show. Defaults to 10." },
109+
},
110+
required: [],
111+
},
112+
},
113+
{
114+
name: "git_commit",
115+
description: "Stage all changes and create a commit in the workspace. Requires explicit approval, like write_file.",
116+
parameters: {
117+
type: "object",
118+
properties: {
119+
message: { type: "string", description: "The commit message." },
120+
},
121+
required: ["message"],
122+
},
123+
},
69124
];
70125

71126
const MAX_READ_CHARS = 100_000;
@@ -284,6 +339,54 @@ export async function runCommand(workspaceRoot: string, command: string, relativ
284339
}
285340
}
286341

342+
// run_code is a thin convenience wrapper over run_command for multi-line
343+
// snippets (avoids shell-quoting hell for real code) — it carries the exact
344+
// same risk and is checked against the exact same blocklist as run_command,
345+
// applied to the source text too since dangerous *content* (not just the
346+
// invocation) could otherwise slip past a check that only looks at the
347+
// command line.
348+
export async function runCode(
349+
workspaceRoot: string,
350+
language: "python" | "javascript",
351+
code: string,
352+
relativeCwd = "."
353+
): Promise<string> {
354+
const dangerReason = findDangerousCommandReason(code);
355+
if (dangerReason) throw new Error(dangerReason);
356+
357+
const ext = language === "python" ? "py" : "js";
358+
const tmpFile = path.join(os.tmpdir(), `modelforge-code-${randomUUID()}.${ext}`);
359+
fs.writeFileSync(tmpFile, code);
360+
try {
361+
const interpreter = language === "python" ? "python3" : "node";
362+
return await runCommand(workspaceRoot, `${interpreter} "${tmpFile}"`, relativeCwd);
363+
} finally {
364+
fs.rmSync(tmpFile, { force: true });
365+
}
366+
}
367+
368+
function gitCommand(workspaceRoot: string, args: string): Promise<string> {
369+
return runCommand(workspaceRoot, `git ${args}`, ".");
370+
}
371+
372+
export function gitStatus(workspaceRoot: string): Promise<string> {
373+
return gitCommand(workspaceRoot, "status");
374+
}
375+
376+
export function gitDiff(workspaceRoot: string, staged = false, relativePath?: string): Promise<string> {
377+
const target = relativePath ? ` -- "${relativePath}"` : "";
378+
return gitCommand(workspaceRoot, `diff${staged ? " --staged" : ""}${target}`);
379+
}
380+
381+
export function gitLog(workspaceRoot: string, count = 10): Promise<string> {
382+
return gitCommand(workspaceRoot, `log -n ${Math.max(1, Math.min(count, 100))} --oneline`);
383+
}
384+
385+
export async function gitCommit(workspaceRoot: string, message: string): Promise<string> {
386+
await gitCommand(workspaceRoot, "add -A");
387+
return gitCommand(workspaceRoot, `commit -m ${JSON.stringify(message)}`);
388+
}
389+
287390
export interface ProjectScripts {
288391
test?: string;
289392
lint?: string;
@@ -321,6 +424,18 @@ export async function executeTool(workspaceRoot: string, name: string, args: Rec
321424
return searchFiles(workspaceRoot, String(args.query ?? ""), args.path ? String(args.path) : ".");
322425
case "run_command":
323426
return runCommand(workspaceRoot, String(args.command ?? ""), args.cwd ? String(args.cwd) : ".");
427+
case "run_code": {
428+
const language = args.language === "python" ? "python" : "javascript";
429+
return runCode(workspaceRoot, language, String(args.code ?? ""), args.cwd ? String(args.cwd) : ".");
430+
}
431+
case "git_status":
432+
return gitStatus(workspaceRoot);
433+
case "git_diff":
434+
return gitDiff(workspaceRoot, args.staged === true, args.path ? String(args.path) : undefined);
435+
case "git_log":
436+
return gitLog(workspaceRoot, typeof args.count === "number" ? args.count : 10);
437+
case "git_commit":
438+
return gitCommit(workspaceRoot, String(args.message ?? ""));
324439
default:
325440
throw new Error(`Unknown tool: ${name}`);
326441
}

‎app/src/providers/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export interface ChatMessage {
3939
// Present on a "tool" role message: which call this is the result of.
4040
toolCallId?: string;
4141
toolName?: string;
42+
// User-set bookmark, purely a UI affordance — never sent to a provider.
43+
pinned?: boolean;
4244
}
4345

4446
export interface ChatChunk {

‎app/src/sessions-store.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ChatSession {
1616
systemPrompt?: string | null;
1717
agentMode?: boolean;
1818
agentWorkspace?: string | null;
19+
tags?: string[];
1920
createdAt: string;
2021
updatedAt: string;
2122
}
@@ -60,7 +61,10 @@ export function createSession(model: string | null, projectId: string | null = n
6061
export function updateSession(
6162
id: string,
6263
partial: Partial<
63-
Pick<ChatSession, "title" | "model" | "messages" | "params" | "projectId" | "systemPrompt" | "agentMode" | "agentWorkspace">
64+
Pick<
65+
ChatSession,
66+
"title" | "model" | "messages" | "params" | "projectId" | "systemPrompt" | "agentMode" | "agentWorkspace" | "tags"
67+
>
6468
>
6569
): ChatSession | null {
6670
const all = readAll();

0 commit comments

Comments
 (0)