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,169 @@
|
|
|
1
|
+
const DEFAULT_BUDGET_CHARS = 3000;
|
|
2
|
+
const DEFAULT_RECENT_LIMIT = 10;
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function hasMedia(m) {
|
|
7
|
+
const attSize = m.attachments && ('size' in m.attachments ? m.attachments.size : 0);
|
|
8
|
+
const embLen = m.embeds && ('length' in m.embeds ? m.embeds.length : 0);
|
|
9
|
+
return (attSize ?? 0) > 0 || (embLen ?? 0) > 0;
|
|
10
|
+
}
|
|
11
|
+
function formatMessageLine(m, botName, suffix) {
|
|
12
|
+
const content = String(m.content ?? '').trim();
|
|
13
|
+
const author = m.author.bot
|
|
14
|
+
? botName
|
|
15
|
+
: (m.author.displayName || m.author.username);
|
|
16
|
+
const tag = suffix ? ` (${suffix})` : '';
|
|
17
|
+
if (content) {
|
|
18
|
+
return `[${author}${tag}]: ${content}`;
|
|
19
|
+
}
|
|
20
|
+
if (hasMedia(m)) {
|
|
21
|
+
return `[${author}${tag}]: [attachment/embed]`;
|
|
22
|
+
}
|
|
23
|
+
return null; // no content and no media — skip
|
|
24
|
+
}
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Main function
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Resolve thread context: the thread's starter message and recent posts.
|
|
30
|
+
*
|
|
31
|
+
* Returns null if the channel is not a thread.
|
|
32
|
+
* When only the thread name is available, returns it alone (still useful context).
|
|
33
|
+
* Errors are caught and logged — never throws.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveThreadContext(channel, currentMessageId, opts = {}) {
|
|
36
|
+
if (!channel.isThread())
|
|
37
|
+
return null;
|
|
38
|
+
const budget = opts.budgetChars ?? DEFAULT_BUDGET_CHARS;
|
|
39
|
+
const recentLimit = opts.recentMessageLimit ?? DEFAULT_RECENT_LIMIT;
|
|
40
|
+
const botName = opts.botDisplayName ?? 'Discoclaw';
|
|
41
|
+
const sections = [];
|
|
42
|
+
let remaining = budget;
|
|
43
|
+
const seenMessageIds = new Set();
|
|
44
|
+
// 1. Thread name
|
|
45
|
+
const threadName = channel.name?.trim();
|
|
46
|
+
if (threadName) {
|
|
47
|
+
sections.push(`Thread: "${threadName}"`);
|
|
48
|
+
}
|
|
49
|
+
// 2. Starter message (the original post that started the thread)
|
|
50
|
+
if (typeof channel.fetchStarterMessage === 'function') {
|
|
51
|
+
try {
|
|
52
|
+
const starter = await channel.fetchStarterMessage();
|
|
53
|
+
if (starter) {
|
|
54
|
+
const line = formatMessageLine(starter, botName, 'thread starter');
|
|
55
|
+
if (line) {
|
|
56
|
+
if (line.length <= remaining) {
|
|
57
|
+
sections.push(line);
|
|
58
|
+
remaining -= line.length + 1;
|
|
59
|
+
seenMessageIds.add(starter.id);
|
|
60
|
+
}
|
|
61
|
+
else if (remaining > 50) {
|
|
62
|
+
sections.push(line.slice(0, remaining - 3) + '...');
|
|
63
|
+
remaining = 0;
|
|
64
|
+
seenMessageIds.add(starter.id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
opts.log?.warn({ err }, 'thread-context: failed to fetch starter message');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// 3. Pinned thread messages (optional)
|
|
74
|
+
if (opts.includePinned && remaining > 50) {
|
|
75
|
+
const fetchPinned = channel.messages.fetchPinned;
|
|
76
|
+
if (typeof fetchPinned === 'function') {
|
|
77
|
+
try {
|
|
78
|
+
const pinned = await fetchPinned.call(channel.messages);
|
|
79
|
+
if (pinned && pinned.size > 0) {
|
|
80
|
+
const sorted = Array.from(pinned.values())
|
|
81
|
+
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
82
|
+
const pinnedLines = [];
|
|
83
|
+
const maxPinnedLines = 3;
|
|
84
|
+
for (const m of sorted) {
|
|
85
|
+
if (pinnedLines.length >= maxPinnedLines)
|
|
86
|
+
break;
|
|
87
|
+
if (remaining <= 0)
|
|
88
|
+
break;
|
|
89
|
+
if (m.id && seenMessageIds.has(m.id))
|
|
90
|
+
continue;
|
|
91
|
+
const line = formatMessageLine(m, botName, 'pinned');
|
|
92
|
+
if (!line)
|
|
93
|
+
continue;
|
|
94
|
+
if (line.length <= remaining) {
|
|
95
|
+
pinnedLines.push(line);
|
|
96
|
+
remaining -= line.length + 1;
|
|
97
|
+
if (m.id)
|
|
98
|
+
seenMessageIds.add(m.id);
|
|
99
|
+
}
|
|
100
|
+
else if (remaining > 50) {
|
|
101
|
+
pinnedLines.push(line.slice(0, remaining - 3) + '...');
|
|
102
|
+
remaining = 0;
|
|
103
|
+
if (m.id)
|
|
104
|
+
seenMessageIds.add(m.id);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (pinnedLines.length > 0) {
|
|
112
|
+
sections.push('Pinned thread messages:');
|
|
113
|
+
sections.push(...pinnedLines);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
opts.log?.warn({ err }, 'thread-context: failed to fetch pinned messages');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 4. Recent thread messages (before the current command message)
|
|
123
|
+
if (remaining > 50 && recentLimit > 0) {
|
|
124
|
+
try {
|
|
125
|
+
const messages = await channel.messages.fetch({
|
|
126
|
+
before: currentMessageId,
|
|
127
|
+
limit: recentLimit,
|
|
128
|
+
});
|
|
129
|
+
if (messages && messages.size > 0) {
|
|
130
|
+
// Sort by snowflake ID (ascending = chronological).
|
|
131
|
+
const sorted = Array.from(messages.values())
|
|
132
|
+
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
133
|
+
const lines = [];
|
|
134
|
+
for (const m of sorted) {
|
|
135
|
+
// Deduplicate: skip the starter message if it appears in recent messages
|
|
136
|
+
if (m.id && seenMessageIds.has(m.id))
|
|
137
|
+
continue;
|
|
138
|
+
const line = formatMessageLine(m, botName);
|
|
139
|
+
if (!line)
|
|
140
|
+
continue;
|
|
141
|
+
if (line.length <= remaining) {
|
|
142
|
+
lines.push(line);
|
|
143
|
+
remaining -= line.length + 1;
|
|
144
|
+
if (m.id)
|
|
145
|
+
seenMessageIds.add(m.id);
|
|
146
|
+
}
|
|
147
|
+
else if (remaining > 50 && m.author.bot) {
|
|
148
|
+
lines.push(line.slice(0, remaining - 3) + '...');
|
|
149
|
+
remaining = 0;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (lines.length > 0) {
|
|
157
|
+
sections.push('Recent thread messages:');
|
|
158
|
+
sections.push(...lines);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
opts.log?.warn({ err }, 'thread-context: failed to fetch recent messages');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (sections.length === 0)
|
|
167
|
+
return null;
|
|
168
|
+
return { section: sections.join('\n') };
|
|
169
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { resolveThreadContext } from './thread-context.js';
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Helpers
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
function fakeMsg(id, content, username, bot = false, extra) {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
author: { username, displayName: username, bot },
|
|
10
|
+
content,
|
|
11
|
+
...extra,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function fakeThread(opts) {
|
|
15
|
+
return {
|
|
16
|
+
isThread: () => true,
|
|
17
|
+
name: opts.name,
|
|
18
|
+
fetchStarterMessage: opts.starterError
|
|
19
|
+
? (async () => { throw new Error('forbidden'); })
|
|
20
|
+
: (async () => opts.starter ?? null),
|
|
21
|
+
messages: {
|
|
22
|
+
fetch: opts.fetchError
|
|
23
|
+
? (async () => { throw new Error('forbidden'); })
|
|
24
|
+
: (async () => {
|
|
25
|
+
const map = new Map();
|
|
26
|
+
// Discord returns newest-first
|
|
27
|
+
const msgs = [...(opts.messages ?? [])].reverse();
|
|
28
|
+
for (const m of msgs)
|
|
29
|
+
map.set(m.id, m);
|
|
30
|
+
return map;
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function fakeNonThread() {
|
|
36
|
+
return {
|
|
37
|
+
isThread: () => false,
|
|
38
|
+
messages: {
|
|
39
|
+
fetch: async () => new Map(),
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Tests
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
describe('resolveThreadContext', () => {
|
|
47
|
+
it('returns null for non-thread channels', async () => {
|
|
48
|
+
const result = await resolveThreadContext(fakeNonThread(), '100');
|
|
49
|
+
expect(result).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
it('returns thread name alone when no starter or messages are available', async () => {
|
|
52
|
+
const ch = fakeThread({ name: 'Login fails when session expires', messages: [] });
|
|
53
|
+
const result = await resolveThreadContext(ch, '100');
|
|
54
|
+
expect(result).not.toBeNull();
|
|
55
|
+
expect(result.section).toContain('Thread: "Login fails when session expires"');
|
|
56
|
+
});
|
|
57
|
+
it('returns context with starter message and thread name', async () => {
|
|
58
|
+
const ch = fakeThread({
|
|
59
|
+
name: 'bug-discussion',
|
|
60
|
+
starter: {
|
|
61
|
+
id: '1',
|
|
62
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
63
|
+
content: 'We need to fix the login flow',
|
|
64
|
+
},
|
|
65
|
+
messages: [],
|
|
66
|
+
});
|
|
67
|
+
const result = await resolveThreadContext(ch, '100');
|
|
68
|
+
expect(result).not.toBeNull();
|
|
69
|
+
expect(result.section).toContain('Thread: "bug-discussion"');
|
|
70
|
+
expect(result.section).toContain('[Alice (thread starter)]: We need to fix the login flow');
|
|
71
|
+
});
|
|
72
|
+
it('returns context with recent messages in chronological order', async () => {
|
|
73
|
+
const ch = fakeThread({
|
|
74
|
+
name: 'feature-request',
|
|
75
|
+
starter: {
|
|
76
|
+
id: '1',
|
|
77
|
+
author: { username: 'Bob', displayName: 'Bob', bot: false },
|
|
78
|
+
content: 'Add dark mode',
|
|
79
|
+
},
|
|
80
|
+
messages: [
|
|
81
|
+
fakeMsg('2', 'I agree, dark mode would be great', 'Charlie'),
|
|
82
|
+
fakeMsg('3', 'What about the sidebar?', 'Bob'),
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
const result = await resolveThreadContext(ch, '100');
|
|
86
|
+
expect(result).not.toBeNull();
|
|
87
|
+
expect(result.section).toContain('[Bob (thread starter)]: Add dark mode');
|
|
88
|
+
expect(result.section).toContain('Recent thread messages:');
|
|
89
|
+
expect(result.section).toContain('[Charlie]: I agree, dark mode would be great');
|
|
90
|
+
expect(result.section).toContain('[Bob]: What about the sidebar?');
|
|
91
|
+
// Verify chronological order: Charlie (id 2) before Bob (id 3)
|
|
92
|
+
const charlieIdx = result.section.indexOf('[Charlie]');
|
|
93
|
+
const bobIdx = result.section.indexOf('[Bob]: What');
|
|
94
|
+
expect(charlieIdx).toBeLessThan(bobIdx);
|
|
95
|
+
});
|
|
96
|
+
it('includes pinned thread messages when includePinned is true', async () => {
|
|
97
|
+
const pinned = fakeMsg('pinned-1', 'Important pinned note', 'PinnedUser');
|
|
98
|
+
const ch = fakeThread({
|
|
99
|
+
name: 'pinned-thread',
|
|
100
|
+
starter: {
|
|
101
|
+
id: '1',
|
|
102
|
+
author: { username: 'Bob', displayName: 'Bob', bot: false },
|
|
103
|
+
content: 'Starter content',
|
|
104
|
+
},
|
|
105
|
+
messages: [
|
|
106
|
+
fakeMsg('2', 'First reply', 'Alice'),
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
ch.messages.fetchPinned = async () => {
|
|
110
|
+
const map = new Map();
|
|
111
|
+
map.set(pinned.id, pinned);
|
|
112
|
+
return map;
|
|
113
|
+
};
|
|
114
|
+
const result = await resolveThreadContext(ch, 'current', { includePinned: true });
|
|
115
|
+
expect(result).not.toBeNull();
|
|
116
|
+
expect(result.section).toContain('Pinned thread messages:');
|
|
117
|
+
expect(result.section).toContain('[PinnedUser (pinned)]: Important pinned note');
|
|
118
|
+
});
|
|
119
|
+
it('deduplicates starter message from recent messages', async () => {
|
|
120
|
+
const starter = {
|
|
121
|
+
id: '1',
|
|
122
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
123
|
+
content: 'The original post',
|
|
124
|
+
};
|
|
125
|
+
const ch = fakeThread({
|
|
126
|
+
name: 'dedup-test',
|
|
127
|
+
starter,
|
|
128
|
+
// Simulate Discord returning the starter in recent messages too
|
|
129
|
+
messages: [
|
|
130
|
+
{ ...starter },
|
|
131
|
+
fakeMsg('2', 'A reply', 'Bob'),
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
const result = await resolveThreadContext(ch, '100');
|
|
135
|
+
expect(result).not.toBeNull();
|
|
136
|
+
// Starter should appear once as thread starter, not again in recent
|
|
137
|
+
const matches = result.section.match(/The original post/g);
|
|
138
|
+
expect(matches).toHaveLength(1);
|
|
139
|
+
expect(result.section).toContain('[Alice (thread starter)]: The original post');
|
|
140
|
+
expect(result.section).toContain('[Bob]: A reply');
|
|
141
|
+
});
|
|
142
|
+
it('uses bot display name for bot-authored messages', async () => {
|
|
143
|
+
const ch = fakeThread({
|
|
144
|
+
name: 'bot-thread',
|
|
145
|
+
starter: {
|
|
146
|
+
id: '1',
|
|
147
|
+
author: { username: 'bot', displayName: 'bot', bot: true },
|
|
148
|
+
content: 'Automated report',
|
|
149
|
+
},
|
|
150
|
+
messages: [
|
|
151
|
+
fakeMsg('2', 'Here are the results', 'bot', true),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
const result = await resolveThreadContext(ch, '100', { botDisplayName: 'TestBot' });
|
|
155
|
+
expect(result).not.toBeNull();
|
|
156
|
+
expect(result.section).toContain('[TestBot (thread starter)]: Automated report');
|
|
157
|
+
expect(result.section).toContain('[TestBot]: Here are the results');
|
|
158
|
+
});
|
|
159
|
+
it('respects budget — truncates starter when content exceeds limit', async () => {
|
|
160
|
+
const longContent = 'A'.repeat(500);
|
|
161
|
+
const ch = fakeThread({
|
|
162
|
+
name: 'test-thread',
|
|
163
|
+
starter: {
|
|
164
|
+
id: '1',
|
|
165
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
166
|
+
content: longContent,
|
|
167
|
+
},
|
|
168
|
+
messages: [],
|
|
169
|
+
});
|
|
170
|
+
const result = await resolveThreadContext(ch, '100', { budgetChars: 100 });
|
|
171
|
+
expect(result).not.toBeNull();
|
|
172
|
+
expect(result.section).toContain('...');
|
|
173
|
+
});
|
|
174
|
+
it('drops recent messages when budget is exhausted', async () => {
|
|
175
|
+
const ch = fakeThread({
|
|
176
|
+
name: 'budget-test',
|
|
177
|
+
starter: {
|
|
178
|
+
id: '1',
|
|
179
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
180
|
+
content: 'A'.repeat(200),
|
|
181
|
+
},
|
|
182
|
+
messages: [
|
|
183
|
+
fakeMsg('2', 'B'.repeat(200), 'Bob'),
|
|
184
|
+
fakeMsg('3', 'should not appear', 'Charlie'),
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
// Budget just enough for thread name + starter + maybe one message
|
|
188
|
+
const result = await resolveThreadContext(ch, '100', { budgetChars: 300 });
|
|
189
|
+
expect(result).not.toBeNull();
|
|
190
|
+
expect(result.section).not.toContain('should not appear');
|
|
191
|
+
});
|
|
192
|
+
it('handles starter message fetch failure gracefully', async () => {
|
|
193
|
+
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
194
|
+
const ch = fakeThread({
|
|
195
|
+
name: 'broken-thread',
|
|
196
|
+
starterError: true,
|
|
197
|
+
messages: [
|
|
198
|
+
fakeMsg('2', 'still here', 'Dave'),
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
const result = await resolveThreadContext(ch, '100', { log });
|
|
202
|
+
expect(result).not.toBeNull();
|
|
203
|
+
expect(result.section).toContain('[Dave]: still here');
|
|
204
|
+
expect(log.warn).toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
it('handles starter message returning null gracefully', async () => {
|
|
207
|
+
const ch = fakeThread({
|
|
208
|
+
name: 'null-starter-thread',
|
|
209
|
+
starter: null,
|
|
210
|
+
messages: [
|
|
211
|
+
fakeMsg('2', 'discussion continues', 'Eve'),
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
const result = await resolveThreadContext(ch, '100');
|
|
215
|
+
expect(result).not.toBeNull();
|
|
216
|
+
expect(result.section).toContain('[Eve]: discussion continues');
|
|
217
|
+
expect(result.section).not.toContain('thread starter');
|
|
218
|
+
});
|
|
219
|
+
it('handles message fetch failure gracefully', async () => {
|
|
220
|
+
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
221
|
+
const ch = fakeThread({
|
|
222
|
+
name: 'broken-thread',
|
|
223
|
+
starter: {
|
|
224
|
+
id: '1',
|
|
225
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
226
|
+
content: 'The original post',
|
|
227
|
+
},
|
|
228
|
+
fetchError: true,
|
|
229
|
+
});
|
|
230
|
+
const result = await resolveThreadContext(ch, '100', { log });
|
|
231
|
+
expect(result).not.toBeNull();
|
|
232
|
+
expect(result.section).toContain('[Alice (thread starter)]: The original post');
|
|
233
|
+
expect(log.warn).toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
it('returns thread name when both fetches fail', async () => {
|
|
236
|
+
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
|
237
|
+
const ch = fakeThread({
|
|
238
|
+
name: 'totally-broken',
|
|
239
|
+
starterError: true,
|
|
240
|
+
fetchError: true,
|
|
241
|
+
});
|
|
242
|
+
const result = await resolveThreadContext(ch, '100', { log });
|
|
243
|
+
expect(result).not.toBeNull();
|
|
244
|
+
expect(result.section).toBe('Thread: "totally-broken"');
|
|
245
|
+
expect(log.warn).toHaveBeenCalledTimes(2);
|
|
246
|
+
});
|
|
247
|
+
it('returns null when thread has no name, no starter, and no messages', async () => {
|
|
248
|
+
const ch = fakeThread({ messages: [] });
|
|
249
|
+
const result = await resolveThreadContext(ch, '100');
|
|
250
|
+
expect(result).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
it('skips empty content messages without media', async () => {
|
|
253
|
+
const ch = fakeThread({
|
|
254
|
+
name: 'test',
|
|
255
|
+
starter: {
|
|
256
|
+
id: '1',
|
|
257
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
258
|
+
content: '',
|
|
259
|
+
},
|
|
260
|
+
messages: [
|
|
261
|
+
fakeMsg('2', '', 'Bob'),
|
|
262
|
+
fakeMsg('3', 'actual content', 'Charlie'),
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
const result = await resolveThreadContext(ch, '100');
|
|
266
|
+
expect(result).not.toBeNull();
|
|
267
|
+
expect(result.section).not.toContain('[Bob]');
|
|
268
|
+
expect(result.section).toContain('[Charlie]: actual content');
|
|
269
|
+
});
|
|
270
|
+
it('renders empty-content messages with attachments as [attachment/embed]', async () => {
|
|
271
|
+
const ch = fakeThread({
|
|
272
|
+
name: 'media-test',
|
|
273
|
+
starter: {
|
|
274
|
+
id: '1',
|
|
275
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
276
|
+
content: '',
|
|
277
|
+
attachments: { size: 1 },
|
|
278
|
+
},
|
|
279
|
+
messages: [],
|
|
280
|
+
});
|
|
281
|
+
const result = await resolveThreadContext(ch, '100');
|
|
282
|
+
expect(result).not.toBeNull();
|
|
283
|
+
expect(result.section).toContain('[Alice (thread starter)]: [attachment/embed]');
|
|
284
|
+
});
|
|
285
|
+
it('renders empty-content messages with embeds as [attachment/embed]', async () => {
|
|
286
|
+
const ch = fakeThread({
|
|
287
|
+
name: 'embed-test',
|
|
288
|
+
messages: [
|
|
289
|
+
fakeMsg('2', '', 'Bob', false, { attachments: { size: 0 }, embeds: [{}] }),
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
const result = await resolveThreadContext(ch, '100');
|
|
293
|
+
expect(result).not.toBeNull();
|
|
294
|
+
expect(result.section).toContain('[Bob]: [attachment/embed]');
|
|
295
|
+
});
|
|
296
|
+
it('handles channel without fetchStarterMessage method', async () => {
|
|
297
|
+
const ch = {
|
|
298
|
+
isThread: () => true,
|
|
299
|
+
name: 'no-starter-method',
|
|
300
|
+
messages: {
|
|
301
|
+
fetch: async () => {
|
|
302
|
+
const map = new Map();
|
|
303
|
+
map.set('2', fakeMsg('2', 'hello', 'User'));
|
|
304
|
+
return map;
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
const result = await resolveThreadContext(ch, '100');
|
|
309
|
+
expect(result).not.toBeNull();
|
|
310
|
+
expect(result.section).toContain('[User]: hello');
|
|
311
|
+
});
|
|
312
|
+
it('truncates bot messages in recent thread posts to fit budget', async () => {
|
|
313
|
+
const longBotResponse = 'B'.repeat(300);
|
|
314
|
+
const ch = fakeThread({
|
|
315
|
+
name: 'test',
|
|
316
|
+
starter: {
|
|
317
|
+
id: '1',
|
|
318
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
319
|
+
content: 'Start',
|
|
320
|
+
},
|
|
321
|
+
messages: [
|
|
322
|
+
fakeMsg('2', longBotResponse, 'Discoclaw', true),
|
|
323
|
+
],
|
|
324
|
+
});
|
|
325
|
+
const result = await resolveThreadContext(ch, '100', { budgetChars: 200 });
|
|
326
|
+
expect(result).not.toBeNull();
|
|
327
|
+
expect(result.section).toContain('...');
|
|
328
|
+
expect(result.section.length).toBeLessThanOrEqual(250);
|
|
329
|
+
});
|
|
330
|
+
it('respects recentMessageLimit option', async () => {
|
|
331
|
+
const ch = fakeThread({
|
|
332
|
+
name: 'busy-thread',
|
|
333
|
+
starter: {
|
|
334
|
+
id: '1',
|
|
335
|
+
author: { username: 'Alice', displayName: 'Alice', bot: false },
|
|
336
|
+
content: 'Start here',
|
|
337
|
+
},
|
|
338
|
+
messages: [
|
|
339
|
+
fakeMsg('2', 'msg one', 'Bob'),
|
|
340
|
+
fakeMsg('3', 'msg two', 'Charlie'),
|
|
341
|
+
fakeMsg('4', 'msg three', 'Dave'),
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
const result = await resolveThreadContext(ch, '100', { recentMessageLimit: 2 });
|
|
345
|
+
expect(result).not.toBeNull();
|
|
346
|
+
expect(result.section).toContain('Recent thread messages:');
|
|
347
|
+
});
|
|
348
|
+
it('defaults botDisplayName to Discoclaw', async () => {
|
|
349
|
+
const ch = fakeThread({
|
|
350
|
+
name: 'test',
|
|
351
|
+
starter: {
|
|
352
|
+
id: '1',
|
|
353
|
+
author: { username: 'bot', displayName: 'bot', bot: true },
|
|
354
|
+
content: 'bot starter',
|
|
355
|
+
},
|
|
356
|
+
messages: [],
|
|
357
|
+
});
|
|
358
|
+
const result = await resolveThreadContext(ch, '100');
|
|
359
|
+
expect(result).not.toBeNull();
|
|
360
|
+
expect(result.section).toContain('[Discoclaw (thread starter)]');
|
|
361
|
+
});
|
|
362
|
+
it('sorts messages by snowflake ID for correct chronological order', async () => {
|
|
363
|
+
// Provide messages in non-chronological order in the Map
|
|
364
|
+
const ch = {
|
|
365
|
+
isThread: () => true,
|
|
366
|
+
name: 'sort-test',
|
|
367
|
+
messages: {
|
|
368
|
+
fetch: async () => {
|
|
369
|
+
const map = new Map();
|
|
370
|
+
// Insert out of order
|
|
371
|
+
map.set('300', fakeMsg('300', 'third', 'Charlie'));
|
|
372
|
+
map.set('100', fakeMsg('100', 'first', 'Alice'));
|
|
373
|
+
map.set('200', fakeMsg('200', 'second', 'Bob'));
|
|
374
|
+
return map;
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
const result = await resolveThreadContext(ch, '999');
|
|
379
|
+
expect(result).not.toBeNull();
|
|
380
|
+
const aliceIdx = result.section.indexOf('first');
|
|
381
|
+
const bobIdx = result.section.indexOf('second');
|
|
382
|
+
const charlieIdx = result.section.indexOf('third');
|
|
383
|
+
expect(aliceIdx).toBeLessThan(bobIdx);
|
|
384
|
+
expect(bobIdx).toBeLessThan(charlieIdx);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { toolActivityLabel } from '../runtime/tool-labels.js';
|
|
2
|
+
export class ToolAwareQueue {
|
|
3
|
+
state = 'idle';
|
|
4
|
+
buffer = '';
|
|
5
|
+
flushTimer = null;
|
|
6
|
+
disposed = false;
|
|
7
|
+
emit;
|
|
8
|
+
flushDelayMs;
|
|
9
|
+
postToolDelayMs;
|
|
10
|
+
constructor(emit, opts) {
|
|
11
|
+
this.emit = emit;
|
|
12
|
+
this.flushDelayMs = opts?.flushDelayMs ?? 2000;
|
|
13
|
+
this.postToolDelayMs = opts?.postToolDelayMs ?? 500;
|
|
14
|
+
}
|
|
15
|
+
handleEvent(evt) {
|
|
16
|
+
if (this.disposed)
|
|
17
|
+
return;
|
|
18
|
+
switch (evt.type) {
|
|
19
|
+
case 'text_delta':
|
|
20
|
+
this.onTextDelta(evt.text);
|
|
21
|
+
break;
|
|
22
|
+
case 'text_final':
|
|
23
|
+
this.onTextFinal(evt.text);
|
|
24
|
+
break;
|
|
25
|
+
case 'tool_start':
|
|
26
|
+
this.onToolStart(evt.name, evt.input);
|
|
27
|
+
break;
|
|
28
|
+
case 'tool_end':
|
|
29
|
+
this.onToolEnd();
|
|
30
|
+
break;
|
|
31
|
+
case 'error':
|
|
32
|
+
case 'done':
|
|
33
|
+
this.cancelTimer();
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
dispose() {
|
|
38
|
+
this.disposed = true;
|
|
39
|
+
this.cancelTimer();
|
|
40
|
+
}
|
|
41
|
+
onTextDelta(text) {
|
|
42
|
+
switch (this.state) {
|
|
43
|
+
case 'idle':
|
|
44
|
+
this.state = 'buffering_text';
|
|
45
|
+
this.buffer = text;
|
|
46
|
+
this.startFlushTimer(this.flushDelayMs);
|
|
47
|
+
break;
|
|
48
|
+
case 'buffering_text':
|
|
49
|
+
this.buffer += text;
|
|
50
|
+
break;
|
|
51
|
+
case 'tool_active':
|
|
52
|
+
// Buffer text during tool execution; discard on next tool or flush after tool ends.
|
|
53
|
+
this.buffer += text;
|
|
54
|
+
break;
|
|
55
|
+
case 'streaming_final':
|
|
56
|
+
this.emit({ type: 'stream_text', text });
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
onTextFinal(text) {
|
|
61
|
+
this.cancelTimer();
|
|
62
|
+
this.state = 'streaming_final';
|
|
63
|
+
this.emit({ type: 'set_final', text });
|
|
64
|
+
}
|
|
65
|
+
onToolStart(name, input) {
|
|
66
|
+
this.cancelTimer();
|
|
67
|
+
const label = toolActivityLabel(name, input);
|
|
68
|
+
switch (this.state) {
|
|
69
|
+
case 'idle':
|
|
70
|
+
case 'buffering_text':
|
|
71
|
+
// Discard buffered narration text.
|
|
72
|
+
this.buffer = '';
|
|
73
|
+
this.state = 'tool_active';
|
|
74
|
+
this.emit({ type: 'show_activity', label });
|
|
75
|
+
break;
|
|
76
|
+
case 'tool_active':
|
|
77
|
+
// New tool replaces the current activity label.
|
|
78
|
+
this.emit({ type: 'show_activity', label });
|
|
79
|
+
break;
|
|
80
|
+
case 'streaming_final':
|
|
81
|
+
// Rare: tool starts after we began streaming. Switch to tool_active.
|
|
82
|
+
this.state = 'tool_active';
|
|
83
|
+
this.emit({ type: 'show_activity', label });
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
onToolEnd() {
|
|
88
|
+
if (this.state !== 'tool_active')
|
|
89
|
+
return;
|
|
90
|
+
this.state = 'buffering_text';
|
|
91
|
+
this.buffer = '';
|
|
92
|
+
this.startFlushTimer(this.postToolDelayMs);
|
|
93
|
+
}
|
|
94
|
+
startFlushTimer(delayMs) {
|
|
95
|
+
this.cancelTimer();
|
|
96
|
+
this.flushTimer = setTimeout(() => {
|
|
97
|
+
this.flushTimer = null;
|
|
98
|
+
this.flush();
|
|
99
|
+
}, delayMs);
|
|
100
|
+
}
|
|
101
|
+
flush() {
|
|
102
|
+
if (this.disposed || this.state !== 'buffering_text')
|
|
103
|
+
return;
|
|
104
|
+
this.state = 'streaming_final';
|
|
105
|
+
if (this.buffer) {
|
|
106
|
+
this.emit({ type: 'stream_text', text: this.buffer });
|
|
107
|
+
this.buffer = '';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
cancelTimer() {
|
|
111
|
+
if (this.flushTimer) {
|
|
112
|
+
clearTimeout(this.flushTimer);
|
|
113
|
+
this.flushTimer = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|