arisa 3.0.14 → 3.1.2

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/AGENTS.md CHANGED
@@ -54,6 +54,17 @@ Every CLI must support:
54
54
  - `node index.js --help`
55
55
  - `node index.js run --request-file <json>`
56
56
 
57
+ ### Tools that need daemons
58
+ Some tools need a persistent process, for example to keep a browser session alive or a local model warm.
59
+ Implement these tools with the shared daemon runtime instead of custom ad hoc process management:
60
+ - use `src/core/tools/daemon-runtime.js`
61
+ - keep runtime files under the tool state directory (`stateDir/<toolName>`)
62
+ - expose normal CLI behavior through `run --request-file`; callers should not manage daemon internals
63
+ - use the runtime for `daemon.pid`, `daemon.log`, `status.json`, and `commands/*.request|processing|result.json`
64
+ - keep one daemon owner per tool/session and avoid opening a second client over the same resource
65
+ - use `beforeStart` only for tool-specific cleanup such as stale browser locks, without deleting persistent session/model data
66
+ - keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
67
+
57
68
  ## Pipe behavior in V1
58
69
  V1 does not have a full automatic planner yet. The agent should:
59
70
  1. understand whether the needed pipe belongs to pre-reasoning normalization or post-reasoning tool chaining
package/README.md CHANGED
@@ -101,6 +101,16 @@ arisa install <source> # install a Pi package into Arisa's runtime
101
101
  arisa remove <source> # remove a Pi package from Arisa's runtime
