arisa 3.1.2 → 3.1.6

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
@@ -3,10 +3,21 @@
3
3
  ## Architecture
4
4
  - Telegram transport handles inbound and outbound messaging.
5
5
  - Pi Agent keeps one session per authorized chat.
6
- - Every incoming or generated message or file becomes an artifact.
6
+ - Incoming messages and files (text, voice, photo, document) and generated files become artifacts.
7
7
  - A tool registry handles tool discovery, help lookup, config writes, and execution.
8
8
  - Tools are isolated and each one has its own manifest, entrypoint, and config defaults.
9
9
 
10
+ ## Runtime directory rules
11
+ Do not build runtime paths by hand. Use `src/runtime/paths.js`:
12
+ - `getToolDir(toolName)`: installed user tool package only; no runtime data here.
13
+ - `getToolStateDir(toolName)`: global tool infrastructure only: daemons, queues, shared browser sessions, model caches.
14
+ - `getChatToolStateDir(chatId, toolName)`: persistent user/chat data: tool DBs, indexes, inboxes, generated sites, vaults.
15
+ - `getChatArtifactsDir(chatId)` / `getChatArtifactsIndexFile(chatId)`: chat artifacts and artifact index. Artifacts are never global.
16
+ - `getChatToolConfigPath(chatId, toolName)`: chat-scoped config overrides.
17
+ - `getToolTmpDir(toolName)` / `getChatToolTmpDir(chatId, toolName)`: ephemeral scratch. Create only while a request runs; remove when empty.
18
+
19
+ Tools receive `chatId` from the registry. Any persisted or indexed user content must be scoped by chat. Avoid ad hoc roots like `~/.arisa/state/<toolName>`, `~/.arisa/state/chats`, or runtime data inside `~/.arisa/tools/<toolName>`.
20
+
10
21
  ## Main rule: everything is piped through artifacts
11
22
  A pipe transforms one input artifact into one output artifact.
12
23
  Examples:
@@ -18,6 +29,7 @@ Each tool declares in `tool.manifest.json`:
18
29
  - `input`: supported input types
19
30
  - `output`: produced output types
20
31
  - `configSchema`: required config fields
32
+ - `skillHints`: optional skills to apply when using or editing the tool
21
33
 
22
34
  ## Conceptual pipe model
23
35
  There are two different moments where pipes can happen:
@@ -34,12 +46,11 @@ There are two different moments where pipes can happen:
34
46
  - Pi Agent may decide to chain tools to achieve a user goal.
35
47
  - Example: text -> TTS audio, or future multi-step workflows.
36
48
 
37
- This distinction is critical. Not every pipe should be decided by Pi Agent at runtime. Some pipes are part of the transport/input normalization layer and must happen before reasoning.
49
+ Not every pipe should be decided by Pi Agent at runtime. Some pipes are part of the transport/input normalization layer and must happen before reasoning.
38
50
 
39
51
  ## Telegram inbound pipeline
40
- Current conceptual behavior:
41
52
  - text -> send directly to Pi Agent
42
- - audio/voice -> transcribe first -> send transcript to Pi Agent
53
+ - voice -> transcribe first -> send transcript to Pi Agent
43
54
  - image/document/other media -> keep as artifacts, and add normalization pipes when needed
44
55
 
45
56
  If inbound media was normalized before reasoning, Pi Agent should use the normalized result as the actual message content.
@@ -50,23 +61,23 @@ Before using a tool, inspect its help:
50
61
  - via the custom tool: `tool_help`
51
62
  - or by running the CLI with `--help`
52
63
 
53
- Every CLI must support:
64
+ Every CLI must support (the entrypoint comes from `manifest.entry`, currently always `index.js`):
54
65
  - `node index.js --help`
55
66
  - `node index.js run --request-file <json>`
56
67
 
57
68
  ### 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:
