discoclaw 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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PermissionFlagsBits } from 'discord.js';
|
|
4
|
+
import { KeyedQueue } from '../group-queue.js';
|
|
5
|
+
export const CURRENT_VERSION = 1;
|
|
6
|
+
export function emptyStore() {
|
|
7
|
+
return { version: CURRENT_VERSION, entries: [] };
|
|
8
|
+
}
|
|
9
|
+
function migrateStore(store) {
|
|
10
|
+
// Migration blocks run first, then the unknown-version guard.
|
|
11
|
+
// Future migrations follow the run-stats.ts pattern:
|
|
12
|
+
// if ((store as any).version === 1) { /* transform fields */; store.version = 2; }
|
|
13
|
+
if (store.version !== CURRENT_VERSION) {
|
|
14
|
+
// Unrecognized (future) version — caller will create a fresh store.
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return store;
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Persistence
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
function safeGuildUserId(guildUserId) {
|
|
23
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(guildUserId)) {
|
|
24
|
+
throw new Error(`Invalid guildUserId for short-term memory path: ${guildUserId}`);
|
|
25
|
+
}
|
|
26
|
+
return guildUserId;
|
|
27
|
+
}
|
|
28
|
+
export async function loadShortTermMemory(dir, guildUserId) {
|
|
29
|
+
const filePath = path.join(dir, `${safeGuildUserId(guildUserId)}.json`);
|
|
30
|
+
try {
|
|
31
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
if (parsed &&
|
|
34
|
+
typeof parsed === 'object' &&
|
|
35
|
+
'version' in parsed &&
|
|
36
|
+
'entries' in parsed &&
|
|
37
|
+
Array.isArray(parsed.entries)) {
|
|
38
|
+
return migrateStore(parsed) ?? emptyStore();
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function saveShortTermMemory(dir, guildUserId, store) {
|
|
47
|
+
await fs.mkdir(dir, { recursive: true });
|
|
48
|
+
const filePath = path.join(dir, `${safeGuildUserId(guildUserId)}.json`);
|
|
49
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
50
|
+
await fs.writeFile(tmp, JSON.stringify(store, null, 2) + '\n', 'utf8');
|
|
51
|
+
await fs.rename(tmp, filePath);
|
|
52
|
+
}
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Write queue (per guild-user key)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
const shortTermWriteQueue = new KeyedQueue();
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Core logic
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
export async function appendEntry(dir, guildUserId, entry, opts) {
|
|
61
|
+
await shortTermWriteQueue.run(guildUserId, async () => {
|
|
62
|
+
const store = (await loadShortTermMemory(dir, guildUserId)) ?? emptyStore();
|
|
63
|
+
store.entries.push(entry);
|
|
64
|
+
// Prune: remove expired entries and enforce cap.
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
store.entries = store.entries
|
|
67
|
+
.filter((e) => now - e.timestamp < opts.maxAgeMs)
|
|
68
|
+
.slice(-opts.maxEntries);
|
|
69
|
+
await saveShortTermMemory(dir, guildUserId, store);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function buildExcerptSummary(userMsg, botResponse, maxLen = 200) {
|
|
73
|
+
const userPart = userMsg.slice(0, Math.floor(maxLen / 2)).trim();
|
|
74
|
+
const botPart = botResponse.slice(0, Math.floor(maxLen / 2)).trim();
|
|
75
|
+
return `User: ${userPart} | Bot: ${botPart}`;
|
|
76
|
+
}
|
|
77
|
+
export function selectEntriesForInjection(store, maxChars, maxAgeMs) {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
const recent = store.entries
|
|
80
|
+
.filter((e) => now - e.timestamp < maxAgeMs)
|
|
81
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
82
|
+
const selected = [];
|
|
83
|
+
let chars = 0;
|
|
84
|
+
for (const entry of recent) {
|
|
85
|
+
const line = formatEntryLine(entry);
|
|
86
|
+
const sep = selected.length > 0 ? 1 : 0;
|
|
87
|
+
if (chars + sep + line.length > maxChars)
|
|
88
|
+
continue;
|
|
89
|
+
selected.push(entry);
|
|
90
|
+
chars += sep + line.length;
|
|
91
|
+
}
|
|
92
|
+
return selected;
|
|
93
|
+
}
|
|
94
|
+
function formatEntryLine(entry) {
|
|
95
|
+
const ago = formatTimeAgo(Date.now() - entry.timestamp);
|
|
96
|
+
return `- ${ago} in #${entry.channelName}: ${entry.summary}`;
|
|
97
|
+
}
|
|
98
|
+
function formatTimeAgo(ms) {
|
|
99
|
+
const minutes = Math.floor(ms / 60_000);
|
|
100
|
+
if (minutes < 60)
|
|
101
|
+
return `${minutes} min ago`;
|
|
102
|
+
const hours = Math.floor(minutes / 60);
|
|
103
|
+
return `${hours} hr${hours > 1 ? 's' : ''} ago`;
|
|
104
|
+
}
|
|
105
|
+
export function formatShortTermSection(entries) {
|
|
106
|
+
return entries.map(formatEntryLine).join('\n');
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Channel privacy check
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
export function isChannelPublic(channel, guild) {
|
|
112
|
+
// DMs have no guild — always skip.
|
|
113
|
+
if (!guild)
|
|
114
|
+
return false;
|
|
115
|
+
// Category / voice channels — skip.
|
|
116
|
+
const type = channel?.type;
|
|
117
|
+
if (type === undefined || type === null)
|
|
118
|
+
return false;
|
|
119
|
+
// Check if it's a thread (public or private) or forum post.
|
|
120
|
+
const isThread = typeof channel.isThread === 'function' ? channel.isThread() : false;
|
|
121
|
+
if (isThread) {
|
|
122
|
+
// Inherit parent channel visibility.
|
|
123
|
+
const parent = channel.parent;
|
|
124
|
+
if (!parent)
|
|
125
|
+
return false;
|
|
126
|
+
return checkEveryoneViewChannel(parent, guild);
|
|
127
|
+
}
|
|
128
|
+
return checkEveryoneViewChannel(channel, guild);
|
|
129
|
+
}
|
|
130
|
+
function checkEveryoneViewChannel(channel, guild) {
|
|
131
|
+
try {
|
|
132
|
+
const everyone = guild.roles.everyone;
|
|
133
|
+
if (!everyone)
|
|
134
|
+
return false;
|
|
135
|
+
const perms = channel.permissionsFor?.(everyone);
|
|
136
|
+
if (!perms)
|
|
137
|
+
return false;
|
|
138
|
+
return perms.has(PermissionFlagsBits.ViewChannel);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Prompt section builder
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
export async function buildShortTermMemorySection(opts) {
|
|
148
|
+
if (!opts.enabled)
|
|
149
|
+
return '';
|
|
150
|
+
if (!opts.guildId)
|
|
151
|
+
return '';
|
|
152
|
+
try {
|
|
153
|
+
const guildUserId = `${opts.guildId}-${opts.userId}`;
|
|
154
|
+
const store = await loadShortTermMemory(opts.shortTermDataDir, guildUserId);
|
|
155
|
+
if (!store)
|
|
156
|
+
return '';
|
|
157
|
+
const entries = selectEntriesForInjection(store, opts.maxChars, opts.maxAgeMs);
|
|
158
|
+
if (entries.length === 0)
|
|
159
|
+
return '';
|
|
160
|
+
return formatShortTermSection(entries);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
opts.log?.warn({ err, userId: opts.userId }, 'short-term memory load failed');
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PermissionsBitField, PermissionFlagsBits } from 'discord.js';
|
|
6
|
+
import { loadShortTermMemory, saveShortTermMemory, appendEntry, buildExcerptSummary, selectEntriesForInjection, isChannelPublic, formatShortTermSection, buildShortTermMemorySection, CURRENT_VERSION, } from './shortterm-memory.js';
|
|
7
|
+
async function makeTmpDir() {
|
|
8
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'shortterm-memory-test-'));
|
|
9
|
+
}
|
|
10
|
+
function makeEntry(overrides = {}) {
|
|
11
|
+
return {
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
sessionKey: 'discord:guild:123:456',
|
|
14
|
+
channelName: 'general',
|
|
15
|
+
summary: 'User asked about APIs | Bot suggested REST',
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Persistence
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
describe('loadShortTermMemory', () => {
|
|
23
|
+
it('returns null for missing file', async () => {
|
|
24
|
+
const dir = await makeTmpDir();
|
|
25
|
+
expect(await loadShortTermMemory(dir, 'nonexistent')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it('parses valid store', async () => {
|
|
28
|
+
const dir = await makeTmpDir();
|
|
29
|
+
const store = { version: 1, entries: [] };
|
|
30
|
+
await fs.writeFile(path.join(dir, 'guild1-user1.json'), JSON.stringify(store), 'utf8');
|
|
31
|
+
expect(await loadShortTermMemory(dir, 'guild1-user1')).toEqual(store);
|
|
32
|
+
});
|
|
33
|
+
it('returns null on malformed JSON', async () => {
|
|
34
|
+
const dir = await makeTmpDir();
|
|
35
|
+
await fs.writeFile(path.join(dir, 'bad.json'), '{nope', 'utf8');
|
|
36
|
+
expect(await loadShortTermMemory(dir, 'bad')).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
it('rejects path traversal', async () => {
|
|
39
|
+
const dir = await makeTmpDir();
|
|
40
|
+
await expect(loadShortTermMemory(dir, '../evil')).rejects.toThrow(/Invalid guildUserId/);
|
|
41
|
+
});
|
|
42
|
+
it('returns store unchanged for version-1 store (migration no-op)', async () => {
|
|
43
|
+
const dir = await makeTmpDir();
|
|
44
|
+
const store = { version: 1, entries: [] };
|
|
45
|
+
await fs.writeFile(path.join(dir, 'guild1-user1.json'), JSON.stringify(store), 'utf8');
|
|
46
|
+
expect(await loadShortTermMemory(dir, 'guild1-user1')).toEqual(store);
|
|
47
|
+
});
|
|
48
|
+
it('returns empty store for unsupported version', async () => {
|
|
49
|
+
const dir = await makeTmpDir();
|
|
50
|
+
const store = { version: 99, entries: [] };
|
|
51
|
+
await fs.writeFile(path.join(dir, 'guild1-user1.json'), JSON.stringify(store), 'utf8');
|
|
52
|
+
expect(await loadShortTermMemory(dir, 'guild1-user1')).toMatchObject({ version: CURRENT_VERSION, entries: [] });
|
|
53
|
+
});
|
|
54
|
+
it('returns null for store with no version field', async () => {
|
|
55
|
+
const dir = await makeTmpDir();
|
|
56
|
+
const store = { entries: [] };
|
|
57
|
+
await fs.writeFile(path.join(dir, 'guild1-user1.json'), JSON.stringify(store), 'utf8');
|
|
58
|
+
expect(await loadShortTermMemory(dir, 'guild1-user1')).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('saveShortTermMemory — path traversal', () => {
|
|
62
|
+
it('rejects path traversal in guildUserId', async () => {
|
|
63
|
+
const dir = await makeTmpDir();
|
|
64
|
+
const store = { version: 1, entries: [] };
|
|
65
|
+
await expect(saveShortTermMemory(dir, '../evil', store)).rejects.toThrow(/Invalid guildUserId/);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('saveShortTermMemory', () => {
|
|
69
|
+
it('creates file and parent directory', async () => {
|
|
70
|
+
const dir = await makeTmpDir();
|
|
71
|
+
const nested = path.join(dir, 'sub');
|
|
72
|
+
const store = { version: 1, entries: [makeEntry()] };
|
|
73
|
+
await saveShortTermMemory(nested, 'guild1-user1', store);
|
|
74
|
+
const raw = await fs.readFile(path.join(nested, 'guild1-user1.json'), 'utf8');
|
|
75
|
+
expect(JSON.parse(raw)).toEqual(store);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// appendEntry
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
describe('appendEntry', () => {
|
|
82
|
+
it('appends and prunes expired entries', async () => {
|
|
83
|
+
const dir = await makeTmpDir();
|
|
84
|
+
const guildUserId = 'g1-u1';
|
|
85
|
+
const maxAgeMs = 60_000; // 1 minute
|
|
86
|
+
// Add an old entry.
|
|
87
|
+
const old = {
|
|
88
|
+
version: 1,
|
|
89
|
+
entries: [makeEntry({ timestamp: Date.now() - 120_000, channelName: 'old' })],
|
|
90
|
+
};
|
|
91
|
+
await saveShortTermMemory(dir, guildUserId, old);
|
|
92
|
+
// Append a new entry.
|
|
93
|
+
await appendEntry(dir, guildUserId, makeEntry({ channelName: 'new' }), { maxEntries: 20, maxAgeMs });
|
|
94
|
+
const store = await loadShortTermMemory(dir, guildUserId);
|
|
95
|
+
expect(store.entries).toHaveLength(1);
|
|
96
|
+
expect(store.entries[0].channelName).toBe('new');
|
|
97
|
+
});
|
|
98
|
+
it('persists channelId when provided', async () => {
|
|
99
|
+
const dir = await makeTmpDir();
|
|
100
|
+
const guildUserId = 'g1-u1';
|
|
101
|
+
await appendEntry(dir, guildUserId, makeEntry({ channelId: 'ch123' }), {
|
|
102
|
+
maxEntries: 20,
|
|
103
|
+
maxAgeMs: 3600_000,
|
|
104
|
+
});
|
|
105
|
+
const store = await loadShortTermMemory(dir, guildUserId);
|
|
106
|
+
expect(store.entries[0].channelId).toBe('ch123');
|
|
107
|
+
});
|
|
108
|
+
it('enforces maxEntries cap', async () => {
|
|
109
|
+
const dir = await makeTmpDir();
|
|
110
|
+
const guildUserId = 'g1-u1';
|
|
111
|
+
for (let i = 0; i < 5; i++) {
|
|
112
|
+
await appendEntry(dir, guildUserId, makeEntry({ channelName: `ch${i}` }), {
|
|
113
|
+
maxEntries: 3,
|
|
114
|
+
maxAgeMs: 3600_000,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
const store = await loadShortTermMemory(dir, guildUserId);
|
|
118
|
+
expect(store.entries).toHaveLength(3);
|
|
119
|
+
// Should keep the 3 most recent (ch2, ch3, ch4).
|
|
120
|
+
expect(store.entries[0].channelName).toBe('ch2');
|
|
121
|
+
expect(store.entries[2].channelName).toBe('ch4');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// buildExcerptSummary
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
describe('buildExcerptSummary', () => {
|
|
128
|
+
it('truncates long messages', () => {
|
|
129
|
+
const long = 'x'.repeat(500);
|
|
130
|
+
const result = buildExcerptSummary(long, long, 100);
|
|
131
|
+
expect(result.length).toBeLessThan(200);
|
|
132
|
+
expect(result).toContain('User:');
|
|
133
|
+
expect(result).toContain('Bot:');
|
|
134
|
+
});
|
|
135
|
+
it('handles short messages', () => {
|
|
136
|
+
const result = buildExcerptSummary('hi', 'hello', 200);
|
|
137
|
+
expect(result).toBe('User: hi | Bot: hello');
|
|
138
|
+
});
|
|
139
|
+
it('handles empty strings', () => {
|
|
140
|
+
const result = buildExcerptSummary('', '', 200);
|
|
141
|
+
expect(result).toBe('User: | Bot: ');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// selectEntriesForInjection
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
describe('selectEntriesForInjection', () => {
|
|
148
|
+
it('filters by age and respects maxChars', () => {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
const store = {
|
|
151
|
+
version: 1,
|
|
152
|
+
entries: [
|
|
153
|
+
makeEntry({ timestamp: now - 1000, channelName: 'recent' }),
|
|
154
|
+
makeEntry({ timestamp: now - 7200_000, channelName: 'expired' }), // 2 hrs old
|
|
155
|
+
],
|
|
156
|
+
};
|
|
157
|
+
const entries = selectEntriesForInjection(store, 10000, 3600_000); // 1hr max age
|
|
158
|
+
expect(entries).toHaveLength(1);
|
|
159
|
+
expect(entries[0].channelName).toBe('recent');
|
|
160
|
+
});
|
|
161
|
+
it('returns empty with maxChars = 0', () => {
|
|
162
|
+
const store = {
|
|
163
|
+
version: 1,
|
|
164
|
+
entries: [makeEntry()],
|
|
165
|
+
};
|
|
166
|
+
expect(selectEntriesForInjection(store, 0, 3600_000)).toHaveLength(0);
|
|
167
|
+
});
|
|
168
|
+
it('returns empty for store with no entries', () => {
|
|
169
|
+
const store = { version: 1, entries: [] };
|
|
170
|
+
expect(selectEntriesForInjection(store, 10000, 3600_000)).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
it('sorts by recency (newest first)', () => {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const store = {
|
|
175
|
+
version: 1,
|
|
176
|
+
entries: [
|
|
177
|
+
makeEntry({ timestamp: now - 5000, channelName: 'older' }),
|
|
178
|
+
makeEntry({ timestamp: now - 1000, channelName: 'newer' }),
|
|
179
|
+
],
|
|
180
|
+
};
|
|
181
|
+
const entries = selectEntriesForInjection(store, 10000, 3600_000);
|
|
182
|
+
expect(entries[0].channelName).toBe('newer');
|
|
183
|
+
expect(entries[1].channelName).toBe('older');
|
|
184
|
+
});
|
|
185
|
+
it('skips oversized newest entry and includes smaller older entries that fit', () => {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const store = {
|
|
188
|
+
version: 1,
|
|
189
|
+
entries: [
|
|
190
|
+
makeEntry({
|
|
191
|
+
timestamp: now - 1000,
|
|
192
|
+
channelName: 'newer',
|
|
193
|
+
summary: 'x'.repeat(600),
|
|
194
|
+
}),
|
|
195
|
+
makeEntry({
|
|
196
|
+
timestamp: now - 2000,
|
|
197
|
+
channelName: 'older',
|
|
198
|
+
summary: 'small summary',
|
|
199
|
+
}),
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const entries = selectEntriesForInjection(store, 120, 3600_000);
|
|
203
|
+
expect(entries).toHaveLength(1);
|
|
204
|
+
expect(entries[0].channelName).toBe('older');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// formatShortTermSection
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
describe('formatShortTermSection', () => {
|
|
211
|
+
it('formats entries as bullet list', () => {
|
|
212
|
+
const entries = [
|
|
213
|
+
makeEntry({ timestamp: Date.now() - 900_000, channelName: 'general', summary: 'Talked about APIs' }),
|
|
214
|
+
];
|
|
215
|
+
const result = formatShortTermSection(entries);
|
|
216
|
+
expect(result).toContain('#general');
|
|
217
|
+
expect(result).toContain('Talked about APIs');
|
|
218
|
+
expect(result).toMatch(/\d+ min ago/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// isChannelPublic
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
describe('isChannelPublic', () => {
|
|
225
|
+
function makeGuild(everyonePerms) {
|
|
226
|
+
const everyone = {
|
|
227
|
+
id: 'everyone-role-id',
|
|
228
|
+
};
|
|
229
|
+
return {
|
|
230
|
+
roles: { everyone },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function makeTextChannel(permsForEveryone, opts) {
|
|
234
|
+
return {
|
|
235
|
+
type: 0, // GuildText
|
|
236
|
+
isThread: () => opts?.isThread ?? false,
|
|
237
|
+
parent: opts?.parent ?? null,
|
|
238
|
+
permissionsFor: (role) => new PermissionsBitField(permsForEveryone),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
it('returns true for public channel (@everyone has ViewChannel)', () => {
|
|
242
|
+
const guild = makeGuild(PermissionFlagsBits.ViewChannel);
|
|
243
|
+
const channel = makeTextChannel(PermissionFlagsBits.ViewChannel);
|
|
244
|
+
expect(isChannelPublic(channel, guild)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
it('returns false for private channel (@everyone lacks ViewChannel)', () => {
|
|
247
|
+
const guild = makeGuild(0n);
|
|
248
|
+
const channel = makeTextChannel(0n);
|
|
249
|
+
expect(isChannelPublic(channel, guild)).toBe(false);
|
|
250
|
+
});
|
|
251
|
+
it('returns false for DMs (no guild)', () => {
|
|
252
|
+
expect(isChannelPublic({}, null)).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
it('returns true for thread in public parent', () => {
|
|
255
|
+
const guild = makeGuild(PermissionFlagsBits.ViewChannel);
|
|
256
|
+
const parent = makeTextChannel(PermissionFlagsBits.ViewChannel);
|
|
257
|
+
const thread = {
|
|
258
|
+
type: 11, // PublicThread
|
|
259
|
+
isThread: () => true,
|
|
260
|
+
parent,
|
|
261
|
+
permissionsFor: () => new PermissionsBitField(0n),
|
|
262
|
+
};
|
|
263
|
+
expect(isChannelPublic(thread, guild)).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
it('returns false for thread in private parent', () => {
|
|
266
|
+
const guild = makeGuild(0n);
|
|
267
|
+
const parent = makeTextChannel(0n);
|
|
268
|
+
const thread = {
|
|
269
|
+
type: 11, // PublicThread
|
|
270
|
+
isThread: () => true,
|
|
271
|
+
parent,
|
|
272
|
+
};
|
|
273
|
+
expect(isChannelPublic(thread, guild)).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
it('returns false for thread with no parent', () => {
|
|
276
|
+
const guild = makeGuild(0n);
|
|
277
|
+
const thread = {
|
|
278
|
+
type: 11,
|
|
279
|
+
isThread: () => true,
|
|
280
|
+
parent: null,
|
|
281
|
+
};
|
|
282
|
+
expect(isChannelPublic(thread, guild)).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// buildShortTermMemorySection
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
describe('buildShortTermMemorySection', () => {
|
|
289
|
+
it('returns empty when disabled', async () => {
|
|
290
|
+
const result = await buildShortTermMemorySection({
|
|
291
|
+
enabled: false,
|
|
292
|
+
shortTermDataDir: '/tmp',
|
|
293
|
+
guildId: 'g1',
|
|
294
|
+
userId: 'u1',
|
|
295
|
+
maxChars: 1000,
|
|
296
|
+
maxAgeMs: 3600_000,
|
|
297
|
+
});
|
|
298
|
+
expect(result).toBe('');
|
|
299
|
+
});
|
|
300
|
+
it('returns empty when guildId is empty', async () => {
|
|
301
|
+
const result = await buildShortTermMemorySection({
|
|
302
|
+
enabled: true,
|
|
303
|
+
shortTermDataDir: '/tmp',
|
|
304
|
+
guildId: '',
|
|
305
|
+
userId: 'u1',
|
|
306
|
+
maxChars: 1000,
|
|
307
|
+
maxAgeMs: 3600_000,
|
|
308
|
+
});
|
|
309
|
+
expect(result).toBe('');
|
|
310
|
+
});
|
|
311
|
+
it('returns formatted section when data exists', async () => {
|
|
312
|
+
const dir = await makeTmpDir();
|
|
313
|
+
const store = {
|
|
314
|
+
version: 1,
|
|
315
|
+
entries: [makeEntry({ timestamp: Date.now() - 60_000, channelName: 'dev' })],
|
|
316
|
+
};
|
|
317
|
+
await saveShortTermMemory(dir, 'g1-u1', store);
|
|
318
|
+
const result = await buildShortTermMemorySection({
|
|
319
|
+
enabled: true,
|
|
320
|
+
shortTermDataDir: dir,
|
|
321
|
+
guildId: 'g1',
|
|
322
|
+
userId: 'u1',
|
|
323
|
+
maxChars: 1000,
|
|
324
|
+
maxAgeMs: 3600_000,
|
|
325
|
+
});
|
|
326
|
+
expect(result).toContain('#dev');
|
|
327
|
+
});
|
|
328
|
+
it('returns empty when no entries within maxAge', async () => {
|
|
329
|
+
const dir = await makeTmpDir();
|
|
330
|
+
const store = {
|
|
331
|
+
version: 1,
|
|
332
|
+
entries: [makeEntry({ timestamp: Date.now() - 7200_000 })], // 2 hrs old
|
|
333
|
+
};
|
|
334
|
+
await saveShortTermMemory(dir, 'g1-u1', store);
|
|
335
|
+
const result = await buildShortTermMemorySection({
|
|
336
|
+
enabled: true,
|
|
337
|
+
shortTermDataDir: dir,
|
|
338
|
+
guildId: 'g1',
|
|
339
|
+
userId: 'u1',
|
|
340
|
+
maxChars: 1000,
|
|
341
|
+
maxAgeMs: 3600_000, // 1 hour
|
|
342
|
+
});
|
|
343
|
+
expect(result).toBe('');
|
|
344
|
+
});
|
|
345
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const FILENAME = 'shutdown-context.json';
|
|
4
|
+
const VALID_REASONS = new Set(['restart-command', 'deploy', 'code-fix', 'unknown']);
|
|
5
|
+
const MAX_FIELD_LENGTH = 500;
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Write (shutdown side)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
/**
|
|
10
|
+
* Write shutdown context atomically (tmp + rename).
|
|
11
|
+
* If `skipIfExists` is true, preserves any richer context already written
|
|
12
|
+
* (e.g., by !restart before the SIGTERM handler fires).
|
|
13
|
+
*/
|
|
14
|
+
export async function writeShutdownContext(dataDir, ctx, opts) {
|
|
15
|
+
const filePath = path.join(dataDir, FILENAME);
|
|
16
|
+
if (opts?.skipIfExists) {
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(filePath);
|
|
19
|
+
return; // File already exists — don't overwrite.
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// File doesn't exist — proceed with write.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
26
|
+
await fs.writeFile(tmpPath, JSON.stringify(ctx) + '\n', 'utf-8');
|
|
27
|
+
await fs.rename(tmpPath, filePath);
|
|
28
|
+
}
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Read + clear (startup side)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
/**
|
|
33
|
+
* Read and delete the shutdown context file. Returns startup classification.
|
|
34
|
+
* Pass `firstBoot: true` when the data directory was freshly created (no prior run)
|
|
35
|
+
* to avoid a false "crash" warning on first-ever boot.
|
|
36
|
+
*/
|
|
37
|
+
export async function readAndClearShutdownContext(dataDir, opts) {
|
|
38
|
+
const filePath = path.join(dataDir, FILENAME);
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// No file → crash unless this is the first-ever boot.
|
|
45
|
+
return { type: opts?.firstBoot ? 'first-boot' : 'crash' };
|
|
46
|
+
}
|
|
47
|
+
// Delete the file regardless of parse outcome.
|
|
48
|
+
try {
|
|
49
|
+
await fs.unlink(filePath);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Best-effort deletion.
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Corrupted file → treat as crash.
|
|
60
|
+
return { type: 'crash' };
|
|
61
|
+
}
|
|
62
|
+
// Non-object JSON (null, string, array, number) → treat as crash.
|
|
63
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
64
|
+
return { type: 'crash' };
|
|
65
|
+
}
|
|
66
|
+
// Validate reason against known union; unknown/missing → graceful-unknown.
|
|
67
|
+
const reason = VALID_REASONS.has(parsed.reason) ? parsed.reason : 'unknown';
|
|
68
|
+
const ctx = {
|
|
69
|
+
reason,
|
|
70
|
+
timestamp: typeof parsed.timestamp === 'string' ? parsed.timestamp : new Date().toISOString(),
|
|
71
|
+
message: typeof parsed.message === 'string' ? parsed.message.slice(0, MAX_FIELD_LENGTH) : undefined,
|
|
72
|
+
activeForge: typeof parsed.activeForge === 'string' ? parsed.activeForge.slice(0, MAX_FIELD_LENGTH) : undefined,
|
|
73
|
+
requestedBy: typeof parsed.requestedBy === 'string' ? parsed.requestedBy : undefined,
|
|
74
|
+
};
|
|
75
|
+
if (ctx.reason === 'unknown') {
|
|
76
|
+
return { type: 'graceful-unknown', shutdown: ctx };
|
|
77
|
+
}
|
|
78
|
+
return { type: 'intentional', shutdown: ctx };
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Format for AI injection
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Format startup context as a one-shot prompt injection string.
|
|
85
|
+
* Returns null if there's nothing meaningful to inject (shouldn't happen
|
|
86
|
+
* in practice, but defensive).
|
|
87
|
+
*/
|
|
88
|
+
export function formatStartupInjection(ctx) {
|
|
89
|
+
let line;
|
|
90
|
+
switch (ctx.type) {
|
|
91
|
+
case 'intentional': {
|
|
92
|
+
const reason = ctx.shutdown?.reason ?? 'restart-command';
|
|
93
|
+
const who = ctx.shutdown?.requestedBy
|
|
94
|
+
? ` by <@${ctx.shutdown.requestedBy}>`
|
|
95
|
+
: '';
|
|
96
|
+
const msg = ctx.shutdown?.message
|
|
97
|
+
? ` (${ctx.shutdown.message})`
|
|
98
|
+
: '';
|
|
99
|
+
const via = reason === 'restart-command' ? ' via !restart'
|
|
100
|
+
: reason === 'deploy' ? ' for a deploy'
|
|
101
|
+
: reason === 'code-fix' ? ' to apply a code fix'
|
|
102
|
+
: '';
|
|
103
|
+
line = `You were restarted${via}${who}${msg}.`;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
case 'graceful-unknown':
|
|
107
|
+
line = 'You were restarted (graceful shutdown, reason unknown — likely a manual systemctl restart).';
|
|
108
|
+
break;
|
|
109
|
+
case 'crash':
|
|
110
|
+
line = 'You appear to have crashed or been killed (no shutdown context found). Consider checking journalctl logs.';
|
|
111
|
+
break;
|
|
112
|
+
case 'first-boot':
|
|
113
|
+
return null; // Nothing to inject on first-ever boot.
|
|
114
|
+
default:
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (ctx.shutdown?.activeForge) {
|
|
118
|
+
line += ` A forge run was in progress: ${ctx.shutdown.activeForge}.`;
|
|
119
|
+
}
|
|
120
|
+
line += ' If the current thread\'s task is already resolved, don\'t announce it — just respond to the user.';
|
|
121
|
+
return line;
|
|
122
|
+
}
|