interference-agent 0.2.2 → 0.2.3
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/package.json +1 -1
- package/src/auth.ts +8 -10
- package/src/paths.ts +25 -0
- package/src/session/__tests__/session.test.ts +9 -0
- package/src/session/store.ts +4 -5
- package/src/skills.ts +9 -9
- package/src/tui/App.tsx +40 -7
- package/src/tui/SessionList.tsx +1 -2
- package/src/version.ts +3 -2
package/package.json
CHANGED
package/src/auth.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
1
|
import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
const AUTH_DIR = path.join(
|
|
5
|
-
process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
|
|
6
|
-
".interference",
|
|
7
|
-
);
|
|
3
|
+
import { interferenceDir } from "./paths.ts";
|
|
8
4
|
|
|
9
5
|
interface ProviderAuth {
|
|
10
6
|
label: string;
|
|
11
7
|
envKey: string;
|
|
12
8
|
}
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
// Risolti a runtime (non all'import) così INTERFERENCE_HOME isola i test.
|
|
11
|
+
const authDir = (): string => interferenceDir();
|
|
12
|
+
const authFile = (): string => path.join(authDir(), "auth.json");
|
|
15
13
|
|
|
16
14
|
export async function loadAuth(): Promise<Record<string, string>> {
|
|
17
15
|
try {
|
|
18
|
-
const raw = await readFile(
|
|
16
|
+
const raw = await readFile(authFile(), "utf-8");
|
|
19
17
|
return JSON.parse(raw) as Record<string, string>;
|
|
20
18
|
} catch {
|
|
21
19
|
return {};
|
|
@@ -23,9 +21,9 @@ export async function loadAuth(): Promise<Record<string, string>> {
|
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
export async function saveAuth(auth: Record<string, string>): Promise<void> {
|
|
26
|
-
await mkdir(
|
|
27
|
-
await writeFile(
|
|
28
|
-
try { await chmod(
|
|
24
|
+
await mkdir(authDir(), { recursive: true });
|
|
25
|
+
await writeFile(authFile(), JSON.stringify(auth, null, 2));
|
|
26
|
+
try { await chmod(authFile(), 0o600); } catch {}
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
export function applyAuthToEnv(auth: Record<string, string>, providers: Record<string, ProviderAuth>): void {
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Home base dei dati di interference (`~/.interference`).
|
|
5
|
+
*
|
|
6
|
+
* `INTERFERENCE_HOME` ridireziona l'intero store dell'app: usato dai test per
|
|
7
|
+
* isolarsi dalla directory reale dell'utente, così nessun test possa scrivere
|
|
8
|
+
* o cancellare dati veri (sessioni, snapshot, credenziali, skill, cache update).
|
|
9
|
+
*
|
|
10
|
+
* Tutti i consumatori di `~/.interference` DEVONO passare da qui — non
|
|
11
|
+
* ricalcolare la home a mano (vedi store/skills/auth/version).
|
|
12
|
+
*/
|
|
13
|
+
export function interferenceHome(): string {
|
|
14
|
+
return (
|
|
15
|
+
process.env.INTERFERENCE_HOME ??
|
|
16
|
+
process.env.HOME ??
|
|
17
|
+
process.env.USERPROFILE ??
|
|
18
|
+
"/tmp"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Path dentro `~/.interference` (o la home reindirizzata da INTERFERENCE_HOME). */
|
|
23
|
+
export function interferenceDir(...segments: string[]): string {
|
|
24
|
+
return path.join(interferenceHome(), ".interference", ...segments);
|
|
25
|
+
}
|
|
@@ -26,7 +26,14 @@ import type { ModelMessage } from "ai";
|
|
|
26
26
|
|
|
27
27
|
const TMP = path.join(process.cwd(), ".test-tmp-session");
|
|
28
28
|
|
|
29
|
+
// ISOLAMENTO STORE: reindirizza ~/.interference verso TMP così i test su
|
|
30
|
+
// saveSession/cleanupSessions/deleteSession NON tocchino mai le sessioni reali
|
|
31
|
+
// dell'utente. Senza questo override, `cleanupSessions(2)` cancellerebbe le
|
|
32
|
+
// chat vere salvate in ~/.interference/<hash>/sessions.
|
|
33
|
+
const PREV_HOME = process.env.INTERFERENCE_HOME;
|
|
34
|
+
|
|
29
35
|
beforeAll(async () => {
|
|
36
|
+
process.env.INTERFERENCE_HOME = TMP;
|
|
30
37
|
await rm(TMP, { recursive: true, force: true });
|
|
31
38
|
await mkdir(TMP, { recursive: true });
|
|
32
39
|
await writeFile(path.join(TMP, "a.txt"), "original a\n");
|
|
@@ -35,6 +42,8 @@ beforeAll(async () => {
|
|
|
35
42
|
|
|
36
43
|
afterAll(async () => {
|
|
37
44
|
await rm(TMP, { recursive: true, force: true });
|
|
45
|
+
if (PREV_HOME === undefined) delete process.env.INTERFERENCE_HOME;
|
|
46
|
+
else process.env.INTERFERENCE_HOME = PREV_HOME;
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
describe("session store", () => {
|
package/src/session/store.ts
CHANGED
|
@@ -3,9 +3,11 @@ import * as path from "node:path";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import type { ModelMessage } from "ai";
|
|
5
5
|
import type { Todo } from "../tools/todowrite.ts";
|
|
6
|
+
import { interferenceDir } from "../paths.ts";
|
|
6
7
|
|
|
7
8
|
export interface SessionMeta {
|
|
8
9
|
id: string;
|
|
10
|
+
title?: string; // nome leggibile (auto dal primo messaggio o via /rename)
|
|
9
11
|
workspace: string;
|
|
10
12
|
startedAt: string;
|
|
11
13
|
updatedAt: string;
|
|
@@ -26,11 +28,8 @@ function projectDir(): string {
|
|
|
26
28
|
.update(process.cwd())
|
|
27
29
|
.digest("hex")
|
|
28
30
|
.slice(0, 12);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
".interference",
|
|
32
|
-
hash,
|
|
33
|
-
);
|
|
31
|
+
// Home reindirizzabile via INTERFERENCE_HOME (isolamento test) — vedi paths.ts.
|
|
32
|
+
return interferenceDir(hash);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
function sessionsDir(): string {
|
package/src/skills.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFile, readdir, mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { interferenceDir } from "./paths.ts";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
);
|
|
5
|
+
/** Directory delle skill (`~/.interference/skills`, reindirizzabile in test). */
|
|
6
|
+
export function skillsDir(): string {
|
|
7
|
+
return interferenceDir("skills");
|
|
8
|
+
}
|
|
9
9
|
|
|
10
10
|
const BUNDLED_SKILLS: Record<string, string> = {
|
|
11
11
|
"agents-setup": `---
|
|
@@ -90,10 +90,10 @@ export async function loadSkillRegistry(): Promise<SkillInfo[]> {
|
|
|
90
90
|
if (registryCache) return registryCache;
|
|
91
91
|
const list: SkillInfo[] = [];
|
|
92
92
|
try {
|
|
93
|
-
const entries = await readdir(
|
|
93
|
+
const entries = await readdir(skillsDir(), { withFileTypes: true });
|
|
94
94
|
for (const entry of entries) {
|
|
95
95
|
if (!entry.isDirectory()) continue;
|
|
96
|
-
const skillFile = path.join(
|
|
96
|
+
const skillFile = path.join(skillsDir(), entry.name, "SKILL.md");
|
|
97
97
|
try {
|
|
98
98
|
const content = await readFile(skillFile, "utf-8");
|
|
99
99
|
const info = parseSkillFrontmatter(content);
|
|
@@ -119,7 +119,7 @@ function parseSkillFrontmatter(content: string): SkillInfo | null {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export async function loadSkillBody(name: string): Promise<string | null> {
|
|
122
|
-
const skillFile = path.join(
|
|
122
|
+
const skillFile = path.join(skillsDir(), name, "SKILL.md");
|
|
123
123
|
try {
|
|
124
124
|
return await readFile(skillFile, "utf-8");
|
|
125
125
|
} catch {
|
|
@@ -165,7 +165,7 @@ const STOPWORDS = new Set([
|
|
|
165
165
|
|
|
166
166
|
export async function bootstrapSkills(): Promise<void> {
|
|
167
167
|
for (const [name, content] of Object.entries(BUNDLED_SKILLS)) {
|
|
168
|
-
const dir = path.join(
|
|
168
|
+
const dir = path.join(skillsDir(), name);
|
|
169
169
|
try { await mkdir(dir, { recursive: true }); } catch {}
|
|
170
170
|
const fp = path.join(dir, "SKILL.md");
|
|
171
171
|
try {
|
package/src/tui/App.tsx
CHANGED
|
@@ -192,6 +192,12 @@ export default function App({ session }: { session: Session }) {
|
|
|
192
192
|
|
|
193
193
|
const nextId = (): number => Date.now() + Math.random();
|
|
194
194
|
|
|
195
|
+
// Titolo leggibile dal primo messaggio (collassa spazi, cap ~40 char).
|
|
196
|
+
const deriveTitle = (text: string): string => {
|
|
197
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
198
|
+
return t.length > 40 ? t.slice(0, 40).trimEnd() + "…" : t;
|
|
199
|
+
};
|
|
200
|
+
|
|
195
201
|
async function doCompact() {
|
|
196
202
|
const pct = getUsagePercent(messagesRef.current);
|
|
197
203
|
if (!shouldCompact(messagesRef.current)) {
|
|
@@ -226,6 +232,10 @@ export default function App({ session }: { session: Session }) {
|
|
|
226
232
|
setHistory((h) => [...h, userMsg]);
|
|
227
233
|
|
|
228
234
|
nextTurn();
|
|
235
|
+
// Auto-title alla prima interazione (se non già rinominata dall'utente).
|
|
236
|
+
if (!sessionRef.current.meta.title) {
|
|
237
|
+
sessionRef.current.meta.title = deriveTitle(userText);
|
|
238
|
+
}
|
|
229
239
|
messagesRef.current.push({ role: "user", content: userText });
|
|
230
240
|
aborterRef.current = new AbortController();
|
|
231
241
|
let acc = "";
|
|
@@ -455,7 +465,7 @@ ${args ? `Additional context: ${args}` : ""}`;
|
|
|
455
465
|
return "";
|
|
456
466
|
},
|
|
457
467
|
doRename: async (name) => {
|
|
458
|
-
sessionRef.current.meta.
|
|
468
|
+
sessionRef.current.meta.title = name;
|
|
459
469
|
await saveSession(sessionRef.current);
|
|
460
470
|
return `Session renamed to '${name}'.`;
|
|
461
471
|
},
|
|
@@ -498,20 +508,36 @@ ${args ? `Additional context: ${args}` : ""}`;
|
|
|
498
508
|
messagesRef.current = loaded.messages;
|
|
499
509
|
sessionRef.current = loaded;
|
|
500
510
|
setTodos(loaded.todos ?? []);
|
|
501
|
-
//
|
|
511
|
+
// Ricostruisci la history dai messaggi salvati. Il content può essere
|
|
512
|
+
// una stringa o un ARRAY di parti ({type:"text"|"reasoning"|...}):
|
|
513
|
+
// estraiamo testo e reasoning invece di stringificare (era reso come JSON grezzo).
|
|
502
514
|
const items: HistoryItem[] = [];
|
|
503
515
|
let nid = Date.now();
|
|
504
516
|
for (const m of loaded.messages) {
|
|
505
|
-
if (m.role
|
|
506
|
-
|
|
507
|
-
|
|
517
|
+
if (m.role !== "user" && m.role !== "assistant") continue;
|
|
518
|
+
let text = "";
|
|
519
|
+
let reasoning = "";
|
|
520
|
+
if (typeof m.content === "string") {
|
|
521
|
+
text = m.content;
|
|
522
|
+
} else if (Array.isArray(m.content)) {
|
|
523
|
+
for (const p of m.content as Array<{ type?: string; text?: string }>) {
|
|
524
|
+
if (p?.type === "text" && p.text) text += p.text;
|
|
525
|
+
else if (p?.type === "reasoning" && p.text) reasoning += p.text;
|
|
526
|
+
}
|
|
508
527
|
}
|
|
528
|
+
if (!text && !reasoning) continue; // salta i messaggi solo-tool
|
|
529
|
+
items.push({
|
|
530
|
+
id: nid++,
|
|
531
|
+
role: m.role as "user" | "assistant",
|
|
532
|
+
content: text,
|
|
533
|
+
reasoning: reasoning || undefined,
|
|
534
|
+
});
|
|
509
535
|
}
|
|
510
536
|
setHistory(items);
|
|
511
537
|
setStreaming("");
|
|
512
538
|
setReasoning("");
|
|
513
539
|
setToolSteps([]);
|
|
514
|
-
addToast(`Resumed
|
|
540
|
+
addToast(`Resumed '${loaded.meta.title || id.slice(0, 12)}' (${loaded.meta.turnCount} turns)`, "success");
|
|
515
541
|
} else {
|
|
516
542
|
addToast(`Session ${id.slice(0, 12)} not found`, "error");
|
|
517
543
|
}
|
|
@@ -604,6 +630,13 @@ ${args ? `Additional context: ${args}` : ""}`;
|
|
|
604
630
|
<SlashAutocomplete filter={draft.slice(1)} selected={acIdx} />
|
|
605
631
|
)}
|
|
606
632
|
|
|
633
|
+
{/* Avviso comandi/stato: subito SOPRA l'input (stile opencode), non nel footer */}
|
|
634
|
+
{statusText && !confirmPreview && !questions && !showSessions && (
|
|
635
|
+
<Box paddingLeft={1}>
|
|
636
|
+
<Text dimColor>{statusText}</Text>
|
|
637
|
+
</Box>
|
|
638
|
+
)}
|
|
639
|
+
|
|
607
640
|
{!confirmPreview && !questions && !showSessions && (
|
|
608
641
|
<Box borderStyle="round" borderColor="gray" paddingX={1}>
|
|
609
642
|
<Text color="white" bold>{"› "}</Text>
|
|
@@ -626,7 +659,7 @@ ${args ? `Additional context: ${args}` : ""}`;
|
|
|
626
659
|
thinking={currentThinking()}
|
|
627
660
|
contextPct={messagesRef.current.length > 0 ? getUsagePercent(messagesRef.current) : 0}
|
|
628
661
|
busy={busy}
|
|
629
|
-
statusLine=
|
|
662
|
+
statusLine=""
|
|
630
663
|
turnCount={sessionRef.current.meta.turnCount}
|
|
631
664
|
cost={formatCost(getTotalCost())}
|
|
632
665
|
gitBranch={gitBranch}
|
package/src/tui/SessionList.tsx
CHANGED
|
@@ -59,8 +59,7 @@ export const SessionList: FC<Props> = ({ onSelect, onCancel }) => {
|
|
|
59
59
|
{sessions.slice(0, PAGE_SIZE).map((s, i) => (
|
|
60
60
|
<SelectRow
|
|
61
61
|
key={s.id}
|
|
62
|
-
label={s.id.slice(0,
|
|
63
|
-
meta={`${s.mode} · ${s.turnCount}t · ${s.updatedAt.slice(0, 10)}`}
|
|
62
|
+
label={s.title || `(untitled ${s.id.slice(0, 8)})`}
|
|
64
63
|
selected={i === idx}
|
|
65
64
|
/>
|
|
66
65
|
))}
|
package/src/version.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { interferenceDir } from "./paths.ts";
|
|
4
5
|
|
|
5
6
|
// Versione corrente letta dal package.json del pacchetto (sync, funziona anche
|
|
6
7
|
// installato globalmente: version.ts sta in <pkg>/src, package.json in <pkg>/).
|
|
@@ -20,8 +21,8 @@ const TTL = 24 * 60 * 60 * 1000; // 24h
|
|
|
20
21
|
const REGISTRY = `https://registry.npmjs.org/${PKG}/latest`;
|
|
21
22
|
|
|
22
23
|
function cachePath(): string {
|
|
23
|
-
|
|
24
|
-
return
|
|
24
|
+
// Reindirizzabile via INTERFERENCE_HOME (isolamento test) — vedi paths.ts.
|
|
25
|
+
return interferenceDir("update-check.json");
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// Confronto semver "x.y.z" (con eventuale prefisso v). true se `latest` > `current`.
|