69
+ A future tool may need a persistent process, for example to keep a browser session alive or a local model warm. The shared daemon runtime exists for this, but no bundled tool uses it yet.
70
+ When such a tool is built, implement it with the shared daemon runtime instead of custom ad hoc process management:
60
71
  - use `src/core/tools/daemon-runtime.js`
61
- - keep runtime files under the tool state directory (`stateDir/<toolName>`)
72
+ - keep runtime files under the tool state directory (`~/.arisa/state/tools/<toolName>`)
62
73
  - expose normal CLI behavior through `run --request-file`; callers should not manage daemon internals
63
74
  - use the runtime for `daemon.pid`, `daemon.log`, `status.json`, and `commands/*.request|processing|result.json`
64
75
  - keep one daemon owner per tool/session and avoid opening a second client over the same resource
65
76
  - use `beforeStart` only for tool-specific cleanup such as stale browser locks, without deleting persistent session/model data
66
77
  - keep daemon tools headless/server-safe by default when they are meant to run on VPS machines
67
78
 
68
- ## Pipe behavior in V1
69
- V1 does not have a full automatic planner yet. The agent should:
79
+ ## Manual pipe behavior
80
+ To run a pipe, the agent should:
70
81
  1. understand whether the needed pipe belongs to pre-reasoning normalization or post-reasoning tool chaining
71
82
  2. use `list_tools`
72
83
  3. use `tool_help` when it needs operational details
@@ -76,7 +87,28 @@ V1 does not have a full automatic planner yet. The agent should:
76
87
  Example manual pipe:
77
88
  1. `run_tool(openai-transcribe, artifact audio)`
78
89
  2. take the returned text `artifactId`
79
- 3. `run_tool(openai-tts, artifact text)` or `send_audio_reply(text)`
90
+ 3. `run_tool(openai-tts, artifact text)` or `send_media_reply(text)`
91
+
92
+ ## Async event queue flow
93
+ Beyond time-based scheduling, tools can drive an event queue that wakes the agent only when there is something to evaluate. Everything goes through the `asyncTask` (single) or `asyncTasks` (array) field the pipeline already supports; no new Pi tools are needed. The 1s poller drains tasks by `kind`:
94
+
95
+ - `agent_task`: a scheduled prompt. The poller delivers it as a prompt for Pi to fulfill (time-based work).
96
+ - `poll_tool`: a recurring checker the poller **runs directly as a tool** (no agent turn spent). The poller materializes its output with the same logic as `run_tool`, so any `agent_event` the checker emits is enqueued for the next tick. Its `recurrence` reschedules the next poll.
97
+ - `agent_event`: an incoming event. The poller delivers it as a prompt so Pi evaluates it and decides the next action (it may stay silent).
98
+
99
+ Tasks without a `runAt` fire immediately, so `agent_event` and the first `poll_tool` run on the next tick.
100
+
101
+ The poller dispatches all three kinds, but only `agent_task` is exercised by a bundled tool today (`schedule-agent-task`). The following is the pattern to follow when a checker tool is built:
102
+
103
+ How a tool wires its own polling:
104
+ 1. From any tool `run`, start the poll by returning an `asyncTask` (or several in `asyncTasks`):
105
+ `{ kind: "poll_tool", payload: { toolName, args }, recurrence: { type: "interval", everySeconds: N } }`.
106
+ 2. On each poll the checker tool (`toolName`) runs headless. It keeps its own cursor of seen state in its config/tmp per chat, so it knows what is new.
107
+ 3. When the checker finds something new, it emits an event from its `run`:
108
+ `{ kind: "agent_event", payload: { prompt: "<content to evaluate>" } }`.
109
+ 4. The agent reasons over the `agent_event` and decides what to do.
110
+
111
+ `list_scheduled_tasks`, `cancel_scheduled_task`, and `cancel_all_scheduled_tasks` are kind-agnostic, so they already work to inspect or cancel active polls.
80
112
 
81
113
  ## Missing config flow
82
114
  If `run_tool` returns `missingConfig`, the agent should:
@@ -101,13 +133,26 @@ The default attitude is:
101
133
  - propose or start creating the needed tool
