interference-agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/assets/screenshot.png +0 -0
  4. package/bun.lock +159 -0
  5. package/package.json +39 -0
  6. package/src/agent/compaction.ts +114 -0
  7. package/src/agent/loop.ts +94 -0
  8. package/src/agent/prompt.ts +89 -0
  9. package/src/agent/subagent.ts +64 -0
  10. package/src/auth.ts +50 -0
  11. package/src/cli-plain.ts +274 -0
  12. package/src/cli.ts +87 -0
  13. package/src/commands/index.ts +184 -0
  14. package/src/config-file.ts +109 -0
  15. package/src/config.ts +212 -0
  16. package/src/context.ts +96 -0
  17. package/src/cost.ts +54 -0
  18. package/src/git.ts +22 -0
  19. package/src/permissions.ts +135 -0
  20. package/src/provider.ts +58 -0
  21. package/src/session/__tests__/session.test.ts +180 -0
  22. package/src/session/snapshot.ts +122 -0
  23. package/src/session/store.ts +120 -0
  24. package/src/skills.ts +177 -0
  25. package/src/tools/__tests__/mutating.test.ts +324 -0
  26. package/src/tools/__tests__/question.test.ts +53 -0
  27. package/src/tools/__tests__/todowrite.test.ts +57 -0
  28. package/src/tools/__tests__/tools.test.ts +217 -0
  29. package/src/tools/_fs.ts +12 -0
  30. package/src/tools/bash.ts +104 -0
  31. package/src/tools/edit.ts +98 -0
  32. package/src/tools/glob.ts +40 -0
  33. package/src/tools/grep.ts +187 -0
  34. package/src/tools/index.ts +21 -0
  35. package/src/tools/ls.ts +70 -0
  36. package/src/tools/question.ts +81 -0
  37. package/src/tools/read.ts +61 -0
  38. package/src/tools/registry.ts +36 -0
  39. package/src/tools/task.ts +71 -0
  40. package/src/tools/todowrite.ts +84 -0
  41. package/src/tools/webfetch.ts +111 -0
  42. package/src/tools/write.ts +51 -0
  43. package/src/tui/App.tsx +738 -0
  44. package/src/tui/ConfirmDialog.tsx +46 -0
  45. package/src/tui/DiffView.tsx +88 -0
  46. package/src/tui/MarkdownText.tsx +63 -0
  47. package/src/tui/Message.tsx +26 -0
  48. package/src/tui/ModelPicker.tsx +44 -0
  49. package/src/tui/Panel.tsx +39 -0
  50. package/src/tui/ProviderPicker.tsx +111 -0
  51. package/src/tui/QuestionDialog.tsx +64 -0
  52. package/src/tui/SessionList.tsx +72 -0
  53. package/src/tui/SlashAutocomplete.tsx +33 -0
  54. package/src/tui/StatusFooter.tsx +71 -0
  55. package/src/tui/ThinkingPicker.tsx +57 -0
  56. package/src/tui/Toast.tsx +64 -0
  57. package/src/tui/TodoList.tsx +49 -0
  58. package/src/tui/ToolStep.tsx +184 -0
  59. package/src/tui/Welcome.tsx +87 -0
  60. package/src/tui/__tests__/tui-render.test.tsx +59 -0
  61. package/src/tui/theme.ts +16 -0
  62. package/src/tui/wordmark.ts +7 -0
  63. package/tsconfig.json +23 -0
