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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/assets/screenshot.png +0 -0
- package/bun.lock +159 -0
- package/package.json +39 -0
- package/src/agent/compaction.ts +114 -0
- package/src/agent/loop.ts +94 -0
- package/src/agent/prompt.ts +89 -0
- package/src/agent/subagent.ts +64 -0
- package/src/auth.ts +50 -0
- package/src/cli-plain.ts +274 -0
- package/src/cli.ts +87 -0
- package/src/commands/index.ts +184 -0
- package/src/config-file.ts +109 -0
- package/src/config.ts +212 -0
- package/src/context.ts +96 -0
- package/src/cost.ts +54 -0
- package/src/git.ts +22 -0
- package/src/permissions.ts +135 -0
- package/src/provider.ts +58 -0
- package/src/session/__tests__/session.test.ts +180 -0
- package/src/session/snapshot.ts +122 -0
- package/src/session/store.ts +120 -0
- package/src/skills.ts +177 -0
- package/src/tools/__tests__/mutating.test.ts +324 -0
- package/src/tools/__tests__/question.test.ts +53 -0
- package/src/tools/__tests__/todowrite.test.ts +57 -0
- package/src/tools/__tests__/tools.test.ts +217 -0
- package/src/tools/_fs.ts +12 -0
- package/src/tools/bash.ts +104 -0
- package/src/tools/edit.ts +98 -0
- package/src/tools/glob.ts +40 -0
- package/src/tools/grep.ts +187 -0
- package/src/tools/index.ts +21 -0
- package/src/tools/ls.ts +70 -0
- package/src/tools/question.ts +81 -0
- package/src/tools/read.ts +61 -0
- package/src/tools/registry.ts +36 -0
- package/src/tools/task.ts +71 -0
- package/src/tools/todowrite.ts +84 -0
- package/src/tools/webfetch.ts +111 -0
- package/src/tools/write.ts +51 -0
- package/src/tui/App.tsx +738 -0
- package/src/tui/ConfirmDialog.tsx +46 -0
- package/src/tui/DiffView.tsx +88 -0
- package/src/tui/MarkdownText.tsx +63 -0
- package/src/tui/Message.tsx +26 -0
- package/src/tui/ModelPicker.tsx +44 -0
- package/src/tui/Panel.tsx +39 -0
- package/src/tui/ProviderPicker.tsx +111 -0
- package/src/tui/QuestionDialog.tsx +64 -0
- package/src/tui/SessionList.tsx +72 -0
- package/src/tui/SlashAutocomplete.tsx +33 -0
- package/src/tui/StatusFooter.tsx +71 -0
- package/src/tui/ThinkingPicker.tsx +57 -0
- package/src/tui/Toast.tsx +64 -0
- package/src/tui/TodoList.tsx +49 -0
- package/src/tui/ToolStep.tsx +184 -0
- package/src/tui/Welcome.tsx +87 -0
- package/src/tui/__tests__/tui-render.test.tsx +59 -0
- package/src/tui/theme.ts +16 -0
- package/src/tui/wordmark.ts +7 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createSession,
|
|
6
|
+
saveSession,
|
|
7
|
+
loadSession,
|
|
8
|
+
listSessions,
|
|
9
|
+
latestSession,
|
|
10
|
+
deleteSession,
|
|
11
|
+
cleanupSessions,
|
|
12
|
+
initStore,
|
|
13
|
+
} from "../store.ts";
|
|
14
|
+
import {
|
|
15
|
+
initSnapshot,
|
|
16
|
+
snapshotFile,
|
|
17
|
+
undo,
|
|
18
|
+
redo,
|
|
19
|
+
nextTurn,
|
|
20
|
+
canUndo,
|
|
21
|
+
canRedo,
|
|
22
|
+
cleanupSnapshots,
|
|
23
|
+
finalizeSnapshots,
|
|
24
|
+
} from "../snapshot.ts";
|
|
25
|
+
import type { ModelMessage } from "ai";
|
|
26
|
+
|
|
27
|
+
const TMP = path.join(process.cwd(), ".test-tmp-session");
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
await rm(TMP, { recursive: true, force: true });
|
|
31
|
+
await mkdir(TMP, { recursive: true });
|
|
32
|
+
await writeFile(path.join(TMP, "a.txt"), "original a\n");
|
|
33
|
+
await writeFile(path.join(TMP, "b.txt"), "original b\n");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await rm(TMP, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("session store", () => {
|
|
41
|
+
test("create and save session", async () => {
|
|
42
|
+
const session = createSession({ mode: "plan" });
|
|
43
|
+
expect(session.meta.id).toBeTruthy();
|
|
44
|
+
expect(session.messages).toEqual([]);
|
|
45
|
+
|
|
46
|
+
session.messages.push({ role: "user", content: "hello" } as ModelMessage);
|
|
47
|
+
await saveSession(session);
|
|
48
|
+
|
|
49
|
+
const loaded = await loadSession(session.meta.id);
|
|
50
|
+
expect(loaded).not.toBeNull();
|
|
51
|
+
expect(loaded!.messages.length).toBe(1);
|
|
52
|
+
expect(loaded!.meta.mode).toBe("plan");
|
|
53
|
+
|
|
54
|
+
await deleteSession(session.meta.id);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("load nonexistent session returns null", async () => {
|
|
58
|
+
const s = await loadSession("nonexistent-id");
|
|
59
|
+
expect(s).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("list sessions", async () => {
|
|
63
|
+
const a = createSession();
|
|
64
|
+
const b = createSession();
|
|
65
|
+
a.messages.push({ role: "user", content: "a" } as ModelMessage);
|
|
66
|
+
b.messages.push({ role: "user", content: "b" } as ModelMessage);
|
|
67
|
+
await saveSession(a);
|
|
68
|
+
await saveSession(b);
|
|
69
|
+
|
|
70
|
+
const list = await listSessions();
|
|
71
|
+
expect(list.length).toBeGreaterThanOrEqual(2);
|
|
72
|
+
|
|
73
|
+
await deleteSession(a.meta.id);
|
|
74
|
+
await deleteSession(b.meta.id);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("latest session", async () => {
|
|
78
|
+
const a = createSession();
|
|
79
|
+
await saveSession(a);
|
|
80
|
+
const latest = await latestSession();
|
|
81
|
+
expect(latest).not.toBeNull();
|
|
82
|
+
expect(latest!.meta.id).toBe(a.meta.id);
|
|
83
|
+
await deleteSession(a.meta.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("cleanup keeps N most recent", async () => {
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
const s = createSession();
|
|
89
|
+
await saveSession(s);
|
|
90
|
+
await Bun.sleep(10);
|
|
91
|
+
}
|
|
92
|
+
await cleanupSessions(2);
|
|
93
|
+
const list = await listSessions();
|
|
94
|
+
expect(list.length).toBeLessThanOrEqual(2);
|
|
95
|
+
for (const m of list) await deleteSession(m.id);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("snapshot undo/redo", () => {
|
|
100
|
+
test("snapshot and undo single file", async () => {
|
|
101
|
+
initSnapshot("test-s1");
|
|
102
|
+
await snapshotFile(path.join(TMP, "a.txt"));
|
|
103
|
+
nextTurn();
|
|
104
|
+
|
|
105
|
+
await writeFile(path.join(TMP, "a.txt"), "modified\n");
|
|
106
|
+
await finalizeSnapshots();
|
|
107
|
+
let content = await Bun.file(path.join(TMP, "a.txt")).text();
|
|
108
|
+
expect(content).toBe("modified\n");
|
|
109
|
+
|
|
110
|
+
const restored = await undo();
|
|
111
|
+
expect(restored).toContain(path.join(TMP, "a.txt"));
|
|
112
|
+
content = await Bun.file(path.join(TMP, "a.txt")).text();
|
|
113
|
+
expect(content).toBe("original a\n");
|
|
114
|
+
expect(canUndo()).toBe(false);
|
|
115
|
+
expect(canRedo()).toBe(true);
|
|
116
|
+
|
|
117
|
+
await cleanupSnapshots();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("snapshot and redo", async () => {
|
|
121
|
+
initSnapshot("test-s2");
|
|
122
|
+
await writeFile(path.join(TMP, "b.txt"), "original b\n");
|
|
123
|
+
|
|
124
|
+
await snapshotFile(path.join(TMP, "b.txt"));
|
|
125
|
+
nextTurn();
|
|
126
|
+
await writeFile(path.join(TMP, "b.txt"), "changed\n");
|
|
127
|
+
await finalizeSnapshots();
|
|
128
|
+
|
|
129
|
+
await undo();
|
|
130
|
+
await redo();
|
|
131
|
+
const content = await Bun.file(path.join(TMP, "b.txt")).text();
|
|
132
|
+
expect(content).toBe("changed\n");
|
|
133
|
+
expect(canRedo()).toBe(false);
|
|
134
|
+
|
|
135
|
+
await cleanupSnapshots();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("multiple snapshots in one turn", async () => {
|
|
139
|
+
initSnapshot("test-s3");
|
|
140
|
+
await writeFile(path.join(TMP, "a.txt"), "v1\n");
|
|
141
|
+
await writeFile(path.join(TMP, "b.txt"), "v1\n");
|
|
142
|
+
|
|
143
|
+
await snapshotFile(path.join(TMP, "a.txt"));
|
|
144
|
+
await snapshotFile(path.join(TMP, "b.txt"));
|
|
145
|
+
nextTurn();
|
|
146
|
+
|
|
147
|
+
await writeFile(path.join(TMP, "a.txt"), "v2\n");
|
|
148
|
+
await writeFile(path.join(TMP, "b.txt"), "v2\n");
|
|
149
|
+
await finalizeSnapshots();
|
|
150
|
+
|
|
151
|
+
const restored = await undo();
|
|
152
|
+
expect(restored.length).toBe(2);
|
|
153
|
+
|
|
154
|
+
expect(await Bun.file(path.join(TMP, "a.txt")).text()).toBe("v1\n");
|
|
155
|
+
expect(await Bun.file(path.join(TMP, "b.txt")).text()).toBe("v1\n");
|
|
156
|
+
|
|
157
|
+
await cleanupSnapshots();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("undo with no snapshots returns empty", async () => {
|
|
161
|
+
initSnapshot("test-s4");
|
|
162
|
+
const restored = await undo();
|
|
163
|
+
expect(restored).toEqual([]);
|
|
164
|
+
expect(canUndo()).toBe(false);
|
|
165
|
+
await cleanupSnapshots();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("redo with no redo stack returns empty", async () => {
|
|
169
|
+
initSnapshot("test-s5");
|
|
170
|
+
const restored = await redo();
|
|
171
|
+
expect(restored).toEqual([]);
|
|
172
|
+
await cleanupSnapshots();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("snapshot without initSession is no-op", async () => {
|
|
176
|
+
initSnapshot("");
|
|
177
|
+
await snapshotFile(path.join(TMP, "a.txt"));
|
|
178
|
+
expect(canUndo()).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, copyFile, rm } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { snapshotsDir } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
interface SnapshotEntry {
|
|
6
|
+
turn: number;
|
|
7
|
+
file: string;
|
|
8
|
+
beforePath: string;
|
|
9
|
+
afterPath: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface UndoEntry {
|
|
13
|
+
turn: number;
|
|
14
|
+
entries: SnapshotEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let undoStack: UndoEntry[] = [];
|
|
18
|
+
let redoStack: UndoEntry[] = [];
|
|
19
|
+
let sessionId = "";
|
|
20
|
+
let turnCounter = 0;
|
|
21
|
+
|
|
22
|
+
export function initSnapshot(sid: string) {
|
|
23
|
+
sessionId = sid;
|
|
24
|
+
undoStack = [];
|
|
25
|
+
redoStack = [];
|
|
26
|
+
turnCounter = 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function nextTurn() {
|
|
30
|
+
turnCounter++;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function snapshotFile(filePath: string): Promise<void> {
|
|
34
|
+
if (!sessionId) return;
|
|
35
|
+
const dir = path.join(snapshotsDir(sessionId), String(turnCounter));
|
|
36
|
+
await mkdir(dir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
const abs = path.resolve(process.cwd(), filePath);
|
|
39
|
+
const base = path.basename(abs);
|
|
40
|
+
const beforePath = path.join(dir, `before_${base}`);
|
|
41
|
+
const afterPath = path.join(dir, `after_${base}`);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await copyFile(abs, beforePath);
|
|
45
|
+
} catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const entry: SnapshotEntry = { turn: turnCounter, file: filePath, beforePath, afterPath };
|
|
50
|
+
|
|
51
|
+
if (undoStack.length > 0 && undoStack[undoStack.length - 1]!.turn === turnCounter) {
|
|
52
|
+
undoStack[undoStack.length - 1]!.entries.push(entry);
|
|
53
|
+
} else {
|
|
54
|
+
undoStack.push({ turn: turnCounter, entries: [entry] });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (undoStack.length > 50) undoStack.shift();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function finalizeSnapshots(): Promise<void> {
|
|
61
|
+
if (!sessionId || undoStack.length === 0) return;
|
|
62
|
+
const entry = undoStack[undoStack.length - 1]!;
|
|
63
|
+
for (const e of entry.entries) {
|
|
64
|
+
try {
|
|
65
|
+
await copyFile(path.resolve(process.cwd(), e.file), e.afterPath);
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function undo(): Promise<string[]> {
|
|
71
|
+
if (undoStack.length === 0) return [];
|
|
72
|
+
|
|
73
|
+
const entry = undoStack.pop()!;
|
|
74
|
+
redoStack.push(entry);
|
|
75
|
+
const restored: string[] = [];
|
|
76
|
+
|
|
77
|
+
for (const e of entry.entries) {
|
|
78
|
+
try {
|
|
79
|
+
await copyFile(e.beforePath, path.resolve(process.cwd(), e.file));
|
|
80
|
+
restored.push(e.file);
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
turnCounter = Math.max(1, turnCounter - 1);
|
|
85
|
+
return restored;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function redo(): Promise<string[]> {
|
|
89
|
+
if (redoStack.length === 0) return [];
|
|
90
|
+
|
|
91
|
+
const entry = redoStack.pop()!;
|
|
92
|
+
undoStack.push(entry);
|
|
93
|
+
const restored: string[] = [];
|
|
94
|
+
|
|
95
|
+
for (const e of entry.entries) {
|
|
96
|
+
try {
|
|
97
|
+
await copyFile(e.afterPath, path.resolve(process.cwd(), e.file));
|
|
98
|
+
restored.push(e.file);
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
turnCounter++;
|
|
103
|
+
return restored;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function canUndo(): boolean {
|
|
107
|
+
return undoStack.length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function canRedo(): boolean {
|
|
111
|
+
return redoStack.length > 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function undoRedoState() {
|
|
115
|
+
return { canUndo: canUndo(), canRedo: canRedo() };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function cleanupSnapshots(): Promise<void> {
|
|
119
|
+
try {
|
|
120
|
+
await rm(snapshotsDir(sessionId), { recursive: true, force: true });
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { ModelMessage } from "ai";
|
|
5
|
+
import type { Todo } from "../tools/todowrite.ts";
|
|
6
|
+
|
|
7
|
+
export interface SessionMeta {
|
|
8
|
+
id: string;
|
|
9
|
+
workspace: string;
|
|
10
|
+
startedAt: string;
|
|
11
|
+
updatedAt: string;
|
|
12
|
+
turnCount: number;
|
|
13
|
+
mode: string;
|
|
14
|
+
provider: string;
|
|
15
|
+
model: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Session {
|
|
19
|
+
meta: SessionMeta;
|
|
20
|
+
messages: ModelMessage[];
|
|
21
|
+
todos?: Todo[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function projectDir(): string {
|
|
25
|
+
const hash = createHash("sha256")
|
|
26
|
+
.update(process.cwd())
|
|
27
|
+
.digest("hex")
|
|
28
|
+
.slice(0, 12);
|
|
29
|
+
return path.join(
|
|
30
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
|
31
|
+
".interference",
|
|
32
|
+
hash,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sessionsDir(): string {
|
|
37
|
+
return path.join(projectDir(), "sessions");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function snapshotsDir(sessionId: string): string {
|
|
41
|
+
return path.join(projectDir(), "snapshots", sessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function initStore(): Promise<void> {
|
|
45
|
+
await mkdir(sessionsDir(), { recursive: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function saveSession(session: Session): Promise<void> {
|
|
49
|
+
await initStore();
|
|
50
|
+
session.meta.updatedAt = new Date().toISOString();
|
|
51
|
+
const file = path.join(sessionsDir(), `${session.meta.id}.json`);
|
|
52
|
+
await writeFile(file, JSON.stringify(session, null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function loadSession(id: string): Promise<Session | null> {
|
|
56
|
+
const file = path.join(sessionsDir(), `${id}.json`);
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(file, "utf-8");
|
|
59
|
+
return JSON.parse(raw) as Session;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function listSessions(): Promise<SessionMeta[]> {
|
|
66
|
+
try {
|
|
67
|
+
const entries = await readdir(sessionsDir());
|
|
68
|
+
const metas: SessionMeta[] = [];
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (!entry.endsWith(".json")) continue;
|
|
71
|
+
const session = await loadSession(entry.replace(".json", ""));
|
|
72
|
+
if (session) metas.push(session.meta);
|
|
73
|
+
}
|
|
74
|
+
return metas.sort(
|
|
75
|
+
(a, b) =>
|
|
76
|
+
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function latestSession(): Promise<Session | null> {
|
|
84
|
+
const metas = await listSessions();
|
|
85
|
+
if (metas.length === 0) return null;
|
|
86
|
+
return loadSession(metas[0]!.id);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function deleteSession(id: string): Promise<void> {
|
|
90
|
+
const file = path.join(sessionsDir(), `${id}.json`);
|
|
91
|
+
try { await rm(file); } catch {}
|
|
92
|
+
try { await rm(snapshotsDir(id), { recursive: true }); } catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function cleanupSessions(keep: number = 10): Promise<void> {
|
|
96
|
+
const metas = await listSessions();
|
|
97
|
+
for (const meta of metas.slice(keep)) {
|
|
98
|
+
await deleteSession(meta.id);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createSession(metaOverrides?: Partial<SessionMeta>): Session {
|
|
103
|
+
const id =
|
|
104
|
+
metaOverrides?.id ??
|
|
105
|
+
`${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
return {
|
|
108
|
+
meta: {
|
|
109
|
+
id,
|
|
110
|
+
workspace: process.cwd(),
|
|
111
|
+
startedAt: metaOverrides?.startedAt ?? now,
|
|
112
|
+
updatedAt: now,
|
|
113
|
+
turnCount: 0,
|
|
114
|
+
mode: metaOverrides?.mode ?? "plan",
|
|
115
|
+
provider: metaOverrides?.provider ?? "unknown",
|
|
116
|
+
model: metaOverrides?.model ?? "unknown",
|
|
117
|
+
},
|
|
118
|
+
messages: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { readFile, readdir, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SKILLS_DIR = path.join(
|
|
5
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
|
6
|
+
".interference",
|
|
7
|
+
"skills",
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const BUNDLED_SKILLS: Record<string, string> = {
|
|
11
|
+
"agents-setup": `---
|
|
12
|
+
name: agents-setup
|
|
13
|
+
description: Generate or update AGENTS.md for a project. Use when the user asks /init,
|
|
14
|
+
"setup agent", "initialize AGENTS.md", "configure project for agents", or wants to
|
|
15
|
+
bootstrap a project for AI coding agents.
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# agents-setup — Project bootstrap for AI agents
|
|
19
|
+
|
|
20
|
+
Generate an AGENTS.md file (the cross-tool standard). This file is the source of truth for any AI agent working on this project.
|
|
21
|
+
|
|
22
|
+
## Structure of AGENTS.md
|
|
23
|
+
|
|
24
|
+
- §1 Overview: name, description, stack, references
|
|
25
|
+
- §2 Agent skills: trigger → skill mapping table
|
|
26
|
+
- §3 Project-specific skills: .agents/skills/<name>/SKILL.md
|
|
27
|
+
- §4 Decision log: .agents/decisions/ (on-demand only)
|
|
28
|
+
- §5 Project memory: .agents/memory/ (living facts)
|
|
29
|
+
- §6 Non-negotiable rules: align to requirements, propagate fixes, completeness pass
|
|
30
|
+
- §7 State snapshot
|
|
31
|
+
- §8 What NOT to do`,
|
|
32
|
+
|
|
33
|
+
"iterations-planner": `---
|
|
34
|
+
name: iterations-planner
|
|
35
|
+
description: Organize a backlog of features/fixes into local iteration folders. Use when
|
|
36
|
+
the user provides a client brief with multiple features, asks to "plan iterations",
|
|
37
|
+
"/iterations", "organize backlog", or reports a bug with "/fix", "correggi", "regressione".
|
|
38
|
+
Creates iterazioni/NN-name/ folders with task.md + plan.md.
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
# iterations-planner — Organize project backlog into iterations
|
|
42
|
+
|
|
43
|
+
Transform a client brief into a structured, trackable backlog — local to the developer, not committed.
|
|
44
|
+
|
|
45
|
+
## Output structure
|
|
46
|
+
|
|
47
|
+
iterazioni/NN-name/task.md + plan.md (gitignored)
|
|
48
|
+
fix/NN-problem/bug.md + fix.md (gitignored)
|
|
49
|
+
|
|
50
|
+
## task.md: status, verbatim brief, objective, atomic tasks, files, deps, DoD
|
|
51
|
+
## plan.md: decisions table, concrete steps, files touched, validation
|
|
52
|
+
## Ordering: foundation first, high-impact next, cosmetic last, bugs by severity`,
|
|
53
|
+
|
|
54
|
+
"interference-tool": `---
|
|
55
|
+
name: interference-tool
|
|
56
|
+
description: Pattern for adding or modifying a tool in the interference agent. Use when
|
|
57
|
+
creating a new tool (read, write, edit, bash, etc.) or modifying an existing one.
|
|
58
|
+
Covers: zod schema, path containment, permission gates, output truncation, registry,
|
|
59
|
+
system prompt update, tests.
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
# Adding a tool to interference
|
|
63
|
+
|
|
64
|
+
All tools live in src/tools/ and follow the same skeleton.
|
|
65
|
+
|
|
66
|
+
## Skeleton
|
|
67
|
+
- import { tool } from "ai"; import { z } from "zod";
|
|
68
|
+
- import { resolveInWorkspace } from "./_fs.ts";
|
|
69
|
+
- import { decide, requestConfirmation } from "../permissions.ts";
|
|
70
|
+
- tool({ description, inputSchema: z.object({...}), execute: async (args) => {...} })
|
|
71
|
+
|
|
72
|
+
## Checklist
|
|
73
|
+
- Zod inputSchema with .describe() on every field
|
|
74
|
+
- resolveInWorkspace() on every file path
|
|
75
|
+
- Permission gate for mutating tools
|
|
76
|
+
- Output truncated (OUTPUT_CAP)
|
|
77
|
+
- Registered in tools/index.ts
|
|
78
|
+
- System prompt updated
|
|
79
|
+
- Tests in tools/__tests__/`,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface SkillInfo {
|
|
83
|
+
name: string;
|
|
84
|
+
description: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let registryCache: SkillInfo[] | null = null;
|
|
88
|
+
|
|
89
|
+
export async function loadSkillRegistry(): Promise<SkillInfo[]> {
|
|
90
|
+
if (registryCache) return registryCache;
|
|
91
|
+
const list: SkillInfo[] = [];
|
|
92
|
+
try {
|
|
93
|
+
const entries = await readdir(SKILLS_DIR, { withFileTypes: true });
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.isDirectory()) continue;
|
|
96
|
+
const skillFile = path.join(SKILLS_DIR, entry.name, "SKILL.md");
|
|
97
|
+
try {
|
|
98
|
+
const content = await readFile(skillFile, "utf-8");
|
|
99
|
+
const info = parseSkillFrontmatter(content);
|
|
100
|
+
if (info) list.push(info);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
registryCache = list;
|
|
105
|
+
return list;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getCachedRegistry(): SkillInfo[] {
|
|
109
|
+
return registryCache ?? [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseSkillFrontmatter(content: string): SkillInfo | null {
|
|
113
|
+
const match = content.match(/^---\nname:\s*(.+)\ndescription:\s*([\s\S]*?)\n---/);
|
|
114
|
+
if (!match) return null;
|
|
115
|
+
return {
|
|
116
|
+
name: (match[1] ?? "").trim(),
|
|
117
|
+
description: (match[2] ?? "").replace(/\n\s*/g, " ").trim(),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function loadSkillBody(name: string): Promise<string | null> {
|
|
122
|
+
const skillFile = path.join(SKILLS_DIR, name, "SKILL.md");
|
|
123
|
+
try {
|
|
124
|
+
return await readFile(skillFile, "utf-8");
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function matchSkills(userMessage: string, skills: SkillInfo[], max = 3): string[] {
|
|
131
|
+
const tokens = tokenize(userMessage);
|
|
132
|
+
if (tokens.length === 0) return [];
|
|
133
|
+
|
|
134
|
+
const scored = skills.map((s) => {
|
|
135
|
+
const descTokens = tokenize(s.description);
|
|
136
|
+
const matches = tokens.filter((t) => descTokens.includes(t));
|
|
137
|
+
return {
|
|
138
|
+
name: s.name,
|
|
139
|
+
score: matches.length,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return scored
|
|
144
|
+
.filter((s) => s.score >= 2)
|
|
145
|
+
.sort((a, b) => b.score - a.score)
|
|
146
|
+
.slice(0, max)
|
|
147
|
+
.map((s) => s.name);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function tokenize(text: string): string[] {
|
|
151
|
+
return text
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.replace(/[^a-z0-9\s/_-]/g, " ")
|
|
154
|
+
.split(/\s+/)
|
|
155
|
+
.filter((t) => t.length > 2 && !STOPWORDS.has(t));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const STOPWORDS = new Set([
|
|
159
|
+
"the", "and", "for", "use", "when", "this", "that", "with", "from",
|
|
160
|
+
"your", "have", "has", "are", "was", "will", "can", "not", "but",
|
|
161
|
+
"all", "any", "its", "you", "how", "what", "where", "which", "who",
|
|
162
|
+
"into", "just", "also", "very", "much", "some", "than", "then",
|
|
163
|
+
"does", "more", "most", "such", "each", "over", "only", "per",
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
export async function bootstrapSkills(): Promise<void> {
|
|
167
|
+
for (const [name, content] of Object.entries(BUNDLED_SKILLS)) {
|
|
168
|
+
const dir = path.join(SKILLS_DIR, name);
|
|
169
|
+
try { await mkdir(dir, { recursive: true }); } catch {}
|
|
170
|
+
const fp = path.join(dir, "SKILL.md");
|
|
171
|
+
try {
|
|
172
|
+
const existing = await readFile(fp, "utf-8");
|
|
173
|
+
if (existing.trim() === content.trim()) continue;
|
|
174
|
+
} catch {}
|
|
175
|
+
await writeFile(fp, content);
|
|
176
|
+
}
|
|
177
|
+
}
|