arisa 5.1.24 → 5.1.60

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.
Files changed (63) hide show
  1. package/AGENTS.md +0 -2
  2. package/package.json +2 -3
  3. package/src/core/agent/agent-manager.js +91 -479
  4. package/src/core/agent/agent-session-lifecycle.js +181 -0
  5. package/src/core/agent/pi-capability-tools.js +183 -0
  6. package/src/core/agent/pi-runtime.js +0 -8
  7. package/src/core/agent/system-shell-tool.js +13 -2
  8. package/src/core/artifacts/artifact-store.js +90 -35
  9. package/src/core/capabilities/capability-service.js +340 -0
  10. package/src/core/config/config-defaults.js +2 -2
  11. package/src/core/conversation/session-seed-store.js +85 -0
  12. package/src/core/tasks/task-routing.js +7 -0
  13. package/src/core/tasks/task-runner.js +53 -0
  14. package/src/core/tasks/task-store.js +316 -92
  15. package/src/core/tools/ipc-client.js +0 -2
  16. package/src/core/tools/tool-output-materializer.js +41 -0
  17. package/src/core/tools/tool-registry.js +96 -23
  18. package/src/official-tools.lock.json +151 -96
  19. package/src/runtime/arisa-capabilities.js +51 -242
  20. package/src/runtime/create-app.js +10 -1
  21. package/src/runtime/create-headless-app.js +5 -2
  22. package/src/runtime/doctor.js +1 -4
  23. package/src/runtime/headless-tool-executor.js +2 -32
  24. package/src/runtime/paths.js +7 -1
  25. package/src/runtime/restart-receipt.js +90 -0
  26. package/src/transport/telegram/bot.js +373 -1273
  27. package/src/transport/telegram/chat-queue.js +198 -0
  28. package/src/transport/telegram/media.js +2 -2
  29. package/src/transport/telegram/model-callback.js +211 -0
  30. package/src/transport/telegram/model-controls.js +164 -0
  31. package/src/transport/telegram/prompt-builders.js +381 -0
  32. package/src/transport/telegram/task-dispatcher.js +131 -0
  33. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  34. package/src/transport/telegram/telegram-session-bridge.js +170 -0
  35. package/src/transport/telegram/telegram-tools-command.js +28 -0
  36. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  37. package/src/transport/telegram/update-command.js +1 -1
  38. package/src/transport/telegram/workspace-group.js +83 -0
  39. package/test/agent-session-lifecycle.test.js +58 -0
  40. package/test/agent-tool-policy.test.js +7 -1
  41. package/test/artifact-store.test.js +38 -2
  42. package/test/capabilities-security.test.js +58 -0
  43. package/test/context-and-task-bounds.test.js +85 -8
  44. package/test/device-code-message.test.js +9 -0
  45. package/test/doctor.test.js +2 -4
  46. package/test/media-caption.test.js +1 -1
  47. package/test/model-selection.test.js +47 -1
  48. package/test/official-tool-dependencies.test.js +2 -0
  49. package/test/paths.test.js +4 -4
  50. package/test/pi-capability-tools.test.js +65 -0
  51. package/test/restart-receipt.test.js +39 -0
  52. package/test/session-start-operational-notes.test.js +47 -0
  53. package/test/task-idempotency.test.js +40 -0
  54. package/test/task-routing.test.js +62 -0
  55. package/test/task-store.test.js +178 -6
  56. package/test/telegram-prompt-builders.test.js +33 -0
  57. package/test/telegram-task-dispatcher.test.js +178 -0
  58. package/test/telegram-text-artifact.test.js +13 -2
  59. package/test/telegram-tools-command.test.js +47 -0
  60. package/test/telegram-workspace-group.test.js +76 -0
  61. package/test/tool-registry-run.test.js +62 -0
  62. package/test/topic-initialization.test.js +66 -0
  63. package/src/core/conversation/conversation-history-store.js +0 -142
