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,281 @@
|
|
|
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 { loadDurableMemory, saveDurableMemory, deriveItemId, addItem, deprecateItems, selectItemsForInjection, formatDurableSection, CURRENT_VERSION, } from './durable-memory.js';
|
|
6
|
+
async function makeTmpDir() {
|
|
7
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'durable-memory-test-'));
|
|
8
|
+
}
|
|
9
|
+
function emptyStore() {
|
|
10
|
+
return { version: 1, updatedAt: 0, items: [] };
|
|
11
|
+
}
|
|
12
|
+
function makeItem(overrides = {}) {
|
|
13
|
+
return {
|
|
14
|
+
id: 'durable-test1234',
|
|
15
|
+
kind: 'fact',
|
|
16
|
+
text: 'test item',
|
|
17
|
+
tags: [],
|
|
18
|
+
status: 'active',
|
|
19
|
+
source: { type: 'manual' },
|
|
20
|
+
createdAt: 1000,
|
|
21
|
+
updatedAt: 1000,
|
|
22
|
+
...overrides,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
describe('loadDurableMemory', () => {
|
|
26
|
+
it('returns null for missing file', async () => {
|
|
27
|
+
const dir = await makeTmpDir();
|
|
28
|
+
const result = await loadDurableMemory(dir, 'nonexistent');
|
|
29
|
+
expect(result).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('parses valid store', async () => {
|
|
32
|
+
const dir = await makeTmpDir();
|
|
33
|
+
const store = { version: 1, updatedAt: 1000, items: [] };
|
|
34
|
+
await fs.writeFile(path.join(dir, '12345.json'), JSON.stringify(store), 'utf8');
|
|
35
|
+
const result = await loadDurableMemory(dir, '12345');
|
|
36
|
+
expect(result).toEqual(store);
|
|
37
|
+
});
|
|
38
|
+
it('returns null on malformed JSON', async () => {
|
|
39
|
+
const dir = await makeTmpDir();
|
|
40
|
+
await fs.writeFile(path.join(dir, 'bad.json'), '{not json!!!', 'utf8');
|
|
41
|
+
const result = await loadDurableMemory(dir, 'bad');
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
it('rejects path traversal in userId', async () => {
|
|
45
|
+
const dir = await makeTmpDir();
|
|
46
|
+
await expect(loadDurableMemory(dir, '../evil')).rejects.toThrow(/Invalid userId/);
|
|
47
|
+
});
|
|
48
|
+
it('returns store unchanged for version-1 store (migration no-op)', async () => {
|
|
49
|
+
const dir = await makeTmpDir();
|
|
50
|
+
const store = { version: 1, updatedAt: 1000, items: [] };
|
|
51
|
+
await fs.writeFile(path.join(dir, 'user1.json'), JSON.stringify(store), 'utf8');
|
|
52
|
+
const result = await loadDurableMemory(dir, 'user1');
|
|
53
|
+
expect(result).toEqual(store);
|
|
54
|
+
});
|
|
55
|
+
it('returns empty store for unsupported version', async () => {
|
|
56
|
+
const dir = await makeTmpDir();
|
|
57
|
+
const store = { version: 99, updatedAt: 1000, items: [] };
|
|
58
|
+
await fs.writeFile(path.join(dir, 'user2.json'), JSON.stringify(store), 'utf8');
|
|
59
|
+
const result = await loadDurableMemory(dir, 'user2');
|
|
60
|
+
expect(result).toMatchObject({ version: CURRENT_VERSION, items: [] });
|
|
61
|
+
});
|
|
62
|
+
it('returns null for store missing version field', async () => {
|
|
63
|
+
const dir = await makeTmpDir();
|
|
64
|
+
const store = { updatedAt: 1000, items: [] };
|
|
65
|
+
await fs.writeFile(path.join(dir, 'user3.json'), JSON.stringify(store), 'utf8');
|
|
66
|
+
const result = await loadDurableMemory(dir, 'user3');
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('saveDurableMemory — path traversal', () => {
|
|
71
|
+
it('rejects path traversal in userId', async () => {
|
|
72
|
+
const dir = await makeTmpDir();
|
|
73
|
+
const store = { version: 1, updatedAt: 0, items: [] };
|
|
74
|
+
await expect(saveDurableMemory(dir, '../evil', store)).rejects.toThrow(/Invalid userId/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe('saveDurableMemory', () => {
|
|
78
|
+
it('creates file, overwrites existing', async () => {
|
|
79
|
+
const dir = await makeTmpDir();
|
|
80
|
+
const store1 = { version: 1, updatedAt: 1000, items: [] };
|
|
81
|
+
await saveDurableMemory(dir, '12345', store1);
|
|
82
|
+
const raw1 = await fs.readFile(path.join(dir, '12345.json'), 'utf8');
|
|
83
|
+
expect(JSON.parse(raw1)).toEqual(store1);
|
|
84
|
+
const store2 = { version: 1, updatedAt: 2000, items: [] };
|
|
85
|
+
await saveDurableMemory(dir, '12345', store2);
|
|
86
|
+
const raw2 = await fs.readFile(path.join(dir, '12345.json'), 'utf8');
|
|
87
|
+
expect(JSON.parse(raw2)).toEqual(store2);
|
|
88
|
+
});
|
|
89
|
+
it('creates parent directory', async () => {
|
|
90
|
+
const dir = await makeTmpDir();
|
|
91
|
+
const nested = path.join(dir, 'a', 'b', 'c');
|
|
92
|
+
const store = { version: 1, updatedAt: 1, items: [] };
|
|
93
|
+
await saveDurableMemory(nested, 'user', store);
|
|
94
|
+
const raw = await fs.readFile(path.join(nested, 'user.json'), 'utf8');
|
|
95
|
+
expect(JSON.parse(raw)).toEqual(store);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('deriveItemId', () => {
|
|
99
|
+
it('produces consistent IDs for same input', () => {
|
|
100
|
+
const id1 = deriveItemId('fact', 'I prefer TypeScript');
|
|
101
|
+
const id2 = deriveItemId('fact', 'I prefer TypeScript');
|
|
102
|
+
expect(id1).toBe(id2);
|
|
103
|
+
expect(id1).toMatch(/^durable-[0-9a-f]{8}$/);
|
|
104
|
+
});
|
|
105
|
+
it('produces different IDs for different input', () => {
|
|
106
|
+
const id1 = deriveItemId('fact', 'I prefer TypeScript');
|
|
107
|
+
const id2 = deriveItemId('fact', 'I prefer JavaScript');
|
|
108
|
+
expect(id1).not.toBe(id2);
|
|
109
|
+
});
|
|
110
|
+
it('normalizes whitespace', () => {
|
|
111
|
+
const id1 = deriveItemId('fact', ' I prefer TypeScript ');
|
|
112
|
+
const id2 = deriveItemId('fact', 'I prefer TypeScript');
|
|
113
|
+
expect(id1).toBe(id2);
|
|
114
|
+
});
|
|
115
|
+
it('produces different IDs for different kinds with same text', () => {
|
|
116
|
+
const factId = deriveItemId('fact', 'uses TypeScript');
|
|
117
|
+
const toolId = deriveItemId('tool', 'uses TypeScript');
|
|
118
|
+
const prefId = deriveItemId('preference', 'uses TypeScript');
|
|
119
|
+
expect(factId).not.toBe(toolId);
|
|
120
|
+
expect(factId).not.toBe(prefId);
|
|
121
|
+
expect(toolId).not.toBe(prefId);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('addItem', () => {
|
|
125
|
+
it('creates new item with kind=fact', () => {
|
|
126
|
+
const store = emptyStore();
|
|
127
|
+
const result = addItem(store, 'User prefers TypeScript', { type: 'manual' }, 200);
|
|
128
|
+
expect(result.items).toHaveLength(1);
|
|
129
|
+
expect(result.items[0].kind).toBe('fact');
|
|
130
|
+
expect(result.items[0].text).toBe('User prefers TypeScript');
|
|
131
|
+
expect(result.items[0].status).toBe('active');
|
|
132
|
+
expect(result.items[0].id).toMatch(/^durable-/);
|
|
133
|
+
});
|
|
134
|
+
it('preserves explicit kind parameter', () => {
|
|
135
|
+
const store = emptyStore();
|
|
136
|
+
addItem(store, 'Uses VS Code', { type: 'summary' }, 200, 'tool');
|
|
137
|
+
expect(store.items).toHaveLength(1);
|
|
138
|
+
expect(store.items[0].kind).toBe('tool');
|
|
139
|
+
expect(store.items[0].source.type).toBe('summary');
|
|
140
|
+
});
|
|
141
|
+
it('updates existing item with same derived ID (dedup)', () => {
|
|
142
|
+
const store = emptyStore();
|
|
143
|
+
addItem(store, 'User prefers TypeScript', { type: 'manual' }, 200);
|
|
144
|
+
expect(store.items).toHaveLength(1);
|
|
145
|
+
const originalCreatedAt = store.items[0].createdAt;
|
|
146
|
+
addItem(store, 'User prefers TypeScript', { type: 'discord', channelId: 'ch1' }, 200);
|
|
147
|
+
expect(store.items).toHaveLength(1);
|
|
148
|
+
expect(store.items[0].source.type).toBe('discord');
|
|
149
|
+
expect(store.items[0].createdAt).toBe(originalCreatedAt);
|
|
150
|
+
});
|
|
151
|
+
it('enforces maxItems cap (drops oldest deprecated first)', () => {
|
|
152
|
+
const store = emptyStore();
|
|
153
|
+
store.items.push(makeItem({ id: 'old-dep', status: 'deprecated', updatedAt: 100 }), makeItem({ id: 'old-active', status: 'active', text: 'old active', updatedAt: 200 }));
|
|
154
|
+
// maxItems=2, adding a third should drop the deprecated item
|
|
155
|
+
addItem(store, 'new item', { type: 'manual' }, 2);
|
|
156
|
+
expect(store.items).toHaveLength(2);
|
|
157
|
+
expect(store.items.find((it) => it.id === 'old-dep')).toBeUndefined();
|
|
158
|
+
expect(store.items.find((it) => it.id === 'old-active')).toBeDefined();
|
|
159
|
+
});
|
|
160
|
+
it('drops oldest active when no deprecated items remain', () => {
|
|
161
|
+
const store = emptyStore();
|
|
162
|
+
store.items.push(makeItem({ id: 'active1', status: 'active', text: 'first', updatedAt: 100 }), makeItem({ id: 'active2', status: 'active', text: 'second', updatedAt: 200 }));
|
|
163
|
+
addItem(store, 'third item', { type: 'manual' }, 2);
|
|
164
|
+
expect(store.items).toHaveLength(2);
|
|
165
|
+
expect(store.items.find((it) => it.id === 'active1')).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('deprecateItems', () => {
|
|
169
|
+
it('matches by 60% text-length threshold', () => {
|
|
170
|
+
const store = emptyStore();
|
|
171
|
+
// text = "TypeScript" (10 chars), substring = "TypeScrip" (9 chars) -> 90% >= 60%
|
|
172
|
+
store.items.push(makeItem({ text: 'TypeScript', status: 'active' }));
|
|
173
|
+
const { deprecatedCount } = deprecateItems(store, 'TypeScrip');
|
|
174
|
+
expect(deprecatedCount).toBe(1);
|
|
175
|
+
expect(store.items[0].status).toBe('deprecated');
|
|
176
|
+
});
|
|
177
|
+
it('does not match when substring is too short', () => {
|
|
178
|
+
const store = emptyStore();
|
|
179
|
+
// text = "TypeScript" (10 chars), substring = "Type" (4 chars) -> 40% < 60%
|
|
180
|
+
store.items.push(makeItem({ text: 'TypeScript', status: 'active' }));
|
|
181
|
+
const { deprecatedCount } = deprecateItems(store, 'Type');
|
|
182
|
+
expect(deprecatedCount).toBe(0);
|
|
183
|
+
expect(store.items[0].status).toBe('active');
|
|
184
|
+
});
|
|
185
|
+
it('ignores already-deprecated items', () => {
|
|
186
|
+
const store = emptyStore();
|
|
187
|
+
store.items.push(makeItem({ text: 'TypeScript', status: 'deprecated' }));
|
|
188
|
+
const { deprecatedCount } = deprecateItems(store, 'TypeScript');
|
|
189
|
+
expect(deprecatedCount).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
it('is case-insensitive', () => {
|
|
192
|
+
const store = emptyStore();
|
|
193
|
+
store.items.push(makeItem({ text: 'TypeScript', status: 'active' }));
|
|
194
|
+
const { deprecatedCount } = deprecateItems(store, 'typescript');
|
|
195
|
+
expect(deprecatedCount).toBe(1);
|
|
196
|
+
});
|
|
197
|
+
it('returns 0 when no match', () => {
|
|
198
|
+
const store = emptyStore();
|
|
199
|
+
store.items.push(makeItem({ text: 'TypeScript', status: 'active' }));
|
|
200
|
+
const { deprecatedCount } = deprecateItems(store, 'completely unrelated');
|
|
201
|
+
expect(deprecatedCount).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('selectItemsForInjection', () => {
|
|
205
|
+
it('returns active items only, sorted by recency', () => {
|
|
206
|
+
const store = emptyStore();
|
|
207
|
+
store.items.push(makeItem({ id: 'a', text: 'old', status: 'active', updatedAt: 100 }), makeItem({ id: 'b', text: 'deprecated', status: 'deprecated', updatedAt: 300 }), makeItem({ id: 'c', text: 'new', status: 'active', updatedAt: 200 }));
|
|
208
|
+
const items = selectItemsForInjection(store, 10000);
|
|
209
|
+
expect(items).toHaveLength(2);
|
|
210
|
+
expect(items[0].id).toBe('c'); // newer first
|
|
211
|
+
expect(items[1].id).toBe('a');
|
|
212
|
+
});
|
|
213
|
+
it('respects char budget', () => {
|
|
214
|
+
const store = emptyStore();
|
|
215
|
+
store.items.push(makeItem({ id: 'a', text: 'first item text', status: 'active', updatedAt: 200 }), makeItem({ id: 'b', text: 'second item text', status: 'active', updatedAt: 100 }));
|
|
216
|
+
// Budget just enough for one item line
|
|
217
|
+
const items = selectItemsForInjection(store, 80);
|
|
218
|
+
expect(items).toHaveLength(1);
|
|
219
|
+
expect(items[0].id).toBe('a');
|
|
220
|
+
});
|
|
221
|
+
it('returns empty with maxChars = 0', () => {
|
|
222
|
+
const store = emptyStore();
|
|
223
|
+
store.items.push(makeItem({ id: 'a', text: 'some item', status: 'active', updatedAt: 200 }));
|
|
224
|
+
const items = selectItemsForInjection(store, 0);
|
|
225
|
+
expect(items).toHaveLength(0);
|
|
226
|
+
});
|
|
227
|
+
it('skips oversized newest item and still includes smaller older items that fit', () => {
|
|
228
|
+
const store = emptyStore();
|
|
229
|
+
store.items.push(makeItem({ id: 'new-big', text: 'x'.repeat(600), status: 'active', updatedAt: 300 }), makeItem({ id: 'older-small', text: 'small item', status: 'active', updatedAt: 200 }));
|
|
230
|
+
const items = selectItemsForInjection(store, 120);
|
|
231
|
+
expect(items).toHaveLength(1);
|
|
232
|
+
expect(items[0].id).toBe('older-small');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
describe('formatDurableSection', () => {
|
|
236
|
+
it('formats items correctly', () => {
|
|
237
|
+
const items = [
|
|
238
|
+
makeItem({
|
|
239
|
+
kind: 'fact',
|
|
240
|
+
text: 'User prefers TypeScript over JavaScript.',
|
|
241
|
+
source: { type: 'manual' },
|
|
242
|
+
updatedAt: new Date('2026-02-09').getTime(),
|
|
243
|
+
}),
|
|
244
|
+
makeItem({
|
|
245
|
+
kind: 'project',
|
|
246
|
+
text: 'Current project: discoclaw memory system.',
|
|
247
|
+
source: { type: 'discord' },
|
|
248
|
+
updatedAt: new Date('2026-02-09').getTime(),
|
|
249
|
+
}),
|
|
250
|
+
];
|
|
251
|
+
const result = formatDurableSection(items);
|
|
252
|
+
expect(result).toContain('- [fact] User prefers TypeScript over JavaScript. (src: manual, updated 2026-02-09)');
|
|
253
|
+
expect(result).toContain('- [project] Current project: discoclaw memory system. (src: discord, updated 2026-02-09)');
|
|
254
|
+
});
|
|
255
|
+
it('includes channel name when present in source', () => {
|
|
256
|
+
const items = [
|
|
257
|
+
makeItem({
|
|
258
|
+
kind: 'fact',
|
|
259
|
+
text: 'Prefers Rust',
|
|
260
|
+
source: { type: 'manual', channelName: 'dev' },
|
|
261
|
+
updatedAt: new Date('2026-01-15').getTime(),
|
|
262
|
+
}),
|
|
263
|
+
];
|
|
264
|
+
const result = formatDurableSection(items);
|
|
265
|
+
expect(result).toContain('#dev');
|
|
266
|
+
expect(result).toMatch(/src: manual, #dev, updated/);
|
|
267
|
+
});
|
|
268
|
+
it('omits channel name when absent from source', () => {
|
|
269
|
+
const items = [
|
|
270
|
+
makeItem({
|
|
271
|
+
kind: 'fact',
|
|
272
|
+
text: 'Prefers Rust',
|
|
273
|
+
source: { type: 'manual' },
|
|
274
|
+
updatedAt: new Date('2026-01-15').getTime(),
|
|
275
|
+
}),
|
|
276
|
+
];
|
|
277
|
+
const result = formatDurableSection(items);
|
|
278
|
+
expect(result).not.toContain('#');
|
|
279
|
+
expect(result).toMatch(/src: manual, updated/);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Keep in sync with image-download.ts
|
|
2
|
+
const ALLOWED_HOSTS = new Set(['cdn.discordapp.com', 'media.discordapp.net']);
|
|
3
|
+
/** Max bytes per individual text file (100 KB). Files exceeding this are truncated. */
|
|
4
|
+
const MAX_FILE_BYTES = 100 * 1024;
|
|
5
|
+
/** Max total bytes across all text files in one message (200 KB). */
|
|
6
|
+
const MAX_TOTAL_BYTES = 200 * 1024;
|
|
7
|
+
/** Per-file download timeout (10 seconds). */
|
|
8
|
+
const DOWNLOAD_TIMEOUT_MS = 10_000;
|
|
9
|
+
/** MIME types that are treated as text (checked via startsWith for text/*). */
|
|
10
|
+
const TEXT_APPLICATION_TYPES = new Set([
|
|
11
|
+
'application/json',
|
|
12
|
+
'application/xml',
|
|
13
|
+
'application/javascript',
|
|
14
|
+
'application/typescript',
|
|
15
|
+
'application/toml',
|
|
16
|
+
'application/sql',
|
|
17
|
+
'application/graphql',
|
|
18
|
+
'application/x-httpd-php',
|
|
19
|
+
'application/x-sh',
|
|
20
|
+
'application/x-yaml',
|
|
21
|
+
]);
|
|
22
|
+
/** Extension-to-MIME fallback map for text types. */
|
|
23
|
+
const EXT_TO_TEXT_MIME = {
|
|
24
|
+
// --- Existing ---
|
|
25
|
+
txt: 'text/plain',
|
|
26
|
+
json: 'application/json',
|
|
27
|
+
csv: 'text/csv',
|
|
28
|
+
md: 'text/markdown',
|
|
29
|
+
js: 'application/javascript',
|
|
30
|
+
ts: 'application/typescript',
|
|
31
|
+
xml: 'application/xml',
|
|
32
|
+
html: 'text/html',
|
|
33
|
+
yml: 'text/yaml',
|
|
34
|
+
yaml: 'text/yaml',
|
|
35
|
+
py: 'text/x-script',
|
|
36
|
+
rb: 'text/x-script',
|
|
37
|
+
sh: 'text/x-script',
|
|
38
|
+
bash: 'text/x-script',
|
|
39
|
+
zsh: 'text/x-script',
|
|
40
|
+
// --- Web / frontend ---
|
|
41
|
+
jsx: 'application/javascript',
|
|
42
|
+
tsx: 'application/typescript',
|
|
43
|
+
mjs: 'application/javascript',
|
|
44
|
+
cjs: 'application/javascript',
|
|
45
|
+
mts: 'application/typescript',
|
|
46
|
+
cts: 'application/typescript',
|
|
47
|
+
css: 'text/css',
|
|
48
|
+
scss: 'text/css',
|
|
49
|
+
sass: 'text/css',
|
|
50
|
+
less: 'text/css',
|
|
51
|
+
vue: 'text/html',
|
|
52
|
+
svelte: 'text/html',
|
|
53
|
+
astro: 'text/html',
|
|
54
|
+
htm: 'text/html',
|
|
55
|
+
// --- Template engines ---
|
|
56
|
+
njk: 'text/html',
|
|
57
|
+
ejs: 'text/html',
|
|
58
|
+
hbs: 'text/html',
|
|
59
|
+
pug: 'text/html',
|
|
60
|
+
// --- Systems / compiled (source is text) ---
|
|
61
|
+
c: 'text/x-script',
|
|
62
|
+
h: 'text/x-script',
|
|
63
|
+
cpp: 'text/x-script',
|
|
64
|
+
cxx: 'text/x-script',
|
|
65
|
+
cc: 'text/x-script',
|
|
66
|
+
hpp: 'text/x-script',
|
|
67
|
+
hxx: 'text/x-script',
|
|
68
|
+
cs: 'text/x-script',
|
|
69
|
+
java: 'text/x-script',
|
|
70
|
+
kt: 'text/x-script',
|
|
71
|
+
kts: 'text/x-script',
|
|
72
|
+
scala: 'text/x-script',
|
|
73
|
+
go: 'text/x-script',
|
|
74
|
+
rs: 'text/x-script',
|
|
75
|
+
swift: 'text/x-script',
|
|
76
|
+
m: 'text/x-script',
|
|
77
|
+
mm: 'text/x-script',
|
|
78
|
+
zig: 'text/x-script',
|
|
79
|
+
nim: 'text/x-script',
|
|
80
|
+
v: 'text/x-script',
|
|
81
|
+
d: 'text/x-script',
|
|
82
|
+
// --- Scripting / dynamic ---
|
|
83
|
+
php: 'text/x-script',
|
|
84
|
+
pl: 'text/x-script',
|
|
85
|
+
pm: 'text/x-script',
|
|
86
|
+
r: 'text/x-script',
|
|
87
|
+
lua: 'text/x-script',
|
|
88
|
+
tcl: 'text/x-script',
|
|
89
|
+
ex: 'text/x-script',
|
|
90
|
+
exs: 'text/x-script',
|
|
91
|
+
erl: 'text/x-script',
|
|
92
|
+
hrl: 'text/x-script',
|
|
93
|
+
clj: 'text/x-script',
|
|
94
|
+
cljs: 'text/x-script',
|
|
95
|
+
cljc: 'text/x-script',
|
|
96
|
+
hs: 'text/x-script',
|
|
97
|
+
ml: 'text/x-script',
|
|
98
|
+
mli: 'text/x-script',
|
|
99
|
+
fs: 'text/x-script',
|
|
100
|
+
fsx: 'text/x-script',
|
|
101
|
+
jl: 'text/x-script',
|
|
102
|
+
dart: 'text/x-script',
|
|
103
|
+
groovy: 'text/x-script',
|
|
104
|
+
gradle: 'text/x-script',
|
|
105
|
+
ps1: 'text/x-script',
|
|
106
|
+
psm1: 'text/x-script',
|
|
107
|
+
fish: 'text/x-script',
|
|
108
|
+
nix: 'text/x-script',
|
|
109
|
+
gd: 'text/x-script',
|
|
110
|
+
// --- Config / data ---
|
|
111
|
+
toml: 'application/toml',
|
|
112
|
+
ini: 'text/plain',
|
|
113
|
+
cfg: 'text/plain',
|
|
114
|
+
conf: 'text/plain',
|
|
115
|
+
env: 'text/plain',
|
|
116
|
+
properties: 'text/plain',
|
|
117
|
+
json5: 'application/json',
|
|
118
|
+
jsonc: 'application/json',
|
|
119
|
+
editorconfig: 'text/plain',
|
|
120
|
+
gitignore: 'text/plain',
|
|
121
|
+
gitattributes: 'text/plain',
|
|
122
|
+
dockerignore: 'text/plain',
|
|
123
|
+
npmrc: 'text/plain',
|
|
124
|
+
nvmrc: 'text/plain',
|
|
125
|
+
prettierrc: 'text/plain',
|
|
126
|
+
eslintrc: 'text/plain',
|
|
127
|
+
babelrc: 'text/plain',
|
|
128
|
+
// --- Infrastructure / IaC ---
|
|
129
|
+
tf: 'text/plain',
|
|
130
|
+
tfvars: 'text/plain',
|
|
131
|
+
hcl: 'text/plain',
|
|
132
|
+
// --- Data / query / IDL ---
|
|
133
|
+
sql: 'application/sql',
|
|
134
|
+
graphql: 'application/graphql',
|
|
135
|
+
gql: 'application/graphql',
|
|
136
|
+
proto: 'text/plain',
|
|
137
|
+
prisma: 'text/plain',
|
|
138
|
+
// --- Markup / docs ---
|
|
139
|
+
rst: 'text/plain',
|
|
140
|
+
tex: 'text/plain',
|
|
141
|
+
latex: 'text/plain',
|
|
142
|
+
adoc: 'text/plain',
|
|
143
|
+
org: 'text/plain',
|
|
144
|
+
wiki: 'text/plain',
|
|
145
|
+
rdoc: 'text/plain',
|
|
146
|
+
// --- Build / CI ---
|
|
147
|
+
// Note: bare Dockerfile/Makefile/Gemfile (no extension) won't match via
|
|
148
|
+
// extension extraction — lastIndexOf('.') returns -1 for extensionless filenames.
|
|
149
|
+
// These entries only cover the foo.dockerfile / foo.makefile variant.
|
|
150
|
+
dockerfile: 'text/plain',
|
|
151
|
+
makefile: 'text/plain',
|
|
152
|
+
cmake: 'text/plain',
|
|
153
|
+
rake: 'text/x-script',
|
|
154
|
+
gemfile: 'text/x-script',
|
|
155
|
+
// --- Shell / terminal ---
|
|
156
|
+
ksh: 'text/x-script',
|
|
157
|
+
csh: 'text/x-script',
|
|
158
|
+
tcsh: 'text/x-script',
|
|
159
|
+
bat: 'text/x-script',
|
|
160
|
+
cmd: 'text/x-script',
|
|
161
|
+
awk: 'text/x-script',
|
|
162
|
+
// --- Misc text ---
|
|
163
|
+
log: 'text/plain',
|
|
164
|
+
diff: 'text/plain',
|
|
165
|
+
patch: 'text/plain',
|
|
166
|
+
svg: 'text/xml',
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a text MIME type from contentType or file extension.
|
|
170
|
+
* Returns the MIME string if it's a supported text type, null otherwise.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveTextType(attachment) {
|
|
173
|
+
if (attachment.contentType) {
|
|
174
|
+
const mime = attachment.contentType.split(';')[0].trim().toLowerCase();
|
|
175
|
+
if (mime.startsWith('text/'))
|
|
176
|
+
return mime;
|
|
177
|
+
if (TEXT_APPLICATION_TYPES.has(mime))
|
|
178
|
+
return mime;
|
|
179
|
+
}
|
|
180
|
+
const name = attachment.name ?? '';
|
|
181
|
+
const dotIdx = name.lastIndexOf('.');
|
|
182
|
+
if (dotIdx >= 0) {
|
|
183
|
+
const ext = name.slice(dotIdx + 1).toLowerCase();
|
|
184
|
+
const mime = EXT_TO_TEXT_MIME[ext];
|
|
185
|
+
if (mime)
|
|
186
|
+
return mime;
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
/** Check if a MIME type is a supported text type. */
|
|
191
|
+
export function isTextType(mime) {
|
|
192
|
+
return mime.startsWith('text/') || TEXT_APPLICATION_TYPES.has(mime);
|
|
193
|
+
}
|
|
194
|
+
/** Sanitize an attachment filename for display. */
|
|
195
|
+
function safeName(attachment) {
|
|
196
|
+
const raw = attachment.name ?? 'unknown';
|
|
197
|
+
return raw.replace(/[\x00-\x1f]/g, '').slice(0, 100).trim() || 'unknown';
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Classify attachments into text, unsupported, and image (skipped) buckets.
|
|
201
|
+
* Image attachments are excluded — they're handled by image-download.ts.
|
|
202
|
+
*/
|
|
203
|
+
export function classifyAttachments(attachments) {
|
|
204
|
+
const text = [];
|
|
205
|
+
const unsupported = [];
|
|
206
|
+
for (const att of attachments) {
|
|
207
|
+
const textMime = resolveTextType(att);
|
|
208
|
+
if (textMime) {
|
|
209
|
+
text.push({ attachment: att, mime: textMime });
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
unsupported.push(att);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { text, unsupported };
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Download non-image text attachments from a Discord message.
|
|
219
|
+
*
|
|
220
|
+
* - Filters for text-like MIME types
|
|
221
|
+
* - Truncates files exceeding MAX_FILE_BYTES with a marker
|
|
222
|
+
* - Skips files once MAX_TOTAL_BYTES is reached
|
|
223
|
+
* - Notes unsupported attachment types in errors
|
|
224
|
+
*/
|
|
225
|
+
export async function downloadTextAttachments(attachments) {
|
|
226
|
+
const { text: candidates, unsupported } = classifyAttachments(attachments);
|
|
227
|
+
const errors = [];
|
|
228
|
+
// Note unsupported types
|
|
229
|
+
for (const att of unsupported) {
|
|
230
|
+
const name = safeName(att);
|
|
231
|
+
const mime = att.contentType?.split(';')[0].trim() ?? 'unknown';
|
|
232
|
+
errors.push(`[Unsupported attachment: ${name} (${mime})]`);
|
|
233
|
+
}
|
|
234
|
+
if (candidates.length === 0)
|
|
235
|
+
return { texts: [], errors };
|
|
236
|
+
const texts = [];
|
|
237
|
+
let totalBytes = 0;
|
|
238
|
+
for (const { attachment, mime } of candidates) {
|
|
239
|
+
const name = safeName(attachment);
|
|
240
|
+
// Total budget check
|
|
241
|
+
if (totalBytes >= MAX_TOTAL_BYTES) {
|
|
242
|
+
errors.push(`${name}: skipped (total size limit exceeded)`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// SSRF protection
|
|
246
|
+
let parsedUrl;
|
|
247
|
+
try {
|
|
248
|
+
parsedUrl = new URL(attachment.url);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
errors.push(`${name}: invalid URL`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (parsedUrl.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsedUrl.hostname)) {
|
|
255
|
+
errors.push(`${name}: blocked (non-Discord CDN host)`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Pre-check size from Discord metadata for total budget
|
|
259
|
+
const metaSize = attachment.size ?? 0;
|
|
260
|
+
if (metaSize > 0 && totalBytes + metaSize > MAX_TOTAL_BYTES) {
|
|
261
|
+
errors.push(`${name}: skipped (total size limit exceeded)`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
const response = await fetch(attachment.url, {
|
|
266
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
267
|
+
redirect: 'error',
|
|
268
|
+
});
|
|
269
|
+
if (!response.ok) {
|
|
270
|
+
errors.push(`${name}: HTTP ${response.status}`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
274
|
+
totalBytes += buffer.length;
|
|
275
|
+
// Decode as UTF-8
|
|
276
|
+
let content;
|
|
277
|
+
try {
|
|
278
|
+
const decoder = new TextDecoder('utf-8', { fatal: true });
|
|
279
|
+
content = decoder.decode(buffer);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
errors.push(`${name}: not valid UTF-8 text`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
// Truncate if exceeding per-file limit
|
|
286
|
+
if (buffer.length > MAX_FILE_BYTES) {
|
|
287
|
+
const truncated = content.slice(0, MAX_FILE_BYTES);
|
|
288
|
+
texts.push({ name, content: truncated + '\n[truncated at 100KB]' });
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
texts.push({ name, content });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
const errObj = err instanceof Error ? err : null;
|
|
296
|
+
if (errObj?.name === 'TimeoutError' || errObj?.name === 'AbortError') {
|
|
297
|
+
errors.push(`${name}: download timed out`);
|
|
298
|
+
}
|
|
299
|
+
else if (errObj?.name === 'TypeError' && String(errObj.message).includes('redirect')) {
|
|
300
|
+
errors.push(`${name}: blocked (unexpected redirect)`);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
errors.push(`${name}: download failed`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return { texts, errors };
|
|
308
|
+
}
|