arisa 5.1.49 → 5.1.64

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 (56) hide show
  1. package/AGENTS.md +0 -2
  2. package/README.md +9 -0
  3. package/package.json +1 -1
  4. package/src/core/agent/agent-manager.js +49 -489
  5. package/src/core/agent/agent-session-lifecycle.js +181 -0
  6. package/src/core/agent/pi-capability-tools.js +183 -0
  7. package/src/core/artifacts/artifact-store.js +73 -17
  8. package/src/core/capabilities/capability-service.js +340 -0
  9. package/src/core/config/config-defaults.js +28 -1
  10. package/src/core/tasks/task-routing.js +7 -0
  11. package/src/core/tasks/task-runner.js +68 -0
  12. package/src/core/tasks/task-store.js +382 -92
  13. package/src/core/tools/tool-output-materializer.js +5 -5
  14. package/src/core/tools/tool-registry.js +20 -5
  15. package/src/core/tools/weighted-resource-governor.js +153 -0
  16. package/src/index.js +20 -0
  17. package/src/official-tools.lock.json +62 -45
  18. package/src/runtime/arisa-capabilities.js +51 -242
  19. package/src/runtime/create-app.js +11 -2
  20. package/src/runtime/create-headless-app.js +7 -4
  21. package/src/runtime/paths.js +4 -0
  22. package/src/runtime/service-manager.js +3 -1
  23. package/src/runtime/service-supervisor.js +98 -0
  24. package/src/transport/telegram/bot.js +186 -374
  25. package/src/transport/telegram/chat-queue.js +83 -6
  26. package/src/transport/telegram/prompt-builders.js +9 -0
  27. package/src/transport/telegram/reply-topic-routing.js +111 -0
  28. package/src/transport/telegram/task-dispatcher.js +96 -36
  29. package/src/transport/telegram/telegram-auth-controller.js +180 -0
  30. package/src/transport/telegram/telegram-session-bridge.js +177 -0
  31. package/src/transport/telegram/telegram-tools-command.js +28 -0
  32. package/src/transport/telegram/telegram-workspace-controller.js +66 -0
  33. package/src/transport/telegram/workspace-topic-store.js +228 -0
  34. package/test/agent-session-lifecycle.test.js +58 -0
  35. package/test/artifact-store.test.js +38 -2
  36. package/test/capabilities-security.test.js +58 -0
  37. package/test/chat-queue.test.js +32 -0
  38. package/test/context-and-task-bounds.test.js +76 -1
  39. package/test/device-code-message.test.js +9 -0
  40. package/test/media-caption.test.js +1 -1
  41. package/test/model-selection.test.js +9 -1
  42. package/test/official-tool-dependencies.test.js +1 -1
  43. package/test/paths.test.js +8 -0
  44. package/test/pi-capability-tools.test.js +65 -0
  45. package/test/service-manager.test.js +48 -0
  46. package/test/session-start-operational-notes.test.js +1 -1
  47. package/test/task-idempotency.test.js +40 -0
  48. package/test/task-routing.test.js +62 -0
  49. package/test/task-store.test.js +231 -7
  50. package/test/telegram-reply-topic-routing.test.js +94 -0
  51. package/test/telegram-task-dispatcher.test.js +150 -23
  52. package/test/telegram-text-artifact.test.js +13 -2
  53. package/test/telegram-tools-command.test.js +47 -0
  54. package/test/telegram-workspace-topic-store.test.js +124 -0
  55. package/test/tool-registry-run.test.js +41 -0
  56. package/test/weighted-resource-governor.test.js +95 -0
@@ -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
+ }
@@ -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,9 +141,7 @@ 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
147
  async createFileArtifact({ fileName, kind, mimeType, source, metadata = {}, writeFileContent }) {
@@ -104,9 +162,7 @@ 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);
110
166
  }
111
167
 
112
168
  async createFromFile({ originalPath, fileName, kind, mimeType, source, metadata = {} }) {
@@ -133,14 +189,14 @@ class ChatArtifactStore {
133
189
 
134
190
  async get(artifactId) {
135
191
  await this.init();
136
- await this.reload();
137
- return this.items.find((item) => item.id === artifactId) || null;
192
+ const items = await this.readIndex();
193
+ return items.find((item) => item.id === artifactId) || null;
138
194
  }
139
195
 
140
196
  async listRecent(limit = 20) {
141
197
  await this.init();
142
- await this.reload();
143
- return [...this.items].slice(-limit).reverse();
198
+ const items = await this.readIndex();
199
+ return [...items].slice(-limit).reverse();
144
200
  }
145
201
  }
146
202