pi-roundtable 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import type { HoldRule } from "../../holds.ts";
|
|
4
|
+
import { assistantName } from "../../i18n/index.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_SHOWN_COMMAND = 600;
|
|
7
|
+
|
|
8
|
+
/** Pi's built-in shell and file tools, which agent sessions activate. */
|
|
9
|
+
export const SHELL_TOOLS = ["bash", "read", "edit", "write"] as const;
|
|
10
|
+
|
|
11
|
+
/** Programs that are held whatever their arguments. */
|
|
12
|
+
const ALWAYS_HELD = new Set([
|
|
13
|
+
"sudo",
|
|
14
|
+
"su",
|
|
15
|
+
"doas",
|
|
16
|
+
"rm",
|
|
17
|
+
"dd",
|
|
18
|
+
"shutdown",
|
|
19
|
+
"reboot",
|
|
20
|
+
"poweroff",
|
|
21
|
+
"halt",
|
|
22
|
+
"kill",
|
|
23
|
+
"pkill",
|
|
24
|
+
"killall",
|
|
25
|
+
]);
|
|
26
|
+
const SYSTEMD_VERBS = new Set([
|
|
27
|
+
"start",
|
|
28
|
+
"stop",
|
|
29
|
+
"restart",
|
|
30
|
+
"reload",
|
|
31
|
+
"try-restart",
|
|
32
|
+
"reload-or-restart",
|
|
33
|
+
"enable",
|
|
34
|
+
"disable",
|
|
35
|
+
"mask",
|
|
36
|
+
"unmask",
|
|
37
|
+
"kill",
|
|
38
|
+
"isolate",
|
|
39
|
+
]);
|
|
40
|
+
const DOCKER_VERBS = new Set(["stop", "kill", "restart", "rm", "rmi", "prune"]);
|
|
41
|
+
const DOCKER_OBJECTS = new Set([
|
|
42
|
+
"container",
|
|
43
|
+
"image",
|
|
44
|
+
"volume",
|
|
45
|
+
"network",
|
|
46
|
+
"system",
|
|
47
|
+
"builder",
|
|
48
|
+
]);
|
|
49
|
+
const COMPOSE_VERBS = new Set(["down", "stop", "kill", "restart", "rm"]);
|
|
50
|
+
const PACKAGE_VERBS = new Set([
|
|
51
|
+
"install",
|
|
52
|
+
"reinstall",
|
|
53
|
+
"remove",
|
|
54
|
+
"purge",
|
|
55
|
+
"autoremove",
|
|
56
|
+
"upgrade",
|
|
57
|
+
"full-upgrade",
|
|
58
|
+
"dist-upgrade",
|
|
59
|
+
]);
|
|
60
|
+
/** Words that run the next word as the command. */
|
|
61
|
+
const WRAPPERS = new Set([
|
|
62
|
+
"env",
|
|
63
|
+
"nohup",
|
|
64
|
+
"time",
|
|
65
|
+
"nice",
|
|
66
|
+
"ionice",
|
|
67
|
+
"timeout",
|
|
68
|
+
"stdbuf",
|
|
69
|
+
"exec",
|
|
70
|
+
"command",
|
|
71
|
+
"xargs",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** A path is inside the workspace when it resolves under it; `~` means the service user's home. */
|
|
75
|
+
function insideWorkspace(path: string, workspace: string): boolean {
|
|
76
|
+
const expanded =
|
|
77
|
+
path === "~" || path.startsWith("~/")
|
|
78
|
+
? `${homedir()}${path.slice(1)}`
|
|
79
|
+
: path;
|
|
80
|
+
const target = resolve(workspace, expanded);
|
|
81
|
+
const rel = relative(resolve(workspace), target);
|
|
82
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Splits a command line into simple commands of words; quotes are removed, operators separate. */
|
|
86
|
+
export function simpleCommands(command: string): string[][] {
|
|
87
|
+
const commands: string[][] = [];
|
|
88
|
+
let words: string[] = [];
|
|
89
|
+
let word = "";
|
|
90
|
+
let inWord = false;
|
|
91
|
+
let quote: "'" | '"' | undefined;
|
|
92
|
+
const endWord = () => {
|
|
93
|
+
if (inWord) words.push(word);
|
|
94
|
+
word = "";
|
|
95
|
+
inWord = false;
|
|
96
|
+
};
|
|
97
|
+
const endCommand = () => {
|
|
98
|
+
endWord();
|
|
99
|
+
if (words.length > 0) commands.push(words);
|
|
100
|
+
words = [];
|
|
101
|
+
};
|
|
102
|
+
for (let i = 0; i < command.length; i++) {
|
|
103
|
+
const ch = command[i] as string;
|
|
104
|
+
if (quote) {
|
|
105
|
+
if (ch === quote) quote = undefined;
|
|
106
|
+
else if (ch === "\\" && quote === '"' && i + 1 < command.length) {
|
|
107
|
+
word += command[++i];
|
|
108
|
+
} else word += ch;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (ch === "'" || ch === '"') {
|
|
112
|
+
quote = ch;
|
|
113
|
+
inWord = true;
|
|
114
|
+
} else if (ch === "\\" && i + 1 < command.length) {
|
|
115
|
+
word += command[++i];
|
|
116
|
+
inWord = true;
|
|
117
|
+
} else if (/\s/.test(ch)) {
|
|
118
|
+
if (ch === "\n") endCommand();
|
|
119
|
+
else endWord();
|
|
120
|
+
} else if (";&|()`{}".includes(ch)) {
|
|
121
|
+
endCommand();
|
|
122
|
+
} else if (ch === "$" && command[i + 1] === "(") {
|
|
123
|
+
endCommand();
|
|
124
|
+
i++;
|
|
125
|
+
} else if (ch === ">" || ch === "<") {
|
|
126
|
+
// Redirections become their own words, so their targets can be checked.
|
|
127
|
+
endWord();
|
|
128
|
+
let op = ch;
|
|
129
|
+
if (command[i + 1] === ">") op += command[++i];
|
|
130
|
+
if (command[i + 1] === "&") {
|
|
131
|
+
// `>&2` duplicates a descriptor rather than naming a file.
|
|
132
|
+
i++;
|
|
133
|
+
op = "dup";
|
|
134
|
+
}
|
|
135
|
+
words.push(op);
|
|
136
|
+
} else {
|
|
137
|
+
word += ch;
|
|
138
|
+
inWord = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
endCommand();
|
|
142
|
+
return commands;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Drops leading variable assignments, wrappers, and their options, leaving the real program. */
|
|
146
|
+
function program(words: string[]): string[] {
|
|
147
|
+
let rest = words;
|
|
148
|
+
for (;;) {
|
|
149
|
+
const [first] = rest;
|
|
150
|
+
if (first === undefined) return rest;
|
|
151
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
|
|
152
|
+
rest = rest.slice(1);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (WRAPPERS.has(basename(first))) {
|
|
156
|
+
rest = rest.slice(1);
|
|
157
|
+
// Wrapper options and the timeout's duration.
|
|
158
|
+
while (
|
|
159
|
+
rest[0] !== undefined &&
|
|
160
|
+
(rest[0].startsWith("-") ||
|
|
161
|
+
/^\d+[smhd]?$/.test(rest[0]) ||
|
|
162
|
+
/^[A-Za-z_][A-Za-z0-9_]*=/.test(rest[0]))
|
|
163
|
+
)
|
|
164
|
+
rest = rest.slice(1);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
return rest;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Why one simple command is held, or undefined. */
|
|
172
|
+
function heldReason(words: string[], workspace: string): string | undefined {
|
|
173
|
+
for (let i = 0; i < words.length; i++) {
|
|
174
|
+
const op = words[i];
|
|
175
|
+
if (op === ">" || op === ">>") {
|
|
176
|
+
const target = words[i + 1];
|
|
177
|
+
if (
|
|
178
|
+
target &&
|
|
179
|
+
target !== "/dev/null" &&
|
|
180
|
+
!insideWorkspace(target, workspace)
|
|
181
|
+
)
|
|
182
|
+
return `writes to ${target}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const argv = program(
|
|
186
|
+
words.filter(
|
|
187
|
+
(w, i) =>
|
|
188
|
+
!["<", ">", ">>", "dup"].includes(w) &&
|
|
189
|
+
!["<", ">", ">>"].includes(words[i - 1] ?? ""),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
const [head, ...args] = argv;
|
|
193
|
+
if (!head) return undefined;
|
|
194
|
+
const name = basename(head);
|
|
195
|
+
if (ALWAYS_HELD.has(name) || /^mkfs(\..+)?$/.test(name)) return name;
|
|
196
|
+
if ((name === "bash" || name === "sh" || name === "zsh") && args[0] === "-c")
|
|
197
|
+
return commandHeld(args[1] ?? "", workspace);
|
|
198
|
+
const verbs = args.filter((a) => !a.startsWith("-"));
|
|
199
|
+
switch (name) {
|
|
200
|
+
case "systemctl":
|
|
201
|
+
return verbs.some((v) => SYSTEMD_VERBS.has(v))
|
|
202
|
+
? "changes a systemd unit"
|
|
203
|
+
: undefined;
|
|
204
|
+
case "service":
|
|
205
|
+
return verbs.some((v) =>
|
|
206
|
+
["start", "stop", "restart", "reload"].includes(v),
|
|
207
|
+
)
|
|
208
|
+
? "changes a service"
|
|
209
|
+
: undefined;
|
|
210
|
+
case "docker": {
|
|
211
|
+
const [first, second] = verbs;
|
|
212
|
+
if (first === "compose")
|
|
213
|
+
return second && COMPOSE_VERBS.has(second)
|
|
214
|
+
? `docker compose ${second}`
|
|
215
|
+
: undefined;
|
|
216
|
+
if (first && DOCKER_VERBS.has(first)) return `docker ${first}`;
|
|
217
|
+
if (
|
|
218
|
+
first &&
|
|
219
|
+
DOCKER_OBJECTS.has(first) &&
|
|
220
|
+
second &&
|
|
221
|
+
(DOCKER_VERBS.has(second) || second === "remove")
|
|
222
|
+
)
|
|
223
|
+
return `docker ${first} ${second}`;
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
case "docker-compose":
|
|
227
|
+
return verbs[0] && COMPOSE_VERBS.has(verbs[0])
|
|
228
|
+
? `docker-compose ${verbs[0]}`
|
|
229
|
+
: undefined;
|
|
230
|
+
case "ufw":
|
|
231
|
+
return verbs[0] === "status" || verbs.length === 0
|
|
232
|
+
? undefined
|
|
233
|
+
: "changes the firewall";
|
|
234
|
+
case "iptables":
|
|
235
|
+
case "ip6tables":
|
|
236
|
+
case "nft":
|
|
237
|
+
return args.some((a) => ["-L", "--list", "-S", "list"].includes(a))
|
|
238
|
+
? undefined
|
|
239
|
+
: "changes the firewall";
|
|
240
|
+
case "apt":
|
|
241
|
+
case "apt-get":
|
|
242
|
+
case "snap":
|
|
243
|
+
return verbs.some((v) => PACKAGE_VERBS.has(v))
|
|
244
|
+
? "installs or removes packages"
|
|
245
|
+
: undefined;
|
|
246
|
+
case "dpkg":
|
|
247
|
+
return args.some((a) =>
|
|
248
|
+
["-i", "-r", "-P", "--install", "--remove", "--purge"].includes(a),
|
|
249
|
+
)
|
|
250
|
+
? "installs or removes packages"
|
|
251
|
+
: undefined;
|
|
252
|
+
case "git": {
|
|
253
|
+
const [verb, ...rest] = gitCommand(args);
|
|
254
|
+
if (verb === "push")
|
|
255
|
+
return rest.some((a) => a === "-f" || a.startsWith("--force"))
|
|
256
|
+
? "force-pushes"
|
|
257
|
+
: `pushes to GitHub, which deploys ${assistantName()} when the branch is main`;
|
|
258
|
+
if (verb === "reset" && rest.includes("--hard"))
|
|
259
|
+
return "git reset --hard";
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
case "gh":
|
|
263
|
+
return ghHeld(args);
|
|
264
|
+
case "crontab":
|
|
265
|
+
return args.includes("-r") ? "removes a crontab" : undefined;
|
|
266
|
+
case "tee": {
|
|
267
|
+
const target = args.find(
|
|
268
|
+
(a) => !a.startsWith("-") && !insideWorkspace(a, workspace),
|
|
269
|
+
);
|
|
270
|
+
return target && target !== "/dev/null"
|
|
271
|
+
? `writes to ${target}`
|
|
272
|
+
: undefined;
|
|
273
|
+
}
|
|
274
|
+
default:
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Git's global options that take a value, as separate words. */
|
|
280
|
+
const GIT_VALUE_OPTIONS = new Set([
|
|
281
|
+
"-C",
|
|
282
|
+
"-c",
|
|
283
|
+
"--git-dir",
|
|
284
|
+
"--work-tree",
|
|
285
|
+
"--namespace",
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
/** The git subcommand and its arguments, past global options such as `-C <dir>`. */
|
|
289
|
+
function gitCommand(args: string[]): string[] {
|
|
290
|
+
let i = 0;
|
|
291
|
+
while (i < args.length && (args[i] as string).startsWith("-"))
|
|
292
|
+
i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
|
|
293
|
+
return args.slice(i);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
|
|
297
|
+
const GH_READ_VERBS = new Set(["list", "view", "download"]);
|
|
298
|
+
const GH_REPO_CHANGES = new Set([
|
|
299
|
+
"create",
|
|
300
|
+
"delete",
|
|
301
|
+
"edit",
|
|
302
|
+
"rename",
|
|
303
|
+
"archive",
|
|
304
|
+
"unarchive",
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
/** Why a `gh` command writes to GitHub in a way the owner approves, or undefined. */
|
|
308
|
+
function ghHeld(args: string[]): string | undefined {
|
|
309
|
+
const [group, verb] = args.filter((a) => !a.startsWith("-"));
|
|
310
|
+
switch (group) {
|
|
311
|
+
case "pr":
|
|
312
|
+
return verb === "merge" ? "merges a pull request" : undefined;
|
|
313
|
+
case "api": {
|
|
314
|
+
const body = args.some((a) =>
|
|
315
|
+
GH_BODY_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)),
|
|
316
|
+
);
|
|
317
|
+
if (verb === "graphql")
|
|
318
|
+
return args.some((a) => /\bmutation\b/.test(a))
|
|
319
|
+
? "changes GitHub through the API"
|
|
320
|
+
: undefined;
|
|
321
|
+
const index = args.findIndex((a) => a === "-X" || a === "--method");
|
|
322
|
+
const method = (
|
|
323
|
+
index === -1
|
|
324
|
+
? args.find((a) => a.startsWith("--method="))?.slice(9)
|
|
325
|
+
: args[index + 1]
|
|
326
|
+
)?.toUpperCase();
|
|
327
|
+
return body || (method !== undefined && method !== "GET")
|
|
328
|
+
? "changes GitHub through the API"
|
|
329
|
+
: undefined;
|
|
330
|
+
}
|
|
331
|
+
case "release":
|
|
332
|
+
case "secret":
|
|
333
|
+
case "variable":
|
|
334
|
+
return verb && !GH_READ_VERBS.has(verb)
|
|
335
|
+
? `changes GitHub ${group}s`
|
|
336
|
+
: undefined;
|
|
337
|
+
case "repo":
|
|
338
|
+
return verb && GH_REPO_CHANGES.has(verb)
|
|
339
|
+
? `changes a GitHub repository (${verb})`
|
|
340
|
+
: undefined;
|
|
341
|
+
default:
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function commandHeld(command: string, workspace: string): string | undefined {
|
|
347
|
+
for (const words of simpleCommands(command)) {
|
|
348
|
+
const reason = heldReason(words, workspace);
|
|
349
|
+
if (reason) return reason;
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Pi's shell and file tools: a bash command that is destructive or reaches outside the shared
|
|
356
|
+
* workspace, and a write or edit outside it, wait for the owner. Reading always runs.
|
|
357
|
+
*/
|
|
358
|
+
export function shellActionNeedingConfirmation(
|
|
359
|
+
toolName: string,
|
|
360
|
+
input: Record<string, unknown>,
|
|
361
|
+
workspace: string,
|
|
362
|
+
): string | undefined {
|
|
363
|
+
if (toolName === "bash") {
|
|
364
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
365
|
+
const reason = commandHeld(command, workspace);
|
|
366
|
+
return reason
|
|
367
|
+
? `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${reason})`
|
|
368
|
+
: undefined;
|
|
369
|
+
}
|
|
370
|
+
if (toolName === "write" || toolName === "edit") {
|
|
371
|
+
const path = typeof input.path === "string" ? input.path : "";
|
|
372
|
+
return insideWorkspace(path.replace(/^@/, ""), workspace)
|
|
373
|
+
? undefined
|
|
374
|
+
: `${toolName} the file ${path} on the host`;
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Shell calls of a session with a workspace; sessions without one have no shell. */
|
|
380
|
+
export const shellHoldRule: HoldRule = {
|
|
381
|
+
name: "shell",
|
|
382
|
+
describe: (tool, input, { workspace }) =>
|
|
383
|
+
workspace === undefined
|
|
384
|
+
? undefined
|
|
385
|
+
: shellActionNeedingConfirmation(tool, input, workspace),
|
|
386
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
test,
|
|
8
|
+
} from "bun:test";
|
|
9
|
+
import { SQL } from "bun";
|
|
10
|
+
import { MemoryError } from "../../domain/errors.ts";
|
|
11
|
+
import {
|
|
12
|
+
describeDb,
|
|
13
|
+
TEST_GUILD as OWNER_ID,
|
|
14
|
+
openTestStore,
|
|
15
|
+
type TestStore,
|
|
16
|
+
testDatabaseUrl,
|
|
17
|
+
} from "../../testing/database.ts";
|
|
18
|
+
import { useTestLocale } from "../../testing/locale.ts";
|
|
19
|
+
import { setTimeZone, zonedDate } from "../../time.ts";
|
|
20
|
+
import { OwnerMemoryStore, searchTerms } from "./owner-memory-store.ts";
|
|
21
|
+
|
|
22
|
+
// Runs against a real PostgreSQL, only when ROUNDTABLE_TEST_DATABASE_URL is set.
|
|
23
|
+
describeDb("PostgreSQL", () => {
|
|
24
|
+
let store: TestStore<OwnerMemoryStore>;
|
|
25
|
+
|
|
26
|
+
const facts = async () => (await store.list()).map((memory) => memory.fact);
|
|
27
|
+
|
|
28
|
+
beforeAll(async () => {
|
|
29
|
+
const admin = new SQL(testDatabaseUrl);
|
|
30
|
+
await admin`DROP TABLE IF EXISTS owner_memory`;
|
|
31
|
+
// The milestone 1 table, so opening it exercises the upgrade.
|
|
32
|
+
await admin`
|
|
33
|
+
CREATE TABLE owner_memory (
|
|
34
|
+
id bigserial PRIMARY KEY,
|
|
35
|
+
fact text NOT NULL CHECK (length(btrim(fact)) > 0),
|
|
36
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
37
|
+
)`;
|
|
38
|
+
await admin`INSERT INTO owner_memory (fact) VALUES ('old fact')`;
|
|
39
|
+
await admin.close();
|
|
40
|
+
store = await openTestStore(OwnerMemoryStore, OWNER_ID);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(async () => {
|
|
44
|
+
await store.close();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("OwnerMemoryStore", () => {
|
|
48
|
+
test("a milestone 1 row becomes a core fact", async () => {
|
|
49
|
+
expect(await store.list()).toMatchObject([
|
|
50
|
+
{ kind: "core", fact: "old fact", eventDate: null },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("after the upgrade", () => {
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
for (const fact of await facts()) await store.remove(fact);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("each speaker has a memory of their own, which no other reads or changes", async () => {
|
|
60
|
+
const other = store.forSpeaker("900000000000000077");
|
|
61
|
+
await store.add("lives in Taipei");
|
|
62
|
+
await other.add("lives in Kaohsiung");
|
|
63
|
+
await other.add("loves coffee", "note");
|
|
64
|
+
expect((await store.list()).map((m) => m.fact)).toEqual([
|
|
65
|
+
"lives in Taipei",
|
|
66
|
+
]);
|
|
67
|
+
expect((await other.list()).map((m) => m.fact)).toEqual([
|
|
68
|
+
"lives in Kaohsiung",
|
|
69
|
+
"loves coffee",
|
|
70
|
+
]);
|
|
71
|
+
expect((await store.search("coffee")).map((m) => m.fact)).toEqual([]);
|
|
72
|
+
expect((await store.forPrompt("2026-09-30")).core).toHaveLength(1);
|
|
73
|
+
const theirs = (await other.list())[0];
|
|
74
|
+
expect(await store.removeById(theirs?.id ?? 0)).toBe(false);
|
|
75
|
+
expect(
|
|
76
|
+
await store.update(theirs?.id ?? 0, { fact: "x", kind: "core" }),
|
|
77
|
+
).toBeUndefined();
|
|
78
|
+
expect(await store.remove("lives in")).toEqual(["lives in Taipei"]);
|
|
79
|
+
expect((await other.list()).map((m) => m.fact)).toEqual([
|
|
80
|
+
"lives in Kaohsiung",
|
|
81
|
+
"loves coffee",
|
|
82
|
+
]);
|
|
83
|
+
for (const fact of await other.list()) await other.removeById(fact.id);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("adds facts of each kind in order and trims them", async () => {
|
|
87
|
+
await store.add(" no coffee ");
|
|
88
|
+
await store.add("writing a dungeon adventure", "note");
|
|
89
|
+
await store.add("going to a whisky fair", "event", "2026-10-03");
|
|
90
|
+
expect(await store.list()).toMatchObject([
|
|
91
|
+
{ kind: "core", fact: "no coffee", eventDate: null },
|
|
92
|
+
{
|
|
93
|
+
kind: "note",
|
|
94
|
+
fact: "writing a dungeon adventure",
|
|
95
|
+
eventDate: null,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
kind: "event",
|
|
99
|
+
fact: "going to a whisky fair",
|
|
100
|
+
eventDate: "2026-10-03",
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("an event needs a date, and only an event has one", async () => {
|
|
106
|
+
expect(store.add("a meeting", "event")).rejects.toBeInstanceOf(
|
|
107
|
+
MemoryError,
|
|
108
|
+
);
|
|
109
|
+
expect(store.add("a meeting", "event", "10/3")).rejects.toBeInstanceOf(
|
|
110
|
+
MemoryError,
|
|
111
|
+
);
|
|
112
|
+
expect(
|
|
113
|
+
store.add("no coffee", "core", "2026-10-03"),
|
|
114
|
+
).rejects.toBeInstanceOf(MemoryError);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("the prompt carries core facts and events not yet passed, never notes", async () => {
|
|
118
|
+
await store.add("no coffee");
|
|
119
|
+
await store.add("writing a dungeon adventure", "note");
|
|
120
|
+
await store.add("last week's game", "event", "2026-09-20");
|
|
121
|
+
await store.add("today's game", "event", "2026-09-26");
|
|
122
|
+
await store.add("next week's game", "event", "2026-10-03");
|
|
123
|
+
const prompt = await store.forPrompt("2026-09-26");
|
|
124
|
+
expect(prompt.core.map((m) => m.fact)).toEqual(["no coffee"]);
|
|
125
|
+
expect(prompt.events.map((m) => m.fact)).toEqual([
|
|
126
|
+
"today's game",
|
|
127
|
+
"next week's game",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("search finds every kind, most matched terms first", async () => {
|
|
132
|
+
await store.add("Obsidian knowledge base uses MOC notes", "note");
|
|
133
|
+
await store.add("Obsidian path is ~/obsidian", "note");
|
|
134
|
+
await store.add("last week's game", "event", "2026-09-20");
|
|
135
|
+
await store.add("no coffee");
|
|
136
|
+
const found = await store.search("obsidian MOC");
|
|
137
|
+
expect(found.map((m) => m.fact)).toEqual([
|
|
138
|
+
"Obsidian knowledge base uses MOC notes",
|
|
139
|
+
"Obsidian path is ~/obsidian",
|
|
140
|
+
]);
|
|
141
|
+
expect((await store.search("game")).map((m) => m.kind)).toEqual([
|
|
142
|
+
"event",
|
|
143
|
+
]);
|
|
144
|
+
expect(await store.search("tea")).toEqual([]);
|
|
145
|
+
expect(store.search(" ")).rejects.toBeInstanceOf(MemoryError);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("search treats terms literally, not as patterns", async () => {
|
|
149
|
+
await store.add("100% sure");
|
|
150
|
+
await store.add("1000 sure");
|
|
151
|
+
expect((await store.search("0%")).map((m) => m.fact)).toEqual([
|
|
152
|
+
"100% sure",
|
|
153
|
+
]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("facts survive a new connection", async () => {
|
|
157
|
+
await store.add("lives in Taipei");
|
|
158
|
+
const other = await openTestStore(OwnerMemoryStore, OWNER_ID);
|
|
159
|
+
expect((await other.list()).map((m) => m.fact)).toEqual([
|
|
160
|
+
"lives in Taipei",
|
|
161
|
+
]);
|
|
162
|
+
await other.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("remove deletes every fact containing the text, case-insensitively", async () => {
|
|
166
|
+
await store.add("Prefers Bun over Node");
|
|
167
|
+
await store.add("bun lover", "note");
|
|
168
|
+
await store.add("no coffee");
|
|
169
|
+
expect(await store.remove("BUN")).toEqual([
|
|
170
|
+
"Prefers Bun over Node",
|
|
171
|
+
"bun lover",
|
|
172
|
+
]);
|
|
173
|
+
expect(await facts()).toEqual(["no coffee"]);
|
|
174
|
+
expect(await store.remove("tea")).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("update replaces one memory's text, kind, and date under the same rules", async () => {
|
|
178
|
+
const note = await store.add("writing a dungeon adventure", "note");
|
|
179
|
+
const other = await store.add("no coffee");
|
|
180
|
+
expect(
|
|
181
|
+
await store.update(note.id, {
|
|
182
|
+
fact: " dungeon session ",
|
|
183
|
+
kind: "event",
|
|
184
|
+
eventDate: "2026-10-03",
|
|
185
|
+
}),
|
|
186
|
+
).toEqual({
|
|
187
|
+
id: note.id,
|
|
188
|
+
kind: "event",
|
|
189
|
+
fact: "dungeon session",
|
|
190
|
+
eventDate: "2026-10-03",
|
|
191
|
+
});
|
|
192
|
+
expect(
|
|
193
|
+
await store.update(note.id, {
|
|
194
|
+
fact: "dungeon session",
|
|
195
|
+
kind: "note",
|
|
196
|
+
}),
|
|
197
|
+
).toMatchObject({ kind: "note", eventDate: null });
|
|
198
|
+
expect(
|
|
199
|
+
store.update(note.id, { fact: "a meeting", kind: "event" }),
|
|
200
|
+
).rejects.toBeInstanceOf(MemoryError);
|
|
201
|
+
expect(
|
|
202
|
+
store.update(note.id, { fact: " ", kind: "note" }),
|
|
203
|
+
).rejects.toBeInstanceOf(MemoryError);
|
|
204
|
+
expect(
|
|
205
|
+
await store.update(other.id + 1000, { fact: "x", kind: "core" }),
|
|
206
|
+
).toBeUndefined();
|
|
207
|
+
expect(await facts()).toEqual(["dungeon session", "no coffee"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("removeById deletes exactly one memory", async () => {
|
|
211
|
+
const first = await store.add("Prefers Bun");
|
|
212
|
+
await store.add("Prefers Bun too");
|
|
213
|
+
expect(await store.removeById(first.id)).toBe(true);
|
|
214
|
+
expect(await store.removeById(first.id)).toBe(false);
|
|
215
|
+
expect(await facts()).toEqual(["Prefers Bun too"]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("empty input is rejected", async () => {
|
|
219
|
+
expect(store.add(" ")).rejects.toBeInstanceOf(MemoryError);
|
|
220
|
+
expect(store.remove("")).rejects.toBeInstanceOf(MemoryError);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("helpers", () => {
|
|
227
|
+
test("search terms split on whitespace, lowercased and deduplicated", () => {
|
|
228
|
+
expect(searchTerms(" Obsidian MOC obsidian\nknowledge ")).toEqual([
|
|
229
|
+
"obsidian",
|
|
230
|
+
"moc",
|
|
231
|
+
"knowledge",
|
|
232
|
+
]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("today is the date in the configured zone", () => {
|
|
236
|
+
setTimeZone("Asia/Taipei");
|
|
237
|
+
try {
|
|
238
|
+
expect(zonedDate(new Date("2026-09-26T16:30:00Z"))).toBe("2026-09-27");
|
|
239
|
+
expect(zonedDate(new Date("2026-09-26T15:30:00Z"))).toBe("2026-09-26");
|
|
240
|
+
} finally {
|
|
241
|
+
useTestLocale();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|