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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "interference-agent",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "The open-source coding agent that lives in your terminal.",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
- const AUTH_FILE = path.join(AUTH_DIR, "auth.json");
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(AUTH_FILE, "utf-8");
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(AUTH_DIR, { recursive: true });
27
- await writeFile(AUTH_FILE, JSON.stringify(auth, null, 2));
28
- try { await chmod(AUTH_FILE, 0o600); } catch {}
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", () => {
@@ -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
- return path.join(
30
- process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
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
- export const SKILLS_DIR = path.join(
5
- process.env.HOME ?? process.env.USERPROFILE ?? "/tmp",
6
- ".interference",
7
- "skills",
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(SKILLS_DIR, { withFileTypes: true });
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(SKILLS_DIR, entry.name, "SKILL.md");
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(SKILLS_DIR, name, "SKILL.md");
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(SKILLS_DIR, name);
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.id = name;
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
- // Rebuild history from messages
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 === "user" || m.role === "assistant") {
506
- const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
507
- items.push({ id: nid++, role: m.role as "user" | "assistant", content });
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 session ${id.slice(0, 12)} (${loaded.meta.turnCount} turns)`, "success");
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={statusText}
662
+ statusLine=""
630
663
  turnCount={sessionRef.current.meta.turnCount}
631
664
  cost={formatCost(getTotalCost())}
632
665
  gitBranch={gitBranch}
@@ -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, 12)}
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
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
24
- return path.join(home, ".interference", "update-check.json");
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`.