102
102
  ```
103
103
 
104
+ Runtime model override (current process only):
105
+
106
+ ```bash
107
+ arisa --pi.model lmstudio/google/gemma-4-26b-a4b
108
+ ```
109
+
110
+ Notes:
111
+
112
+ - it only affects the current Arisa process and does not update `~/.arisa/state/config.json`
113
+
104
114
  ## Experimental features
105
115
 
106
116
  ### Pi Agent packages
@@ -124,6 +134,33 @@ On first run, Arisa will:
124
134
  6. validate that Pi Agent works
125
135
  7. only then start listening to Telegram
126
136
 
137
+ ### Non-interactive bootstrap (CLI overrides)
138
+
139
+ You can skip the interactive questions by providing `--telegram.token` and optional overrides:
140
+
141
+ ```bash
142
+ node src/index.js --telegram.token <token>
143
+ ```
144
+
145
+ With this mode, Arisa creates `~/.arisa/state/config.json` without prompts and applies these defaults when not provided:
146
+
147
+ - `pi.provider`: `openai-codex` when available, otherwise first provider from the current Pi provider list
148
+ - `pi.model`: first model after bootstrap sorting (currently prioritizes `openai-codex/gpt-5.4`)
149
+ - `telegram.maxChatIds`: `1`
150
+
151
+ Supported overrides:
152
+
153
+ ```bash
154
+ node src/index.js --telegram.token <token> --telegram.maxChatIds 3 --pi.provider openai-codex --pi.model gpt-5.4 --pi.apiKey <optional-provider-key>
155
+ ```
156
+
157
+ Notes:
158
+
159
+ - interactive bootstrap remains unchanged when no CLI overrides are provided
160
+ - `--bootstrap` can be combined with overrides to regenerate config non-interactively
161
+ - when `--pi.apiKey` is omitted and the provider supports OAuth, Arisa starts a temporary web page on `PORT` (default `10000`) where you can complete authentication from any browser
162
+ - unknown `--pi.provider` or `--pi.model` values are ignored and replaced by safe defaults
163
+
127
164
  Telegram bot tokens can be created with:
128
165
 
129
166
  - https://t.me/BotFather
@@ -0,0 +1,68 @@
1
+ # Flow genérico de eventos asíncronos para tools
2
+
3
+ > Estado: propuesta / no implementado. Guardado como referencia.
4
+ > La implementación actual (timer) se mantiene; este documento describe una evolución posible.
5
+
6
+ ## Problema
7
+
8
+ Hoy la única re-entrada asíncrona al agente es por tiempo: una tool devuelve `asyncTask` con `runAt` y el poller de 1s en `src/transport/telegram/bot.js` lo dispara como prompt. Eso obliga a resolver con timer (polling crudo, latencia fija, re-spawn de la tool y un turno completo del agente en cada chequeo). Falta una **cola de eventos entrantes** que despierte al agente solo cuando hay algo que evaluar.
9
+
10
+ ## Solución (polling ordenado por cola, reusando TaskStore)
11
+
12
+ Dos nuevos `kind` de tarea, drenados por el mismo poller hacia el mismo `enqueuePrompt`:
13
+
14
+ - `poll_tool`: tarea recurrente que el poller **ejecuta directamente como tool** (no gasta turno del agente). El checker mantiene su propio cursor de estado en su config/tmp por chat. Si hay novedad, emite un `agent_event`.
15
+ - `agent_event`: evento entrante que se dispara de inmediato. El poller lo entrega como prompt para que Pi lo evalúe y decida.
16
+
17
+ ```mermaid
18
+ flowchart LR
19
+ Tool[Tool run normal] -->|asyncTask poll_tool| TS[TaskStore]
20
+ TS --> Poller[1s poller dispatcher]
21
+ Poller -->|kind poll_tool| Run[agentManager.runTool checker]
22
+ Run -->|si hay novedad: asyncTask agent_event| TS
23
+ Poller -->|kind agent_event| EP[enqueuePrompt]
24
+ Poller -->|kind agent_task| EP
25
+ EP --> Pi[Pi evalua y decide]
26
+ ```
27
+
28
+ ## Cambios
29
+
30
+ ### 1. TaskStore: eventos/polls sin hora se disparan ya
31
+
32
+ `src/core/tasks/task-store.js` - en `normalizeTask`, default `runAt` a `now` cuando no viene (los `agent_event` y el primer disparo de `poll_tool` deben ser inmediatos; `computeNextRunAt` ya reprograma `poll_tool` por su `recurrence`). Cambio de una línea, no rompe `agent_task` (siempre trae `runAt`).
33
+
34
+ ### 2. AgentManager: extraer "run + materializar" (DRY)
35
+
36
+ `src/core/agent/agent-manager.js` - hoy el `execute` de `run_tool` (líneas ~184-242) hace: correr la tool, convertir `output.text`/`output.filePath` en artifacts y mandar `asyncTask(s)` al `TaskStore` con el `chatId`. Extraer eso a un método reusable `runTool({ name, request, chatId })`. El Pi tool `run_tool` pasa a llamarlo. Así el poller puede correr tools con la **misma** lógica de materialización (incluido el alta de `agent_event` que emita el checker).
37
+
38
+ ### 3. Poller -> dispatcher por kind
39
+
40
+ `src/transport/telegram/bot.js` - reemplazar el handler de un solo kind dentro del `setInterval` (líneas ~361-380) por un dispatcher:
41
+
42
+ - `agent_task` -> `enqueuePrompt(buildAsyncTaskPrompt(task))` + `complete` (igual que hoy).
43
+ - `agent_event` -> `enqueuePrompt(buildAsyncEventPrompt(task))` + `complete`.
44
+ - `poll_tool` -> `agentManager.runTool({ name: task.payload.toolName, request: { args: task.payload.args || {} }, chatId })`; los `agent_event` que emita el checker quedan encolados para el próximo tick; luego `complete` (la `recurrence` reprograma el poll). Si la tool falla: log + `complete` para no matar el poll.
45
+
46
+ Agregar `buildAsyncEventPrompt(task)` junto a `buildAsyncTaskPrompt` (línea ~82), con framing de "llegó un evento externo, evalualo y decidí la próxima acción". Si el branch queda denso, extraer `dispatchDueTasks(...)` a una función para mantener `bot.js` como transporte.
47
+
48
+ ### 4. Documentar el flow
49
+
50
+ `AGENTS.md` - sección nueva (en inglés) explicando: cómo una tool arma su auto-polling devolviendo un `asyncTask` kind `poll_tool` con `recurrence`, cómo emite novedades con `asyncTask` kind `agent_event`, que el checker guarda su cursor en su config/tmp por chat, y que el agente razona sobre el `agent_event` para decidir. `list_scheduled_tasks`/`cancel_scheduled_task` ya sirven (son kind-agnostic) para ver/cancelar polls.
51
+
52
+ ## Contrato del checker tool (sin nuevas Pi tools)
53
+
54
+ Todo pasa por el campo `asyncTasks` que el pipeline ya soporta:
55
+
56
+ - Arranque del poll (desde el `run` de cualquier tool): `asyncTasks: [{ kind: "poll_tool", payload: { toolName, args }, recurrence: { type: "interval", everySeconds: N } }]`.
57
+ - Novedad (desde el `run` del checker): `asyncTasks: [{ kind: "agent_event", payload: { prompt: "<contenido a evaluar>" } }]`.
58
+
59
+ ## No-goals (por ahora)
60
+
61
+ - No se agrega listener persistente (`node index.js listen`) ni proceso de fondo con IPC.
62
+ - No se agrega endpoint HTTP entrante para eventos.
63
+ - No se resuelve el caso de conexión sostenida (tipo cliente logueado): los checkers son one-shot y persisten su cursor entre corridas.
64
+
65
+ ## Alternativas consideradas (descartadas para esta versión)
66
+
67
+ - **Listener tools**: la tool corre como proceso de larga duración (`node index.js listen`) y emite eventos por stdout que Arisa drena a la cola. Más general y realtime, pero agrega ciclo de vida de proceso a la service e IPC.
68
+ - **Webhook entrante**: Arisa expone un endpoint HTTP interno donde sistemas externos hacen POST de eventos. Bueno para callbacks; no sirve para los que requieren sostener una conexión.
package/package.json CHANGED
@@ -1,16 +1,12 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.0.14",
3
+ "version": "3.1.2",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "bin": {
8
8
  "arisa": "bin/arisa.js"
9
9
  },
10
- "scripts": {
11
- "start": "node src/index.js",
12
- "bootstrap": "node src/index.js --bootstrap"
13
- },
14
10
  "keywords": [
15
11
  "telegram",
16
12
  "pi-agent",
@@ -32,10 +28,13 @@
32
28
  ],
33
29
  "author": "Martin Clasen",
34
30
  "license": "MIT",
35
- "packageManager": "pnpm@10.32.1",
36
31
  "dependencies": {
37
- "@mariozechner/pi-coding-agent": "^0.65.0",
32
+ "@mariozechner/pi-coding-agent": "^0.73.1",
38
33
  "@sinclair/typebox": "^0.34.41",
39
34
  "grammy": "^1.42.0"
35
+ },
36
+ "scripts": {
37
+ "start": "node src/index.js",
38
+ "bootstrap": "node src/index.js --bootstrap"
40
39
  }
41
- }
40
+ }
@@ -0,0 +1,12 @@
1
+ overrides:
2
+ '@protobufjs/utf8@<=1.1.0': '>=1.1.1'
3
+ basic-ftp@<=5.2.1: '>=5.2.2'
4
+ basic-ftp@<=5.2.2: '>=5.3.0'
5
+ basic-ftp@<=5.3.0: '>=5.3.1'
6
+ basic-ftp@=5.2.0: '>=5.2.1'
7
+ brace-expansion@>=5.0.0 <5.0.6: '>=5.0.6'
8
+ ip-address@<=10.1.0: '>=10.1.1'
9
+ protobufjs@<7.5.5: '>=7.5.5'
10
+ protobufjs@<=7.5.5: '>=7.5.6'
11
+ protobufjs@<=7.5.7: '>=7.5.8'
12
+ ws@>=8.0.0 <8.20.1: '>=8.20.1'
@@ -5,7 +5,21 @@ import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
6
  import { loadProjectInstructions } from "./project-instructions.js";
7
7
  import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
8
- import { arisaHomeDir } from "../../runtime/paths.js";
8
+ import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
9
+
10
+ function isLocalBaseUrl(value) {
11
+ if (typeof value !== "string" || !value.trim()) return false;
12
+ try {
13
+ const parsed = new URL(value);
14
+ return parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost";
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function requiresProviderAuth(model) {
21
+ return !isLocalBaseUrl(model?.baseUrl);
22
+ }
9
23
 
10
24
  export class AgentManager {
11
25
  constructor({ config, artifactStore, toolRegistry, taskStore, logger }) {
@@ -15,15 +29,30 @@ export class AgentManager {
15
29
  this.taskStore = taskStore;
16
30
  this.logger = logger;
17
31
  this.sessions = new Map();
32
+ this.pendingNewSessions = new Set();
18
33
  }
19
34
 
20
35
  setConfig(config) {
21
36
  this.config = config;
22
37
  this.sessions.clear();
38
+ this.pendingNewSessions.clear();
23
39
  }
24
40
 
25
41
  resetSession(chatId) {
26
- this.sessions.delete(chatId);
42
+ const sessionKey = String(chatId);
43
+ this.sessions.delete(sessionKey);
44
+ this.pendingNewSessions.add(sessionKey);
45
+ }
46
+
47
+ createSessionManager(chatId) {
48
+ const sessionKey = String(chatId);
49
+ const sessionDir = getChatPiSessionsDir(sessionKey);
50
+ if (this.pendingNewSessions.has(sessionKey)) {
51
+ this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
52
+ return { sessionManager: SessionManager.create(arisaInstallDir, sessionDir), isNewSession: true };
53
+ }
54
+ this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
55
+ return { sessionManager: SessionManager.continueRecent(arisaInstallDir, sessionDir), isNewSession: false };
27
56
  }
28
57
 
29
58
  async validatePiAgent() {
@@ -36,7 +65,7 @@ export class AgentManager {
36
65
  if (!model) {
37
66
  throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model}`);
