pi-roundtable 0.1.0 → 0.2.1
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/CHANGELOG.md +148 -0
- package/README.md +10 -6
- package/README.zh-TW.md +144 -0
- package/docs/plugins.md +1448 -66
- package/examples/echo-runtime.test.ts +122 -0
- package/examples/echo-runtime.ts +107 -0
- package/examples/events.test.ts +1 -0
- package/examples/events.ts +2 -2
- package/examples/fake-surface.test.ts +180 -0
- package/examples/fake-surface.ts +109 -0
- package/examples/interactions.test.ts +12 -3
- package/examples/interactions.ts +19 -19
- package/examples/shared-services.test.ts +74 -0
- package/examples/shared-services.ts +60 -0
- package/examples/study-room.test.ts +69 -0
- package/examples/study-room.ts +52 -0
- package/examples/support-desk.test.ts +34 -0
- package/examples/support-desk.ts +40 -0
- package/package.json +11 -3
- package/src/cli/checks/database.ts +11 -30
- package/src/cli/checks/images.ts +19 -0
- package/src/cli/doctor.ts +6 -0
- package/src/cli/main.ts +2 -1
- package/src/core/agents/agent-claim.ts +42 -16
- package/src/core/agents/agent-messages.ts +4 -4
- package/src/core/agents/agent-ports.ts +8 -21
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-store.ts +6 -4
- package/src/core/agents/agent-team-fixture.ts +389 -0
- package/src/core/agents/agent-team.ts +26 -25
- package/src/core/agents/agent-tools.ts +34 -15
- package/src/core/agents/avatar-studio.ts +40 -6
- package/src/core/agents/fallback-avatar.ts +85 -0
- package/src/core/agents/team-editing.ts +49 -10
- package/src/core/agents/team-keys.ts +22 -9
- package/src/core/agents/team-layout.ts +4 -3
- package/src/core/agents/team-lifecycle.ts +28 -2
- package/src/core/agents/team-options.ts +9 -5
- package/src/core/agents/team-status.ts +4 -4
- package/src/core/agents/team-text.ts +8 -6
- package/src/core/agents/team-turn-types.ts +4 -4
- package/src/core/agents/team-turns.ts +6 -5
- package/src/core/builtin/agent-server.ts +187 -121
- package/src/core/builtin/discord-admin.ts +41 -0
- package/src/core/builtin/discord.ts +65 -40
- package/src/core/builtin/modules.ts +67 -58
- package/src/core/builtin/session-tool.ts +24 -0
- package/src/core/builtin/skills.ts +72 -0
- package/src/core/builtin/stores.ts +55 -31
- package/src/core/config/config.ts +45 -4
- package/src/core/config/schema.ts +6 -0
- package/src/core/contract/channels.ts +54 -14
- package/src/core/contract/providers.ts +17 -0
- package/src/core/contract/runtime.ts +138 -0
- package/src/core/contract/services.ts +45 -0
- package/src/core/contract/surface.ts +88 -0
- package/src/core/db/migrations.ts +118 -3
- package/src/core/define-roundtable.ts +70 -46
- package/src/core/define.ts +2 -1
- package/src/core/discord/agent-commands.ts +39 -14
- package/src/core/discord/agent-panel.ts +66 -0
- package/src/core/discord/channel-executor.ts +6 -3
- package/src/core/discord/channel-operations.ts +14 -11
- package/src/core/discord/command-collection.ts +46 -0
- package/src/core/{registry/interactions.ts → discord/compose-commands.ts} +5 -5
- package/src/core/discord/connection.ts +35 -0
- package/src/core/discord/discord-surface.ts +32 -94
- package/src/core/discord/dispatch-threads.ts +2 -2
- package/src/core/discord/inbound-message.ts +99 -0
- package/src/core/discord/interaction-module.ts +57 -1
- package/src/core/discord/owner-cards.ts +4 -4
- package/src/core/discord/owner-command.ts +44 -11
- package/src/core/discord/owner-discord.ts +2 -2
- package/src/core/discord/schedule-commands.ts +21 -18
- package/src/core/discord/stop-button.ts +44 -1
- package/src/core/domain/attachment.ts +12 -8
- package/src/core/domain/conversation.ts +3 -4
- package/src/core/domain/owner-prompts.ts +5 -5
- package/src/core/domain/ports.ts +9 -53
- package/src/core/freeze.ts +9 -0
- package/src/core/host.ts +255 -82
- package/src/core/http/listeners.ts +55 -20
- package/src/core/i18n/agent-panel.ts +4 -0
- package/src/core/i18n/index.ts +12 -2
- package/src/core/i18n/owner.ts +7 -1
- package/src/core/i18n/schedules.ts +12 -6
- package/src/core/identity.ts +1 -1
- package/src/core/judging/effort-judge.ts +25 -3
- package/src/core/log.ts +21 -3
- package/src/core/models.ts +2 -2
- package/src/core/modules/background/background-turns.ts +6 -4
- package/src/core/modules/delegation/delegate.ts +3 -2
- package/src/core/modules/delegation/delegator.ts +15 -13
- package/src/core/modules/delegation/{sol-worker.ts → web-research-worker.ts} +6 -6
- package/src/core/modules/discord-admin/discord-admin.ts +6 -6
- package/src/core/modules/host-shell/shell-policy.ts +8 -3
- package/src/core/modules/memory/owner-memory-store.ts +27 -25
- package/src/core/modules/memory/owner-memory.ts +9 -13
- package/src/core/modules/schedules/recurrence.ts +5 -3
- package/src/core/modules/schedules/schedule-store.ts +11 -11
- package/src/core/modules/schedules/schedule-tools.ts +22 -20
- package/src/core/modules/schedules/scheduler.ts +7 -2
- package/src/core/modules/schedules/schedules.ts +9 -3
- package/src/core/modules/skills/skill-registry.ts +3 -2
- package/src/core/modules/skills/skill-store.ts +2 -15
- package/src/core/modules/skills/skill-tools.ts +10 -4
- package/src/core/ops/error-reporter.ts +1 -1
- package/src/core/plugin.ts +232 -28
- package/src/core/registry/contributions.ts +173 -14
- package/src/core/registry/providers.ts +35 -5
- package/src/core/registry/services.ts +229 -0
- package/src/core/routing/channel-queue.ts +5 -0
- package/src/core/routing/channel-router.ts +21 -7
- package/src/core/routing/conversation-turns.ts +139 -0
- package/src/core/routing/message-text.ts +9 -2
- package/src/core/routing/surface-port.ts +39 -0
- package/src/core/runtime/conversation-sessions.ts +4 -3
- package/src/core/runtime/extensions/confirmation-fixture.ts +11 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +11 -9
- package/src/core/runtime/mcp.ts +1 -1
- package/src/core/runtime/pending-confirmation-store.ts +10 -12
- package/src/core/runtime/pi-agent-runtime.ts +9 -6
- package/src/core/runtime/prompt-slot.ts +8 -3
- package/src/core/runtime/runtime-types.ts +9 -35
- package/src/core/runtime/session-factory.ts +21 -5
- package/src/core/runtime/text-tools.ts +1 -3
- package/src/core/services.ts +238 -90
- package/src/core/sessions.ts +3 -2
- package/src/core/shared/{profile-tools.ts → active-tools.ts} +2 -2
- package/src/core/shared/delegate-tool.ts +4 -3
- package/src/core/shared/schedule-tools.ts +22 -13
- package/src/core/shared/session-messages.ts +1 -1
- package/src/core/speakers.ts +6 -38
- package/src/core/testing/database.ts +1 -7
- package/src/core/testing/eager-catalog.ts +30 -0
- package/src/core/testing/locale.ts +23 -5
- package/src/core/testing/modules.ts +105 -41
- package/src/core/testing/test-host.ts +279 -0
- package/src/core/testing/tool-set.ts +64 -0
- package/src/core/time.ts +6 -1
- package/src/core/tool-set.snapshot.json +288 -0
- package/src/core/tool-tiers.ts +17 -57
- package/src/discord/index.ts +64 -0
- package/src/index.ts +181 -10
- package/src/kit/channels.ts +14 -0
- package/src/kit/domain.ts +18 -0
- package/src/kit/holds.ts +4 -0
- package/src/kit/index.ts +129 -0
- package/src/kit/judging.ts +10 -0
- package/src/kit/memory.ts +3 -0
- package/src/kit/mirror.ts +19 -0
- package/src/kit/presentation.ts +7 -0
- package/src/kit/shell.ts +7 -0
- package/src/kit/skills.ts +8 -0
- package/src/kit/support.ts +21 -0
- package/src/kit/threads.ts +7 -0
- package/src/kit/tools.ts +14 -0
- package/src/kit/worker.ts +16 -0
- package/src/testing.ts +394 -34
- package/examples/guide.test.ts +0 -52
- package/src/cli/add-plugin.test.ts +0 -107
- package/src/cli/checks/basic.test.ts +0 -276
- package/src/cli/checks/database.test.ts +0 -117
- package/src/cli/checks/discord.test.ts +0 -200
- package/src/cli/cli.test.ts +0 -122
- package/src/cli/config-edit.test.ts +0 -130
- package/src/cli/doctor.test.ts +0 -184
- package/src/cli/init.test.ts +0 -127
- package/src/cli/size.test.ts +0 -9
- package/src/cli/templates.test.ts +0 -129
- package/src/cli/testing/fixtures.ts +0 -106
- package/src/core/agents/agent-claim.test.ts +0 -142
- package/src/core/agents/agent-dashboard.test.ts +0 -120
- package/src/core/agents/agent-guild.test.ts +0 -124
- package/src/core/agents/agent-store.test.ts +0 -205
- package/src/core/agents/avatar-studio.test.ts +0 -88
- package/src/core/agents/group-round.test.ts +0 -143
- package/src/core/agents/owner-identity.test.ts +0 -172
- package/src/core/agents/team-turns.test.ts +0 -278
- package/src/core/attachments/attachments.test.ts +0 -85
- package/src/core/boundary.test.ts +0 -45
- package/src/core/builtin/modules.test.ts +0 -127
- package/src/core/config/config.test.ts +0 -135
- package/src/core/contract/discord.ts +0 -33
- package/src/core/db/migrations.test.ts +0 -237
- package/src/core/define-roundtable.test.ts +0 -134
- package/src/core/define.test.ts +0 -144
- package/src/core/discord/agent-commands.test.ts +0 -58
- package/src/core/discord/agent-discord.test.ts +0 -70
- package/src/core/discord/dispatch-thread-host.test.ts +0 -114
- package/src/core/discord/dispatch-threads.test.ts +0 -94
- package/src/core/discord/owner-cards.test.ts +0 -435
- package/src/core/discord/owner-discord.test.ts +0 -325
- package/src/core/domain/expression.ts +0 -21
- package/src/core/domain/profile.ts +0 -32
- package/src/core/drain.test.ts +0 -46
- package/src/core/events.test.ts +0 -81
- package/src/core/holds.test.ts +0 -54
- package/src/core/host.test.ts +0 -536
- package/src/core/http/listeners.test.ts +0 -164
- package/src/core/i18n/i18n.test.ts +0 -139
- package/src/core/identity.test.ts +0 -18
- package/src/core/judging/effort-judge.test.ts +0 -112
- package/src/core/judging/model-judge.test.ts +0 -126
- package/src/core/modules/delegation/delegator.test.ts +0 -170
- package/src/core/modules/memory/owner-memory-store.test.ts +0 -244
- package/src/core/modules/schedules/schedule.test.ts +0 -358
- package/src/core/modules/skills/skill-kind.test.ts +0 -55
- package/src/core/modules/skills/skill-registry.test.ts +0 -376
- package/src/core/ops/error-reporter.test.ts +0 -283
- package/src/core/presentation/card-cadence.ts +0 -41
- package/src/core/presentation/presentation.test.ts +0 -166
- package/src/core/public-entry.test.ts +0 -59
- package/src/core/registry/contributions.test.ts +0 -215
- package/src/core/registry/interactions.test.ts +0 -87
- package/src/core/registry/providers.test.ts +0 -103
- package/src/core/routing/channel-queue.test.ts +0 -31
- package/src/core/routing/channel-router.test.ts +0 -326
- package/src/core/routing/conversation-kind.ts +0 -14
- package/src/core/routing/settle-turn.test.ts +0 -21
- package/src/core/runtime/compaction-tiers.test.ts +0 -227
- package/src/core/runtime/extensions/ask-user.test.ts +0 -114
- package/src/core/runtime/extensions/self-compact-guard.test.ts +0 -50
- package/src/core/runtime/pending-confirmation-store.test.ts +0 -52
- package/src/core/runtime/session-archive.test.ts +0 -20
- package/src/core/runtime/steerable-run.test.ts +0 -264
- package/src/core/runtime/text-tools.test.ts +0 -110
- package/src/core/runtime/turn-answer.test.ts +0 -72
- package/src/core/runtime/worker-task.test.ts +0 -82
- package/src/core/services.test.ts +0 -28
- package/src/core/sessions.test.ts +0 -85
- package/src/core/shared/unix-server.ts +0 -18
- package/src/core/size.test.ts +0 -9
- package/src/core/speakers.test.ts +0 -78
- package/src/core/testing/file-size.ts +0 -34
- package/src/core/time.test.ts +0 -166
- package/src/core/tool-tiers.test.ts +0 -117
- package/src/entries.test.ts +0 -112
- package/src/testing.test.ts +0 -156
|
@@ -1,376 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
existsSync,
|
|
4
|
-
mkdirSync,
|
|
5
|
-
mkdtempSync,
|
|
6
|
-
readFileSync,
|
|
7
|
-
rmSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
10
|
-
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { SQL } from "bun";
|
|
14
|
-
import { AgentError } from "../../domain/errors.ts";
|
|
15
|
-
import { silentLogger } from "../../log.ts";
|
|
16
|
-
import {
|
|
17
|
-
describeDb,
|
|
18
|
-
openTestStore,
|
|
19
|
-
TEST_GUILD,
|
|
20
|
-
type TestStore,
|
|
21
|
-
testDatabaseUrl,
|
|
22
|
-
} from "../../testing/database.ts";
|
|
23
|
-
import { SkillRegistry } from "./skill-registry.ts";
|
|
24
|
-
import { SkillStore } from "./skill-store.ts";
|
|
25
|
-
|
|
26
|
-
const BUILTIN = join(import.meta.dir, "..", "..", "assets", "skills");
|
|
27
|
-
|
|
28
|
-
// Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
|
|
29
|
-
describeDb("PostgreSQL", () => {
|
|
30
|
-
let store: TestStore<SkillStore>;
|
|
31
|
-
let registry: SkillRegistry;
|
|
32
|
-
let dir: string;
|
|
33
|
-
|
|
34
|
-
const skillFile = (name: string, description: string) =>
|
|
35
|
-
`---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n`;
|
|
36
|
-
|
|
37
|
-
function repoSkill(
|
|
38
|
-
repo: string,
|
|
39
|
-
path: string,
|
|
40
|
-
name: string,
|
|
41
|
-
description = `Use when ${name}.`,
|
|
42
|
-
) {
|
|
43
|
-
mkdirSync(join(dir, "repos", repo, ".git"), { recursive: true });
|
|
44
|
-
mkdirSync(join(dir, "repos", repo, path), { recursive: true });
|
|
45
|
-
writeFileSync(
|
|
46
|
-
join(dir, "repos", repo, path, "SKILL.md"),
|
|
47
|
-
skillFile(name, description),
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const open = async () => {
|
|
52
|
-
store = await openTestStore(SkillStore, TEST_GUILD);
|
|
53
|
-
registry = new SkillRegistry({
|
|
54
|
-
store,
|
|
55
|
-
reposDir: join(dir, "repos"),
|
|
56
|
-
writtenDir: join(dir, "skills"),
|
|
57
|
-
builtinDir: BUILTIN,
|
|
58
|
-
logger: silentLogger(),
|
|
59
|
-
});
|
|
60
|
-
registry.init();
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
beforeEach(async () => {
|
|
64
|
-
await store?.close();
|
|
65
|
-
const admin = new SQL(testDatabaseUrl);
|
|
66
|
-
for (const table of ["skills", "skill_groups", "agent_skills"])
|
|
67
|
-
await admin.unsafe(`DROP TABLE IF EXISTS ${table}`);
|
|
68
|
-
await admin.close();
|
|
69
|
-
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
70
|
-
dir = mkdtempSync(join(tmpdir(), "roundtable-skills-"));
|
|
71
|
-
repoSkill("acme/skills", "skills/plan-work", "plan-work");
|
|
72
|
-
repoSkill("acme/skills", "skills/write-tests", "write-tests");
|
|
73
|
-
await open();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
afterAll(async () => {
|
|
77
|
-
await store.close();
|
|
78
|
-
rmSync(dir, { recursive: true, force: true });
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const refuses = async (run: () => Promise<unknown>, text: string) => {
|
|
82
|
-
const error = await run().then(
|
|
83
|
-
() => undefined,
|
|
84
|
-
(e: unknown) => e,
|
|
85
|
-
);
|
|
86
|
-
expect(error).toBeInstanceOf(AgentError);
|
|
87
|
-
expect((error as Error).message).toContain(text);
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
describe("built-in skill", () => {
|
|
91
|
-
test("every set carries writing-skills from the release, with a trigger-only description", () => {
|
|
92
|
-
const set = registry.resolve([]);
|
|
93
|
-
expect(set.missing).toEqual([]);
|
|
94
|
-
expect(set.skills.map((s) => s.name)).toEqual(["writing-skills"]);
|
|
95
|
-
expect(set.skills[0]?.description.startsWith("Use when")).toBe(true);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("it cannot be removed, changed, or taken as a name", async () => {
|
|
99
|
-
await refuses(
|
|
100
|
-
() => registry.attach("notes", [], ["writing-skills"]),
|
|
101
|
-
"built in",
|
|
102
|
-
);
|
|
103
|
-
await refuses(
|
|
104
|
-
() => registry.update("writing-skills", { body: "x" }),
|
|
105
|
-
"built in",
|
|
106
|
-
);
|
|
107
|
-
await refuses(
|
|
108
|
-
() =>
|
|
109
|
-
registry.create({
|
|
110
|
-
name: "writing-skills",
|
|
111
|
-
description: "Use when x.",
|
|
112
|
-
body: "x",
|
|
113
|
-
}),
|
|
114
|
-
"already exists",
|
|
115
|
-
);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
describe("linked skills", () => {
|
|
120
|
-
test("a folder of skills links every child with its own name and description", async () => {
|
|
121
|
-
const text = await registry.link("acme/skills", "skills");
|
|
122
|
-
expect(text).toContain("Linked 2 skills");
|
|
123
|
-
const set = registry.resolve(["plan-work", "write-tests"]);
|
|
124
|
-
expect(set.skills.map((s) => s.name)).toEqual([
|
|
125
|
-
"writing-skills",
|
|
126
|
-
"plan-work",
|
|
127
|
-
"write-tests",
|
|
128
|
-
]);
|
|
129
|
-
expect(set.skills[1]?.file).toBe(
|
|
130
|
-
join(dir, "repos/acme/skills/skills/plan-work/SKILL.md"),
|
|
131
|
-
);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test("a single skill folder links just that skill", async () => {
|
|
135
|
-
await registry.link("acme/skills", "./skills/plan-work/");
|
|
136
|
-
expect(store.skill("plan-work")).toEqual({
|
|
137
|
-
kind: "linked",
|
|
138
|
-
name: "plan-work",
|
|
139
|
-
repo: "acme/skills",
|
|
140
|
-
path: "skills/plan-work",
|
|
141
|
-
});
|
|
142
|
-
expect(store.skill("write-tests")).toBeUndefined();
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test("a pull that changes the description shows on the next resolve", async () => {
|
|
146
|
-
await registry.link("acme/skills", "skills/plan-work");
|
|
147
|
-
repoSkill(
|
|
148
|
-
"acme/skills",
|
|
149
|
-
"skills/plan-work",
|
|
150
|
-
"plan-work",
|
|
151
|
-
"Use when changed.",
|
|
152
|
-
);
|
|
153
|
-
expect(registry.resolve(["plan-work"]).skills[1]?.description).toBe(
|
|
154
|
-
"Use when changed.",
|
|
155
|
-
);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("one taken name refuses the whole call", async () => {
|
|
159
|
-
await registry.link("acme/skills", "skills/plan-work");
|
|
160
|
-
await refuses(
|
|
161
|
-
() => registry.link("acme/skills", "skills"),
|
|
162
|
-
"Nothing was linked",
|
|
163
|
-
);
|
|
164
|
-
expect(store.skill("write-tests")).toBeUndefined();
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("unmanaged repositories, escaping paths, and folders without skills are refused", async () => {
|
|
168
|
-
await refuses(
|
|
169
|
-
() => registry.link("acme/other", "skills"),
|
|
170
|
-
"not a managed repository",
|
|
171
|
-
);
|
|
172
|
-
await refuses(() => registry.link("acme/skills", "../../x"), "inside");
|
|
173
|
-
mkdirSync(join(dir, "repos/acme/skills/docs"), { recursive: true });
|
|
174
|
-
await refuses(() => registry.link("acme/skills", "docs"), "no SKILL.md");
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("linked skills are read-only through the skill tools", async () => {
|
|
178
|
-
await registry.link("acme/skills", "skills/plan-work");
|
|
179
|
-
await refuses(
|
|
180
|
-
() => registry.update("plan-work", { body: "x" }),
|
|
181
|
-
"repo_task",
|
|
182
|
-
);
|
|
183
|
-
await refuses(() => registry.delete("plan-work"), "read-only");
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("a vanished or renamed file is reported missing, not loaded", async () => {
|
|
187
|
-
await registry.link("acme/skills", "skills");
|
|
188
|
-
rmSync(join(dir, "repos/acme/skills/skills/write-tests"), {
|
|
189
|
-
recursive: true,
|
|
190
|
-
});
|
|
191
|
-
repoSkill("acme/skills", "skills/plan-work", "plan-work-renamed");
|
|
192
|
-
const set = registry.resolve(["plan-work", "write-tests"]);
|
|
193
|
-
expect(set.skills.map((s) => s.name)).toEqual(["writing-skills"]);
|
|
194
|
-
expect(set.missing.map((m) => m.name)).toEqual([
|
|
195
|
-
"plan-work",
|
|
196
|
-
"write-tests",
|
|
197
|
-
]);
|
|
198
|
-
expect(set.missing[0]?.reason).toContain("plan-work-renamed");
|
|
199
|
-
expect(registry.list()).toContain("missing: file not found");
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
describe("agent-written skills", () => {
|
|
204
|
-
test("create writes a SKILL.md Pi reads back with the same name and description", async () => {
|
|
205
|
-
await registry.create({
|
|
206
|
-
name: "daily-notes",
|
|
207
|
-
description: 'Use when writing a "daily" note: summaries or logs.',
|
|
208
|
-
body: "# Daily notes\n\n1. Write.",
|
|
209
|
-
});
|
|
210
|
-
const file = join(dir, "skills/daily-notes/SKILL.md");
|
|
211
|
-
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(
|
|
212
|
-
readFileSync(file, "utf8"),
|
|
213
|
-
);
|
|
214
|
-
expect(frontmatter.name).toBe("daily-notes");
|
|
215
|
-
expect(frontmatter.description).toBe(
|
|
216
|
-
'Use when writing a "daily" note: summaries or logs.',
|
|
217
|
-
);
|
|
218
|
-
expect(body.trim()).toBe("# Daily notes\n\n1. Write.");
|
|
219
|
-
expect(registry.resolve(["daily-notes"]).skills[1]?.file).toBe(file);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test("descriptions must be triggers, bodies bounded, names Pi-valid", async () => {
|
|
223
|
-
await refuses(
|
|
224
|
-
() =>
|
|
225
|
-
registry.create({
|
|
226
|
-
name: "x",
|
|
227
|
-
description: "Writes notes.",
|
|
228
|
-
body: "b",
|
|
229
|
-
}),
|
|
230
|
-
"Use when",
|
|
231
|
-
);
|
|
232
|
-
await refuses(
|
|
233
|
-
() =>
|
|
234
|
-
registry.create({
|
|
235
|
-
name: "x",
|
|
236
|
-
description: "Use when x.",
|
|
237
|
-
body: "b".repeat(12_001),
|
|
238
|
-
}),
|
|
239
|
-
"12000",
|
|
240
|
-
);
|
|
241
|
-
await refuses(
|
|
242
|
-
() =>
|
|
243
|
-
registry.create({
|
|
244
|
-
name: "Bad--name",
|
|
245
|
-
description: "Use when x.",
|
|
246
|
-
body: "b",
|
|
247
|
-
}),
|
|
248
|
-
"not a usable skill name",
|
|
249
|
-
);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("update rewrites the file; delete removes it and detaches carriers and groups", async () => {
|
|
253
|
-
await registry.create({
|
|
254
|
-
name: "daily-notes",
|
|
255
|
-
description: "Use when a.",
|
|
256
|
-
body: "one",
|
|
257
|
-
});
|
|
258
|
-
await registry.update("daily-notes", { body: "two" });
|
|
259
|
-
expect(
|
|
260
|
-
readFileSync(join(dir, "skills/daily-notes/SKILL.md"), "utf8"),
|
|
261
|
-
).toContain("two");
|
|
262
|
-
await registry.attach("notes", ["daily-notes"], []);
|
|
263
|
-
await registry.setGroup("writing", "Writing skills.", [
|
|
264
|
-
"daily-notes",
|
|
265
|
-
"writing-skills",
|
|
266
|
-
]);
|
|
267
|
-
const text = await registry.delete("daily-notes");
|
|
268
|
-
expect(text).toBe(
|
|
269
|
-
"Removed skill daily-notes; detached from agents notes and groups writing.",
|
|
270
|
-
);
|
|
271
|
-
expect(existsSync(join(dir, "skills/daily-notes"))).toBe(false);
|
|
272
|
-
expect(store.carriedBy("notes")).toEqual([]);
|
|
273
|
-
expect(store.group("writing")?.skills).toEqual(["writing-skills"]);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("a restart rewrites files from the database and clears stale folders", async () => {
|
|
277
|
-
await registry.create({
|
|
278
|
-
name: "daily-notes",
|
|
279
|
-
description: "Use when a.",
|
|
280
|
-
body: "one",
|
|
281
|
-
});
|
|
282
|
-
rmSync(join(dir, "skills/daily-notes"), { recursive: true });
|
|
283
|
-
mkdirSync(join(dir, "skills/stale"), { recursive: true });
|
|
284
|
-
await store.close();
|
|
285
|
-
await open();
|
|
286
|
-
expect(existsSync(join(dir, "skills/daily-notes/SKILL.md"))).toBe(true);
|
|
287
|
-
expect(existsSync(join(dir, "skills/stale"))).toBe(false);
|
|
288
|
-
});
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
describe("groups and carrying", () => {
|
|
292
|
-
test("attach adds and removes together and survives a restart", async () => {
|
|
293
|
-
await registry.link("acme/skills", "skills");
|
|
294
|
-
expect(
|
|
295
|
-
await registry.attach("infra", ["plan-work", "write-tests"], []),
|
|
296
|
-
).toEqual(["plan-work", "write-tests"]);
|
|
297
|
-
expect(await registry.attach("infra", [], ["write-tests"])).toEqual([
|
|
298
|
-
"plan-work",
|
|
299
|
-
]);
|
|
300
|
-
await store.close();
|
|
301
|
-
await open();
|
|
302
|
-
expect(store.carriedBy("infra")).toEqual(["plan-work"]);
|
|
303
|
-
expect(registry.describeCarried("infra")).toBe(
|
|
304
|
-
"writing-skills (built in), plan-work",
|
|
305
|
-
);
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test("an unknown name changes nothing", async () => {
|
|
309
|
-
await registry.link("acme/skills", "skills");
|
|
310
|
-
await refuses(
|
|
311
|
-
() => registry.attach("infra", ["plan-work", "nope"], []),
|
|
312
|
-
"Unknown skills: nope",
|
|
313
|
-
);
|
|
314
|
-
expect(store.carriedBy("infra")).toEqual([]);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
test("the catalog lists every skill with its source, groups, carriers, and missing files", async () => {
|
|
318
|
-
await registry.link("acme/skills", "skills");
|
|
319
|
-
await registry.setGroup("coding", "Coding workflow.", ["plan-work"]);
|
|
320
|
-
await registry.attach("infra", ["plan-work"], []);
|
|
321
|
-
rmSync(join(dir, "repos", "acme/skills", "skills/write-tests"), {
|
|
322
|
-
recursive: true,
|
|
323
|
-
});
|
|
324
|
-
const catalog = registry.catalog();
|
|
325
|
-
expect(catalog.map((entry) => entry.name)).toEqual([
|
|
326
|
-
"writing-skills",
|
|
327
|
-
"plan-work",
|
|
328
|
-
"write-tests",
|
|
329
|
-
]);
|
|
330
|
-
expect(catalog[0]).toMatchObject({
|
|
331
|
-
source: { kind: "builtin" },
|
|
332
|
-
file: join(BUILTIN, "writing-skills", "SKILL.md"),
|
|
333
|
-
});
|
|
334
|
-
expect(catalog[1]).toEqual({
|
|
335
|
-
name: "plan-work",
|
|
336
|
-
source: {
|
|
337
|
-
kind: "linked",
|
|
338
|
-
repo: "acme/skills",
|
|
339
|
-
path: "skills/plan-work",
|
|
340
|
-
},
|
|
341
|
-
description: "Use when plan-work.",
|
|
342
|
-
file: join(dir, "repos", "acme/skills", "skills/plan-work", "SKILL.md"),
|
|
343
|
-
groups: ["coding"],
|
|
344
|
-
carriers: ["infra"],
|
|
345
|
-
});
|
|
346
|
-
expect(catalog[2]?.missing).toBeString();
|
|
347
|
-
expect(catalog[2]?.file).toBeUndefined();
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test("groups filter the list; unlinking detaches from groups too", async () => {
|
|
351
|
-
await registry.link("acme/skills", "skills");
|
|
352
|
-
await registry.setGroup("coding", "Coding workflow.", ["plan-work"]);
|
|
353
|
-
await registry.attach("infra", ["plan-work"], []);
|
|
354
|
-
const listed = registry.list({ group: "coding" });
|
|
355
|
-
expect(listed).toContain(
|
|
356
|
-
"- plan-work [linked from acme/skills:skills/plan-work]",
|
|
357
|
-
);
|
|
358
|
-
expect(listed).toContain("carried by: infra");
|
|
359
|
-
expect(listed).not.toContain("- write-tests");
|
|
360
|
-
expect(registry.list({ query: "write-tests" })).toContain(
|
|
361
|
-
"- write-tests",
|
|
362
|
-
);
|
|
363
|
-
expect(await registry.unlink("plan-work")).toBe(
|
|
364
|
-
"Removed skill plan-work; detached from agents infra and groups coding.",
|
|
365
|
-
);
|
|
366
|
-
await refuses(
|
|
367
|
-
() => registry.setGroup("coding", "x", ["nope"]),
|
|
368
|
-
"Unknown skills",
|
|
369
|
-
);
|
|
370
|
-
await refuses(
|
|
371
|
-
async () => registry.list({ group: "nope" }),
|
|
372
|
-
"no skill group",
|
|
373
|
-
);
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
});
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import pino from "pino";
|
|
3
|
-
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
4
|
-
import { createLogger, type LogEntry } from "../log.ts";
|
|
5
|
-
import type { ChannelKey } from "../sessions.ts";
|
|
6
|
-
import { ErrorReporter, fingerprint, reportText } from "./error-reporter.ts";
|
|
7
|
-
|
|
8
|
-
const HOUR = 3_600_000;
|
|
9
|
-
|
|
10
|
-
/** The ops agent as the delivery sees it: active with a channel, or not. */
|
|
11
|
-
interface OpsAgent {
|
|
12
|
-
status: "active" | "archived";
|
|
13
|
-
channelId?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const infra: OpsAgent = { status: "active", channelId: "42" };
|
|
17
|
-
|
|
18
|
-
function stack(file: string, line: number): string {
|
|
19
|
-
return `Error: boom\n at run (/opt/app/${file}:${line}:7)\n at next (/opt/app/node_modules/x/index.js:1:1)`;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function entry(msg: string, fields: LogEntry = {}): LogEntry {
|
|
23
|
-
return {
|
|
24
|
-
level: 50,
|
|
25
|
-
time: Date.now(),
|
|
26
|
-
pid: 1,
|
|
27
|
-
hostname: "h",
|
|
28
|
-
app: "roundtable",
|
|
29
|
-
msg,
|
|
30
|
-
...fields,
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function setup(options: { agent?: OpsAgent; turn?: () => Promise<void> } = {}) {
|
|
35
|
-
let now = 1_000_000;
|
|
36
|
-
const timers: { run: () => void; at: number }[] = [];
|
|
37
|
-
const reporter = new ErrorReporter({
|
|
38
|
-
opsAgent: "infra",
|
|
39
|
-
app: "Roundtable",
|
|
40
|
-
now: () => now,
|
|
41
|
-
setTimer: (run, ms) => timers.push({ run, at: now + ms }),
|
|
42
|
-
ignored: [/expected noise/],
|
|
43
|
-
});
|
|
44
|
-
const posts: { channel: ChannelKey; text: string }[] = [];
|
|
45
|
-
const turns: string[] = [];
|
|
46
|
-
const lines: LogEntry[] = [];
|
|
47
|
-
const logger = pino(
|
|
48
|
-
{ base: { app: "roundtable" } },
|
|
49
|
-
{
|
|
50
|
-
write(line: string) {
|
|
51
|
-
const logged = JSON.parse(line) as LogEntry;
|
|
52
|
-
lines.push(logged);
|
|
53
|
-
reporter.record(logged);
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
);
|
|
57
|
-
const agent = "agent" in options ? options.agent : infra;
|
|
58
|
-
const delivery = {
|
|
59
|
-
channelOf: (name: string): ChannelKey | undefined =>
|
|
60
|
-
name === "infra" && agent?.status === "active" && agent.channelId
|
|
61
|
-
? `discord:${agent.channelId}`
|
|
62
|
-
: undefined,
|
|
63
|
-
post: async (channel: ChannelKey, text: string) => {
|
|
64
|
-
posts.push({ channel, text });
|
|
65
|
-
},
|
|
66
|
-
turn: async (_channel: ChannelKey, text: string) => {
|
|
67
|
-
turns.push(text);
|
|
68
|
-
await options.turn?.();
|
|
69
|
-
return { status: "ran" } as ScheduledOutcome;
|
|
70
|
-
},
|
|
71
|
-
logger,
|
|
72
|
-
};
|
|
73
|
-
return {
|
|
74
|
-
reporter,
|
|
75
|
-
delivery,
|
|
76
|
-
posts,
|
|
77
|
-
turns,
|
|
78
|
-
lines,
|
|
79
|
-
logger,
|
|
80
|
-
connect: () => reporter.connect(delivery),
|
|
81
|
-
advance(ms: number) {
|
|
82
|
-
now += ms;
|
|
83
|
-
for (const timer of timers.splice(0))
|
|
84
|
-
if (timer.at <= now) timer.run();
|
|
85
|
-
else timers.push(timer);
|
|
86
|
-
},
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
91
|
-
|
|
92
|
-
describe("ErrorReporter", () => {
|
|
93
|
-
test("an error is posted in Infra's channel and starts its report turn", async () => {
|
|
94
|
-
const { reporter, connect, posts, turns } = setup();
|
|
95
|
-
connect();
|
|
96
|
-
reporter.record(
|
|
97
|
-
entry("webhook post failed", {
|
|
98
|
-
module: "discord",
|
|
99
|
-
channel: "discord:7",
|
|
100
|
-
err: { type: "Error", message: "boom", stack: stack("src/a.ts", 12) },
|
|
101
|
-
}),
|
|
102
|
-
);
|
|
103
|
-
await settle();
|
|
104
|
-
expect(posts).toHaveLength(1);
|
|
105
|
-
expect(posts[0]?.channel).toBe("discord:42");
|
|
106
|
-
const text = posts[0]?.text ?? "";
|
|
107
|
-
expect(text).toStartWith(
|
|
108
|
-
"Roundtable logged an error: webhook post failed\n",
|
|
109
|
-
);
|
|
110
|
-
expect(text).toContain("app: roundtable · module: discord");
|
|
111
|
-
expect(text).toContain("channel: discord:7");
|
|
112
|
-
expect(text).toContain("at run (/opt/app/src/a.ts:12:7)");
|
|
113
|
-
expect(text).not.toContain("pid");
|
|
114
|
-
expect(turns).toEqual([text]);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
test("a long report is trimmed", () => {
|
|
118
|
-
const text = reportText(
|
|
119
|
-
entry("big", {
|
|
120
|
-
err: { message: "x", stack: `Error: ${"y".repeat(5000)}` },
|
|
121
|
-
}),
|
|
122
|
-
"Roundtable",
|
|
123
|
-
);
|
|
124
|
-
expect(text.length).toBeLessThanOrEqual(1500);
|
|
125
|
-
expect(text).toEndWith("…");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("the fingerprint ignores times and ids but not where the error was raised", () => {
|
|
129
|
-
const a = entry("failed", {
|
|
130
|
-
channel: "discord:1",
|
|
131
|
-
err: { stack: stack("src/a.ts", 3) },
|
|
132
|
-
});
|
|
133
|
-
const b = entry("failed", {
|
|
134
|
-
channel: "discord:2",
|
|
135
|
-
time: 5,
|
|
136
|
-
err: { stack: stack("src/a.ts", 3) },
|
|
137
|
-
});
|
|
138
|
-
const c = entry("failed", { err: { stack: stack("src/b.ts", 3) } });
|
|
139
|
-
expect(fingerprint(a)).toBe(fingerprint(b));
|
|
140
|
-
expect(fingerprint(a)).not.toBe(fingerprint(c));
|
|
141
|
-
expect(fingerprint(entry("no stack", { job: 1 }))).toBe(
|
|
142
|
-
fingerprint(entry("no stack", { job: 2 })),
|
|
143
|
-
);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("the same error twice in an hour is reported once, and again after it", async () => {
|
|
147
|
-
const { reporter, connect, posts, advance } = setup();
|
|
148
|
-
connect();
|
|
149
|
-
const same = () =>
|
|
150
|
-
entry("failed", { err: { stack: stack("src/a.ts", 3) } });
|
|
151
|
-
reporter.record(same());
|
|
152
|
-
await settle();
|
|
153
|
-
advance(HOUR - 1);
|
|
154
|
-
reporter.record(same());
|
|
155
|
-
await settle();
|
|
156
|
-
expect(posts).toHaveLength(1);
|
|
157
|
-
advance(1);
|
|
158
|
-
reporter.record(same());
|
|
159
|
-
await settle();
|
|
160
|
-
expect(posts).toHaveLength(2);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("six distinct errors in an hour make five reports and a later summary", async () => {
|
|
164
|
-
const { reporter, connect, posts, turns, advance } = setup();
|
|
165
|
-
connect();
|
|
166
|
-
for (let i = 0; i < 6; i++) {
|
|
167
|
-
reporter.record(entry(`failure ${i}`));
|
|
168
|
-
advance(60_000);
|
|
169
|
-
}
|
|
170
|
-
reporter.record(entry("failure 5"));
|
|
171
|
-
await settle();
|
|
172
|
-
expect(posts).toHaveLength(5);
|
|
173
|
-
advance(HOUR);
|
|
174
|
-
await settle();
|
|
175
|
-
expect(posts).toHaveLength(6);
|
|
176
|
-
expect(posts[5]?.text).toBe(
|
|
177
|
-
"Roundtable error reports: 2 more errors suppressed: failure 5 (×2)",
|
|
178
|
-
);
|
|
179
|
-
expect(turns).toHaveLength(6);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("errors logged while Infra runs an error-report turn are summed up after it", async () => {
|
|
183
|
-
let finish = () => {};
|
|
184
|
-
const running = new Promise<void>((resolve) => {
|
|
185
|
-
finish = resolve;
|
|
186
|
-
});
|
|
187
|
-
const { reporter, connect, posts, logger } = setup({ turn: () => running });
|
|
188
|
-
connect();
|
|
189
|
-
reporter.record(entry("first"));
|
|
190
|
-
await settle();
|
|
191
|
-
reporter.record(entry("during the turn"));
|
|
192
|
-
logger.error("also during the turn");
|
|
193
|
-
await settle();
|
|
194
|
-
expect(posts).toHaveLength(1);
|
|
195
|
-
finish();
|
|
196
|
-
await settle();
|
|
197
|
-
await settle();
|
|
198
|
-
expect(posts).toHaveLength(2);
|
|
199
|
-
expect(posts[1]?.text).toStartWith(
|
|
200
|
-
"Roundtable error reports: 2 more errors suppressed:",
|
|
201
|
-
);
|
|
202
|
-
expect(posts[1]?.text).toContain("during the turn");
|
|
203
|
-
expect(posts[1]?.text).toContain("also during the turn");
|
|
204
|
-
await settle();
|
|
205
|
-
reporter.record(entry("after the turn"));
|
|
206
|
-
await settle();
|
|
207
|
-
expect(posts).toHaveLength(3);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("the reporter's own failures are only logged", async () => {
|
|
211
|
-
const { reporter, delivery, lines, turns } = setup();
|
|
212
|
-
delivery.post = async () => {
|
|
213
|
-
throw new Error("discord down");
|
|
214
|
-
};
|
|
215
|
-
delivery.turn = async () => {
|
|
216
|
-
throw new Error("turn broke");
|
|
217
|
-
};
|
|
218
|
-
reporter.connect(delivery);
|
|
219
|
-
reporter.record(entry("first"));
|
|
220
|
-
await settle();
|
|
221
|
-
await settle();
|
|
222
|
-
expect(lines.map((l) => l.msg)).toEqual([
|
|
223
|
-
"error report not posted",
|
|
224
|
-
"error report turn failed",
|
|
225
|
-
]);
|
|
226
|
-
expect(turns).toHaveLength(0);
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
test("nothing is reported when Infra is missing or archived", async () => {
|
|
230
|
-
for (const agent of [
|
|
231
|
-
undefined,
|
|
232
|
-
{ ...infra, status: "archived" as const },
|
|
233
|
-
]) {
|
|
234
|
-
const { reporter, connect, posts, turns } = setup({ agent });
|
|
235
|
-
connect();
|
|
236
|
-
reporter.record(entry("failed"));
|
|
237
|
-
await settle();
|
|
238
|
-
expect(posts).toHaveLength(0);
|
|
239
|
-
expect(turns).toHaveLength(0);
|
|
240
|
-
}
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
test("warnings and ignored errors are not reported", async () => {
|
|
244
|
-
const { reporter, connect, posts } = setup();
|
|
245
|
-
connect();
|
|
246
|
-
reporter.record({ ...entry("just a warning"), level: 40 });
|
|
247
|
-
reporter.record(entry("some expected noise here"));
|
|
248
|
-
await settle();
|
|
249
|
-
expect(posts).toHaveLength(0);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
test("errors before the agent server is ready wait, the last twenty kept", async () => {
|
|
253
|
-
const { reporter, connect, posts, lines } = setup();
|
|
254
|
-
for (let i = 0; i < 23; i++) reporter.record(entry(`early ${i}`));
|
|
255
|
-
expect(posts).toHaveLength(0);
|
|
256
|
-
connect();
|
|
257
|
-
await settle();
|
|
258
|
-
expect(posts.map((p) => p.text.split("\n")[0])).toEqual(
|
|
259
|
-
[3, 4, 5, 6, 7].map((i) => `Roundtable logged an error: early ${i}`),
|
|
260
|
-
);
|
|
261
|
-
expect(lines[0]).toMatchObject({
|
|
262
|
-
level: 40,
|
|
263
|
-
dropped: 3,
|
|
264
|
-
msg: "early errors dropped before reporting started",
|
|
265
|
-
});
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe("createLogger", () => {
|
|
270
|
-
test("hands error and fatal entries to the sink, not warnings", () => {
|
|
271
|
-
const taken: LogEntry[] = [];
|
|
272
|
-
const logger = createLogger("roundtable", (logged) => taken.push(logged));
|
|
273
|
-
logger.level = "error";
|
|
274
|
-
logger.warn("a warning");
|
|
275
|
-
logger.error({ job: 1 }, "an error");
|
|
276
|
-
logger.fatal("fatal");
|
|
277
|
-
expect(taken.map((t) => [t.level, t.msg, t.app])).toEqual([
|
|
278
|
-
[50, "an error", "roundtable"],
|
|
279
|
-
[60, "fatal", "roundtable"],
|
|
280
|
-
]);
|
|
281
|
-
expect(taken[0]?.job).toBe(1);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
|
-
import type { Expression } from "../domain/expression.ts";
|
|
3
|
-
|
|
4
|
-
/** A channel quiet this long starts a new conversation, whose first reply shows a card. */
|
|
5
|
-
export const CONVERSATION_GAP_MS = 30 * 60_000;
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Decides which replies carry a card: one that opens a conversation or changes its tone.
|
|
9
|
-
* Kept in memory; after a restart every channel's next reply opens a conversation.
|
|
10
|
-
*/
|
|
11
|
-
export class CardCadence {
|
|
12
|
-
readonly #channels = new Map<
|
|
13
|
-
ChannelKey,
|
|
14
|
-
{ lastReplyAt: number; lastCard: Expression | undefined }
|
|
15
|
-
>();
|
|
16
|
-
|
|
17
|
-
/** Records a reply about to be posted and says whether it shows a card. */
|
|
18
|
-
shows(
|
|
19
|
-
channel: ChannelKey,
|
|
20
|
-
expression: Expression,
|
|
21
|
-
failed: boolean,
|
|
22
|
-
now = Date.now(),
|
|
23
|
-
): boolean {
|
|
24
|
-
const state = this.#channels.get(channel);
|
|
25
|
-
const card =
|
|
26
|
-
failed ||
|
|
27
|
-
!state ||
|
|
28
|
-
now - state.lastReplyAt >= CONVERSATION_GAP_MS ||
|
|
29
|
-
(expression !== "neutral" && expression !== state.lastCard);
|
|
30
|
-
this.#channels.set(channel, {
|
|
31
|
-
lastReplyAt: now,
|
|
32
|
-
lastCard: card ? expression : state?.lastCard,
|
|
33
|
-
});
|
|
34
|
-
return card;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** The channel's next reply opens a conversation again. */
|
|
38
|
-
forget(channel: ChannelKey): void {
|
|
39
|
-
this.#channels.delete(channel);
|
|
40
|
-
}
|
|
41
|
-
}
|