arisa 5.1.13 → 5.1.49
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 +4 -0
- package/README.md +2 -0
- package/package.json +8 -10
- package/src/core/agent/agent-manager.js +132 -70
- package/src/core/agent/pi-runtime.js +0 -8
- package/src/core/agent/system-shell-tool.js +13 -2
- package/src/core/artifacts/artifact-store.js +17 -18
- package/src/core/config/config-defaults.js +2 -2
- package/src/core/conversation/session-seed-store.js +85 -0
- package/src/core/tools/ipc-client.js +0 -2
- package/src/core/tools/official-tool-installer.js +78 -6
- package/src/core/tools/tool-dependencies.js +99 -0
- package/src/core/tools/tool-output-materializer.js +41 -0
- package/src/core/tools/tool-registry.js +145 -28
- package/src/official-tools.lock.json +209 -5
- package/src/runtime/arisa-capabilities.js +12 -1
- package/src/runtime/create-app.js +1 -0
- package/src/runtime/doctor.js +72 -23
- package/src/runtime/headless-tool-executor.js +2 -32
- package/src/runtime/paths.js +7 -1
- package/src/runtime/restart-receipt.js +90 -0
- package/src/runtime/tool-usage-report.js +25 -10
- package/src/transport/telegram/bot.js +403 -1015
- package/src/transport/telegram/chat-queue.js +132 -0
- package/src/transport/telegram/media.js +2 -2
- package/src/transport/telegram/model-callback.js +211 -0
- package/src/transport/telegram/model-controls.js +164 -0
- package/src/transport/telegram/prompt-builders.js +372 -0
- package/src/transport/telegram/task-dispatcher.js +94 -0
- package/src/transport/telegram/update-command.js +1 -1
- package/src/transport/telegram/workspace-group.js +83 -0
- package/test/agent-tool-policy.test.js +7 -1
- package/test/capabilities-security.test.js +21 -0
- package/test/context-and-task-bounds.test.js +33 -5
- package/test/doctor.test.js +57 -4
- package/test/model-selection.test.js +47 -1
- package/test/official-tool-dependencies.test.js +25 -0
- package/test/official-tool-installer.test.js +37 -9
- package/test/paths.test.js +4 -4
- package/test/restart-receipt.test.js +39 -0
- package/test/session-start-operational-notes.test.js +47 -0
- package/test/telegram-prompt-builders.test.js +33 -0
- package/test/telegram-task-dispatcher.test.js +102 -0
- package/test/telegram-workspace-group.test.js +76 -0
- package/test/tool-dependencies.test.js +53 -0
- package/test/tool-registry-run.test.js +81 -1
- package/test/tool-usage.test.js +26 -4
- package/test/topic-initialization.test.js +66 -0
- package/src/core/conversation/conversation-history-store.js +0 -142
|
@@ -89,6 +89,15 @@ for (const frame of frames) {
|
|
|
89
89
|
return dir;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
async function createHangingTool(name = "hanging-tool") {
|
|
93
|
+
const dir = await createFakeTool(name);
|
|
94
|
+
await writeFile(path.join(dir, "index.js"), `
|
|
95
|
+
process.on("SIGTERM", () => {});
|
|
96
|
+
setInterval(() => {}, 1_000);
|
|
97
|
+
`, "utf8");
|
|
98
|
+
return dir;
|
|
99
|
+
}
|
|
100
|
+
|
|
92
101
|
test("loads and lists installed tools from the user tools directory", async () => {
|
|
93
102
|
await resetHome();
|
|
94
103
|
await createFakeTool("fake-tool", {
|
|
@@ -104,6 +113,7 @@ test("loads and lists installed tools from the user tools directory", async () =
|
|
|
104
113
|
version: null,
|
|
105
114
|
packageDigest: null,
|
|
106
115
|
requirements: [],
|
|
116
|
+
toolDependencies: {},
|
|
107
117
|
description: "Fake test tool",
|
|
108
118
|
input: ["text/plain"],
|
|
109
119
|
output: ["text/plain"],
|
|
@@ -116,6 +126,28 @@ test("loads and lists installed tools from the user tools directory", async () =
|
|
|
116
126
|
}]);
|
|
117
127
|
});
|
|
118
128
|
|
|
129
|
+
test("keeps the previous complete snapshot visible while a reload is in progress", async () => {
|
|
130
|
+
const registry = new ToolRegistry();
|
|
131
|
+
const previous = { name: "stable-tool" };
|
|
132
|
+
const replacement = { name: "replacement-tool" };
|
|
133
|
+
registry.tools = new Map([[previous.name, previous]]);
|
|
134
|
+
|
|
135
|
+
let releaseSnapshot;
|
|
136
|
+
registry.buildSnapshot = () => new Promise((resolve) => {
|
|
137
|
+
releaseSnapshot = () => resolve(new Map([[replacement.name, replacement]]));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const loading = registry.load();
|
|
141
|
+
for (let attempt = 0; attempt < 1_000; attempt += 1) {
|
|
142
|
+
assert.equal(registry.get(previous.name), previous);
|
|
143
|
+
}
|
|
144
|
+
releaseSnapshot();
|
|
145
|
+
await loading;
|
|
146
|
+
|
|
147
|
+
assert.equal(registry.get(previous.name), null);
|
|
148
|
+
assert.equal(registry.get(replacement.name), replacement);
|
|
149
|
+
});
|
|
150
|
+
|
|
119
151
|
test("lists optional semantic metadata with stable defaults", async () => {
|
|
120
152
|
await resetHome();
|
|
121
153
|
await createFakeTool("fake-tool");
|
|
@@ -144,6 +176,23 @@ test("shows semantic metadata in tool help", async () => {
|
|
|
144
176
|
assert.match(help, /Assigned skills:/);
|
|
145
177
|
});
|
|
146
178
|
|
|
179
|
+
test("reports dependency status in help and blocks a tool with a missing dependency", async () => {
|
|
180
|
+
await resetHome();
|
|
181
|
+
await createFakeTool("dependent-tool", {
|
|
182
|
+
version: "1.0.0",
|
|
183
|
+
toolDependencies: { "base-tool": "^1.0.0" }
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const registry = new ToolRegistry();
|
|
187
|
+
await registry.load();
|
|
188
|
+
|
|
189
|
+
assert.match(await registry.help("dependent-tool"), /base-tool@\^1\.0\.0: missing/);
|
|
190
|
+
await assert.rejects(
|
|
191
|
+
() => registry.run({ name: "dependent-tool", request: { args: {} } }),
|
|
192
|
+
/Tool dependency missing/
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
147
196
|
test("runs a registered tool process with an enriched request and cleans up request files", async () => {
|
|
148
197
|
await resetHome();
|
|
149
198
|
await createFakeTool("fake-tool");
|
|
@@ -178,7 +227,7 @@ test("runs a registered tool process with an enriched request and cleans up requ
|
|
|
178
227
|
});
|
|
179
228
|
assert.equal(result.output.env.ARISA_PACKAGE_DIR, arisaPackageDir);
|
|
180
229
|
assert.equal(result.output.env.ARISA_IPC_SOCKET, arisaIpcSocketFile);
|
|
181
|
-
assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1 }]);
|
|
230
|
+
assert.deepEqual(await registry.usage("chat-1"), [{ name: "fake-tool", count: 1, official: false }]);
|
|
182
231
|
|
|
183
232
|
const requestFile = result.output.requestFile;
|
|
184
233
|
await assert.rejects(() => access(requestFile), { code: "ENOENT" });
|
|
@@ -216,6 +265,37 @@ test("rejects unknown tools", async () => {
|
|
|
216
265
|
);
|
|
217
266
|
});
|
|
218
267
|
|
|
268
|
+
test("terminates timed-out tool runs and requires a status check before retry", async () => {
|
|
269
|
+
await resetHome();
|
|
270
|
+
await createHangingTool();
|
|
271
|
+
const registry = new ToolRegistry({ runTimeoutMs: 20, killGraceMs: 20 });
|
|
272
|
+
await registry.load();
|
|
273
|
+
|
|
274
|
+
const result = await registry.run({
|
|
275
|
+
name: "hanging-tool",
|
|
276
|
+
chatId: "chat-1",
|
|
277
|
+
request: { args: {} }
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
assert.equal(result.ok, false);
|
|
281
|
+
assert.equal(result.status, "outcome_uncertain");
|
|
282
|
+
assert.equal(result.resolution.type, "status_check_required");
|
|
283
|
+
assert.equal(result.resolution.retry, false);
|
|
284
|
+
assert.match(result.error, /timed out after 20ms/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("terminates timed-out tool help processes", async () => {
|
|
288
|
+
await resetHome();
|
|
289
|
+
await createHangingTool();
|
|
290
|
+
const registry = new ToolRegistry({ helpTimeoutMs: 20, killGraceMs: 20 });
|
|
291
|
+
await registry.load();
|
|
292
|
+
|
|
293
|
+
await assert.rejects(
|
|
294
|
+
() => registry.help("hanging-tool"),
|
|
295
|
+
/Tool help for hanging-tool timed out after 20ms/
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
219
299
|
test("parses fragmented NDJSON incrementally and keeps stderr diagnostic-only", async () => {
|
|
220
300
|
await resetHome();
|
|
221
301
|
await createStreamingTool();
|
package/test/tool-usage.test.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { ToolRegistry } from "../src/core/tools/tool-registry.js";
|
|
6
7
|
import { ToolUsageStore } from "../src/core/tools/tool-usage-store.js";
|
|
7
8
|
import { formatToolUsageReport } from "../src/runtime/tool-usage-report.js";
|
|
8
9
|
|
|
@@ -26,16 +27,37 @@ test("counts concurrent tool uses per chat", async () => {
|
|
|
26
27
|
}
|
|
27
28
|
});
|
|
28
29
|
|
|
30
|
+
test("reports recorded usage for local tools not present in the startup registry", async () => {
|
|
31
|
+
const registry = new ToolRegistry({
|
|
32
|
+
usageStore: {
|
|
33
|
+
counts: async () => ({ "creator-scout": 4 })
|
|
34
|
+
},
|
|
35
|
+
resolveOfficialToolNames: async () => new Set(["gmail-workspace"])
|
|
36
|
+
});
|
|
37
|
+
registry.tools.set("gmail-workspace", {
|
|
38
|
+
name: "gmail-workspace",
|
|
39
|
+
input: ["application/json"],
|
|
40
|
+
output: ["application/json"]
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.deepEqual(await registry.usage("chat-1"), [
|
|
44
|
+
{ name: "creator-scout", count: 4, official: false },
|
|
45
|
+
{ name: "gmail-workspace", count: 0, official: true }
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
29
49
|
test("formats narrow tool usage counts with bullets and right-aligned numbers", () => {
|
|
30
50
|
const report = formatToolUsageReport([
|
|
31
|
-
{ name: "gmail-workspace", count: 3 },
|
|
32
|
-
{ name: "campaign-draft-runner", count: 12 }
|
|
51
|
+
{ name: "gmail-workspace", count: 3, official: true },
|
|
52
|
+
{ name: "campaign-draft-runner", count: 12, official: false }
|
|
33
53
|
]);
|
|
54
|
+
assert.match(report, /Official\n- gmail-workspace/);
|
|
55
|
+
assert.match(report, /Local\n- campaign-draft-runner/);
|
|
34
56
|
assert.match(report, /- campaign-draft-runner 12/);
|
|
35
57
|
assert.match(report, /- gmail-workspace\s+3/);
|
|
36
58
|
const rows = report.split("\n").filter((line) => line.startsWith("- "));
|
|
37
|
-
assert.match(rows[0], /
|
|
38
|
-
assert.match(rows[1], /
|
|
59
|
+
assert.match(rows[0], /gmail-workspace/);
|
|
60
|
+
assert.match(rows[1], /campaign-draft-runner/);
|
|
39
61
|
assert.deepEqual(rows.map((line) => line.match(/\d+$/).index + line.match(/\d+$/)[0].length), [27, 27]);
|
|
40
62
|
assert.deepEqual(rows.map((line) => line.length), [27, 27]);
|
|
41
63
|
assert.ok(report.split("\n").every((line) => [...line].length <= 35));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { buildTopicInitializationHandoff, isProcessableTelegramMessage, startTelegramTyping } from "../src/transport/telegram/bot.js";
|
|
7
|
+
import { SessionSeedStore } from "../src/core/conversation/session-seed-store.js";
|
|
8
|
+
|
|
9
|
+
test("Telegram typing action stays inside the message topic", async () => {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const stop = await startTelegramTyping({
|
|
12
|
+
chat: { id: "chat-1" },
|
|
13
|
+
message: { message_thread_id: 42 },
|
|
14
|
+
api: {
|
|
15
|
+
sendChatAction: async (chatId, action, options) => {
|
|
16
|
+
calls.push({ chatId, action, options });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
stop();
|
|
21
|
+
|
|
22
|
+
assert.deepEqual(calls, [
|
|
23
|
+
{ chatId: "chat-1", action: "typing", options: { message_thread_id: 42 } }
|
|
24
|
+
]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Telegram service messages do not become empty agent prompts", () => {
|
|
28
|
+
assert.equal(isProcessableTelegramMessage({ forum_topic_created: { name: "Stories" } }), false);
|
|
29
|
+
assert.equal(isProcessableTelegramMessage({ forum_topic_edited: { name: "Stories" } }), false);
|
|
30
|
+
assert.equal(isProcessableTelegramMessage({ text: "hello" }), true);
|
|
31
|
+
assert.equal(isProcessableTelegramMessage({ voice: { file_id: "voice" } }), true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("topic initialization context is explicit and bounded to the topic", () => {
|
|
35
|
+
const handoff = buildTopicInitializationHandoff({ name: "Stories", context: "Draft first-person development stories for arisa.sh." });
|
|
36
|
+
assert.match(handoff, /Telegram topic: Stories/);
|
|
37
|
+
assert.match(handoff, /isolated conversation session/);
|
|
38
|
+
assert.match(handoff, /Draft first-person development stories/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a persisted session seed is consumed exactly once and can be replaced", async () => {
|
|
42
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), "arisa-topic-seed-"));
|
|
43
|
+
const seedFile = (chatId) => path.join(directory, `${chatId}.jsonl`);
|
|
44
|
+
const legacyFile = (chatId) => path.join(directory, `${chatId}.legacy.jsonl`);
|
|
45
|
+
const store = new SessionSeedStore({ seedFile, legacyFile });
|
|
46
|
+
try {
|
|
47
|
+
await store.set("topic", "Stories context");
|
|
48
|
+
assert.equal(await store.consume("topic"), "Stories context");
|
|
49
|
+
assert.equal(await store.consume("topic"), "");
|
|
50
|
+
|
|
51
|
+
await store.set("topic", "Replacement context");
|
|
52
|
+
assert.equal(await store.consume("topic"), "Replacement context");
|
|
53
|
+
|
|
54
|
+
await writeFile(legacyFile("pending-legacy"), `${JSON.stringify({ kind: "seed", history: "Pending legacy context" })}\n`, "utf8");
|
|
55
|
+
assert.equal(await store.consume("pending-legacy"), "Pending legacy context");
|
|
56
|
+
|
|
57
|
+
await writeFile(legacyFile("legacy"), [
|
|
58
|
+
JSON.stringify({ kind: "seed", history: "Old portable context" }),
|
|
59
|
+
JSON.stringify({ kind: "turn", prompt: "stale", response: "stale" }),
|
|
60
|
+
""
|
|
61
|
+
].join("\n"), "utf8");
|
|
62
|
+
assert.equal(await store.consume("legacy"), "");
|
|
63
|
+
} finally {
|
|
64
|
+
await rm(directory, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { getChatConversationHistoryFile } from "../../runtime/paths.js";
|
|
5
|
-
|
|
6
|
-
const utf8Bom = "\uFEFF";
|
|
7
|
-
|
|
8
|
-
function normalizeText(value) {
|
|
9
|
-
return String(value || "").trim();
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function parseHistory(contents) {
|
|
13
|
-
return String(contents || "")
|
|
14
|
-
.replace(/^\uFEFF/, "")
|
|
15
|
-
.split(/\r?\n/)
|
|
16
|
-
.filter(Boolean)
|
|
17
|
-
.map((line) => JSON.parse(line));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function serializeRecord(record) {
|
|
21
|
-
return `${JSON.stringify(record)}\n`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function formatPortableConversation(records) {
|
|
25
|
-
if (!records.length) return "";
|
|
26
|
-
const sections = [
|
|
27
|
-
"Portable Arisa conversation history.",
|
|
28
|
-
"This portable history belongs to the same Telegram chat and is independent of the active agent harness.",
|
|
29
|
-
"Use it as prior conversation context. Do not repeat it unless the user asks."
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
for (const record of records) {
|
|
33
|
-
if (record.kind === "seed") {
|
|
34
|
-
sections.push(`Imported earlier conversation:\n${record.history}`);
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
const parts = [];
|
|
38
|
-
if (record.prompt) parts.push(`User or system request:\n${record.prompt}`);
|
|
39
|
-
if (record.response) parts.push(`Assistant response:\n${record.response}`);
|
|
40
|
-
if (parts.length) sections.push(parts.join("\n\n"));
|
|
41
|
-
}
|
|
42
|
-
return sections.join("\n\n---\n\n");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export class ConversationHistoryStore {
|
|
46
|
-
constructor({ historyFile = getChatConversationHistoryFile } = {}) {
|
|
47
|
-
this.locks = new Map();
|
|
48
|
-
this.historyFile = historyFile;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async withChatLock(chatId, work) {
|
|
52
|
-
const key = String(chatId);
|
|
53
|
-
const previous = this.locks.get(key) || Promise.resolve();
|
|
54
|
-
const current = previous.catch(() => {}).then(work);
|
|
55
|
-
this.locks.set(key, current);
|
|
56
|
-
try {
|
|
57
|
-
return await current;
|
|
58
|
-
} finally {
|
|
59
|
-
if (this.locks.get(key) === current) this.locks.delete(key);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async read(chatId) {
|
|
64
|
-
try {
|
|
65
|
-
return parseHistory(await readFile(this.historyFile(chatId), "utf8"));
|
|
66
|
-
} catch (error) {
|
|
67
|
-
if (error?.code === "ENOENT") return [];
|
|
68
|
-
throw error;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async hasEntries(chatId) {
|
|
73
|
-
return (await this.read(chatId)).length > 0;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async appendRecord(chatId, record) {
|
|
77
|
-
const file = this.historyFile(chatId);
|
|
78
|
-
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
79
|
-
const handle = await open(file, "a+", 0o600);
|
|
80
|
-
try {
|
|
81
|
-
const stats = await handle.stat();
|
|
82
|
-
if (stats.size === 0) await handle.write(utf8Bom);
|
|
83
|
-
await handle.write(serializeRecord(record));
|
|
84
|
-
} finally {
|
|
85
|
-
await handle.close();
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async ensureSeed(chatId, { runtime, history }) {
|
|
90
|
-
const normalizedHistory = normalizeText(history);
|
|
91
|
-
if (!normalizedHistory) return false;
|
|
92
|
-
return this.withChatLock(chatId, async () => {
|
|
93
|
-
if ((await this.read(chatId)).length) return false;
|
|
94
|
-
await this.appendRecord(chatId, {
|
|
95
|
-
id: crypto.randomUUID(),
|
|
96
|
-
kind: "seed",
|
|
97
|
-
runtime,
|
|
98
|
-
history: normalizedHistory,
|
|
99
|
-
createdAt: new Date().toISOString()
|
|
100
|
-
});
|
|
101
|
-
return true;
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async appendTurn(chatId, { runtime, prompt, response }) {
|
|
106
|
-
const normalizedPrompt = normalizeText(prompt);
|
|
107
|
-
const normalizedResponse = normalizeText(response);
|
|
108
|
-
if (!normalizedPrompt && !normalizedResponse) return null;
|
|
109
|
-
const record = {
|
|
110
|
-
id: crypto.randomUUID(),
|
|
111
|
-
kind: "turn",
|
|
112
|
-
runtime,
|
|
113
|
-
prompt: normalizedPrompt,
|
|
114
|
-
response: normalizedResponse,
|
|
115
|
-
createdAt: new Date().toISOString()
|
|
116
|
-
};
|
|
117
|
-
await this.withChatLock(chatId, () => this.appendRecord(chatId, record));
|
|
118
|
-
return record;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
async reset(chatId, { runtime, history = "" } = {}) {
|
|
122
|
-
return this.withChatLock(chatId, async () => {
|
|
123
|
-
const file = this.historyFile(chatId);
|
|
124
|
-
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
125
|
-
const normalizedHistory = normalizeText(history);
|
|
126
|
-
const seed = normalizedHistory
|
|
127
|
-
? serializeRecord({
|
|
128
|
-
id: crypto.randomUUID(),
|
|
129
|
-
kind: "seed",
|
|
130
|
-
runtime,
|
|
131
|
-
history: normalizedHistory,
|
|
132
|
-
createdAt: new Date().toISOString()
|
|
133
|
-
})
|
|
134
|
-
: "";
|
|
135
|
-
await writeFile(file, `${utf8Bom}${seed}`, { encoding: "utf8", mode: 0o600 });
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async buildHandoff(chatId) {
|
|
140
|
-
return formatPortableConversation(await this.read(chatId));
|
|
141
|
-
}
|
|
142
|
-
}
|