38
67
  }
39
- if (!this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
68
+ if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
40
69
  throw new Error(`No auth found for ${this.config.pi.provider}. Provide a Pi API key in bootstrap, or authenticate with Pi login for this provider during bootstrap.`);
41
70
  }
42
71
 
@@ -50,22 +79,32 @@ export class AgentManager {
50
79
  }
51
80
 
52
81
  async getSessionContext(chatId, telegram) {
53
- if (this.sessions.has(chatId)) {
54
- this.logger?.log("agent", `reusing session for chat ${chatId}`);
55
- return this.sessions.get(chatId);
82
+ const sessionKey = String(chatId);
83
+ const effectiveModelId = this.config.pi.model;
84
+ if (this.sessions.has(sessionKey)) {
85
+ const existing = this.sessions.get(sessionKey);
86
+ if (existing?.modelId === effectiveModelId) {
87
+ this.logger?.log("agent", `reusing session for chat ${sessionKey}`);
88
+ return existing;
89
+ }
90
+ this.logger?.log("agent", `model changed for chat ${sessionKey}: ${existing?.modelId || "unknown"} -> ${effectiveModelId}; recreating session`);
91
+ this.sessions.delete(sessionKey);
92
+ this.pendingNewSessions.add(sessionKey);
56
93
  }
57
94
 
58
95
  const { authStorage, modelRegistry } = createPiRuntime({
59
96
  provider: this.config.pi.provider,
60
97
  apiKey: this.config.pi.apiKey
61
98
  });
62
- const model = modelRegistry.find(this.config.pi.provider, this.config.pi.model);
63
- if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${this.config.pi.model}`);
64
- if (!this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
99
+ const model = modelRegistry.find(this.config.pi.provider, effectiveModelId);
100
+ if (!model) throw new Error(`Model not found: ${this.config.pi.provider}/${effectiveModelId}`);
101
+ if (requiresProviderAuth(model) && !this.config.pi.apiKey && !hasProviderAuth(this.config.pi.provider, { authStorage, modelRegistry })) {
65
102
  throw new Error(`No auth found for ${this.config.pi.provider}. Re-run bootstrap and complete login for this provider before Telegram starts.`);
66
103
  }
67
104
 
68
- this.logger?.log("agent", `creating session for chat ${chatId}`);
105
+ const { sessionManager, isNewSession } = this.createSessionManager(sessionKey);
106
+ const hasExistingSession = sessionManager.buildSessionContext().messages.length > 0;
107
+ this.logger?.log("agent", `${hasExistingSession ? "resuming" : "creating"} session for chat ${sessionKey} with model ${effectiveModelId}`);
69
108
  const customTools = this.createTools(telegram, chatId);
70
109
  const { session } = await createAgentSession({
71
110
  cwd: arisaInstallDir,
@@ -74,21 +113,28 @@ export class AgentManager {
74
113
  modelRegistry,
75
114
  model,
76
115
  customTools,
77
- sessionManager: SessionManager.inMemory()
116
+ sessionManager
78
117
  });
79
118
 
80
- const instructions = await loadProjectInstructions();
81
- const runtimeContext = buildAgentRuntimeContext();
82
- this.logger?.log("agent", `injecting project instructions for chat ${chatId}`);
83
- this.logger?.log("agent", `runtime context for chat ${chatId}:\n${runtimeContext}`);
84
- await session.prompt(`${instructions}\n\n${runtimeContext}\n\nAcknowledge with exactly: OK`);
119
+ if (!hasExistingSession) {
120
+ const instructions = await loadProjectInstructions();
121
+ const runtimeContext = buildAgentRuntimeContext();
122
+ this.logger?.log("agent", `injecting project instructions for chat ${sessionKey}`);
123
+ this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${runtimeContext}`);
124
+ await session.prompt(`${instructions}\n\n${runtimeContext}\n\nAcknowledge with exactly: OK`);
125
+ }
85
126
 