@@ -0,0 +1,71 @@
1
+ import { tool, type ModelMessage } from "ai";
2
+ import { z } from "zod";
3
+ import { getSubagent } from "../agent/subagent.ts";
4
+ import { runTurn } from "../agent/loop.ts";
5
+ import { currentMode } from "../config.ts";
6
+ import { decide } from "../permissions.ts";
7
+
8
+ export const task = tool({
9
+ description:
10
+ "Launch a new subagent to handle complex, multistep tasks autonomously. " +
11
+ "Use this when you need to delegate research or code exploration to an isolated agent. " +
12
+ "The subagent runs with its own context and returns a condensed result. " +
13
+ "Available types: 'explore' (read-only) for searching and understanding code, " +
14
+ "'general' (full access) for multi-step tasks.",
15
+ inputSchema: z.object({
16
+ description: z.string().describe("A short (3-5 words) description of the task"),
17
+ prompt: z.string().describe("The task for the subagent to perform"),
18
+ subagent_type: z.enum(["explore", "general"]).describe("The type of subagent to use"),
19
+ task_id: z
20
+ .string()
21
+ .optional()
22
+ .describe("Resume a previous subagent session by ID"),
23
+ }),
24
+ execute: async ({ description, prompt, subagent_type, task_id }) => {
25
+ const def = getSubagent(subagent_type);
26
+ if (!def) {
27
+ return `<task type="${subagent_type}" state="error">Unknown subagent type: ${subagent_type}</task>`;
28
+ }
29
+
30
+ const permissionCheck = decide("task", subagent_type);
31
+ if (permissionCheck === "deny") {
32
+ return `<task type="${subagent_type}" state="error">Task denied by permission policy</task>`;
33
+ }
34
+
35
+ const mode = currentMode();
36
+ if (mode === "plan" && subagent_type === "general") {
37
+ return `<task type="${subagent_type}" state="error">Cannot run 'general' subagent in Plan mode. Switch to Build mode first.</task>`;
38
+ }
39
+
40
+ const messages: ModelMessage[] = [
41
+ {
42
+ role: "user",
43
+ content: `Task: ${description}\n\n${prompt}`,
44
+ },
45
+ ];
46
+
47
+ try {
48
+ let output = "";
49
+ const chunks = runTurn(messages, undefined, mode, undefined, def.systemPrompt);
50
+
51
+ for await (const chunk of chunks) {
52
+ if (chunk.type === "text") {
53
+ output += chunk.text;
54
+ }
55
+ }
56
+
57
+ const id = task_id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
58
+ const result = output.slice(0, 4000);
59
+ const truncated = output.length > 4000 ? " [truncated]" : "";
60
+
61
+ return (
62
+ `<task id="${id}" type="${subagent_type}" state="completed">\n` +
63
+ `<task_result>${result}${truncated}</task_result>\n` +
64
+ `</task>`
65
+ );
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ return `<task type="${subagent_type}" state="error">${msg}</task>`;
69
+ }
70
+ },
71
+ });
@@ -0,0 +1,84 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+
4
+ export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
5
+ export type TodoPriority = "high" | "medium" | "low";
6
+
7
+ export interface Todo {
8
+ content: string;
9
+ status: TodoStatus;
10
+ priority?: TodoPriority;
11
+ }
12
+
13
+ // Stato osservabile della task list (RF-TOOL pattern: tool senza filesystem).
14
+ // La TUI/CLI si iscrive con subscribeTodos per renderizzare i progressi in tempo reale;
15
+ // la sessione lo persiste con getTodos()/setTodos().
16
+ let currentTodos: Todo[] = [];
17
+ const listeners = new Set<(todos: Todo[]) => void>();
18
+
19
+ export function getTodos(): Todo[] {
20
+ return currentTodos;
21
+ }
22
+
23
+ export function setTodos(todos: Todo[]): void {
24
+ currentTodos = todos;
25
+ for (const cb of listeners) cb(currentTodos);
26
+ }
27
+
28
+ export function resetTodos(): void {
29
+ setTodos([]);
30
+ }
31
+
32
+ export function subscribeTodos(cb: (todos: Todo[]) => void): () => void {
33
+ listeners.add(cb);
34
+ return () => listeners.delete(cb);
35
+ }
36
+
37
+ const MARKER: Record<TodoStatus, string> = {
38
+ pending: "[ ]",
39
+ in_progress: "[~]",
40
+ completed: "[x]",
41
+ cancelled: "[-]",
42
+ };
43
+
44
+ function render(todos: Todo[]): string {
45
+ if (todos.length === 0) return "Todo list cleared.";
46
+ const lines = todos.map((t) => {
47
+ const prio = t.priority && t.priority !== "medium" ? ` (${t.priority})` : "";
48
+ return `${MARKER[t.status]} ${t.content}${prio}`;
49
+ });
50
+ const done = todos.filter((t) => t.status === "completed").length;
51
+ return `Todos updated (${done}/${todos.length} done):\n${lines.join("\n")}`;
52
+ }
53
+
54
+ export const todowrite = tool({
55
+ description:
56
+ "Maintain a structured task list for the current work. Replaces the entire list on each call. " +
57
+ "Use it for non-trivial work (3+ distinct steps): create the plan up front, then update statuses " +
58
+ "in real time as you progress. Keep exactly ONE task 'in_progress' at a time; mark a task " +
59
+ "'completed' immediately when it is done before starting the next. Skip it for trivial or single-step tasks.",
60
+ inputSchema: z.object({
61
+ todos: z
62
+ .array(
63
+ z.object({
64
+ content: z.string().min(1).describe("Short imperative description of the task"),
65
+ status: z
66
+ .enum(["pending", "in_progress", "completed", "cancelled"])
67
+ .describe("Current status of the task"),
68
+ priority: z
69
+ .enum(["high", "medium", "low"])
70
+ .optional()
71
+ .describe("Optional priority of the task"),
72
+ }),
73
+ )
74
+ .describe("The full task list (replaces any previous list)"),
75
+ }),
76
+ execute: async ({ todos }) => {
77
+ const inProgress = todos.filter((t) => t.status === "in_progress").length;
78
+ if (inProgress > 1) {
79
+ return `Error: only one task may be 'in_progress' at a time (got ${inProgress}). Set the others to 'pending'.`;
80
+ }
81
+ setTodos(todos);
82
+ return render(todos);
83
+ },
84
+ });
@@ -0,0 +1,111 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+
4
+ const OUTPUT_CAP = 20_000;
5
+ const TIMEOUT_MS = 15_000;
6
+
7
+ export const webfetch = tool({
8
+ description:
9
+ "Fetch content from a URL and return it as text. " +
10
+ "Use this to read documentation, check API references, or research external resources. " +
11
+ "Returns the text content of the page (HTML stripped).",
12
+ inputSchema: z.object({
13
+ url: z.string().describe("The URL to fetch. Must be a fully-formed valid URL (http/https)."),
14
+ timeout: z
15
+ .number()
16
+ .int()
17
+ .min(1_000)
18
+ .max(60_000)
19
+ .optional()
20
+ .describe("Timeout in milliseconds (default: 15000)"),
21
+ }),
22
+ execute: async ({ url, timeout }) => {
23
+ const ms = timeout ?? TIMEOUT_MS;
24
+ const controller = new AbortController();
25
+ const timer = setTimeout(() => controller.abort(), ms);
26
+
27
+ try {
28
+ const response = await fetch(url, {
29
+ signal: controller.signal,
30
+ headers: {
31
+ "User-Agent": "interference/0.1 (+https://github.com/ricciviero/interference)",
32
+ Accept: "text/html, text/plain, */*",
33
+ },
34
+ });
35
+ clearTimeout(timer);
36
+
37
+ if (!response.ok) {
38
+ return `Error fetching URL: ${response.status} ${response.statusText}`;
39
+ }
40
+
41
+ const contentType = response.headers.get("content-type") ?? "";
42
+ const isHtml = contentType.includes("text/html") || contentType.includes("application/xhtml");
43
+
44
+ if (isHtml) {
45
+ const html = await response.text();
46
+ const text = stripHtml(html);
47
+ if (text.length === 0) return "Fetched page but no text content extracted.";
48
+ return truncate(text, OUTPUT_CAP, url);
49
+ }
50
+
51
+ const text = await response.text();
52
+ if (text.length === 0) return "Fetched empty response.";
53
+ return truncate(text, OUTPUT_CAP, url);
54
+ } catch (err) {
55
+ clearTimeout(timer);
56
+ if (err instanceof DOMException && err.name === "AbortError") {
57
+ return `Fetch timed out after ${ms}ms for URL: ${url}`;
58
+ }
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ return `Error fetching URL: ${msg}`;
61
+ }
62
+ },
63
+ });
64
+
65
+ function stripHtml(html: string): string {
66
+ let text = html
67
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
68
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
69
+ .replace(/<noscript[^>]*>[\s\S]*?<\/noscript>/gi, "")
70
+ .replace(/<head[^>]*>[\s\S]*?<\/head>/gi, "")
71
+ .replace(/<header[^>]*>[\s\S]*?<\/header>/gi, "")
72
+ .replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, "")
73
+ .replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, "")
74
+ .replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, "");
75
+
76
+ text = text
77
+ .replace(/<br\s*\/?>/gi, "\n")
78
+ .replace(/<\/p>/gi, "\n\n")
79
+ .replace(/<\/h[1-6]>/gi, "\n\n")
80
+ .replace(/<\/li>/gi, "\n")
81
+ .replace(/<\/div>/gi, "\n")
82
+ .replace(/<\/tr>/gi, "\n")
83
+ .replace(/<\/pre>/gi, "\n");
84
+
85
+ text = text.replace(/<[^>]+>/g, "");
86
+
87
+ text = text
88
+ .replace(/&amp;/g, "&")
89
+ .replace(/&lt;/g, "<")
90
+ .replace(/&gt;/g, ">")
91
+ .replace(/&quot;/g, '"')
92
+ .replace(/&#39;/g, "'")
93
+ .replace(/&nbsp;/g, " ")
94
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number.parseInt(n)))
95
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(Number.parseInt(n, 16)));
96
+
97
+ text = text
98
+ .split("\n")
99
+ .map((l) => l.trim())
100
+ .filter((l) => l.length > 0)
101
+ .join("\n");
102
+
103
+ return text.trim();
104
+ }
105
+
106
+ function truncate(text: string, cap: number, url: string): string {
107
+ if (text.length <= cap) return text;
108
+ const truncated = text.slice(0, cap);
109
+ const originalSize = `${(text.length / 1024).toFixed(1)}K`;
110
+ return `${truncated}\n\n… [truncated from ${originalSize}, original: ${url}]`;
111
+ }
@@ -0,0 +1,51 @@
1
+ import { tool } from "ai";
2
+ import { z } from "zod";
3
+ import { resolveInWorkspace } from "./_fs.ts";
4
+ import { decide, requestConfirmation } from "../permissions.ts";
5
+ import { snapshotFile } from "../session/snapshot.ts";
6
+ import { mkdir } from "node:fs/promises";
7
+ import * as path from "node:path";
8
+
9
+ const OUTPUT_CAP = 10_000;
10
+
11
+ export const write = tool({
12
+ description:
13
+ "Create or overwrite a file in the workspace. " +
14
+ "Use this to create new files from scratch. " +
15
+ "Prefer `edit` for small targeted changes to existing files.",
16
+ inputSchema: z.object({
17
+ path: z.string().describe("Path to the file, relative to workspace root"),
18
+ content: z.string().describe("The full content to write to the file"),
19
+ }),
20
+ execute: async ({ path: filePath, content }) => {
21
+ const abs = resolveInWorkspace(filePath);
22
+ const rel = path.relative(process.cwd(), abs) || abs;
23
+
24
+ const decision = decide("write", filePath);
25
+ if (decision === "deny") {
26
+ return `Error: write denied by policy for '${rel}'`;
27
+ }
28
+ if (decision === "ask") {
29
+ const preview = generateWritePreview(abs, content);
30
+ const allowed = await requestConfirmation("write", preview);
31
+ if (!allowed) {
32
+ return `Write refused by user for '${rel}'`;
33
+ }
34
+ }
35
+
36
+ await snapshotFile(filePath);
37
+ await mkdir(path.dirname(abs), { recursive: true });
38
+ await Bun.write(abs, content);
39
+
40
+ const truncated = content.length > OUTPUT_CAP ? " [content truncated]" : "";
41
+ return `Wrote ${rel} (${content.length} chars)${truncated}`;
42
+ },
43
+ });
44
+
45
+ function generateWritePreview(abs: string, content: string): string {
46
+ const name = path.relative(process.cwd(), abs) || abs;
47
+ const preview = content.length > 500 ? content.slice(0, 500) + "\n… [truncated preview]" : content;
48
+ const exists = Bun.file(abs);
49
+ const action = exists.size > 0 ? "Overwrite" : "Create";
50
+ return `[write] ${action}: ${name}\n---\n${preview}\n---`;
51
+ }