@@ -0,0 +1,181 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { getChatPiSessionsDir, sessionStartOperationalNotesFile } from "../../runtime/paths.js";
4
+ import { arisaInstallDir } from "./runtime-context.js";
5
+
6
+ const operationalNoteMaxChars = 220;
7
+
8
+ function normalizeOperationalNote(note) {
9
+ const text = typeof note === "string" ? note : note?.text;
10
+ const trimmed = String(text || "").replace(/\s+/g, " ").trim();
11
+ if (!trimmed) return "";
12
+ return trimmed.length <= operationalNoteMaxChars
13
+ ? trimmed
14
+ : `${trimmed.slice(0, operationalNoteMaxChars - 1).trim()}…`;
15
+ }
16
+
17
+ export function loadSessionStartOperationalNotes() {
18
+ try {
19
+ const raw = readFileSync(sessionStartOperationalNotesFile, "utf8");
20
+ const parsed = JSON.parse(raw);
21
+ const notes = Array.isArray(parsed) ? parsed : parsed?.notes;
22
+ if (!Array.isArray(notes)) return [];
23
+ return notes.map(normalizeOperationalNote).filter(Boolean).slice(0, 20);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ function formatSessionStartOperationalNotes(notes) {
30
+ if (!notes.length) return "";
31
+ return [
32
+ "Durable operating notes for this Arisa session:",
33
+ ...notes.map((note) => `- ${note}`)
34
+ ].join("\n");
35
+ }
36
+
37
+ function closeAgentSession(session) {
38
+ if (session?.close) return session.close();
39
+ if (session?.dispose) return session.dispose();
40
+ return undefined;
41
+ }
42
+
43
+ export class AgentSessionLifecycle {
44
+ constructor({ logger, summarizeContext }) {
45
+ this.logger = logger;
46
+ this.summarizeContext = summarizeContext;
47
+ this.sessions = new Map();
48
+ this.pendingNewSessions = new Set();
49
+ this.pendingSessionHandoffs = new Map();
50
+ this.sessionClosePromises = new Map();
51
+ }
52
+
53
+ closeCached(sessionKey) {
54
+ const key = String(sessionKey);
55
+ const existing = this.sessions.get(key);
56
+ this.sessions.delete(key);
57
+ const closeSession = (existing?.session?.close || existing?.session?.dispose)
58
+ ? () => closeAgentSession(existing.session)
59
+ : null;
60
+ if (!closeSession) return this.sessionClosePromises.get(key) || Promise.resolve();
61
+
62
+ const previousClose = this.sessionClosePromises.get(key);
63
+ const closePromise = Promise.resolve(previousClose)
64
+ .catch(() => {})
65
+ .then(closeSession)
66
+ .catch((error) => {
67
+ this.logger?.error?.("agent", `session close failed for chat ${key}: ${error instanceof Error ? error.message : String(error)}`);
68
+ })
69
+ .finally(() => {
70
+ if (this.sessionClosePromises.get(key) === closePromise) {
71
+ this.sessionClosePromises.delete(key);
72
+ }
73
+ });
74
+ this.sessionClosePromises.set(key, closePromise);
75
+ return closePromise;
76
+ }
77
+
78
+ async waitForClose(sessionKey) {
79
+ const key = String(sessionKey);
80
+ let closing = this.sessionClosePromises.get(key);
81
+ while (closing) {
82
+ await closing;
83
+ closing = this.sessionClosePromises.get(key);
84
+ }
85
+ }
86
+
87
+ resetConfigState() {
88
+ for (const key of this.sessions.keys()) this.closeCached(key);
89
+ this.pendingNewSessions.clear();
90
+ this.pendingSessionHandoffs.clear();
91
+ }
92
+
93
+ resetSession(chatId, { handoff = "", parentSession = "" } = {}) {
94
+ const sessionKey = String(chatId);
95
+ this.closeCached(sessionKey);
96
+ this.pendingNewSessions.add(sessionKey);
97
+ const text = String(handoff || "").trim();
98
+ const parent = String(parentSession || "").trim();
99
+ if (text || parent) {
100
+ this.pendingSessionHandoffs.set(sessionKey, { text, parentSession: parent });
101
+ } else {
102
+ this.pendingSessionHandoffs.delete(sessionKey);
103
+ }
104
+ }
105
+
106
+ createSessionManager(chatId, workspaceDir = arisaInstallDir, sessionRevision = 0) {
107
+ const sessionKey = String(chatId);
108
+ const sessionDir = getChatPiSessionsDir(sessionKey, sessionRevision);
109
+ if (this.pendingNewSessions.has(sessionKey)) {
110
+ this.logger?.log("agent", `starting new persisted session for chat ${sessionKey}`);
111
+ const handoff = this.pendingSessionHandoffs.get(sessionKey);
112
+ const sessionManager = SessionManager.create(
113
+ workspaceDir,
114
+ sessionDir,
115
+ handoff?.parentSession ? { parentSession: handoff.parentSession } : undefined
116
+ );
117
+ const operationalNotes = formatSessionStartOperationalNotes(loadSessionStartOperationalNotes());
118
+ if (operationalNotes) {
119
+ sessionManager.appendCustomMessageEntry(
120
+ "arisa-operational-notes",
121
+ operationalNotes,
122
+ false,
123
+ { source: "session-start" }
124
+ );
125
+ }
126
+ if (handoff?.text) {
127
+ sessionManager.appendCustomMessageEntry(
128
+ "arisa-session-handoff",
129
+ handoff.text,
130
+ false,
131
+ { source: "telegram-new" }
132
+ );
133
+ }
134
+ return { sessionManager, isNewSession: true };
135
+ }
136
+ this.logger?.log("agent", `recovering persisted session for chat ${sessionKey}`);
137
+ return {
138
+ sessionManager: SessionManager.continueRecent(workspaceDir, sessionDir),
139
+ isNewSession: false
140
+ };
141
+ }
142
+
143
+ completeNewSession(sessionKey) {
144
+ this.pendingNewSessions.delete(sessionKey);
145
+ this.pendingSessionHandoffs.delete(sessionKey);
146
+ }
147
+
148
+ async getDiagnostic() {
149
+ const contexts = await Promise.all([...this.sessions.entries()].map(async ([chatId, context]) => {
150
+ const base = { chatId };
151
+ try {
152
+ const stats = context.session.getSessionStats();
153
+ const retained = this.summarizeContext(context.session.messages);
154
+ return {
155
+ ...base,
156
+ ...retained,
157
+ tokens: stats.contextUsage?.tokens ?? null,
158
+ contextWindow: stats.contextUsage?.contextWindow ?? null,
159
+ percent: stats.contextUsage?.percent ?? null
160
+ };
161
+ } catch (error) {
162
+ return { ...base, error: error instanceof Error ? error.message : String(error) };
163
+ }
164
+ }));
165
+ return {
166
+ harness: "pi",
167
+ sessions: this.sessions.size,
168
+ closingSessions: this.sessionClosePromises.size,
169
+ contexts
170
+ };
171
+ }
172
+
173
+ async closeAll() {
174
+ const contexts = [...this.sessions.values()];
175
+ this.sessions.clear();
176
+ await Promise.allSettled([
177
+ ...this.sessionClosePromises.values(),
178
+ ...contexts.map((context) => closeAgentSession(context.session))
179
+ ]);
180
+ }
181
+ }
@@ -0,0 +1,183 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "@sinclair/typebox";
3
+ import { getCoreCodingTools } from "./core-tools.js";
4
+ import { maxScheduledTaskListLimit } from "../capabilities/capability-service.js";
5
+
6
+ function jsonResult(result, text = JSON.stringify(result, null, 2)) {
7
+ return { content: [{ type: "text", text }], details: result };
8
+ }
9
+
10
+ function nativeTools(policy) {
11
+ return [{
12
+ name: "system_shell",
13
+ source: "arisa-native",
14
+ description: "Run native system shell commands in the active Arisa workspace.",
15
+ workspaceDir: policy.workspaceDir,
16
+ shell: policy.shell.shellPath || (process.platform === "win32" ? "powershell" : "sh"),
17
+ enabled: !(policy.excludeTools || []).includes("system_shell")
18
+ }];
19
+ }
20
+
21
+ export function createPiCapabilityTools({ capabilityService, telegram, chatId, policy, logger }) {
22
+ if (!capabilityService?.execute) throw new Error("Pi capability tools require CapabilityService");
23
+
24
+ const baseContext = {
25
+ telegram,
26
+ returnMissingArtifact: true,
27
+ selectScheduledTasks: true,
28
+ wrapTaskResult: true,
29
+ allowTargetToolName: true,
30
+ delivery: async (artifact, options) => {
31
+ logger?.log("agent", `deliver artifact ${artifact.id} as ${options.method}`);
32
+ await telegram.sendMedia(artifact.path, options);
33
+ return {
34
+ method: options.method,
35
+ fileName: options.filename,
36
+ artifactId: artifact.id
37
+ };
38
+ }
39
+ };
40
+
41
+ const execute = (actorToolName, method, params = {}, context = {}) => capabilityService.execute({
42
+ method,
43
+ actorToolName,
44
+ chatId,
45
+ params,
46
+ context: {
47
+ ...baseContext,
48
+ taskContext: telegram.getTaskContext(),
49
+ ...context
50
+ }
51
+ });
52
+
53
+ return [
54
+ defineTool({
55
+ name: "list_tools",
56
+ label: "List tools",
57
+ description: "List Arisa tools, or search installed tool metadata by capability with automatic official-catalog fallback.",
58
+ parameters: Type.Object({ query: Type.Optional(Type.String()) }),
59
+ execute: async (_id, params) => jsonResult(await execute("list_tools", "tools.list", params, {
60
+ workspaceDir: policy.workspaceDir,
61
+ coreTools: getCoreCodingTools({ tools: policy.tools, excludeTools: policy.excludeTools }),
62
+ nativeTools: nativeTools(policy)
63
+ }))
64
+ }),
65
+ defineTool({
66
+ name: "tool_help",
67
+ label: "Tool help",
68
+ description: "Show --help text for a CLI tool.",
69
+ parameters: Type.Object({ name: Type.String() }),
70
+ execute: async (_id, params) => {
71
+ const help = await execute("tool_help", "tools.help", params);
72
+ return { content: [{ type: "text", text: help }], details: { help } };
73
+ }
74
+ }),
75
+ defineTool({
76
+ name: "tool_skills",
77
+ label: "Tool skills",
78
+ description: "Show skills assigned to a CLI tool via its manifest skillHints.",
79
+ parameters: Type.Object({ name: Type.String() }),
80
+ execute: async (_id, params) => {
81
+ const skills = await execute("tool_skills", "tools.skills", params);
82
+ const visible = skills.map(({ content, ...item }) => item);
83
+ return jsonResult(visible);
84
+ }
85
+ }),
86
+ defineTool({
87
+ name: "set_tool_config",
88
+ label: "Set tool config",
89
+ description: "Write a tool config value scoped to the current chat.",
90
+ parameters: Type.Object({ name: Type.String(), field: Type.String(), value: Type.String() }),
91
+ execute: async (_id, params) => jsonResult(await execute("set_tool_config", "tools.setConfig", params))
92
+ }),
93
+ defineTool({
94
+ name: "set_tool_resource_note",
95
+ label: "Set tool resource note",
96
+ description: "Set or clear a deterministic chat-scoped note of up to 200 characters for one tool resource.",
97
+ parameters: Type.Object({
98
+ name: Type.String(),
99
+ resourceId: Type.String(),
100
+ note: Type.String()
101
+ }),
102
+ execute: async (_id, params) => jsonResult(await execute("set_tool_resource_note", "tools.setResourceNote", params))
103
+ }),
104
+ defineTool({
105
+ name: "run_tool",
106
+ label: "Run tool",
107
+ description: "Run a CLI tool using text input or an artifactId. Inspect the returned status/resolution fields. If a tool reports missing config, ask the user naturally, use set_tool_config, and retry. Set `deliver: true` to also send the generated file to the chat in one step (only when you want the user to receive it now, not for intermediate pipe steps).",
108
+ parameters: Type.Object({
109
+ name: Type.String(),
110
+ artifactId: Type.Optional(Type.String()),
111
+ text: Type.Optional(Type.String()),
112
+ resourceId: Type.Optional(Type.String()),
113
+ args: Type.Optional(Type.Record(Type.String(), Type.String())),
114
+ deliver: Type.Optional(Type.Boolean())
115
+ }),
116
+ execute: async (_id, params) => jsonResult(await execute("run_tool", "tools.run", params))
117
+ }),
118
+ defineTool({
119
+ name: "list_scheduled_tasks",
120
+ label: "List scheduled tasks",
121
+ description: "List scheduled async tasks for the current Telegram chat. Results default to 50 tasks, always include pending/running tasks, and accept an optional limit up to 100.",
122
+ parameters: Type.Object({
123
+ status: Type.Optional(Type.String()),
124
+ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: maxScheduledTaskListLimit }))
125
+ }),
126
+ execute: async (_id, params) => jsonResult(await execute("list_scheduled_tasks", "tasks.list", params))
127
+ }),
128
+ defineTool({
129
+ name: "cancel_scheduled_task",
130
+ label: "Cancel scheduled task",
131
+ description: "Cancel one scheduled async task by id for the current Telegram chat.",
132
+ parameters: Type.Object({ id: Type.String() }),
133
+ execute: async (_id, params) => jsonResult(await execute("cancel_scheduled_task", "tasks.cancel", params))
134
+ }),
135
+ defineTool({
136
+ name: "cancel_all_scheduled_tasks",
137
+ label: "Cancel all scheduled tasks",
138
+ description: "Cancel all pending or running async tasks for the current Telegram chat.",
139
+ parameters: Type.Object({}),
140
+ execute: async () => jsonResult(await execute("cancel_all_scheduled_tasks", "tasks.cancelAll"))
141
+ }),
142
+ defineTool({
143
+ name: "create_telegram_topic",
144
+ label: "Create Telegram topic",
145
+ description: "Create and initialize a new topic in the current owner-only Telegram forum. Topic names are dynamic, and context seeds the isolated session without copying unrelated history.",
146
+ parameters: Type.Object({
147
+ name: Type.String({ minLength: 1, maxLength: 128 }),
148
+ context: Type.String({ minLength: 1, maxLength: 4000 })
149
+ }),
150
+ execute: async (_id, params) => jsonResult(await execute("create_telegram_topic", "telegram.createTopic", params))
151
+ }),
152
+ defineTool({
153
+ name: "initialize_telegram_topic",
154
+ label: "Initialize Telegram topic",
155
+ description: "Seed or replace the isolated context of an existing topic in the current owner-only Telegram forum.",
156
+ parameters: Type.Object({
157
+ messageThreadId: Type.Integer({ minimum: 2 }),
158
+ name: Type.String({ minLength: 1, maxLength: 128 }),
159
+ context: Type.String({ minLength: 1, maxLength: 4000 })
160
+ }),
161
+ execute: async (_id, params) => jsonResult(await execute("initialize_telegram_topic", "telegram.initializeTopic", params))
162
+ }),
163
+ defineTool({
164
+ name: "send_artifact",
165
+ label: "Send artifact",
166
+ description: "Deliver an existing chat artifact to the current Telegram chat. Pass the `artifactId` returned by run_tool or from an inbound file. The delivery method and filename are derived from the artifact (its delivery hint, kind, and stored name); internal local paths are never exposed. No caption is shown by default, since the filename already appears on the attachment; set `caption` only to add a separate visible label, or `method` to override the delivery method. The artifact is not deleted.",
167
+ parameters: Type.Object({
168
+ artifactId: Type.String(),
169
+ caption: Type.Optional(Type.String()),
170
+ method: Type.Optional(Type.Union([
171
+ Type.Literal("voice"),
172
+ Type.Literal("audio"),
173
+ Type.Literal("document")
174
+ ]))
175
+ }),
176
+ execute: async (_id, params) => {
177
+ const result = await execute("send_artifact", "artifacts.deliver", params);
178
+ if (result?.ok === false) return jsonResult(result);
179
+ return jsonResult({ ok: true, sent: result }, `Media sent to Telegram as ${result.method}.`);
180
+ }
181
+ })
182
+ ];
183
+ }
@@ -101,11 +101,3 @@ export function clampModelThinkingLevel(model, level) {
101
101
  export function modelSupportsThinking(model) {
102
102
  return listModelThinkingLevels(model).some((level) => level !== "off");
103
103
  }
104
-
105
- export function findPiModel({ provider, model, apiKey } = {}) {
106
- const runtime = createPiRuntime({ provider, apiKey });
107
- return {
108
- ...runtime,
109
- model: provider && model ? runtime.modelRegistry.find(provider, model) : null
110
- };
111
- }
@@ -164,25 +164,36 @@ async function runShellCommand({ command, cwd, shellPath, timeoutMs }) {
164
164
  });