102
134
 
103
135
  When creating or editing tools:
104
- - use the shared path helpers and the runtime paths provided in the prompt instead of assuming fixed locations
105
- - consult the local skill for that workflow when building new tools
136
+ - use the path helpers in `src/runtime/paths.js`
137
+ - follow the existing bundled tools under `tools/` as the reference pattern for new tools
106
138
  - keep all help text, usage instructions, manifests, and user-facing operational strings in English
107
139
  - follow the One Thing Rule: each function or method should do one thing well; if it mixes low-level operations with high-level policy, split it into smaller focused units
108
140
 
141
+ ### Tool skill hints
142
+ Tools may declare skills in `tool.manifest.json`:
143
+
144
+ ```json
145
+ {
146
+ "skillHints": [
147
+ { "name": "stop-slop", "when": "writing public page copy" }
148
+ ]
149
+ }
150
+ ```
151
+
152
+ The tool registry resolves these from the installed skills directory and injects them into the tool request as `skills`. `list_tools` exposes the hints and `tool_help` shows their resolution status. Skills are guidance for the agent/tool; they are not separate runtime dependencies.
153
+
109
154
  ## Dependency installation
110
- Arisa installs tool dependencies itself.
155
+ Tool dependencies are installed as part of building or running the tool, not delegated to the user.
111
156
  - Prefer `pnpm install`.
112
157
  - Fall back to `npm install`.
113
158
  - Do not ask the user to do it manually.
package/README.md CHANGED
@@ -145,13 +145,13 @@ node src/index.js --telegram.token <token>
145
145
  With this mode, Arisa creates `~/.arisa/state/config.json` without prompts and applies these defaults when not provided:
146
146
 
147
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`)
148
+ - `pi.model`: first model after bootstrap sorting (currently prioritizes `openai-codex/gpt-5.5`)
149
149
  - `telegram.maxChatIds`: `1`
150
150
 
151
151
  Supported overrides:
152
152
 
153
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>
154
+ node src/index.js --telegram.token <token> --telegram.maxChatIds 3 --pi.provider openai-codex --pi.model gpt-5.5 --pi.apiKey <optional-provider-key>
155
155
  ```
156
156
 
157
157
  Notes:
@@ -171,7 +171,7 @@ For providers with internal Pi login support, such as Codex, leaving the API key
171
171
 
172
172
  For example, selecting:
173
173
 
174
- - `openai-codex/gpt-5.4`
174
+ - `openai-codex/gpt-5.5`
175
175
 
176
176
  allows Arisa to authenticate through Pi's Codex OAuth flow instead of requiring a normal OpenAI API key.
177
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arisa",
3
- "version": "3.1.2",
3
+ "version": "3.1.6",
4
4
  "description": "Telegram + Pi Agent modular assistant",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,10 +3,12 @@ import { unlink } from "node:fs/promises";
3
3
  import { createAgentSession, SessionManager, defineTool } from "@mariozechner/pi-coding-agent";
4
4
  import { Type } from "@sinclair/typebox";
5
5
  import { createPiRuntime, hasProviderAuth } from "./pi-runtime.js";
6
- import { loadProjectInstructions } from "./project-instructions.js";
7
6
  import { arisaInstallDir, buildAgentRuntimeContext } from "./runtime-context.js";
7
+ import { withTimeout } from "./prompt-timeout.js";
8
8
  import { arisaHomeDir, getChatPiSessionsDir } from "../../runtime/paths.js";
9
9
 