86
- const ctx = { session };
87
- this.sessions.set(chatId, ctx);
127
+ const ctx = { session, modelId: effectiveModelId };
128
+ this.sessions.set(sessionKey, ctx);
129
+ if (isNewSession) {
130
+ this.pendingNewSessions.delete(sessionKey);
131
+ }
88
132
  return ctx;
89
133
  }
90
134
 
91
135
  createTools(telegram, chatId) {
136
+ const chatArtifactStore = this.artifactStore.forChat(chatId);
137
+
92
138
  return [
93
139
  defineTool({
94
140
  name: "list_tools",
@@ -117,11 +163,11 @@ export class AgentManager {
117
163
  defineTool({
118
164
  name: "set_tool_config",
119
165
  label: "Set tool config",
120
- description: "Write a value into ~/.arisa/tools/<tool>/config.js.",
166
+ description: "Write a tool config value scoped to the current chat.",
121
167
  parameters: Type.Object({ name: Type.String(), field: Type.String(), value: Type.String() }),
122
168
  execute: async (_id, params) => {
123
169
  await this.toolRegistry.load();
124
- const result = await this.toolRegistry.setConfig(params.name, params.field, params.value);
170
+ const result = await this.toolRegistry.setConfig(params.name, params.field, params.value, chatId);
125
171
  return { content: [{ type: "text", text: JSON.stringify(result) }], details: result };
126
172
  }
127
173
  }),
@@ -140,7 +186,7 @@ export class AgentManager {
140
186
  this.logger?.log("agent", `run_tool ${params.name}`);
141
187
  let artifact = null;
142
188
  if (params.artifactId) {
143
- artifact = await this.artifactStore.get(params.artifactId);
189
+ artifact = await chatArtifactStore.get(params.artifactId);
144
190
  if (!artifact) {
145
191
  return { content: [{ type: "text", text: `Artifact not found: ${params.artifactId}` }], details: { ok: false } };
146
192
  }
@@ -151,11 +197,12 @@ export class AgentManager {
151
197
  artifact,
152
198
  text: params.text,
153
199
  args: params.args || {}
154
- }
200
+ },
201
+ chatId
155
202
  });
156
203
 
157
204
  if (result.output?.text) {
158
- const outArtifact = await this.artifactStore.createText({
205
+ const outArtifact = await chatArtifactStore.createText({
159
206
  text: result.output.text,
160
207
  source: { type: "tool", toolName: params.name },
161
208
  metadata: { tool: params.name }
@@ -164,7 +211,7 @@ export class AgentManager {
164
211
  }
165
212
 
166
213
  if (result.output?.filePath) {
167
- const generated = await this.artifactStore.createFromFile({
214
+ const generated = await chatArtifactStore.createFromFile({
168
215
  originalPath: result.output.filePath,
169
216
  fileName: result.output.fileName || path.basename(result.output.filePath),
170
217
  kind: result.output.kind || "file",
@@ -261,7 +308,8 @@ export class AgentManager {
261
308
  this.logger?.log("agent", `send_media_reply via ${toolName}`);
262
309
  const result = await this.toolRegistry.run({
263
310
  name: toolName,
264
- request: { text: params.text, args: {} }
311
+ request: { text: params.text, args: {} },
312
+ chatId
265
313
  });
266
314
  if (!result.ok || !result.output?.filePath) {
267
315
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { arisaHomeDir, artifactsDir, stateDir, toolsDir } from "../../runtime/paths.js";
2
+ import { arisaHomeDir, chatsDir, stateDir, toolsDir } from "../../runtime/paths.js";
3
3
 
4
4
  export const arisaInstallDir = fileURLToPath(new URL("../../..", import.meta.url));
5
5
  export const bundledToolsDir = fileURLToPath(new URL("../../../tools", import.meta.url));
@@ -10,7 +10,7 @@ export function buildAgentRuntimeContext() {
10
10
  `arisaInstallDir: ${arisaInstallDir}`,
11
11
  `bundledToolsDir: ${bundledToolsDir}`,
12
12
  `userToolsDir: ${toolsDir}`,
13
- `artifactsDir: ${artifactsDir}`,
13
+ `chatsDir: ${chatsDir}`,
14
14
  `stateDir: ${stateDir}`
15
15
  ].join("\n");
16
16
  }
@@ -1,39 +1,44 @@
1
1
  import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
- import { artifactsDir as rootDir, artifactsIndexFile as indexFile } from "../../runtime/paths.js";
5
-
6
- async function loadIndex() {
7
- try {
8
- return JSON.parse(await readFile(indexFile, "utf8"));
9
- } catch {
10
- return [];
11
- }
12
- }
13
-
14
- async function saveIndex(items) {
15
- await mkdir(path.dirname(indexFile), { recursive: true });
16
- await writeFile(indexFile, `${JSON.stringify(items, null, 2)}\n`, "utf8");
17
- }
4
+ import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
18
5
 
19
6
  function id() {
20
7
  return crypto.randomUUID();
21
8
  }
22
9
 
23
- export class ArtifactStore {
24
- constructor() {
10
+ class ChatArtifactStore {
11
+ constructor(chatId) {
12
+ this.chatId = String(chatId);
13
+ this.rootDir = getChatArtifactsDir(this.chatId);
14
+ this.indexFile = getChatArtifactsIndexFile(this.chatId);
25
15
  this.items = null;
26
16
  }
27
17
 
18
+ async reload() {
19
+ try {
20
+ this.items = JSON.parse(await readFile(this.indexFile, "utf8"));
21
+ } catch {
22
+ this.items = [];
23
+ }
24
+ }
25
+
28
26
  async init() {
29
- if (!this.items) this.items = await loadIndex();
30
- await mkdir(rootDir, { recursive: true });
27
+ await mkdir(this.rootDir, { recursive: true });
28
+ if (!this.items) await this.reload();
29
+ }
30
+
31
+ async saveIndex() {
32
+ await mkdir(path.dirname(this.indexFile), { recursive: true });
33
+ await writeFile(this.indexFile, `${JSON.stringify(this.items, null, 2)}\n`, "utf8");
31
34
  }
32
35
 
33
36
  async createText({ text, mimeType = "text/plain", source, metadata = {} }) {
34
37
  await this.init();
38
+ await this.reload();
35
39
  const artifact = {
36
40
  id: id(),
41
+ chatId: this.chatId,
37
42
  kind: "text",
38
43
  mimeType,
39
44
  text,
@@ -42,19 +47,21 @@ export class ArtifactStore {
42
47
  createdAt: new Date().toISOString()
43
48
  };
44
49
  this.items.push(artifact);
45
- await saveIndex(this.items);
50
+ await this.saveIndex();
46
51
  return artifact;
47
52
  }
48
53
 
49
54
  async createFromFile({ originalPath, fileName, kind, mimeType, source, metadata = {} }) {
50
55
  await this.init();
56
+ await this.reload();
51
57
  const artifactId = id();
52
- const dir = path.join(rootDir, artifactId);
58
+ const dir = path.join(this.rootDir, artifactId);
53
59
  await mkdir(dir, { recursive: true });
54
60
  const destPath = path.join(dir, fileName);
55
61
  await copyFile(originalPath, destPath);
56
62
  const artifact = {
57
63
  id: artifactId,
64
+ chatId: this.chatId,
58
65
  kind,
59
66
  mimeType,
60
67
  path: destPath,
@@ -63,19 +70,21 @@ export class ArtifactStore {
63
70
  createdAt: new Date().toISOString()
64
71
  };
65
72
  this.items.push(artifact);
66
- await saveIndex(this.items);
73
+ await this.saveIndex();
67
74
  return artifact;
68
75
  }
69
76
 
70
77
  async createGeneratedFile({ fileName, content, kind, mimeType, source, metadata = {} }) {
71
78
  await this.init();
79
+ await this.reload();
72
80
  const artifactId = id();
73
- const dir = path.join(rootDir, artifactId);
81
+ const dir = path.join(this.rootDir, artifactId);
74
82
  await mkdir(dir, { recursive: true });
75
83
  const destPath = path.join(dir, fileName);
76
84
  await writeFile(destPath, content);
77
85
  const artifact = {
78
86
  id: artifactId,
87
+ chatId: this.chatId,
79
88
  kind,
80
89
  mimeType,
81
90
  path: destPath,
@@ -84,17 +93,33 @@ export class ArtifactStore {
84
93
  createdAt: new Date().toISOString()
85
94
  };
86
95
  this.items.push(artifact);
87
- await saveIndex(this.items);
96
+ await this.saveIndex();
88
97
  return artifact;
89
98
  }
90
99
 
91
- async get(id) {
100
+ async get(artifactId) {
92
101
  await this.init();
93
- return this.items.find((item) => item.id === id) || null;
102
+ await this.reload();
103
+ return this.items.find((item) => item.id === artifactId) || null;
94
104
  }
95
105
 
96
106
  async listRecent(limit = 20) {
97
107
  await this.init();
108
+ await this.reload();
98
109
  return [...this.items].slice(-limit).reverse();
99
110
  }
100
111
  }
112
+
113
+ export class ArtifactStore {
114
+ constructor() {
115
+ this.chatStores = new Map();
116
+ }
117
+
118
+ forChat(chatId) {
119
+ const key = String(chatId);
120
+ if (!this.chatStores.has(key)) {
121
+ this.chatStores.set(key, new ChatArtifactStore(key));
122
+ }
123
+ return this.chatStores.get(key);
124
+ }
125
+ }
@@ -0,0 +1,90 @@
1
+ function mimeMatches(pattern, mimeType = "") {
2
+ if (!pattern || !mimeType) return false;
3
+ if (pattern === mimeType) return true;
4
+ if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
5
+ return false;
6
+ }
7
+
8
+ function toolSupportsArtifact(tool, artifact) {
9
+ const inputs = Array.isArray(tool.input) ? tool.input : [];
10
+ return inputs.some((input) => mimeMatches(input, artifact.mimeType));
11
+ }
12
+
13
+ function toolProduces(tool, mimeType) {
14
+ const outputs = Array.isArray(tool.output) ? tool.output : [];
15
+ return outputs.some((output) => mimeMatches(output, mimeType));
16
+ }
17
+
18
+ function looksLikeAudioTranscriptionTool(tool) {
19
+ return /transcri|whisper|speech.?to.?text|audio.?to.?text/i.test(`${tool.name} ${tool.description || ""}`);
20
+ }
21
+
22
+ function shouldNormalizeAudioToText(artifact, desiredMimeType) {
23
+ return artifact?.mimeType?.startsWith("audio/") && desiredMimeType === "text/plain";
24
+ }
25
+
26
+ export function selectPipeTool({ toolRegistry, artifact, desiredMimeType }) {
27
+ const tools = toolRegistry.list()
28
+ .filter((tool) => toolSupportsArtifact(tool, artifact))
29
+ .filter((tool) => toolProduces(tool, desiredMimeType));
30
+
31
+ if (shouldNormalizeAudioToText(artifact, desiredMimeType)) {
32
+ return tools.find(looksLikeAudioTranscriptionTool) || null;
33
+ }
34
+
35
+ return null;
36
+ }
37
+
38
+ export async function normalizeArtifactForReasoning({
39
+ artifact,
40
+ desiredMimeType = "text/plain",
41
+ toolRegistry,
42
+ chatArtifactStore,
43
+ chatId
44
+ }) {
45
+ if (!artifact) return { normalizedArtifact: null, toolResult: null, toolName: "" };
46
+
47
+ if (!shouldNormalizeAudioToText(artifact, desiredMimeType)) {
48
+ return { normalizedArtifact: null, toolResult: null, toolName: "" };
49
+ }
50
+
51
+ const tool = selectPipeTool({ toolRegistry, artifact, desiredMimeType });
52
+ if (!tool) {
53
+ return {
54
+ normalizedArtifact: null,
55
+ toolResult: {
56
+ ok: false,
57
+ status: "failed",
58
+ error: `No registered tool can normalize ${artifact.mimeType} to ${desiredMimeType}.`
59
+ },
60
+ toolName: ""
61
+ };
62
+ }
63
+
64
+ const result = await toolRegistry.run({
65
+ name: tool.name,
66
+ request: { artifact, args: {} },
67
+ chatId
68
+ });
69
+
70
+ if (!result.ok) {
71
+ return { normalizedArtifact: null, toolResult: result, toolName: tool.name };
72
+ }
73
+
74
+ if (!result.output?.text) {
75
+ return {
76
+ normalizedArtifact: null,
77
+ toolResult: { ok: false, status: "failed", error: "Normalization returned no text." },
78
+ toolName: tool.name
79
+ };
80
+ }
81
+
82
+ const normalizedArtifact = await chatArtifactStore.createText({
83
+ text: result.output.text,
84
+ mimeType: desiredMimeType,
85
+ source: { type: "tool", toolName: tool.name },
86
+ metadata: { fromArtifactId: artifact.id, tool: tool.name, normalization: true }
87
+ });
88
+
89
+ return { normalizedArtifact, toolResult: result, toolName: tool.name };
90
+ }