arisa 4.2.6 → 4.3.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.
@@ -7,22 +7,106 @@ import {
7
7
  readJson,
8
8
  startManagedDaemon,
9
9
  stopManagedDaemon,
10
+ writeDaemonStatus,
10
11
  writeJson
11
12
  } from "./daemon-processes.js";
13
+ import { loadDaemonPolicy } from "./daemon-policy.js";
12
14
 
13
- export function createDaemonRuntime({ toolName, entryPath, beforeStart = null }) {
14
- const paths = daemonPaths(toolName);
15
+ const CONTROL_FIELD = "__daemon";
15
16
 
16
- async function ensure() {
17
- await mkdir(paths.commandsDir, { recursive: true });
17
+ function sleep(ms) {
18
+ return new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+
21
+ async function withTimeout(work, timeoutMs, message) {
22
+ let timer;
23
+ try {
24
+ return await Promise.race([
25
+ Promise.resolve().then(work),
26
+ new Promise((_, reject) => {
27
+ timer = setTimeout(() => {
28
+ const error = new Error(message);
29
+ error.code = "DAEMON_OPERATION_TIMEOUT";
30
+ reject(error);
31
+ }, timeoutMs);
32
+ })
33
+ ]);
34
+ } finally {
35
+ clearTimeout(timer);
18
36
  }
37
+ }
19
38
 
20
- function jobPaths(id) {
21
- return {
22
- request: path.join(paths.commandsDir, `${id}.request.json`),
23
- processing: path.join(paths.commandsDir, `${id}.processing.json`),
24
- result: path.join(paths.commandsDir, `${id}.result.json`)
25
- };
39
+ function jobPaths(paths, id) {
40
+ return {
41
+ request: path.join(paths.commandsDir, `${id}.request.json`),
42
+ processing: path.join(paths.commandsDir, `${id}.processing.json`),
43
+ result: path.join(paths.commandsDir, `${id}.result.json`)
44
+ };
45
+ }
46
+
47
+ async function waitForResult(paths, id, { timeoutMs, intervalMs }) {
48
+ const files = jobPaths(paths, id);
49
+ const startedAt = Date.now();
50
+ while (Date.now() - startedAt < timeoutMs) {
51
+ const result = await readJson(files.result, null);
52
+ if (result) {
53
+ await unlink(files.result).catch(() => {});
54
+ if (!result.ok) {
55
+ const error = new Error(result.error || `${paths.toolName} daemon job failed`);
56
+ if (result.code) error.code = result.code;
57
+ throw error;
58
+ }
59
+ return result.output || {};
60
+ }
61
+ await sleep(intervalMs);
62
+ }
63
+ const error = new Error(`${paths.toolName} daemon job timed out after ${timeoutMs}ms`);
64
+ error.code = "DAEMON_JOB_TIMEOUT";
65
+ throw error;
66
+ }
67
+
68
+ async function enqueue(paths, payload, { control = false, timeoutMs, intervalMs }) {
69
+ await mkdir(paths.commandsDir, { recursive: true });
70
+ const id = `${control ? "control" : "job"}-${crypto.randomUUID()}`;
71
+ await writeJson(jobPaths(paths, id).request, { id, ...payload });
72
+ return waitForResult(paths, id, { timeoutMs, intervalMs });
73
+ }
74
+
75
+ export async function submitDaemonControl(record, operation, { timeoutMs } = {}) {
76
+ const paths = daemonPaths({ toolName: record.toolName, scope: record.scope });
77
+ const policy = await loadDaemonPolicy();
78
+ return enqueue(paths, {
79
+ [CONTROL_FIELD]: { operation }
80
+ }, {
81
+ control: true,
82
+ timeoutMs: timeoutMs ?? policy.healthTimeoutMs,
83
+ intervalMs: policy.queuePollIntervalMs
84
+ });
85
+ }
86
+
87
+ export function isDaemonReady(status, pid, policy, now = Date.now()) {
88
+ if (status.state !== "ready" || !isProcessAlive(pid)) return false;
89
+ const heartbeatAt = new Date(status.heartbeatAt || 0).getTime();
90
+ const healthAt = new Date(status.lastHealthSuccessAt || 0).getTime();
91
+ if (!heartbeatAt || now - heartbeatAt > policy.heartbeatStaleMs) return false;
92
+ if (!healthAt || now - healthAt > policy.healthIntervalMs + policy.healthTimeoutMs) return false;
93
+ return true;
94
+ }
95
+
96
+ export function createDaemonRuntime({
97
+ toolName,
98
+ entryPath,
99
+ scope = { type: "global" },
100
+ startupContext = {},
101
+ beforeStart = null,
102
+ autoStart = true
103
+ }) {
104
+ const paths = daemonPaths({ toolName, scope });
105
+ const registration = { toolName, entryPath, scope: paths.scope, startupContext, autoStart };
106
+ let statusWrite = Promise.resolve();
107
+
108
+ async function ensure() {
109
+ await mkdir(paths.commandsDir, { recursive: true });
26
110
  }
27
111
 
28
112
  async function getPid() {
@@ -30,60 +114,72 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
30
114
  }
31
115
 
32
116
  async function writeStatus(patch) {
33
- const current = await readJson(paths.statusFile, {});
34
- await writeJson(paths.statusFile, { ...current, ...patch, updatedAt: new Date().toISOString() });
117
+ statusWrite = statusWrite.catch(() => {}).then(() => writeDaemonStatus(paths, patch));
118
+ return statusWrite;
35
119
  }
36
120
 
37
121
  async function start() {
38
122
  return startManagedDaemon({
39
- toolName,
40
- entryPath,
123
+ ...registration,
41
124
  beforeStart
42
125
  });
43
126
  }
44
127
 
45
128
  async function stop() {
46
- await stopManagedDaemon(toolName);
129
+ await stopManagedDaemon({ toolName, scope: paths.scope });
47
130
  }
48
131
 
49
- async function waitReady({ timeoutMs = 120000, readyStates = ["ready"] } = {}) {
132
+ async function waitReady({ timeoutMs } = {}) {
133
+ const policy = await loadDaemonPolicy();
134
+ const effectiveTimeoutMs = timeoutMs ?? policy.startupTimeoutMs;
50
135
  const startTime = Date.now();
51
- while (Date.now() - startTime < timeoutMs) {
136
+ while (Date.now() - startTime < effectiveTimeoutMs) {
52
137
  const status = await readJson(paths.statusFile, {});
53
138
  const pid = await getPid();
54
- if (readyStates.includes(status.state) && isProcessAlive(pid)) return status;
55
- if (status.state === "error") throw new Error(status.message || `${toolName} daemon failed`);
56
- await new Promise((resolve) => setTimeout(resolve, 500));
139
+ if (isDaemonReady(status, pid, policy)) return status;
140
+ if (status.state === "failed") throw new Error(status.message || `${toolName} daemon failed`);
141
+ await sleep(policy.queuePollIntervalMs);
57
142
  }
58
- throw new Error(`${toolName} daemon was not ready after ${timeoutMs}ms`);
143
+ throw new Error(`${toolName} daemon was not ready after ${effectiveTimeoutMs}ms`);
59
144
  }
60
145
 
61
- async function submit(payload, { timeoutMs = 180000, readyTimeoutMs = 120000 } = {}) {
62
- await start();
63
- await waitReady({ timeoutMs: readyTimeoutMs });
64
- const id = crypto.randomUUID();
65
- const files = jobPaths(id);
66
- await writeJson(files.request, { id, ...payload });
146
+ async function ensureReady({ timeoutMs } = {}) {
147
+ const policy = await loadDaemonPolicy();
148
+ const status = await readJson(paths.statusFile, {});
149
+ const pid = await getPid();
150
+ if (isDaemonReady(status, pid, policy)) return status;
151
+ await submitDaemonControl(registration, "health", {
152
+ timeoutMs: timeoutMs ?? policy.healthTimeoutMs
153
+ });
154
+ return waitReady({ timeoutMs: timeoutMs ?? policy.startupTimeoutMs });
155
+ }
67
156
 
68
- const startTime = Date.now();
69
- while (Date.now() - startTime < timeoutMs) {
70
- const result = await readJson(files.result, null);
71
- if (result) {
72
- await unlink(files.result).catch(() => {});
73
- if (!result.ok) throw new Error(result.error || `${toolName} job failed`);
74
- return result.output || {};
75
- }
76
- await new Promise((resolve) => setTimeout(resolve, 250));
77
- }
78
- throw new Error(`${toolName} job timed out after ${timeoutMs}ms`);
157
+ async function submit(payload, {
158
+ timeoutMs,
159
+ readyTimeoutMs,
160
+ requireReady = true
161
+ } = {}) {
162
+ const policy = await loadDaemonPolicy();
163
+ await start();
164
+ if (requireReady) await ensureReady({ timeoutMs: readyTimeoutMs });
165
+ return enqueue(paths, payload, {
166
+ timeoutMs: timeoutMs ?? policy.startupTimeoutMs,
167
+ intervalMs: policy.queuePollIntervalMs
168
+ });
79
169
  }
80
170
 
81
171
  async function claimNext() {
82
172
  await ensure();
83
- const files = (await readdir(paths.commandsDir)).filter((file) => file.endsWith(".request.json"));
173
+ const files = (await readdir(paths.commandsDir))
174
+ .filter((file) => file.endsWith(".request.json"))
175
+ .sort((a, b) => {
176
+ const aControl = a.startsWith("control-") ? 0 : 1;
177
+ const bControl = b.startsWith("control-") ? 0 : 1;
178
+ return aControl - bControl || a.localeCompare(b);
179
+ });
84
180
  for (const file of files) {
85
181
  const id = file.replace(/\.request\.json$/, "");
86
- const item = jobPaths(id);
182
+ const item = jobPaths(paths, id);
87
183
  try {
88
184
  await rename(item.request, item.processing);
89
185
  return { id, ...item, payload: await readJson(item.processing, null) };
@@ -98,33 +194,152 @@ export function createDaemonRuntime({ toolName, entryPath, beforeStart = null })
98
194
  }
99
195
 
100
196
  async function fail(job, error) {
101
- await writeJson(job.result, { ok: false, error: error?.message || String(error) });
197
+ await writeJson(job.result, {
198
+ ok: false,
199
+ error: error?.message || String(error),
200
+ code: error?.code || null
201
+ });
102
202
  await unlink(job.processing).catch(() => {});
103
203
  }
104
204
 
105
- async function workLoop({ processJob, idleTimeoutMs = 0, intervalMs = 250 }) {
205
+ async function workLoop({
206
+ processJob,
207
+ healthCheck,
208
+ recover = null,
209
+ beforeExit = null,
210
+ idleTimeoutMs = 0
211
+ }) {
212
+ if (typeof healthCheck !== "function") {
213
+ throw new Error(`${toolName} daemon must declare healthCheck`);
214
+ }
215
+ const policy = await loadDaemonPolicy();
216
+ const intervalMs = policy.queuePollIntervalMs;
106
217
  let lastActivity = Date.now();
107
- setInterval(async () => {
218
+ let processing = false;
219
+ let exiting = false;
220
+ let acceptingWork = true;
221
+
222
+ await ensure();
223
+ await writeStatus({
224
+ state: "starting",
225
+ pid: process.pid,
226
+ heartbeatAt: new Date().toISOString(),
227
+ supportsRecovery: typeof recover === "function",
228
+ message: "Daemon work loop started; waiting for health check"
229
+ });
230
+
231
+ const heartbeatTimer = setInterval(() => {
232
+ writeStatus({ heartbeatAt: new Date().toISOString() }).catch(() => {});
233
+ }, policy.heartbeatIntervalMs);
234
+
235
+ const workTimer = setInterval(async () => {
236
+ if (processing || exiting || !acceptingWork) return;
237
+ processing = true;
108
238
  try {
109
239
  const job = await claimNext();
110
240
  if (job) {
111
- lastActivity = Date.now();
241
+ const operation = job.payload?.[CONTROL_FIELD]?.operation;
112
242
  try {
113
- await complete(job, await processJob(job.payload));
243
+ if (operation === "health") {
244
+ const checkedAt = new Date().toISOString();
245
+ await writeStatus({ lastHealthCheckAt: checkedAt });
246
+ const output = await withTimeout(
247
+ healthCheck,
248
+ policy.healthTimeoutMs,
249
+ `${toolName} health check timed out after ${policy.healthTimeoutMs}ms`
250
+ );
251
+ await writeStatus({
252
+ state: "ready",
253
+ lastHealthSuccessAt: new Date().toISOString(),
254
+ consecutiveHealthFailures: 0,
255
+ restartAttempts: 0,
256
+ restartRequested: false,
257
+ nextRestartAt: null,
258
+ message: output?.message || "Daemon health check passed"
259
+ });
260
+ await complete(job, output || { ok: true });
261
+ } else if (operation === "recover") {
262
+ const recovered = typeof recover === "function"
263
+ ? await withTimeout(
264
+ recover,
265
+ policy.healthTimeoutMs,
266
+ `${toolName} recovery timed out after ${policy.healthTimeoutMs}ms`
267
+ )
268
+ : false;
269
+ await complete(job, { recovered: recovered !== false });
270
+ } else {
271
+ lastActivity = Date.now();
272
+ const output = await processJob(job.payload);
273
+ await writeStatus({ lastSuccessfulJobAt: new Date().toISOString() });
274
+ await complete(job, output);
275
+ lastActivity = Date.now();
276
+ }
114
277
  } catch (error) {
278
+ if (error?.code === "DAEMON_OPERATION_TIMEOUT") {
279
+ acceptingWork = false;
280
+ }
281
+ const current = await readJson(paths.statusFile, {});
282
+ await writeStatus({
283
+ ...(operation === "health"
284
+ ? {
285
+ state: error?.code === "DAEMON_OPERATION_TIMEOUT" ? "unhealthy" : "degraded",
286
+ consecutiveHealthFailures: Number(current.consecutiveHealthFailures || 0) + 1
287
+ }
288
+ : {}),
289
+ lastError: {
290
+ at: new Date().toISOString(),
291
+ phase: operation || "job",
292
+ message: error?.message || String(error)
293
+ },
294
+ message: error?.message || String(error)
295
+ });
115
296
  await fail(job, error);
116
297
  }
117
- lastActivity = Date.now();
118
298
  }
119
299
  if (idleTimeoutMs > 0 && Date.now() - lastActivity > idleTimeoutMs) {
120
- await writeStatus({ state: "stopped", message: "Idle timeout reached" });
300
+ exiting = true;
301
+ clearInterval(heartbeatTimer);
302
+ clearInterval(workTimer);
303
+ if (beforeExit) await beforeExit();
304
+ await writeStatus({
305
+ state: "stopped",
306
+ restartRequested: false,
307
+ nextRestartAt: null,
308
+ message: "Idle timeout reached"
309
+ });
121
310
  process.exit(0);
122
311
  }
123
312
  } catch (error) {
124
- await writeStatus({ state: "error", message: error?.message || String(error) });
313
+ await writeStatus({
314
+ state: "degraded",
315
+ lastError: {
316
+ at: new Date().toISOString(),
317
+ phase: "work-loop",
318
+ message: error?.message || String(error)
319
+ },
320
+ message: error?.message || String(error)
321
+ });
322
+ } finally {
323
+ processing = false;
125
324
  }
126
325
  }, intervalMs);
326
+
327
+ submitDaemonControl(registration, "health", {
328
+ timeoutMs: policy.healthTimeoutMs
329
+ }).catch(() => {});
127
330
  }
128
331
 
129
- return { paths, ensure, getPid, writeStatus, start, stop, waitReady, submit, workLoop };
332
+ return {
333
+ paths,
334
+ registration,
335
+ ensure,
336
+ getPid,
337
+ writeStatus,
338
+ start,
339
+ stop,
340
+ waitReady,
341
+ ensureReady,
342
+ submit,
343
+ workLoop
344
+ };
130
345
  }
@@ -6,6 +6,7 @@ import { spawn } from "node:child_process";
6
6
  import { Bot } from "grammy";
7
7
  import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
8
8
  import { createPiRuntime, hasProviderAuth, listPiProviders, listProviderModels, supportsProviderOAuth } from "../core/agent/pi-runtime.js";
9
+ import { applyConfigDefaults } from "../core/config/config-defaults.js";
9
10
  import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
10
11
  import { configFile, ensureArisaHome } from "./paths.js";
11
12
 
@@ -28,7 +29,7 @@ async function exists(file) {
28
29
  }
29
30
 
30
31
  function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [], chatMeta = {}, provider, model, piApiKey }) {
31
- return {
32
+ return applyConfigDefaults({
32
33
  telegram: {
33
34
  token: telegramApiKey,
34
35
  maxChatIds: telegramMaxChatIds,
@@ -41,7 +42,7 @@ function buildConfig({ telegramApiKey, telegramMaxChatIds, authorizedChatIds = [
41
42
  apiKey: piApiKey
42
43
  },
43
44
  createdAt: new Date().toISOString()
44
- };
45
+ });
45
46
  }
46
47
 
47
48
  function sortBootstrapProviders(providers) {
@@ -98,7 +98,7 @@ export async function createApp({ logger, runtimeOverrides } = {}) {
98
98
  }
99
99
 
100
100
  const artifactStore = new ArtifactStore();
101
- const toolProcessSupervisor = createToolProcessSupervisor({ logger });
101
+ const toolProcessSupervisor = createToolProcessSupervisor({ logger, policy: config.daemons });
102
102
  const toolRegistry = new ToolRegistry({ logger });
103
103
  const taskStore = new TaskStore();
104
104
  await toolRegistry.load();
@@ -43,6 +43,35 @@ export function getChatToolStateDir(chatId, toolName) {
43
43
  return path.join(getChatDir(chatId), "state", "tools", toolName);
44
44
  }
45
45
 
46
+ export function normalizeDaemonScope(scope = { type: "global" }) {
47
+ if (!scope || scope === "global" || scope.type === "global") {
48
+ return { type: "global" };
49
+ }
50
+ if (scope === "chat") {
51
+ throw new Error("chat daemon scope requires chatId");
52
+ }
53
+ if (scope.type !== "chat") {
54
+ throw new Error(`Unsupported daemon scope: ${scope.type || scope}`);
55
+ }
56
+ const chatId = String(scope.chatId ?? "").trim();
57
+ if (!/^-?\d+$/.test(chatId)) {
58
+ throw new Error(`Invalid chat daemon scope: ${chatId || "missing chatId"}`);
59
+ }
60
+ return { type: "chat", chatId };
61
+ }
62
+
63
+ export function getDaemonInstanceId(scope = { type: "global" }) {
64
+ const normalized = normalizeDaemonScope(scope);
65
+ return normalized.type === "global" ? "global" : `chat:${normalized.chatId}`;
66
+ }
67
+
68
+ export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
69
+ const normalized = normalizeDaemonScope(scope);
70
+ return normalized.type === "global"
71
+ ? getToolStateDir(toolName)
72
+ : path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
73
+ }
74
+
46
75
  export function getChatPiSessionsDir(chatId) {
47
76
  return path.join(getChatDir(chatId), "state", "pi-sessions");
48
77
  }
@@ -1,11 +1,7 @@
1
- import { access, rm } from "node:fs/promises";
2
- import {
3
- daemonPaths,
4
- isProcessAlive,
5
- listRegisteredDaemons,
6
- readJson,
7
- startManagedDaemon
8
- } from "../core/tools/daemon-processes.js";
1
+ import { access } from "node:fs/promises";
2
+ import { superviseDaemon } from "../core/tools/daemon-health.js";
3
+ import { listRegisteredDaemons } from "../core/tools/daemon-processes.js";
4
+ import { loadDaemonPolicy } from "../core/tools/daemon-policy.js";
9
5
  import { ensureArisaHome } from "./paths.js";
10
6
 
11
7
  async function fileExists(file) {
@@ -17,47 +13,63 @@ async function fileExists(file) {
17
13
  }
18
14
  }
19
15
 
20
- export function createToolProcessSupervisor({ logger } = {}) {
16
+ export function createToolProcessSupervisor({ logger, policy } = {}) {
21
17
  let running = false;
18
+ let timer = null;
19
+ let reconciliation = null;
20
+ let daemonPolicy = policy;
21
+
22
+ function reportLoopError(error) {
23
+ logger?.error?.("tools", `daemon supervisor loop failed: ${error?.message || error}`);
24
+ }
22
25
 
23
26
  async function reconcileDaemons() {
24
27
  await ensureArisaHome();
25
28
  for (const record of await listRegisteredDaemons()) {
26
- if (!record.autoStart) continue;
27
29
  if (!(await fileExists(record.entryPath))) {
28
30
  logger?.log("tools", `skipping daemon ${record.toolName}: missing entry ${record.entryPath}`);
29
31
  continue;
30
32
  }
31
-
32
- const paths = daemonPaths(record.toolName);
33
- const { pid } = await readJson(paths.pidFile, {});
34
- if (isProcessAlive(pid)) {
35
- logger?.log("tools", `adopted managed daemon ${record.toolName} (pid ${pid})`);
36
- continue;
37
- }
38
- if (pid) {
39
- await rm(paths.pidFile, { force: true });
40
- logger?.log("tools", `removed stale daemon pid for ${record.toolName} (${pid})`);
33
+ try {
34
+ const outcome = await superviseDaemon(record, daemonPolicy);
35
+ if (outcome !== "healthy") {
36
+ logger?.log("tools", `${record.toolName} (${record.instanceId || "global"}): ${outcome}`);
37
+ }
38
+ } catch (error) {
39
+ logger?.error?.("tools", `daemon supervision failed for ${record.toolName}: ${error?.message || error}`);
41
40
  }
41
+ }
42
+ }
42
43
 
43
- logger?.log("tools", `starting managed daemon ${record.toolName}`);
44
- await startManagedDaemon({
45
- toolName: record.toolName,
46
- entryPath: record.entryPath
47
- });
44
+ async function runLoop() {
45
+ if (!running || reconciliation) return;
46
+ reconciliation = reconcileDaemons();
47
+ try {
48
+ await reconciliation;
49
+ } finally {
50
+ reconciliation = null;
51
+ if (running) {
52
+ timer = setTimeout(() => {
53
+ runLoop().catch(reportLoopError);
54
+ }, daemonPolicy.supervisorIntervalMs);
55
+ }
48
56
  }
49
57
  }
50
58
 
51
59
  return {
52
60
  async start() {
53
61
  if (running) return;
62
+ daemonPolicy ||= await loadDaemonPolicy();
54
63
  running = true;
55
- await reconcileDaemons();
64
+ runLoop().catch(reportLoopError);
56
65
  },
57
66
 
58
67
  async stop() {
59
68
  if (!running) return;
60
69
  running = false;
70
+ clearTimeout(timer);
71
+ timer = null;
72
+ await reconciliation?.catch(() => {});
61
73
  }
62
74
  };
63
75
  }
@@ -78,12 +78,12 @@ test("creates generated file artifacts", async () => {
78
78
  source: { type: "assistant" }
79
79
  });
80
80
 
81
- assert.equal(await readFile(artifact.path, "utf8"), "# Hello\n");
81
+ assert.equal(await readFile(artifact.path, "utf8"), "\ufeff# Hello\n");
82
82
  assert.equal(artifact.kind, "document");
83
83
  assert.equal(artifact.mimeType, "text/markdown");
84
84
  });
85
85
 
86
- test("writes generated text file artifacts as UTF-8", async () => {
86
+ test("writes generated text file artifacts as BOM UTF-8", async () => {
87
87
  await resetHome();
88
88
  const content = "# Español\nÑandú\n";
89
89
  const artifact = await new ArtifactStore().forChat("chat-1").createGeneratedFile({
@@ -94,6 +94,43 @@ test("writes generated text file artifacts as UTF-8", async () => {
94
94
  source: { type: "assistant" }
95
95
  });
96
96
 
97
+ assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
98
+ });
99
+
100
+ test("normalizes copied generated text files to BOM UTF-8", async () => {
101
+ await resetHome();
102
+ const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
103
+ const originalPath = path.join(originalDir, "report.json");
104
+ const content = "{\"message\":\"Español\"}\n";
105
+ await writeFile(originalPath, content, "utf8");
106
+
107
+ const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
108
+ originalPath,
109
+ fileName: "report.json",
110
+ kind: "document",
111
+ mimeType: "application/json",
112
+ source: { type: "tool", toolName: "reporter" }
113
+ });
114
+
115
+ assert.deepEqual(await readFile(artifact.path), Buffer.from(`\ufeff${content}`, "utf8"));
116
+ assert.equal(await readFile(originalPath, "utf8"), content);
117
+ });
118
+
119
+ test("does not duplicate an existing UTF-8 BOM", async () => {
120
+ await resetHome();
121
+ const originalDir = await mkdtemp(path.join(os.tmpdir(), "arisa-source-file-"));
122
+ const originalPath = path.join(originalDir, "report.txt");
123
+ const content = "\ufeffAlready marked\n";
124
+ await writeFile(originalPath, content, "utf8");
125
+
126
+ const artifact = await new ArtifactStore().forChat("chat-1").createFromFile({
127
+ originalPath,
128
+ fileName: "report.txt",
129
+ kind: "document",
130
+ mimeType: "text/plain",
131
+ source: { type: "tool", toolName: "reporter" }
132
+ });
133
+
97
134
  assert.deepEqual(await readFile(artifact.path), Buffer.from(content, "utf8"));
98
135
  });
99
136
 
@@ -0,0 +1,28 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ const expected = {
9
+ "whispermix-transcribe": { scope: "global", autoStart: false },
10
+ "whatsapp-web": { scope: "chat", autoStart: false },
11
+ "roster-sites": { scope: "global", autoStart: true },
12
+ "turn-server": { scope: "global", autoStart: true },
13
+ "signaling-server": { scope: "global", autoStart: true }
14
+ };
15
+
16
+ for (const [toolName, daemon] of Object.entries(expected)) {
17
+ test(`${toolName} declares the managed daemon contract`, async () => {
18
+ const toolDir = path.join(repositoryRoot, "tools", toolName);
19
+ const manifest = JSON.parse(await readFile(path.join(toolDir, "tool.manifest.json"), "utf8"));
20
+ const source = await readFile(path.join(toolDir, manifest.entry), "utf8");
21
+
22
+ assert.equal(manifest.daemon.scope, daemon.scope);
23
+ assert.equal(manifest.daemon.autoStart, daemon.autoStart);
24
+ assert.equal(manifest.daemon.health, "internal");
25
+ assert.match(source, /createDaemonRuntime/);
26
+ assert.match(source, /healthCheck/);
27
+ });
28
+ }