10
+ const piValidationTimeoutMs = 60_000;
11
+
10
12
  function isLocalBaseUrl(value) {
11
13
  if (typeof value !== "string" || !value.trim()) return false;
12
14
  try {
@@ -21,6 +23,32 @@ function requiresProviderAuth(model) {
21
23
  return !isLocalBaseUrl(model?.baseUrl);
22
24
  }
23
25
 
26
+ function mimeMatches(pattern, mimeType = "") {
27
+ if (!pattern || !mimeType) return false;
28
+ if (pattern === mimeType) return true;
29
+ if (pattern.endsWith("/*")) return mimeType.startsWith(`${pattern.slice(0, -2)}/`);
30
+ return false;
31
+ }
32
+
33
+ function toolSupportsTextInput(tool) {
34
+ return (tool.input || []).some((input) => mimeMatches(input, "text/plain"));
35
+ }
36
+
37
+ function toolProducesAudio(tool) {
38
+ return (tool.output || []).some((output) => mimeMatches(output, "audio/ogg") || mimeMatches(output, "audio/*"));
39
+ }
40
+
41
+ function looksLikeTextToSpeechTool(tool) {
42
+ return /tts|text.?to.?speech|speech.?audio|speech/i.test(`${tool.name} ${tool.description || ""}`);
43
+ }
44
+
45
+ function selectMediaReplyTool(toolRegistry) {
46
+ const tools = toolRegistry.list()
47
+ .filter(toolSupportsTextInput)
48
+ .filter(toolProducesAudio);
49
+ return tools.find(looksLikeTextToSpeechTool) || tools[0] || null;
50
+ }
51
+
24
52
  export class AgentManager {
25
53
  constructor({ config, artifactStore, toolRegistry, taskStore, logger }) {
26
54
  this.config = config;
@@ -75,7 +103,10 @@ export class AgentManager {
75
103
  model,
76
104
  sessionManager: SessionManager.inMemory(),
77
105
  });
78
- await session.prompt("Reply with exactly: OK");
106
+ await withTimeout(session.prompt("Reply with exactly: OK"), {
107
+ timeoutMs: piValidationTimeoutMs,
108
+ label: "Pi validation prompt"
109
+ });
79
110
  }
80
111
 
81
112
  async getSessionContext(chatId, telegram) {
@@ -117,11 +148,8 @@ export class AgentManager {
117
148
  });
118
149
 
119
150
  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`);
151
+ this.logger?.log("agent", `created new session for chat ${sessionKey}`);
152
+ this.logger?.log("agent", `runtime context for chat ${sessionKey}:\n${buildAgentRuntimeContext()}`);
125
153
  }
126
154
 
127
155
  const ctx = { session, modelId: effectiveModelId };
@@ -132,6 +160,49 @@ export class AgentManager {
132
160
  return ctx;
133
161
  }
134
162
 
163
+ async runTool({ name, request, chatId }) {
164
+ await this.toolRegistry.load();
165
+ this.logger?.log("agent", `run_tool ${name}`);
166
+ const chatArtifactStore = this.artifactStore.forChat(chatId);
167
+ const result = await this.toolRegistry.run({ name, request, chatId });
168
+
169
+ if (result.output?.text) {
170
+ const outArtifact = await chatArtifactStore.createText({
171
+ text: result.output.text,
172
+ source: { type: "tool", toolName: name },
173
+ metadata: { tool: name }
174
+ });
175
+ result.output.artifactId = outArtifact.id;
176
+ }
177
+
178
+ if (result.output?.filePath) {
179
+ const generated = await chatArtifactStore.createFromFile({
180
+ originalPath: result.output.filePath,
181
+ fileName: result.output.fileName || path.basename(result.output.filePath),
182
+ kind: result.output.kind || "file",
183
+ mimeType: result.output.mimeType || "application/octet-stream",
184
+ source: { type: "tool", toolName: name },
185
+ metadata: { tool: name }
186
+ });
187
+ result.output.artifactId = generated.id;
188
+ await unlink(result.output.filePath).catch(() => {});
189
+ }
190
+
191
+ if (result.asyncTask || result.asyncTasks?.length) {
192
+ const scheduled = await this.taskStore.addMany(
193
+ result.asyncTasks || [result.asyncTask],
194
+ {
195
+ payload: { chatId },
196
+ source: { type: "tool", toolName: name, chatId }
197
+ }
198
+ );
199
+ result.asyncTasks = scheduled;
200
+ delete result.asyncTask;
201
+ }
202
+
203
+ return result;
204
+ }
205
+
135
206
  createTools(telegram, chatId) {
136
207
  const chatArtifactStore = this.artifactStore.forChat(chatId);
137
208
 
@@ -160,6 +231,18 @@ export class AgentManager {
160
231
  return { content: [{ type: "text", text: help }], details: { help } };
161
232
  }
162
233
  }),
234
+ defineTool({
235
+ name: "tool_skills",
236
+ label: "Tool skills",
237
+ description: "Show skills assigned to a CLI tool via its manifest skillHints.",
238
+ parameters: Type.Object({ name: Type.String() }),
239
+ execute: async (_id, params) => {
240
+ await this.toolRegistry.load();
241
+ const skills = await this.toolRegistry.resolveSkills(params.name);
242
+ const visible = skills.map(({ content, ...item }) => item);
243
+ return { content: [{ type: "text", text: JSON.stringify(visible, null, 2) }], details: visible };
244
+ }
245
+ }),
163
246
  defineTool({
164
247
  name: "set_tool_config",
165
248
  label: "Set tool config",
@@ -182,8 +265,6 @@ export class AgentManager {
182
265
  args: Type.Optional(Type.Record(Type.String(), Type.String()))
183
266
  }),
184
267
  execute: async (_id, params) => {
185
- await this.toolRegistry.load();
186
- this.logger?.log("agent", `run_tool ${params.name}`);
187
268
  let artifact = null;
188
269
  if (params.artifactId) {
189
270
  artifact = await chatArtifactStore.get(params.artifactId);
@@ -191,7 +272,7 @@ export class AgentManager {
191
272
  return { content: [{ type: "text", text: `Artifact not found: ${params.artifactId}` }], details: { ok: false } };
192
273
  }
193
274
  }
194
- const result = await this.toolRegistry.run({
275
+ const result = await this.runTool({
195
276
  name: params.name,
196
277
  request: {
197
278
  artifact,
@@ -201,40 +282,6 @@ export class AgentManager {
201
282
  chatId
202
283
  });
203
284
 
204
- if (result.output?.text) {
205
- const outArtifact = await chatArtifactStore.createText({
206
- text: result.output.text,
207
- source: { type: "tool", toolName: params.name },
208
- metadata: { tool: params.name }
209
- });
210
- result.output.artifactId = outArtifact.id;
211
- }
212
-
213
- if (result.output?.filePath) {
214
- const generated = await chatArtifactStore.createFromFile({
215
- originalPath: result.output.filePath,
216
- fileName: result.output.fileName || path.basename(result.output.filePath),
217
- kind: result.output.kind || "file",
218
- mimeType: result.output.mimeType || "application/octet-stream",
219
- source: { type: "tool", toolName: params.name },
220
- metadata: { tool: params.name }
221
- });
222
- result.output.artifactId = generated.id;
223
- await unlink(result.output.filePath).catch(() => {});
224
- }
225
-
226
- if (result.asyncTask || result.asyncTasks?.length) {
227
- const scheduled = await this.taskStore.addMany(
228
- result.asyncTasks || [result.asyncTask],
229
- {
230
- payload: { chatId },
231
- source: { type: "tool", toolName: params.name, chatId }
232
- }
233
- );
234
- result.asyncTasks = scheduled;
235
- delete result.asyncTask;
236
- }
237
-
238
285
  return {
239
286
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
240
287
  details: result
@@ -304,7 +351,20 @@ export class AgentManager {
304
351
  }),
305
352
  execute: async (_id, params) => {
306
353
  await this.toolRegistry.load();
307
- const toolName = params.toolName || "openai-tts";
354
+ const selectedTool = params.toolName
355
+ ? this.toolRegistry.get(params.toolName)
356
+ : selectMediaReplyTool(this.toolRegistry);
357
+ if (!selectedTool) {
358
+ const result = {
359
+ ok: false,
360
+ status: "failed",
361
+ error: params.toolName
362
+ ? `Tool not found: ${params.toolName}`
363
+ : "No registered text-to-speech tool can generate an audio reply."
364
+ };
365
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], details: result };
366
+ }
367
+ const toolName = selectedTool.name;
308
368
  this.logger?.log("agent", `send_media_reply via ${toolName}`);
309
369
  const result = await this.toolRegistry.run({
310
370
  name: toolName,
@@ -0,0 +1,22 @@
1
+ export class PromptTimeoutError extends Error {
2
+ constructor(label, timeoutMs) {
3
+ super(`${label} timed out after ${timeoutMs}ms`);
4
+ this.name = "PromptTimeoutError";
5
+ this.timeoutMs = timeoutMs;
6
+ }
7
+ }
8
+
9
+ export async function withTimeout(promise, { timeoutMs, label }) {
10
+ let timer = null;
11
+ const timeout = new Promise((_, reject) => {
12
+ timer = setTimeout(() => {
13
+ reject(new PromptTimeoutError(label, timeoutMs));
14
+ }, timeoutMs);
15
+ });
16
+
17
+ try {
18
+ return await Promise.race([promise, timeout]);
19
+ } finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
@@ -1,5 +1,5 @@
1
1
  import { fileURLToPath } from "node:url";
2
- import { arisaHomeDir, chatsDir, stateDir, toolsDir } from "../../runtime/paths.js";
2
+ import { arisaHomeDir, chatsDir, stateDir, toolStateDir, 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,6 +10,7 @@ export function buildAgentRuntimeContext() {
10
10
  `arisaInstallDir: ${arisaInstallDir}`,
11
11
  `bundledToolsDir: ${bundledToolsDir}`,
12
12
  `userToolsDir: ${toolsDir}`,
13
+ `toolStateDir: ${toolStateDir}`,
13
14
  `chatsDir: ${chatsDir}`,
14
15
  `stateDir: ${stateDir}`
15
16
  ].join("\n");
@@ -19,8 +19,9 @@ function looksLikeAudioTranscriptionTool(tool) {
19
19
  return /transcri|whisper|speech.?to.?text|audio.?to.?text/i.test(`${tool.name} ${tool.description || ""}`);
20
20
  }
21
21
 
22
- function shouldNormalizeAudioToText(artifact, desiredMimeType) {
23
- return artifact?.mimeType?.startsWith("audio/") && desiredMimeType === "text/plain";
22
+ export function shouldNormalizeArtifactToText(artifact, desiredMimeType = "text/plain") {
23
+ return desiredMimeType === "text/plain"
24
+ && (artifact?.mimeType?.startsWith("audio/") || artifact?.mimeType?.startsWith("video/"));
24
25
  }
25
26
 
26
27
  export function selectPipeTool({ toolRegistry, artifact, desiredMimeType }) {
@@ -28,7 +29,7 @@ export function selectPipeTool({ toolRegistry, artifact, desiredMimeType }) {
28
29
  .filter((tool) => toolSupportsArtifact(tool, artifact))
29
30
  .filter((tool) => toolProduces(tool, desiredMimeType));
30
31
 
31
- if (shouldNormalizeAudioToText(artifact, desiredMimeType)) {
32
+ if (shouldNormalizeArtifactToText(artifact, desiredMimeType)) {
32
33
  return tools.find(looksLikeAudioTranscriptionTool) || null;
33
34
  }
34
35
 
@@ -44,7 +45,7 @@ export async function normalizeArtifactForReasoning({
44
45
  }) {
45
46
  if (!artifact) return { normalizedArtifact: null, toolResult: null, toolName: "" };
46
47
 
47
- if (!shouldNormalizeAudioToText(artifact, desiredMimeType)) {
48
+ if (!shouldNormalizeArtifactToText(artifact, desiredMimeType)) {
48
49
  return { normalizedArtifact: null, toolResult: null, toolName: "" };
49
50
  }
50
51
 
@@ -0,0 +1,71 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
+
5
+ const defaultSkillsDir = path.join(os.homedir(), ".agents", "skills");
6
+
7
+ function parseFrontmatter(source = "") {
8
+ if (!source.startsWith("---")) return {};
9
+ const end = source.indexOf("\n---", 3);
10
+ if (end === -1) return {};
11
+ const block = source.slice(3, end).trim();
12
+ const data = {};
13
+ for (const line of block.split("\n")) {
14
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
15
+ if (match) data[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
16
+ }
17
+ return data;
18
+ }
19
+
20
+ function normalizeSkillHint(value) {
21
+ if (typeof value === "string") return { name: value, when: "" };
22
+ if (value && typeof value === "object" && value.name) {
23
+ return { name: String(value.name), when: String(value.when || "") };
24
+ }
25
+ return null;
26
+ }
27
+
28
+ export class SkillRegistry {
29
+ constructor({ skillsDir = defaultSkillsDir } = {}) {
30
+ this.skillsDir = skillsDir;
31
+ this.cache = new Map();
32
+ }
33
+
34
+ async get(name) {
35
+ const key = String(name || "").trim();
36
+ if (!key) return null;
37
+ if (this.cache.has(key)) return this.cache.get(key);
38
+
39
+ const file = path.join(this.skillsDir, key, "SKILL.md");
40
+ try {
41
+ const content = await readFile(file, "utf8");
42
+ const metadata = parseFrontmatter(content);
43
+ const skill = {
44
+ name: metadata.name || key,
45
+ description: metadata.description || "",
46
+ path: file,
47
+ content
48
+ };
49
+ this.cache.set(key, skill);
50
+ return skill;
51
+ } catch {
52
+ this.cache.set(key, null);
53
+ return null;
54
+ }
55
+ }
56
+
57
+ normalizeHints(manifest = {}) {
58
+ const raw = manifest.skillHints || manifest.skills || [];
59
+ if (!Array.isArray(raw)) return [];
60
+ return raw.map(normalizeSkillHint).filter(Boolean);
61
+ }
62
+
63
+ async resolveHints(hints = []) {
64
+ const resolved = [];
65
+ for (const hint of hints) {
66
+ const skill = await this.get(hint.name);
67
+ resolved.push({ ...hint, found: Boolean(skill), skill });
68
+ }
69
+ return resolved;
70
+ }
71
+ }
@@ -27,7 +27,7 @@ function normalizeTask(task, defaults = {}) {
27
27
  createdAt: task.createdAt || new Date().toISOString(),
28
28
  updatedAt: new Date().toISOString(),
29
29
  kind: task.kind,
30
- runAt: task.runAt,
30
+ runAt: task.runAt || new Date().toISOString(),
31
31
  payload: {
32
32
  ...(defaults.payload || {}),
33
33
  ...(task.payload || {})
@@ -0,0 +1,134 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { getToolStateDir, toolStateDir } from "../../runtime/paths.js";
6
+
7
+ export const OWNER_HEARTBEAT_INTERVAL_MS = 2000;
8
+ export const OWNER_HEARTBEAT_TTL_MS = 10000;
9
+
10
+ export function daemonPaths(toolName) {
11
+ const root = getToolStateDir(toolName);
12
+ return {
13
+ root,
14
+ commandsDir: path.join(root, "commands"),
15
+ pidFile: path.join(root, "daemon.pid"),
16
+ metaFile: path.join(root, "daemon.meta.json"),
17
+ statusFile: path.join(root, "status.json"),
18
+ logFile: path.join(root, "daemon.log")
19
+ };
20
+ }
21
+
22
+ export async function readJson(file, fallback = {}) {
23
+ try {
24
+ return JSON.parse(await readFile(file, "utf8"));
25
+ } catch {
26
+ return fallback;
27
+ }
28
+ }
29
+
30
+ export async function writeJson(file, value) {
31
+ await mkdir(path.dirname(file), { recursive: true });
32
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`, "utf8");
33
+ }
34
+
35
+ export function isProcessAlive(pid) {
36
+ if (!pid) return false;
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ async function waitForExit(pid, timeoutMs) {
46
+ const startedAt = Date.now();
47
+ while (Date.now() - startedAt < timeoutMs) {
48
+ if (!isProcessAlive(pid)) return true;
49
+ await new Promise((resolve) => setTimeout(resolve, 100));
50
+ }
51
+ return !isProcessAlive(pid);
52
+ }
53
+
54
+ export async function stopManagedDaemon(toolName, { signal = "SIGTERM", forceAfterMs = 3000 } = {}) {
55
+ const paths = daemonPaths(toolName);
56
+ const { pid } = await readJson(paths.pidFile, {});
57
+ if (isProcessAlive(pid)) {
58
+ try {
59
+ process.kill(pid, signal);
60
+ } catch {}
61
+ if (signal !== "SIGKILL" && forceAfterMs > 0 && !(await waitForExit(pid, forceAfterMs))) {
62
+ try {
63
+ process.kill(pid, "SIGKILL");
64
+ } catch {}
65
+ }
66
+ }
67
+ await rm(paths.pidFile, { force: true });
68
+ }
69
+
70
+ export async function startManagedDaemon({ toolName, entryPath, beforeStart = null, ownerEnv = {} }) {
71
+ const paths = daemonPaths(toolName);
72
+ await mkdir(paths.commandsDir, { recursive: true });
73
+
74
+ const current = await readJson(paths.pidFile, {});
75
+ if (isProcessAlive(current.pid)) {
76
+ const sameOwner = !ownerEnv.ARISA_TOOL_OWNER_TOKEN
77
+ || current.ownerToken === ownerEnv.ARISA_TOOL_OWNER_TOKEN;
78
+ if (sameOwner) return current.pid;
79
+ await stopManagedDaemon(toolName);
80
+ }
81
+
82
+ await rm(paths.commandsDir, { recursive: true, force: true });
83
+ await mkdir(paths.commandsDir, { recursive: true });
84
+ if (beforeStart) await beforeStart();
85
+
86
+ const out = openSync(paths.logFile, "a");
87
+ try {
88
+ const child = spawn(process.execPath, [entryPath, "daemon"], {
89
+ detached: false,
90
+ stdio: ["ignore", out, out],
91
+ env: { ...process.env, ...ownerEnv }
92
+ });
93
+ child.unref();
94
+
95
+ const startedAt = new Date().toISOString();
96
+ const record = {
97
+ pid: child.pid,
98
+ startedAt,
99
+ ownerPid: Number.parseInt(ownerEnv.ARISA_OWNER_PID || "", 10) || null,
100
+ ownerToken: ownerEnv.ARISA_TOOL_OWNER_TOKEN || ""
101
+ };
102
+ await writeJson(paths.pidFile, record);
103
+ await writeJson(paths.metaFile, {
104
+ toolName,
105
+ entryPath,
106
+ autoStart: true,
107
+ lastStartedAt: startedAt,
108
+ ownerFile: ownerEnv.ARISA_TOOL_OWNER_FILE || "",
109
+ ownerPid: record.ownerPid,
110
+ ownerToken: record.ownerToken
111
+ });
112
+ return child.pid;
113
+ } finally {
114
+ closeSync(out);
115
+ }
116
+ }
117
+
118
+ export async function listRegisteredDaemons() {
119
+ let entries = [];
120
+ try {
121
+ entries = await readdir(toolStateDir, { withFileTypes: true });
122
+ } catch {
123
+ return [];
124
+ }
125
+
126
+ const records = [];
127
+ for (const entry of entries) {
128
+ if (!entry.isDirectory()) continue;
129
+ const paths = daemonPaths(entry.name);
130
+ const meta = await readJson(paths.metaFile, null);
131
+ if (meta?.toolName && meta?.entryPath) records.push(meta);
132
+ }
133
+ return records;
134
+ }