165
165
  }
166
166
 
167
- export function createSystemShellTool({ workspaceDir, shell = {} }) {
167
+ export function isArisaRestartCommand(command) {
168
+ return /(?:^|[;&|]\s*)arisa\s+restart(?:\s*(?:$|[;&|]))/i.test(String(command || ""));
169
+ }
170
+
171
+ export function createSystemShellTool({ workspaceDir, shell = {}, beforeRestart, cancelRestart }) {
168
172
  return defineTool({
169
173
  name: "system_shell",
170
174
  label: "System Shell",
171
175
  description: "Run a command in the active Arisa workspace using the native system shell: PowerShell on Windows, and sh/bash-compatible shell on Unix.",
172
176
  parameters: Type.Object({
173
177
  command: Type.String({ description: "Command to execute in the active workspace." }),
174
- timeoutMs: Type.Optional(Type.Number({ description: "Optional timeout in milliseconds for this command." }))
178
+ timeoutMs: Type.Optional(Type.Number({ description: "Optional timeout in milliseconds for this command." })),
179
+ restartSummary: Type.Optional(Type.String({ maxLength: 500, description: "Concrete user-facing result to report after an arisa restart succeeds." }))
175
180
  }),
176
181
  execute: async (_id, params) => {
177
182
  const timeoutMs = Number.isFinite(Number(params.timeoutMs)) && Number(params.timeoutMs) > 0
178
183
  ? Math.floor(Number(params.timeoutMs))
179
184
  : (shell.timeoutMs || defaultTimeoutMs);
185
+ const restartReceipt = isArisaRestartCommand(params.command) && typeof beforeRestart === "function"
186
+ ? await beforeRestart(params.restartSummary)
187
+ : null;
180
188
  const result = await runShellCommand({
181
189
  command: params.command,
182
190
  cwd: workspaceDir,
183
191
  shellPath: shell.shellPath,
184
192
  timeoutMs
185
193
  });
194
+ if (!result.ok && restartReceipt?.id && typeof cancelRestart === "function") {
195
+ await cancelRestart(restartReceipt.id).catch(() => {});
196
+ }
186
197
  const details = {
187
198
  stdout: result.stdout,
188
199
  stderr: result.stderr,
@@ -1,9 +1,10 @@
1
- import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
1
+ import { copyFile, mkdir, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
4
  import { getChatArtifactsDir, getChatArtifactsIndexFile } from "../../runtime/paths.js";
5
5
 
6
6
  const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]);
7
+ const indexOperations = new Map();
7
8
 
8
9
  function id() {
9
10
  return crypto.randomUUID();
@@ -42,6 +43,48 @@ async function copyArtifactFile(originalPath, destPath, mimeType) {
42
43
  return writeFile(destPath, withUtf8Bom(content));
43
44
  }
44
45
 
46
+ async function serializeIndexOperation(indexFile, operation) {
47
+ const previous = indexOperations.get(indexFile) || Promise.resolve();
48
+ const current = previous.catch(() => {}).then(operation);
49
+ indexOperations.set(indexFile, current);
50
+ try {
51
+ return await current;
52
+ } finally {
53
+ if (indexOperations.get(indexFile) === current) indexOperations.delete(indexFile);
54
+ }
55
+ }
56
+
57
+ async function syncParentDirectory(file) {
58
+ let handle;
59
+ try {
60
+ handle = await open(path.dirname(file), "r");
61
+ await handle.sync();
62
+ } catch {
63
+ // Some platforms do not support fsync on directories; rename remains atomic there.
64
+ } finally {
65
+ await handle?.close().catch(() => {});
66
+ }
67
+ }
68
+
69
+ async function writeJsonAtomically(file, value) {
70
+ await mkdir(path.dirname(file), { recursive: true });
71
+ const temporary = `${file}.${process.pid}.${id()}.tmp`;
72
+ let handle;
73
+ try {
74
+ handle = await open(temporary, "wx", 0o600);
75
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
76
+ await handle.sync();
77
+ await handle.close();
78
+ handle = null;
79
+ await rename(temporary, file);
80
+ await syncParentDirectory(file);
81
+ } catch (error) {
82
+ await handle?.close().catch(() => {});
83
+ await unlink(temporary).catch(() => {});
84
+ throw error;
85
+ }
86
+ }
87
+
45
88
  class ChatArtifactStore {
46
89
  constructor(chatId) {
47
90
  this.chatId = String(chatId);
@@ -52,9 +95,15 @@ class ChatArtifactStore {
52
95
 
53
96
  async reload() {
54
97
  try {
55
- this.items = JSON.parse(await readFile(this.indexFile, "utf8"));
56
- } catch {
57
- this.items = [];
98
+ const parsed = JSON.parse(await readFile(this.indexFile, "utf8"));
99
+ if (!Array.isArray(parsed)) throw new Error("Artifact index must contain a JSON array");
100
+ this.items = parsed;
101
+ } catch (error) {
102
+ if (error?.code === "ENOENT") {
103
+ this.items = [];
104
+ return;
105
+ }
106
+ throw new Error(`Artifact index is unreadable: ${this.indexFile}`, { cause: error });
58
107
  }
59
108
  }
60
109
 
@@ -63,9 +112,20 @@ class ChatArtifactStore {
63
112
  if (!this.items) await this.reload();
64
113
  }
65
114
 
66
- async saveIndex() {
67
- await mkdir(path.dirname(this.indexFile), { recursive: true });
68
- await writeFile(this.indexFile, `${JSON.stringify(this.items, null, 2)}\n`, "utf8");
115
+ async appendToIndex(artifact) {
116
+ return serializeIndexOperation(this.indexFile, async () => {
117
+ await this.reload();
118
+ this.items.push(artifact);
119
+ await writeJsonAtomically(this.indexFile, this.items);
120
+ return artifact;
121
+ });
122
+ }
123
+
124
+ async readIndex() {
125
+ return serializeIndexOperation(this.indexFile, async () => {
126
+ await this.reload();
127
+ return this.items;
128
+ });
69
129
  }
70
130
 
71
131
  async createText({ text, mimeType = "text/plain", source, metadata = {} }) {
@@ -81,19 +141,17 @@ class ChatArtifactStore {
81
141
  metadata,
82
142
  createdAt: new Date().toISOString()
83
143
  };
84
- this.items.push(artifact);
85
- await this.saveIndex();
86
- return artifact;
144
+ return this.appendToIndex(artifact);
87
145
  }
88
146
 
89
- async createFromFile({ originalPath, fileName, kind, mimeType, source, metadata = {} }) {
147
+ async createFileArtifact({ fileName, kind, mimeType, source, metadata = {}, writeFileContent }) {
90
148
  await this.init();
91
149
  await this.reload();
92
150
  const artifactId = id();
93
151
  const dir = path.join(this.rootDir, artifactId);
94
152
  await mkdir(dir, { recursive: true });
95
153
  const destPath = path.join(dir, fileName);
96
- await copyArtifactFile(originalPath, destPath, mimeType);
154
+ await writeFileContent(destPath);
97
155
  const artifact = {
98
156
  id: artifactId,
99
157
  chatId: this.chatId,
@@ -104,44 +162,41 @@ class ChatArtifactStore {
104
162
  metadata,
105
163
  createdAt: new Date().toISOString()
106
164
  };
107
- this.items.push(artifact);
108
- await this.saveIndex();
109
- return artifact;
165
+ return this.appendToIndex(artifact);
166
+ }
167
+
168
+ async createFromFile({ originalPath, fileName, kind, mimeType, source, metadata = {} }) {
169
+ return this.createFileArtifact({
170
+ fileName,
171
+ kind,
172
+ mimeType,
173
+ source,
174
+ metadata,
175
+ writeFileContent: (destPath) => copyArtifactFile(originalPath, destPath, mimeType)
176
+ });
110
177
  }
111
178
 
112
179
  async createGeneratedFile({ fileName, content, kind, mimeType, source, metadata = {} }) {
113
- await this.init();
114
- await this.reload();
115
- const artifactId = id();
116
- const dir = path.join(this.rootDir, artifactId);
117
- await mkdir(dir, { recursive: true });
118
- const destPath = path.join(dir, fileName);
119
- await writeArtifactFile(destPath, content);
120
- const artifact = {
121
- id: artifactId,
122
- chatId: this.chatId,
180
+ return this.createFileArtifact({
181
+ fileName,
123
182
  kind,
124
183
  mimeType,
125
- path: destPath,
126
184
  source,
127
185
  metadata,
128
- createdAt: new Date().toISOString()
129
- };
130
- this.items.push(artifact);
131
- await this.saveIndex();
132
- return artifact;
186
+ writeFileContent: (destPath) => writeArtifactFile(destPath, content)
187
+ });
133
188
  }
134
189
 
135
190
  async get(artifactId) {
136
191
  await this.init();
137
- await this.reload();
138
- return this.items.find((item) => item.id === artifactId) || null;
192
+ const items = await this.readIndex();
193
+ return items.find((item) => item.id === artifactId) || null;
139
194
  }
140
195
 
141
196
  async listRecent(limit = 20) {
142
197
  await this.init();
143
- await this.reload();
144
- return [...this.items].slice(-limit).reverse();
198
+ const items = await this.readIndex();
199
+ return [...items].slice(-limit).reverse();
145
200
  }
146
201
  }
147
202