@mxalbert/context-mode 2.0.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/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +31 -0
- package/.codex-plugin/hooks.json +65 -0
- package/.codex-plugin/mcp.json +13 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.openclaw-plugin/index.ts +12 -0
- package/.openclaw-plugin/openclaw.plugin.json +38 -0
- package/.openclaw-plugin/package.json +28 -0
- package/LICENSE +94 -0
- package/README.md +1619 -0
- package/bin/statusline.mjs +378 -0
- package/build/adapters/antigravity/index.d.ts +51 -0
- package/build/adapters/antigravity/index.js +169 -0
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/base.d.ts +92 -0
- package/build/adapters/base.js +135 -0
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +106 -0
- package/build/adapters/claude-code/hooks.js +183 -0
- package/build/adapters/claude-code/index.d.ts +80 -0
- package/build/adapters/claude-code/index.js +569 -0
- package/build/adapters/claude-code-base.d.ts +49 -0
- package/build/adapters/claude-code-base.js +113 -0
- package/build/adapters/client-map.d.ts +10 -0
- package/build/adapters/client-map.js +42 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +55 -0
- package/build/adapters/codex/hooks.js +64 -0
- package/build/adapters/codex/index.d.ts +87 -0
- package/build/adapters/codex/index.js +995 -0
- package/build/adapters/codex/paths.d.ts +1 -0
- package/build/adapters/codex/paths.js +12 -0
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/copilot-base.d.ts +94 -0
- package/build/adapters/copilot-base.js +297 -0
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/cursor/config.d.ts +4 -0
- package/build/adapters/cursor/config.js +4 -0
- package/build/adapters/cursor/hooks.d.ts +55 -0
- package/build/adapters/cursor/hooks.js +82 -0
- package/build/adapters/cursor/index.d.ts +64 -0
- package/build/adapters/cursor/index.js +517 -0
- package/build/adapters/detect.d.ts +136 -0
- package/build/adapters/detect.js +623 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +83 -0
- package/build/adapters/gemini-cli/hooks.js +115 -0
- package/build/adapters/gemini-cli/index.d.ts +80 -0
- package/build/adapters/gemini-cli/index.js +483 -0
- package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
- package/build/adapters/jetbrains-copilot/config.js +8 -0
- package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
- package/build/adapters/jetbrains-copilot/hooks.js +87 -0
- package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
- package/build/adapters/jetbrains-copilot/index.js +131 -0
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +21 -0
- package/build/adapters/kimi/paths.js +52 -0
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/kiro/hooks.d.ts +61 -0
- package/build/adapters/kiro/hooks.js +94 -0
- package/build/adapters/kiro/index.d.ts +61 -0
- package/build/adapters/kiro/index.js +301 -0
- package/build/adapters/omp/index.d.ts +65 -0
- package/build/adapters/omp/index.js +182 -0
- package/build/adapters/omp/plugin.d.ts +81 -0
- package/build/adapters/omp/plugin.js +331 -0
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/config.d.ts +8 -0
- package/build/adapters/openclaw/config.js +8 -0
- package/build/adapters/openclaw/hooks.d.ts +50 -0
- package/build/adapters/openclaw/hooks.js +61 -0
- package/build/adapters/openclaw/index.d.ts +74 -0
- package/build/adapters/openclaw/index.js +439 -0
- package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
- package/build/adapters/openclaw/mcp-tools.js +207 -0
- package/build/adapters/openclaw/plugin.d.ts +141 -0
- package/build/adapters/openclaw/plugin.js +818 -0
- package/build/adapters/openclaw/session-db.d.ts +55 -0
- package/build/adapters/openclaw/session-db.js +88 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/openclaw/workspace-router.d.ts +29 -0
- package/build/adapters/openclaw/workspace-router.js +64 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +144 -0
- package/build/adapters/opencode/index.js +638 -0
- package/build/adapters/opencode/plugin.d.ts +249 -0
- package/build/adapters/opencode/plugin.js +1536 -0
- package/build/adapters/opencode/v2.d.ts +117 -0
- package/build/adapters/opencode/v2.js +224 -0
- package/build/adapters/opencode/zod3tov4.d.ts +1 -0
- package/build/adapters/opencode/zod3tov4.js +132 -0
- package/build/adapters/pi/extension.d.ts +81 -0
- package/build/adapters/pi/extension.js +861 -0
- package/build/adapters/pi/index.d.ts +57 -0
- package/build/adapters/pi/index.js +173 -0
- package/build/adapters/pi/mcp-bridge.d.ts +268 -0
- package/build/adapters/pi/mcp-bridge.js +894 -0
- package/build/adapters/qwen-code/hooks.d.ts +26 -0
- package/build/adapters/qwen-code/hooks.js +29 -0
- package/build/adapters/qwen-code/index.d.ts +40 -0
- package/build/adapters/qwen-code/index.js +361 -0
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/adapters/types.d.ts +360 -0
- package/build/adapters/types.js +118 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
- package/build/adapters/vscode-copilot/hooks.js +98 -0
- package/build/adapters/vscode-copilot/index.d.ts +32 -0
- package/build/adapters/vscode-copilot/index.js +222 -0
- package/build/adapters/zed/index.d.ts +40 -0
- package/build/adapters/zed/index.js +163 -0
- package/build/cli.d.ts +34 -0
- package/build/cli.js +1828 -0
- package/build/db-base.d.ts +250 -0
- package/build/db-base.js +756 -0
- package/build/executor.d.ts +72 -0
- package/build/executor.js +678 -0
- package/build/exit-classify.d.ts +19 -0
- package/build/exit-classify.js +12 -0
- package/build/fetch/blocks.d.ts +145 -0
- package/build/fetch/blocks.js +244 -0
- package/build/fetch/extract.d.ts +68 -0
- package/build/fetch/extract.js +123 -0
- package/build/fetch/page-store.d.ts +66 -0
- package/build/fetch/page-store.js +174 -0
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +124 -0
- package/build/lifecycle.js +278 -0
- package/build/package-identity.d.ts +26 -0
- package/build/package-identity.js +28 -0
- package/build/runPool.d.ts +36 -0
- package/build/runPool.js +51 -0
- package/build/runtime.d.ts +71 -0
- package/build/runtime.js +659 -0
- package/build/search/auto-memory.d.ts +47 -0
- package/build/search/auto-memory.js +170 -0
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/search/unified.d.ts +56 -0
- package/build/search/unified.js +104 -0
- package/build/security.d.ts +206 -0
- package/build/security.js +780 -0
- package/build/server.d.ts +240 -0
- package/build/server.js +5034 -0
- package/build/session/analytics.d.ts +773 -0
- package/build/session/analytics.js +2420 -0
- package/build/session/db.d.ts +468 -0
- package/build/session/db.js +1330 -0
- package/build/session/error-classifier.d.ts +87 -0
- package/build/session/error-classifier.js +303 -0
- package/build/session/event-emit.d.ts +48 -0
- package/build/session/event-emit.js +101 -0
- package/build/session/extract.d.ts +333 -0
- package/build/session/extract.js +2688 -0
- package/build/session/model-prices.json +429 -0
- package/build/session/persist-tool-calls.d.ts +54 -0
- package/build/session/persist-tool-calls.js +105 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/build/session/project-attribution.d.ts +73 -0
- package/build/session/project-attribution.js +245 -0
- package/build/session/purge.d.ts +138 -0
- package/build/session/purge.js +240 -0
- package/build/session/retrieval-marker.d.ts +39 -0
- package/build/session/retrieval-marker.js +65 -0
- package/build/session/snapshot.d.ts +46 -0
- package/build/session/snapshot.js +532 -0
- package/build/store-directory.d.ts +56 -0
- package/build/store-directory.js +254 -0
- package/build/store.d.ts +147 -0
- package/build/store.js +1714 -0
- package/build/truncate.d.ts +57 -0
- package/build/truncate.js +142 -0
- package/build/types.d.ts +117 -0
- package/build/types.js +20 -0
- package/build/util/claude-config.d.ts +32 -0
- package/build/util/claude-config.js +84 -0
- package/build/util/hook-config.d.ts +27 -0
- package/build/util/hook-config.js +76 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/build/util/plugin-cache-integrity.d.ts +51 -0
- package/build/util/plugin-cache-integrity.js +146 -0
- package/build/util/project-dir.d.ts +149 -0
- package/build/util/project-dir.js +340 -0
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +1495 -0
- package/configs/antigravity/GEMINI.md +68 -0
- package/configs/antigravity/mcp_config.json +7 -0
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +91 -0
- package/configs/codex/AGENTS.md +99 -0
- package/configs/codex/config.toml +8 -0
- package/configs/codex/hooks.json +47 -0
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/cursor/context-mode.mdc +76 -0
- package/configs/cursor/hooks.json +21 -0
- package/configs/cursor/mcp.json +7 -0
- package/configs/gemini-cli/GEMINI.md +88 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +60 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
- package/configs/jetbrains-copilot/hooks.json +16 -0
- package/configs/jetbrains-copilot/mcp.json +7 -0
- package/configs/kilo/AGENTS.md +84 -0
- package/configs/kilo/kilo.json +6 -0
- package/configs/kimi/hooks.json +54 -0
- package/configs/kiro/KIRO.md +84 -0
- package/configs/kiro/agent.json +18 -0
- package/configs/kiro/mcp.json +7 -0
- package/configs/omp/SYSTEM.md +85 -0
- package/configs/omp/mcp.json +7 -0
- package/configs/openclaw/AGENTS.md +87 -0
- package/configs/openclaw/openclaw.json +13 -0
- package/configs/opencode/AGENTS.md +84 -0
- package/configs/opencode/opencode.json +6 -0
- package/configs/pi/AGENTS.md +3 -0
- package/configs/qwen-code/QWEN.md +91 -0
- package/configs/vscode-copilot/copilot-instructions.md +80 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +7 -0
- package/configs/zed/AGENTS.md +68 -0
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/auto-injection.mjs +102 -0
- package/hooks/cache-heal-utils.mjs +379 -0
- package/hooks/codex/platform.mjs +1 -0
- package/hooks/codex/posttooluse.mjs +66 -0
- package/hooks/codex/precompact.mjs +70 -0
- package/hooks/codex/pretooluse.mjs +42 -0
- package/hooks/codex/sessionstart.mjs +121 -0
- package/hooks/codex/stop.mjs +138 -0
- package/hooks/codex/userpromptsubmit.mjs +75 -0
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +356 -0
- package/hooks/core/mcp-ready.mjs +109 -0
- package/hooks/core/platform-detect.mjs +49 -0
- package/hooks/core/routing.mjs +1050 -0
- package/hooks/core/stdin.mjs +90 -0
- package/hooks/core/tool-naming.mjs +57 -0
- package/hooks/cursor/afteragentresponse.mjs +74 -0
- package/hooks/cursor/hooks.json +31 -0
- package/hooks/cursor/posttooluse.mjs +72 -0
- package/hooks/cursor/pretooluse.mjs +26 -0
- package/hooks/cursor/sessionstart.mjs +97 -0
- package/hooks/cursor/stop.mjs +48 -0
- package/hooks/ensure-deps.mjs +254 -0
- package/hooks/formatters/claude-code.mjs +113 -0
- package/hooks/formatters/cursor.mjs +37 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/gemini-cli/aftertool.mjs +61 -0
- package/hooks/gemini-cli/beforeagent.mjs +99 -0
- package/hooks/gemini-cli/beforetool.mjs +27 -0
- package/hooks/gemini-cli/precompress.mjs +55 -0
- package/hooks/gemini-cli/sessionstart.mjs +137 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/hooks.json +143 -0
- package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
- package/hooks/jetbrains-copilot/precompact.mjs +55 -0
- package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
- package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +132 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/kiro/agentspawn.mjs +97 -0
- package/hooks/kiro/posttooluse.mjs +51 -0
- package/hooks/kiro/pretooluse.mjs +64 -0
- package/hooks/kiro/userpromptsubmit.mjs +88 -0
- package/hooks/normalize-hooks.mjs +323 -0
- package/hooks/platform-bridge.mjs +340 -0
- package/hooks/posttooluse.mjs +225 -0
- package/hooks/precompact.mjs +100 -0
- package/hooks/pretooluse.mjs +227 -0
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/routing-block.mjs +105 -0
- package/hooks/run-hook.mjs +95 -0
- package/hooks/security.bundle.mjs +2 -0
- package/hooks/session-attribution.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +145 -0
- package/hooks/session-directive.mjs +520 -0
- package/hooks/session-extract.bundle.mjs +3 -0
- package/hooks/session-helpers.mjs +428 -0
- package/hooks/session-loaders.mjs +448 -0
- package/hooks/session-snapshot.bundle.mjs +32 -0
- package/hooks/sessionstart.mjs +464 -0
- package/hooks/stop.mjs +82 -0
- package/hooks/suppress-stderr.mjs +25 -0
- package/hooks/userpromptsubmit.mjs +108 -0
- package/hooks/vscode-copilot/posttooluse.mjs +61 -0
- package/hooks/vscode-copilot/precompact.mjs +55 -0
- package/hooks/vscode-copilot/pretooluse.mjs +27 -0
- package/hooks/vscode-copilot/sessionstart.mjs +129 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +132 -0
- package/scripts/heal-better-sqlite3.mjs +430 -0
- package/scripts/heal-installed-plugins.mjs +635 -0
- package/scripts/plugin-cache-integrity.mjs +248 -0
- package/scripts/postinstall.mjs +397 -0
- package/server.bundle.mjs +1421 -0
- package/server.js +5 -0
- package/skills/.ignore +7 -0
- package/skills/context-mode/SKILL.md +300 -0
- package/skills/context-mode/references/anti-patterns.md +283 -0
- package/skills/context-mode/references/patterns-javascript.md +298 -0
- package/skills/context-mode/references/patterns-python.md +304 -0
- package/skills/context-mode/references/patterns-shell.md +277 -0
- package/skills/ctx-doctor/SKILL.md +22 -0
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-insight/SKILL.md +24 -0
- package/skills/ctx-purge/SKILL.md +49 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/skills/ctx-stats/SKILL.md +26 -0
- package/skills/ctx-upgrade/SKILL.md +31 -0
- package/start.mjs +616 -0
package/build/store.js
ADDED
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContentStore — FTS5 BM25-based knowledge base for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Chunks markdown content by headings (keeping code blocks intact),
|
|
5
|
+
* stores in SQLite FTS5, and retrieves via BM25-ranked search.
|
|
6
|
+
*
|
|
7
|
+
* Use for documentation, API references, and any content where
|
|
8
|
+
* you need EXACT text later — not summaries.
|
|
9
|
+
*/
|
|
10
|
+
var _a;
|
|
11
|
+
import { loadDatabase, applyWALPragmas, closeDB, cleanOrphanedWALFiles, withRetry, deleteDBFiles, isSQLiteCorruptionError } from "./db-base.js";
|
|
12
|
+
import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync, fstatSync, closeSync } from "node:fs";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { walkDirectoryDetailed } from "./store-directory.js";
|
|
17
|
+
// ─────────────────────────────────────────────────────────
|
|
18
|
+
// Constants
|
|
19
|
+
// ─────────────────────────────────────────────────────────
|
|
20
|
+
const STOPWORDS = new Set([
|
|
21
|
+
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
|
|
22
|
+
"her", "was", "one", "our", "out", "has", "his", "how", "its", "may",
|
|
23
|
+
"new", "now", "old", "see", "way", "who", "did", "get", "got", "let",
|
|
24
|
+
"say", "she", "too", "use", "will", "with", "this", "that", "from",
|
|
25
|
+
"they", "been", "have", "many", "some", "them", "than", "each", "make",
|
|
26
|
+
"like", "just", "over", "such", "take", "into", "year", "your", "good",
|
|
27
|
+
"could", "would", "about", "which", "their", "there", "other", "after",
|
|
28
|
+
"should", "through", "also", "more", "most", "only", "very", "when",
|
|
29
|
+
"what", "then", "these", "those", "being", "does", "done", "both",
|
|
30
|
+
"same", "still", "while", "where", "here", "were", "much",
|
|
31
|
+
// Common in code/changelogs
|
|
32
|
+
"update", "updates", "updated", "deps", "dev", "tests", "test",
|
|
33
|
+
"add", "added", "fix", "fixed", "run", "running", "using",
|
|
34
|
+
]);
|
|
35
|
+
// ─────────────────────────────────────────────────────────
|
|
36
|
+
// Helpers
|
|
37
|
+
// ─────────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Remove case-insensitive duplicate tokens while preserving the first
|
|
40
|
+
* occurrence's original casing. FTS5's unicode61 tokenizer lowercases on
|
|
41
|
+
* both sides, so `"Error" OR "error"` produces no extra recall — just
|
|
42
|
+
* redundant index lookups. Dedup keeps the compiled query minimal.
|
|
43
|
+
*/
|
|
44
|
+
function dedupeTokens(tokens) {
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const t of tokens) {
|
|
48
|
+
const key = t.toLowerCase();
|
|
49
|
+
if (!seen.has(key)) {
|
|
50
|
+
seen.add(key);
|
|
51
|
+
out.push(t);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export function sanitizeQuery(query, mode = "AND") {
|
|
57
|
+
const words = dedupeTokens(query
|
|
58
|
+
.replace(/['"(){}[\]*:^~]/g, " ")
|
|
59
|
+
.split(/\s+/)
|
|
60
|
+
.filter((w) => w.length > 0 &&
|
|
61
|
+
!["AND", "OR", "NOT", "NEAR"].includes(w.toUpperCase())));
|
|
62
|
+
if (words.length === 0)
|
|
63
|
+
return '""';
|
|
64
|
+
// Filter stopwords to improve BM25 ranking — common terms like "update",
|
|
65
|
+
// "test", "fix" appear everywhere and dilute relevance scoring.
|
|
66
|
+
// Fall back to unfiltered words if ALL terms are stopwords.
|
|
67
|
+
const meaningful = words.filter((w) => !STOPWORDS.has(w.toLowerCase()));
|
|
68
|
+
const final = meaningful.length > 0 ? meaningful : words;
|
|
69
|
+
return final.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
|
|
70
|
+
}
|
|
71
|
+
export function sanitizeTrigramQuery(query, mode = "AND") {
|
|
72
|
+
const cleaned = query.replace(/["'(){}[\]*:^~]/g, "").trim();
|
|
73
|
+
if (cleaned.length < 3)
|
|
74
|
+
return "";
|
|
75
|
+
const words = dedupeTokens(cleaned.split(/\s+/).filter((w) => w.length >= 3));
|
|
76
|
+
if (words.length === 0)
|
|
77
|
+
return "";
|
|
78
|
+
const meaningful = words.filter((w) => !STOPWORDS.has(w.toLowerCase()));
|
|
79
|
+
const final = meaningful.length > 0 ? meaningful : words;
|
|
80
|
+
return final.map((w) => `"${w}"`).join(mode === "OR" ? " OR " : " ");
|
|
81
|
+
}
|
|
82
|
+
function levenshtein(a, b) {
|
|
83
|
+
if (a.length === 0)
|
|
84
|
+
return b.length;
|
|
85
|
+
if (b.length === 0)
|
|
86
|
+
return a.length;
|
|
87
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
88
|
+
for (let i = 1; i <= a.length; i++) {
|
|
89
|
+
const curr = [i];
|
|
90
|
+
for (let j = 1; j <= b.length; j++) {
|
|
91
|
+
curr[j] =
|
|
92
|
+
a[i - 1] === b[j - 1]
|
|
93
|
+
? prev[j - 1]
|
|
94
|
+
: 1 + Math.min(prev[j], curr[j - 1], prev[j - 1]);
|
|
95
|
+
}
|
|
96
|
+
prev = curr;
|
|
97
|
+
}
|
|
98
|
+
return prev[b.length];
|
|
99
|
+
}
|
|
100
|
+
function maxEditDistance(wordLength) {
|
|
101
|
+
if (wordLength <= 4)
|
|
102
|
+
return 1;
|
|
103
|
+
if (wordLength <= 12)
|
|
104
|
+
return 2;
|
|
105
|
+
return 3;
|
|
106
|
+
}
|
|
107
|
+
// Oversized chunks (e.g., a 50KB section between two headings) hurt BM25
|
|
108
|
+
// length normalization and produce unwieldy search results. Split at paragraph
|
|
109
|
+
// boundaries when a chunk exceeds this cap.
|
|
110
|
+
const MAX_CHUNK_BYTES = 4096;
|
|
111
|
+
// Blank-line sectioning is used only for output that is *naturally* sectioned:
|
|
112
|
+
// at least a few sections, not an unbounded explosion, and no single section so
|
|
113
|
+
// large that the split is clearly not the real structure (those fall back to
|
|
114
|
+
// line-grouping). Sections that pass the heuristic but still exceed
|
|
115
|
+
// MAX_CHUNK_BYTES are sub-split so no persisted chunk breaks the cap.
|
|
116
|
+
const MIN_BLANK_LINE_SECTIONS = 3;
|
|
117
|
+
const MAX_BLANK_LINE_SECTIONS = 200;
|
|
118
|
+
const BLANK_SECTION_STRATEGY_MAX_BYTES = 5000;
|
|
119
|
+
// Number of leading characters of a chunk's first line used as its title.
|
|
120
|
+
const CHUNK_TITLE_MAX_CHARS = 80;
|
|
121
|
+
// When byte-splitting an oversized single line, prefer to break at a whitespace
|
|
122
|
+
// boundary for readability — but only if that boundary is past this fraction of
|
|
123
|
+
// the slice, otherwise we'd waste too much of the byte budget.
|
|
124
|
+
const WHITESPACE_BREAK_RATIO = 0.5;
|
|
125
|
+
// ─────────────────────────────────────────────────────────
|
|
126
|
+
// ContentStore
|
|
127
|
+
// ─────────────────────────────────────────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* Remove stale DB files from previous sessions whose processes no longer exist.
|
|
130
|
+
*/
|
|
131
|
+
export function cleanupStaleDBs() {
|
|
132
|
+
const dir = tmpdir();
|
|
133
|
+
let cleaned = 0;
|
|
134
|
+
try {
|
|
135
|
+
const files = readdirSync(dir);
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
const match = file.match(/^context-mode-(\d+)\.db$/);
|
|
138
|
+
if (!match)
|
|
139
|
+
continue;
|
|
140
|
+
const pid = parseInt(match[1], 10);
|
|
141
|
+
if (pid === process.pid)
|
|
142
|
+
continue;
|
|
143
|
+
try {
|
|
144
|
+
process.kill(pid, 0);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
const base = join(dir, file);
|
|
148
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
149
|
+
try {
|
|
150
|
+
unlinkSync(base + suffix);
|
|
151
|
+
}
|
|
152
|
+
catch { /* ignore */ }
|
|
153
|
+
}
|
|
154
|
+
cleaned++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch { /* ignore readdir errors */ }
|
|
159
|
+
return cleaned;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Check if a PID is still alive (not a zombie holding a WAL lock).
|
|
163
|
+
* Returns true if the process exists, false if it's dead.
|
|
164
|
+
*/
|
|
165
|
+
function isProcessAlive(pid) {
|
|
166
|
+
try {
|
|
167
|
+
process.kill(pid, 0);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Clean up stale per-project content store DBs older than maxAgeDays.
|
|
176
|
+
* Scans the given directory for *.db files and checks mtime.
|
|
177
|
+
* Also detects zombie processes holding WAL locks — if a WAL file exists
|
|
178
|
+
* but the owning PID is dead, the DB files are cleaned up regardless of age.
|
|
179
|
+
*/
|
|
180
|
+
export function cleanupStaleContentDBs(contentDir, maxAgeDays) {
|
|
181
|
+
let cleaned = 0;
|
|
182
|
+
try {
|
|
183
|
+
if (!existsSync(contentDir))
|
|
184
|
+
return 0;
|
|
185
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
186
|
+
const files = readdirSync(contentDir).filter(f => f.endsWith(".db"));
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
try {
|
|
189
|
+
const filePath = join(contentDir, file);
|
|
190
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
191
|
+
let shouldClean = mtime < cutoff;
|
|
192
|
+
// Detect zombie processes holding WAL locks:
|
|
193
|
+
// If a WAL file exists, try to read the WAL header to extract the PID.
|
|
194
|
+
// WAL files from dead processes can block new connections.
|
|
195
|
+
if (!shouldClean) {
|
|
196
|
+
const walPath = filePath + "-wal";
|
|
197
|
+
if (existsSync(walPath)) {
|
|
198
|
+
try {
|
|
199
|
+
const walStat = statSync(walPath);
|
|
200
|
+
// If WAL file is non-empty and DB hasn't been modified in >1 hour,
|
|
201
|
+
// the owning process may be dead — check via mtime staleness
|
|
202
|
+
if (walStat.size > 0 && (Date.now() - walStat.mtimeMs) > 3600_000) {
|
|
203
|
+
shouldClean = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch { /* ignore WAL check errors */ }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (shouldClean) {
|
|
210
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
211
|
+
try {
|
|
212
|
+
unlinkSync(filePath + suffix);
|
|
213
|
+
}
|
|
214
|
+
catch { /* ignore */ }
|
|
215
|
+
}
|
|
216
|
+
cleaned++;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch { /* ignore per-file errors */ }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch { /* ignore readdir errors */ }
|
|
223
|
+
return cleaned;
|
|
224
|
+
}
|
|
225
|
+
// ── Proximity helpers (pure functions) ──
|
|
226
|
+
/** Find all positions of a term in text. */
|
|
227
|
+
function findAllPositions(text, term) {
|
|
228
|
+
const positions = [];
|
|
229
|
+
let idx = text.indexOf(term);
|
|
230
|
+
while (idx !== -1) {
|
|
231
|
+
positions.push(idx);
|
|
232
|
+
idx = text.indexOf(term, idx + 1);
|
|
233
|
+
}
|
|
234
|
+
return positions;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Count matched adjacent pairs across consecutive query terms.
|
|
238
|
+
* For each pair (term[i], term[i+1]), pairs each left position with at most one
|
|
239
|
+
* right position whose offset falls within `gap` chars of `p + len(term[i])`.
|
|
240
|
+
* `positionLists` must be sorted ascending (output of `findAllPositions` is).
|
|
241
|
+
* Each right position is consumed by at most one left, so `"foo foo bar"`
|
|
242
|
+
* counts 1 pair, not 2 — matches IR phrase-occurrence intent and avoids
|
|
243
|
+
* inflating boosts for repeated-token queries.
|
|
244
|
+
* Used by reranker to layer a frequency signal on top of minSpan proximity:
|
|
245
|
+
* 30-char gap covers natural prose without rewarding distant matches.
|
|
246
|
+
*/
|
|
247
|
+
function countAdjacentPairs(positionLists, terms, gap = 30) {
|
|
248
|
+
if (positionLists.length < 2 || terms.length < 2)
|
|
249
|
+
return 0;
|
|
250
|
+
let total = 0;
|
|
251
|
+
const pairs = Math.min(positionLists.length, terms.length) - 1;
|
|
252
|
+
for (let i = 0; i < pairs; i++) {
|
|
253
|
+
const left = positionLists[i];
|
|
254
|
+
const right = positionLists[i + 1];
|
|
255
|
+
const leftLen = terms[i].length;
|
|
256
|
+
let j = 0;
|
|
257
|
+
for (const p of left) {
|
|
258
|
+
const minStart = p + leftLen;
|
|
259
|
+
const maxStart = minStart + gap;
|
|
260
|
+
while (j < right.length && right[j] < minStart)
|
|
261
|
+
j++;
|
|
262
|
+
if (j < right.length && right[j] <= maxStart) {
|
|
263
|
+
total++;
|
|
264
|
+
j++;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return total;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Find minimum span (window) covering at least one position from each list.
|
|
272
|
+
* Uses a sweep-line approach: advance the pointer at the current minimum.
|
|
273
|
+
*/
|
|
274
|
+
function findMinSpan(positionLists) {
|
|
275
|
+
if (positionLists.length === 0)
|
|
276
|
+
return Infinity;
|
|
277
|
+
if (positionLists.length === 1)
|
|
278
|
+
return 0;
|
|
279
|
+
const sorted = positionLists;
|
|
280
|
+
const ptrs = new Array(sorted.length).fill(0);
|
|
281
|
+
let minSpan = Infinity;
|
|
282
|
+
while (true) {
|
|
283
|
+
let curMin = Infinity;
|
|
284
|
+
let curMax = -Infinity;
|
|
285
|
+
let minIdx = 0;
|
|
286
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
287
|
+
const val = sorted[i][ptrs[i]];
|
|
288
|
+
if (val < curMin) {
|
|
289
|
+
curMin = val;
|
|
290
|
+
minIdx = i;
|
|
291
|
+
}
|
|
292
|
+
if (val > curMax) {
|
|
293
|
+
curMax = val;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const span = curMax - curMin;
|
|
297
|
+
if (span < minSpan)
|
|
298
|
+
minSpan = span;
|
|
299
|
+
ptrs[minIdx]++;
|
|
300
|
+
if (ptrs[minIdx] >= sorted[minIdx].length)
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
return minSpan;
|
|
304
|
+
}
|
|
305
|
+
export class ContentStore {
|
|
306
|
+
#db;
|
|
307
|
+
#dbPath;
|
|
308
|
+
// Optional deny-policy callback. When set (by server.ts at startup),
|
|
309
|
+
// #refreshStaleSources consults it before re-reading file_path during
|
|
310
|
+
// auto-refresh. This catches policy edits between initial indexing and
|
|
311
|
+
// a later search: a file that was allowed at index time may have been
|
|
312
|
+
// added to the Read deny list afterwards. Without this hook, refresh
|
|
313
|
+
// would re-read and re-expose the file. See #442 round-3.
|
|
314
|
+
#denyChecker;
|
|
315
|
+
// ── Cached Prepared Statements ──
|
|
316
|
+
// Prepared once at construction, reused on every call to avoid
|
|
317
|
+
// re-compiling SQL on each invocation.
|
|
318
|
+
// Write path
|
|
319
|
+
#stmtInsertSourceEmpty;
|
|
320
|
+
#stmtInsertSource;
|
|
321
|
+
#stmtInsertChunk;
|
|
322
|
+
#stmtInsertChunkTrigram;
|
|
323
|
+
#stmtInsertVocab;
|
|
324
|
+
// Dedup path (delete previous source with same label before re-indexing)
|
|
325
|
+
#stmtDeleteChunksByLabel;
|
|
326
|
+
#stmtDeleteChunksTrigramByLabel;
|
|
327
|
+
#stmtDeleteSourcesByLabel;
|
|
328
|
+
// Search path (hot)
|
|
329
|
+
#stmtSearchPorter;
|
|
330
|
+
#stmtSearchPorterFiltered;
|
|
331
|
+
#stmtSearchPorterExact;
|
|
332
|
+
#stmtSearchTrigram;
|
|
333
|
+
#stmtSearchTrigramFiltered;
|
|
334
|
+
#stmtSearchTrigramExact;
|
|
335
|
+
#stmtFuzzyVocab;
|
|
336
|
+
#stmtSearchPorterContentType;
|
|
337
|
+
#stmtSearchPorterFilteredContentType;
|
|
338
|
+
#stmtSearchPorterExactContentType;
|
|
339
|
+
#stmtSearchTrigramContentType;
|
|
340
|
+
#stmtSearchTrigramFilteredContentType;
|
|
341
|
+
#stmtSearchTrigramExactContentType;
|
|
342
|
+
// Read path
|
|
343
|
+
#stmtListSources;
|
|
344
|
+
#stmtChunksBySource;
|
|
345
|
+
#stmtSourceChunkCount;
|
|
346
|
+
#stmtChunkContent;
|
|
347
|
+
#stmtStats;
|
|
348
|
+
#stmtSourceMeta;
|
|
349
|
+
// Cleanup path
|
|
350
|
+
#stmtCleanupChunks;
|
|
351
|
+
#stmtCleanupChunksTrigram;
|
|
352
|
+
#stmtCleanupSources;
|
|
353
|
+
// FTS5 optimization: track inserts and optimize periodically to defragment
|
|
354
|
+
// the index. FTS5 b-trees fragment over many insert/delete cycles, degrading
|
|
355
|
+
// search performance. SQLite's built-in 'optimize' merges b-tree segments.
|
|
356
|
+
#insertCount = 0;
|
|
357
|
+
static OPTIMIZE_EVERY = 50;
|
|
358
|
+
// Fuzzy correction cache (process-local LRU). fuzzyCorrect() hits the vocab
|
|
359
|
+
// DB and runs levenshtein against every candidate within length tolerance,
|
|
360
|
+
// which is CPU-linear in |candidates|. Repeated queries ("erro", "erro" …)
|
|
361
|
+
// recompute the same answer. The vocabulary table is insert-only, so cache
|
|
362
|
+
// entries only become stale when new words enter — we clear on actual insert.
|
|
363
|
+
#fuzzyCache = new Map();
|
|
364
|
+
static FUZZY_CACHE_SIZE = 256;
|
|
365
|
+
constructor(dbPath) {
|
|
366
|
+
const Database = loadDatabase();
|
|
367
|
+
this.#dbPath =
|
|
368
|
+
dbPath ?? join(tmpdir(), `context-mode-${process.pid}.db`);
|
|
369
|
+
cleanOrphanedWALFiles(this.#dbPath);
|
|
370
|
+
let db;
|
|
371
|
+
try {
|
|
372
|
+
db = new Database(this.#dbPath, { timeout: 30000 });
|
|
373
|
+
applyWALPragmas(db);
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
377
|
+
if (isSQLiteCorruptionError(msg)) {
|
|
378
|
+
deleteDBFiles(this.#dbPath);
|
|
379
|
+
cleanOrphanedWALFiles(this.#dbPath);
|
|
380
|
+
try {
|
|
381
|
+
db = new Database(this.#dbPath, { timeout: 30000 });
|
|
382
|
+
applyWALPragmas(db);
|
|
383
|
+
}
|
|
384
|
+
catch (retryErr) {
|
|
385
|
+
throw new Error(`Failed to create fresh DB after deleting corrupt file: ${retryErr instanceof Error ? retryErr.message : String(retryErr)}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
this.#db = db;
|
|
393
|
+
this.#initSchema();
|
|
394
|
+
this.#prepareStatements();
|
|
395
|
+
}
|
|
396
|
+
/** Delete this session's DB files. Call on process exit. */
|
|
397
|
+
cleanup() {
|
|
398
|
+
try {
|
|
399
|
+
this.#db.close();
|
|
400
|
+
}
|
|
401
|
+
catch { /* ignore */ }
|
|
402
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
403
|
+
try {
|
|
404
|
+
unlinkSync(this.#dbPath + suffix);
|
|
405
|
+
}
|
|
406
|
+
catch { /* ignore */ }
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// ── Schema ──
|
|
410
|
+
#initSchema() {
|
|
411
|
+
this.#db.exec(`
|
|
412
|
+
CREATE TABLE IF NOT EXISTS sources (
|
|
413
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
414
|
+
label TEXT NOT NULL,
|
|
415
|
+
chunk_count INTEGER NOT NULL DEFAULT 0,
|
|
416
|
+
code_chunk_count INTEGER NOT NULL DEFAULT 0,
|
|
417
|
+
indexed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
418
|
+
file_path TEXT,
|
|
419
|
+
content_hash TEXT
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
|
|
423
|
+
title,
|
|
424
|
+
content,
|
|
425
|
+
source_id UNINDEXED,
|
|
426
|
+
content_type UNINDEXED,
|
|
427
|
+
source_category UNINDEXED,
|
|
428
|
+
session_id UNINDEXED,
|
|
429
|
+
event_id UNINDEXED,
|
|
430
|
+
timestamp UNINDEXED,
|
|
431
|
+
tokenize='porter unicode61'
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_trigram USING fts5(
|
|
435
|
+
title,
|
|
436
|
+
content,
|
|
437
|
+
source_id UNINDEXED,
|
|
438
|
+
content_type UNINDEXED,
|
|
439
|
+
source_category UNINDEXED,
|
|
440
|
+
session_id UNINDEXED,
|
|
441
|
+
event_id UNINDEXED,
|
|
442
|
+
timestamp UNINDEXED,
|
|
443
|
+
tokenize='trigram'
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
CREATE TABLE IF NOT EXISTS vocabulary (
|
|
447
|
+
word TEXT PRIMARY KEY
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
CREATE INDEX IF NOT EXISTS idx_sources_label ON sources(label);
|
|
451
|
+
`);
|
|
452
|
+
// FTS5 schema migration: old schema (4 cols) → new schema (8 cols).
|
|
453
|
+
// FTS5 virtual tables do not support ALTER TABLE ADD COLUMN, so we must
|
|
454
|
+
// DROP + re-CREATE. Detection: check for sentinel column `source_category`
|
|
455
|
+
// via pragma_table_xinfo. Three states:
|
|
456
|
+
// 1. No table → CREATE above handled it (fresh DB)
|
|
457
|
+
// 2. Old schema (4 cols) → DROP + CREATE new
|
|
458
|
+
// 3. New schema (8 cols) → do nothing
|
|
459
|
+
try {
|
|
460
|
+
const cols = this.#db.prepare("SELECT name FROM pragma_table_xinfo('chunks')").all();
|
|
461
|
+
const colNames = new Set(cols.map(c => c.name));
|
|
462
|
+
if (cols.length > 0 && !colNames.has("source_category")) {
|
|
463
|
+
// Old schema detected — drop both FTS5 tables and re-create with new columns
|
|
464
|
+
this.#db.exec("DROP TABLE IF EXISTS chunks");
|
|
465
|
+
this.#db.exec("DROP TABLE IF EXISTS chunks_trigram");
|
|
466
|
+
this.#db.exec(`
|
|
467
|
+
CREATE VIRTUAL TABLE chunks USING fts5(
|
|
468
|
+
title,
|
|
469
|
+
content,
|
|
470
|
+
source_id UNINDEXED,
|
|
471
|
+
content_type UNINDEXED,
|
|
472
|
+
source_category UNINDEXED,
|
|
473
|
+
session_id UNINDEXED,
|
|
474
|
+
event_id UNINDEXED,
|
|
475
|
+
timestamp UNINDEXED,
|
|
476
|
+
tokenize='porter unicode61'
|
|
477
|
+
);
|
|
478
|
+
CREATE VIRTUAL TABLE chunks_trigram USING fts5(
|
|
479
|
+
title,
|
|
480
|
+
content,
|
|
481
|
+
source_id UNINDEXED,
|
|
482
|
+
content_type UNINDEXED,
|
|
483
|
+
source_category UNINDEXED,
|
|
484
|
+
session_id UNINDEXED,
|
|
485
|
+
event_id UNINDEXED,
|
|
486
|
+
timestamp UNINDEXED,
|
|
487
|
+
tokenize='trigram'
|
|
488
|
+
);
|
|
489
|
+
`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
catch { /* pragma_table_xinfo may fail if table doesn't exist yet — safe to ignore */ }
|
|
493
|
+
// Stale detection columns — safe for existing DBs (ALTER is O(1) in SQLite)
|
|
494
|
+
try {
|
|
495
|
+
this.#db.exec("ALTER TABLE sources ADD COLUMN file_path TEXT");
|
|
496
|
+
}
|
|
497
|
+
catch { /* already exists */ }
|
|
498
|
+
try {
|
|
499
|
+
this.#db.exec("ALTER TABLE sources ADD COLUMN content_hash TEXT");
|
|
500
|
+
}
|
|
501
|
+
catch { /* already exists */ }
|
|
502
|
+
}
|
|
503
|
+
#prepareStatements() {
|
|
504
|
+
// Write path
|
|
505
|
+
this.#stmtInsertSourceEmpty = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, 0, 0, ?, ?)");
|
|
506
|
+
this.#stmtInsertSource = this.#db.prepare("INSERT INTO sources (label, chunk_count, code_chunk_count, file_path, content_hash) VALUES (?, ?, ?, ?, ?)");
|
|
507
|
+
this.#stmtInsertChunk = this.#db.prepare("INSERT INTO chunks (title, content, source_id, content_type, source_category, session_id, event_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
508
|
+
this.#stmtInsertChunkTrigram = this.#db.prepare("INSERT INTO chunks_trigram (title, content, source_id, content_type, source_category, session_id, event_id, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
509
|
+
this.#stmtInsertVocab = this.#db.prepare("INSERT OR IGNORE INTO vocabulary (word) VALUES (?)");
|
|
510
|
+
// Dedup path: delete previous source with same label before re-indexing
|
|
511
|
+
// Prevents stale outputs from accumulating in iterative workflows (build-fix-build)
|
|
512
|
+
this.#stmtDeleteChunksByLabel = this.#db.prepare("DELETE FROM chunks WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
|
|
513
|
+
this.#stmtDeleteChunksTrigramByLabel = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE label = ?)");
|
|
514
|
+
this.#stmtDeleteSourcesByLabel = this.#db.prepare("DELETE FROM sources WHERE label = ?");
|
|
515
|
+
// Search path (hot)
|
|
516
|
+
this.#stmtSearchPorter = this.#db.prepare(`
|
|
517
|
+
SELECT
|
|
518
|
+
chunks.title,
|
|
519
|
+
chunks.content,
|
|
520
|
+
chunks.content_type,
|
|
521
|
+
chunks.timestamp,
|
|
522
|
+
sources.label,
|
|
523
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
524
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
525
|
+
chunks.session_id
|
|
526
|
+
FROM chunks
|
|
527
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
528
|
+
WHERE chunks MATCH ?
|
|
529
|
+
ORDER BY rank
|
|
530
|
+
LIMIT ?
|
|
531
|
+
`);
|
|
532
|
+
this.#stmtSearchPorterFiltered = this.#db.prepare(`
|
|
533
|
+
SELECT
|
|
534
|
+
chunks.title,
|
|
535
|
+
chunks.content,
|
|
536
|
+
chunks.content_type,
|
|
537
|
+
chunks.timestamp,
|
|
538
|
+
sources.label,
|
|
539
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
540
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
541
|
+
chunks.session_id
|
|
542
|
+
FROM chunks
|
|
543
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
544
|
+
WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\'
|
|
545
|
+
ORDER BY rank
|
|
546
|
+
LIMIT ?
|
|
547
|
+
`);
|
|
548
|
+
this.#stmtSearchPorterExact = this.#db.prepare(`
|
|
549
|
+
SELECT
|
|
550
|
+
chunks.title,
|
|
551
|
+
chunks.content,
|
|
552
|
+
chunks.content_type,
|
|
553
|
+
chunks.timestamp,
|
|
554
|
+
sources.label,
|
|
555
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
556
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
557
|
+
chunks.session_id
|
|
558
|
+
FROM chunks
|
|
559
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
560
|
+
WHERE chunks MATCH ? AND sources.label = ?
|
|
561
|
+
ORDER BY rank
|
|
562
|
+
LIMIT ?
|
|
563
|
+
`);
|
|
564
|
+
this.#stmtSearchTrigram = this.#db.prepare(`
|
|
565
|
+
SELECT
|
|
566
|
+
chunks_trigram.title,
|
|
567
|
+
chunks_trigram.content,
|
|
568
|
+
chunks_trigram.content_type,
|
|
569
|
+
chunks_trigram.timestamp,
|
|
570
|
+
sources.label,
|
|
571
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
572
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
573
|
+
chunks_trigram.session_id
|
|
574
|
+
FROM chunks_trigram
|
|
575
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
576
|
+
WHERE chunks_trigram MATCH ?
|
|
577
|
+
ORDER BY rank
|
|
578
|
+
LIMIT ?
|
|
579
|
+
`);
|
|
580
|
+
this.#stmtSearchTrigramFiltered = this.#db.prepare(`
|
|
581
|
+
SELECT
|
|
582
|
+
chunks_trigram.title,
|
|
583
|
+
chunks_trigram.content,
|
|
584
|
+
chunks_trigram.content_type,
|
|
585
|
+
chunks_trigram.timestamp,
|
|
586
|
+
sources.label,
|
|
587
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
588
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
589
|
+
chunks_trigram.session_id
|
|
590
|
+
FROM chunks_trigram
|
|
591
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
592
|
+
WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\'
|
|
593
|
+
ORDER BY rank
|
|
594
|
+
LIMIT ?
|
|
595
|
+
`);
|
|
596
|
+
this.#stmtSearchTrigramExact = this.#db.prepare(`
|
|
597
|
+
SELECT
|
|
598
|
+
chunks_trigram.title,
|
|
599
|
+
chunks_trigram.content,
|
|
600
|
+
chunks_trigram.content_type,
|
|
601
|
+
chunks_trigram.timestamp,
|
|
602
|
+
sources.label,
|
|
603
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
604
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
605
|
+
chunks_trigram.session_id
|
|
606
|
+
FROM chunks_trigram
|
|
607
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
608
|
+
WHERE chunks_trigram MATCH ? AND sources.label = ?
|
|
609
|
+
ORDER BY rank
|
|
610
|
+
LIMIT ?
|
|
611
|
+
`);
|
|
612
|
+
// Content-type filtered variants
|
|
613
|
+
this.#stmtSearchPorterContentType = this.#db.prepare(`
|
|
614
|
+
SELECT
|
|
615
|
+
chunks.title,
|
|
616
|
+
chunks.content,
|
|
617
|
+
chunks.content_type,
|
|
618
|
+
chunks.timestamp,
|
|
619
|
+
sources.label,
|
|
620
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
621
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
622
|
+
chunks.session_id
|
|
623
|
+
FROM chunks
|
|
624
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
625
|
+
WHERE chunks MATCH ? AND chunks.content_type = ?
|
|
626
|
+
ORDER BY rank
|
|
627
|
+
LIMIT ?
|
|
628
|
+
`);
|
|
629
|
+
this.#stmtSearchPorterFilteredContentType = this.#db.prepare(`
|
|
630
|
+
SELECT
|
|
631
|
+
chunks.title,
|
|
632
|
+
chunks.content,
|
|
633
|
+
chunks.content_type,
|
|
634
|
+
chunks.timestamp,
|
|
635
|
+
sources.label,
|
|
636
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
637
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
638
|
+
chunks.session_id
|
|
639
|
+
FROM chunks
|
|
640
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
641
|
+
WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks.content_type = ?
|
|
642
|
+
ORDER BY rank
|
|
643
|
+
LIMIT ?
|
|
644
|
+
`);
|
|
645
|
+
this.#stmtSearchPorterExactContentType = this.#db.prepare(`
|
|
646
|
+
SELECT
|
|
647
|
+
chunks.title,
|
|
648
|
+
chunks.content,
|
|
649
|
+
chunks.content_type,
|
|
650
|
+
chunks.timestamp,
|
|
651
|
+
sources.label,
|
|
652
|
+
bm25(chunks, 5.0, 1.0) AS rank,
|
|
653
|
+
highlight(chunks, 1, char(2), char(3)) AS highlighted,
|
|
654
|
+
chunks.session_id
|
|
655
|
+
FROM chunks
|
|
656
|
+
JOIN sources ON sources.id = chunks.source_id
|
|
657
|
+
WHERE chunks MATCH ? AND sources.label = ? AND chunks.content_type = ?
|
|
658
|
+
ORDER BY rank
|
|
659
|
+
LIMIT ?
|
|
660
|
+
`);
|
|
661
|
+
this.#stmtSearchTrigramContentType = this.#db.prepare(`
|
|
662
|
+
SELECT
|
|
663
|
+
chunks_trigram.title,
|
|
664
|
+
chunks_trigram.content,
|
|
665
|
+
chunks_trigram.content_type,
|
|
666
|
+
chunks_trigram.timestamp,
|
|
667
|
+
sources.label,
|
|
668
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
669
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
670
|
+
chunks_trigram.session_id
|
|
671
|
+
FROM chunks_trigram
|
|
672
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
673
|
+
WHERE chunks_trigram MATCH ? AND chunks_trigram.content_type = ?
|
|
674
|
+
ORDER BY rank
|
|
675
|
+
LIMIT ?
|
|
676
|
+
`);
|
|
677
|
+
this.#stmtSearchTrigramFilteredContentType = this.#db.prepare(`
|
|
678
|
+
SELECT
|
|
679
|
+
chunks_trigram.title,
|
|
680
|
+
chunks_trigram.content,
|
|
681
|
+
chunks_trigram.content_type,
|
|
682
|
+
chunks_trigram.timestamp,
|
|
683
|
+
sources.label,
|
|
684
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
685
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
686
|
+
chunks_trigram.session_id
|
|
687
|
+
FROM chunks_trigram
|
|
688
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
689
|
+
WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks_trigram.content_type = ?
|
|
690
|
+
ORDER BY rank
|
|
691
|
+
LIMIT ?
|
|
692
|
+
`);
|
|
693
|
+
this.#stmtSearchTrigramExactContentType = this.#db.prepare(`
|
|
694
|
+
SELECT
|
|
695
|
+
chunks_trigram.title,
|
|
696
|
+
chunks_trigram.content,
|
|
697
|
+
chunks_trigram.content_type,
|
|
698
|
+
chunks_trigram.timestamp,
|
|
699
|
+
sources.label,
|
|
700
|
+
bm25(chunks_trigram, 5.0, 1.0) AS rank,
|
|
701
|
+
highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
|
|
702
|
+
chunks_trigram.session_id
|
|
703
|
+
FROM chunks_trigram
|
|
704
|
+
JOIN sources ON sources.id = chunks_trigram.source_id
|
|
705
|
+
WHERE chunks_trigram MATCH ? AND sources.label = ? AND chunks_trigram.content_type = ?
|
|
706
|
+
ORDER BY rank
|
|
707
|
+
LIMIT ?
|
|
708
|
+
`);
|
|
709
|
+
// Fuzzy path
|
|
710
|
+
this.#stmtFuzzyVocab = this.#db.prepare("SELECT word FROM vocabulary WHERE length(word) BETWEEN ? AND ?");
|
|
711
|
+
// Read path
|
|
712
|
+
this.#stmtListSources = this.#db.prepare("SELECT label, chunk_count as chunkCount FROM sources ORDER BY id DESC");
|
|
713
|
+
this.#stmtChunksBySource = this.#db.prepare(`SELECT c.title, c.content, c.content_type, s.label
|
|
714
|
+
FROM chunks c
|
|
715
|
+
JOIN sources s ON s.id = c.source_id
|
|
716
|
+
WHERE c.source_id = ?
|
|
717
|
+
ORDER BY c.rowid`);
|
|
718
|
+
this.#stmtSourceChunkCount = this.#db.prepare("SELECT chunk_count FROM sources WHERE id = ?");
|
|
719
|
+
this.#stmtChunkContent = this.#db.prepare("SELECT content FROM chunks WHERE source_id = ?");
|
|
720
|
+
this.#stmtSourceMeta = this.#db.prepare("SELECT label, chunk_count, code_chunk_count, indexed_at, file_path, content_hash FROM sources WHERE label = ?");
|
|
721
|
+
this.#stmtStats = this.#db.prepare(`
|
|
722
|
+
SELECT
|
|
723
|
+
(SELECT COUNT(*) FROM sources) AS sources,
|
|
724
|
+
(SELECT COUNT(*) FROM chunks) AS chunks,
|
|
725
|
+
(SELECT COUNT(*) FROM chunks WHERE content_type = 'code') AS codeChunks
|
|
726
|
+
`);
|
|
727
|
+
// Cleanup path — cached to avoid recompiling SQL on each periodic call
|
|
728
|
+
this.#stmtCleanupChunks = this.#db.prepare("DELETE FROM chunks WHERE source_id IN (SELECT id FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days'))");
|
|
729
|
+
this.#stmtCleanupChunksTrigram = this.#db.prepare("DELETE FROM chunks_trigram WHERE source_id IN (SELECT id FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days'))");
|
|
730
|
+
this.#stmtCleanupSources = this.#db.prepare("DELETE FROM sources WHERE datetime(indexed_at) < datetime('now', '-' || ? || ' days')");
|
|
731
|
+
}
|
|
732
|
+
// ── Deny Policy Hook ──
|
|
733
|
+
/**
|
|
734
|
+
* Register a deny-policy checker. When set, #refreshStaleSources
|
|
735
|
+
* calls it before re-reading any file_path during auto-refresh.
|
|
736
|
+
* Returning `true` causes the source to be skipped (kept in cache,
|
|
737
|
+
* not re-indexed). server.ts wires this to the Read deny patterns.
|
|
738
|
+
*/
|
|
739
|
+
setDenyChecker(fn) {
|
|
740
|
+
this.#denyChecker = fn;
|
|
741
|
+
}
|
|
742
|
+
// ── Index ──
|
|
743
|
+
index(options) {
|
|
744
|
+
const { content, path, source, attribution } = options;
|
|
745
|
+
// Treat empty string as "no content" so an empty `content` paired with a
|
|
746
|
+
// valid `path` falls back to reading the file. Some MCP clients
|
|
747
|
+
// materialize optional string fields as `""` and the previous
|
|
748
|
+
// `content ?? readFileSync(path)` kept the empty string, indexing 0
|
|
749
|
+
// chunks. See issue #350.
|
|
750
|
+
const hasContent = typeof content === "string" && content.length > 0;
|
|
751
|
+
if (!hasContent && !path) {
|
|
752
|
+
throw new Error("Either content or path must be provided");
|
|
753
|
+
}
|
|
754
|
+
// Read file via fd to close the TOCTOU window between the security
|
|
755
|
+
// gate (security.ts evaluateFilePath calls realpathSync) and the read
|
|
756
|
+
// here. Lexical re-read by path string allowed an attacker to swap a
|
|
757
|
+
// symlink to a denied target (e.g. ~/.ssh/id_rsa) AFTER gate passed.
|
|
758
|
+
// openSync + fstat + readFileSync(fd) binds the read to the inode
|
|
759
|
+
// captured at gate-time. fstat also rejects non-regular files
|
|
760
|
+
// (directories, character devices) which would otherwise read as ""
|
|
761
|
+
// or throw inconsistently. See #442 round-3.
|
|
762
|
+
let text;
|
|
763
|
+
if (hasContent) {
|
|
764
|
+
text = content;
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
const fd = openSync(path, "r");
|
|
768
|
+
try {
|
|
769
|
+
const st = fstatSync(fd);
|
|
770
|
+
if (!st.isFile()) {
|
|
771
|
+
throw new Error(`refusing to index ${path}: not a regular file`);
|
|
772
|
+
}
|
|
773
|
+
text = readFileSync(fd, "utf-8");
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
closeSync(fd);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
const label = source ?? path ?? "untitled";
|
|
780
|
+
const chunks = this.#chunkMarkdown(text);
|
|
781
|
+
// Stale detection: store file_path + SHA-256 for file-backed sources
|
|
782
|
+
const filePath = path ?? undefined;
|
|
783
|
+
const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
|
|
784
|
+
return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
|
|
785
|
+
}
|
|
786
|
+
// ── Index Directory (#687) ──
|
|
787
|
+
/**
|
|
788
|
+
* Index every file under a directory by walking it with `walkDirectory` and
|
|
789
|
+
* delegating each discovered file to `this.index({ path })`. The per-file
|
|
790
|
+
* `openSync + fstatSync.isFile()` security gate at line ~845 stays active
|
|
791
|
+
* for every file — directory support never bypasses the TOCTOU defense
|
|
792
|
+
* from #442 round-3.
|
|
793
|
+
*
|
|
794
|
+
* Reported by @matiasduartee in #687.
|
|
795
|
+
*/
|
|
796
|
+
indexDirectory(opts) {
|
|
797
|
+
const { path: rootPath, source, attribution, perFileDeny, ...walkOpts } = opts;
|
|
798
|
+
const walked = walkDirectoryDetailed(rootPath, walkOpts);
|
|
799
|
+
let filesIndexed = 0;
|
|
800
|
+
let totalChunks = 0;
|
|
801
|
+
let denied = 0;
|
|
802
|
+
let failed = 0;
|
|
803
|
+
for (const file of walked.files) {
|
|
804
|
+
if (perFileDeny && perFileDeny(file)) {
|
|
805
|
+
denied++;
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
// Per-file source label so ctx_search(source: "<file>") still works.
|
|
810
|
+
const fileSource = source ? `${source}:${file}` : file;
|
|
811
|
+
const r = this.index({ path: file, source: fileSource, attribution });
|
|
812
|
+
filesIndexed++;
|
|
813
|
+
totalChunks += r.totalChunks;
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
// Per-file failure (e.g. fd-bound fstat rejection of a non-regular
|
|
817
|
+
// file that races between walk and read) — count + continue.
|
|
818
|
+
failed++;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return {
|
|
822
|
+
filesIndexed,
|
|
823
|
+
totalChunks,
|
|
824
|
+
capped: walked.capped,
|
|
825
|
+
totalSeen: walked.totalSeen,
|
|
826
|
+
denied,
|
|
827
|
+
failed,
|
|
828
|
+
label: source ?? rootPath,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
// ── Index Plain Text ──
|
|
832
|
+
/**
|
|
833
|
+
* Index plain-text output (logs, build output, test results) by splitting
|
|
834
|
+
* into fixed-size line groups. Unlike markdown indexing, this does not
|
|
835
|
+
* look for headings — it chunks by line count with overlap.
|
|
836
|
+
*/
|
|
837
|
+
indexPlainText(content, source, linesPerChunk = 20, attribution, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
838
|
+
if (!content || content.trim().length === 0) {
|
|
839
|
+
return this.#insertChunks([], source, "", undefined, undefined, attribution);
|
|
840
|
+
}
|
|
841
|
+
const chunks = this.#chunkPlainText(content, linesPerChunk, maxChunkBytes);
|
|
842
|
+
return withRetry(() => this.#insertChunks(chunks.map((c) => ({ ...c, hasCode: false })), source, content, undefined, undefined, attribution));
|
|
843
|
+
}
|
|
844
|
+
// ── Index JSON ──
|
|
845
|
+
/**
|
|
846
|
+
* Index JSON content by walking the object tree and using key paths
|
|
847
|
+
* as chunk titles (analogous to heading hierarchy in markdown). Objects
|
|
848
|
+
* recurse by key; arrays batch items by size.
|
|
849
|
+
*
|
|
850
|
+
* Falls back to `indexPlainText` if the content is not valid JSON.
|
|
851
|
+
*/
|
|
852
|
+
indexJSON(content, source, maxChunkBytes = MAX_CHUNK_BYTES, attribution) {
|
|
853
|
+
if (!content || content.trim().length === 0) {
|
|
854
|
+
return this.indexPlainText("", source, undefined, attribution, maxChunkBytes);
|
|
855
|
+
}
|
|
856
|
+
let parsed;
|
|
857
|
+
try {
|
|
858
|
+
parsed = JSON.parse(content);
|
|
859
|
+
}
|
|
860
|
+
catch {
|
|
861
|
+
return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
|
|
862
|
+
}
|
|
863
|
+
const chunks = [];
|
|
864
|
+
this.#walkJSON(parsed, [], chunks, maxChunkBytes);
|
|
865
|
+
if (chunks.length === 0) {
|
|
866
|
+
return this.indexPlainText(content, source, undefined, attribution, maxChunkBytes);
|
|
867
|
+
}
|
|
868
|
+
return withRetry(() => this.#insertChunks(chunks, source, content, undefined, undefined, attribution));
|
|
869
|
+
}
|
|
870
|
+
// ── Shared DB Insertion ──
|
|
871
|
+
/**
|
|
872
|
+
* Shared DB insertion logic for all index methods. Inserts chunks
|
|
873
|
+
* into both FTS5 tables within a transaction and extracts vocabulary.
|
|
874
|
+
* Uses cached prepared statements from #prepareStatements().
|
|
875
|
+
*/
|
|
876
|
+
#insertChunks(chunks, label, text, filePath, contentHash, attribution) {
|
|
877
|
+
const codeChunks = chunks.filter((c) => c.hasCode).length;
|
|
878
|
+
// FK columns on chunks. Empty-string fallback preserves the FTS5-friendly
|
|
879
|
+
// "not-null but unattributed" sentinel used by legacy rows.
|
|
880
|
+
const sessionIdCol = attribution?.sessionId ?? "";
|
|
881
|
+
const eventIdCol = attribution?.eventId ?? "";
|
|
882
|
+
// Atomic dedup + insert: delete previous source with same label,
|
|
883
|
+
// then insert new content — all within a single transaction.
|
|
884
|
+
// Prevents stale results in iterative workflows. (See: GitHub issue #67)
|
|
885
|
+
const transaction = this.#db.transaction(() => {
|
|
886
|
+
this.#stmtDeleteChunksByLabel.run(label);
|
|
887
|
+
this.#stmtDeleteChunksTrigramByLabel.run(label);
|
|
888
|
+
this.#stmtDeleteSourcesByLabel.run(label);
|
|
889
|
+
if (chunks.length === 0) {
|
|
890
|
+
const info = this.#stmtInsertSourceEmpty.run(label, filePath ?? null, contentHash ?? null);
|
|
891
|
+
return Number(info.lastInsertRowid);
|
|
892
|
+
}
|
|
893
|
+
const info = this.#stmtInsertSource.run(label, chunks.length, codeChunks, filePath ?? null, contentHash ?? null);
|
|
894
|
+
const sourceId = Number(info.lastInsertRowid);
|
|
895
|
+
const now = new Date().toISOString();
|
|
896
|
+
for (const chunk of chunks) {
|
|
897
|
+
const ct = chunk.hasCode ? "code" : "prose";
|
|
898
|
+
this.#stmtInsertChunk.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
|
|
899
|
+
this.#stmtInsertChunkTrigram.run(chunk.title, chunk.content, sourceId, ct, null, sessionIdCol, eventIdCol, now);
|
|
900
|
+
}
|
|
901
|
+
return sourceId;
|
|
902
|
+
});
|
|
903
|
+
const sourceId = transaction();
|
|
904
|
+
if (text)
|
|
905
|
+
this.#extractAndStoreVocabulary(text);
|
|
906
|
+
// Periodically optimize FTS5 indexes to merge b-tree segments.
|
|
907
|
+
// Fragmentation accumulates over insert/delete cycles (dedup re-indexes
|
|
908
|
+
// every source on update). The 'optimize' command merges segments into
|
|
909
|
+
// a single b-tree, improving search latency for long-running sessions.
|
|
910
|
+
this.#insertCount++;
|
|
911
|
+
if (this.#insertCount % _a.OPTIMIZE_EVERY === 0) {
|
|
912
|
+
this.#optimizeFTS();
|
|
913
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
sourceId,
|
|
916
|
+
label,
|
|
917
|
+
totalChunks: chunks.length,
|
|
918
|
+
codeChunks,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
// ── Search ──
|
|
922
|
+
#mapSearchRows(rows) {
|
|
923
|
+
return rows.map((r) => ({
|
|
924
|
+
title: r.title,
|
|
925
|
+
content: r.content,
|
|
926
|
+
source: r.label,
|
|
927
|
+
rank: r.rank,
|
|
928
|
+
contentType: r.content_type,
|
|
929
|
+
highlighted: r.highlighted,
|
|
930
|
+
timestamp: r.timestamp ?? undefined,
|
|
931
|
+
sessionId: r.session_id ?? "",
|
|
932
|
+
}));
|
|
933
|
+
}
|
|
934
|
+
#sourceFilterParam(source, sourceMatchMode) {
|
|
935
|
+
if (sourceMatchMode === "exact")
|
|
936
|
+
return source;
|
|
937
|
+
// Escape SQLite LIKE metacharacters so user-supplied source labels
|
|
938
|
+
// containing `_`, `%`, or `\` are matched literally rather than as
|
|
939
|
+
// wildcards. Backslash must be replaced first (otherwise subsequent
|
|
940
|
+
// escapes would themselves be re-escaped). Paired with `ESCAPE '\'`
|
|
941
|
+
// in the four prepared LIKE statements (#stmtSearchPorter*,
|
|
942
|
+
// #stmtSearchTrigram*). Regression: #646.
|
|
943
|
+
const escaped = source
|
|
944
|
+
.replace(/\\/g, "\\\\")
|
|
945
|
+
.replace(/%/g, "\\%")
|
|
946
|
+
.replace(/_/g, "\\_");
|
|
947
|
+
return `%${escaped}%`;
|
|
948
|
+
}
|
|
949
|
+
search(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
|
|
950
|
+
const sanitized = sanitizeQuery(query, mode);
|
|
951
|
+
let stmt;
|
|
952
|
+
let params;
|
|
953
|
+
if (source && contentType) {
|
|
954
|
+
stmt = sourceMatchMode === "exact"
|
|
955
|
+
? this.#stmtSearchPorterExactContentType
|
|
956
|
+
: this.#stmtSearchPorterFilteredContentType;
|
|
957
|
+
params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
|
|
958
|
+
}
|
|
959
|
+
else if (source) {
|
|
960
|
+
stmt = sourceMatchMode === "exact"
|
|
961
|
+
? this.#stmtSearchPorterExact
|
|
962
|
+
: this.#stmtSearchPorterFiltered;
|
|
963
|
+
params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
|
|
964
|
+
}
|
|
965
|
+
else if (contentType) {
|
|
966
|
+
stmt = this.#stmtSearchPorterContentType;
|
|
967
|
+
params = [sanitized, contentType, limit];
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
stmt = this.#stmtSearchPorter;
|
|
971
|
+
params = [sanitized, limit];
|
|
972
|
+
}
|
|
973
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
974
|
+
}
|
|
975
|
+
// ── Trigram Search (Layer 2) ──
|
|
976
|
+
searchTrigram(query, limit = 3, source, mode = "AND", contentType, sourceMatchMode = "like") {
|
|
977
|
+
const sanitized = sanitizeTrigramQuery(query, mode);
|
|
978
|
+
if (!sanitized)
|
|
979
|
+
return [];
|
|
980
|
+
let stmt;
|
|
981
|
+
let params;
|
|
982
|
+
if (source && contentType) {
|
|
983
|
+
stmt = sourceMatchMode === "exact"
|
|
984
|
+
? this.#stmtSearchTrigramExactContentType
|
|
985
|
+
: this.#stmtSearchTrigramFilteredContentType;
|
|
986
|
+
params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), contentType, limit];
|
|
987
|
+
}
|
|
988
|
+
else if (source) {
|
|
989
|
+
stmt = sourceMatchMode === "exact"
|
|
990
|
+
? this.#stmtSearchTrigramExact
|
|
991
|
+
: this.#stmtSearchTrigramFiltered;
|
|
992
|
+
params = [sanitized, this.#sourceFilterParam(source, sourceMatchMode), limit];
|
|
993
|
+
}
|
|
994
|
+
else if (contentType) {
|
|
995
|
+
stmt = this.#stmtSearchTrigramContentType;
|
|
996
|
+
params = [sanitized, contentType, limit];
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
stmt = this.#stmtSearchTrigram;
|
|
1000
|
+
params = [sanitized, limit];
|
|
1001
|
+
}
|
|
1002
|
+
return withRetry(() => this.#mapSearchRows(stmt.all(...params)));
|
|
1003
|
+
}
|
|
1004
|
+
// ── Fuzzy Correction (Layer 3) ──
|
|
1005
|
+
fuzzyCorrect(query) {
|
|
1006
|
+
const word = query.toLowerCase().trim();
|
|
1007
|
+
if (word.length < 3)
|
|
1008
|
+
return null;
|
|
1009
|
+
// Cache hit: promote to tail (Map preserves insertion order → LRU).
|
|
1010
|
+
if (this.#fuzzyCache.has(word)) {
|
|
1011
|
+
const cached = this.#fuzzyCache.get(word) ?? null;
|
|
1012
|
+
this.#fuzzyCache.delete(word);
|
|
1013
|
+
this.#fuzzyCache.set(word, cached);
|
|
1014
|
+
return cached;
|
|
1015
|
+
}
|
|
1016
|
+
const maxDist = maxEditDistance(word.length);
|
|
1017
|
+
const candidates = this.#stmtFuzzyVocab.all(word.length - maxDist, word.length + maxDist);
|
|
1018
|
+
let bestWord = null;
|
|
1019
|
+
let bestDist = maxDist + 1;
|
|
1020
|
+
let exactMatch = false;
|
|
1021
|
+
for (const { word: candidate } of candidates) {
|
|
1022
|
+
if (candidate === word) {
|
|
1023
|
+
exactMatch = true;
|
|
1024
|
+
break;
|
|
1025
|
+
}
|
|
1026
|
+
const dist = levenshtein(word, candidate);
|
|
1027
|
+
if (dist < bestDist) {
|
|
1028
|
+
bestDist = dist;
|
|
1029
|
+
bestWord = candidate;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const result = exactMatch ? null : bestDist <= maxDist ? bestWord : null;
|
|
1033
|
+
// Evict the oldest entry before insert if we hit the size cap.
|
|
1034
|
+
if (this.#fuzzyCache.size >= _a.FUZZY_CACHE_SIZE) {
|
|
1035
|
+
const oldestKey = this.#fuzzyCache.keys().next().value;
|
|
1036
|
+
if (oldestKey !== undefined)
|
|
1037
|
+
this.#fuzzyCache.delete(oldestKey);
|
|
1038
|
+
}
|
|
1039
|
+
this.#fuzzyCache.set(word, result);
|
|
1040
|
+
return result;
|
|
1041
|
+
}
|
|
1042
|
+
// ── Reciprocal Rank Fusion (Cormack et al. 2009) ──
|
|
1043
|
+
#rrfSearch(query, limit, source, contentType, sourceMatchMode = "like") {
|
|
1044
|
+
const K = 60; // Standard RRF constant
|
|
1045
|
+
const fetchLimit = Math.max(limit * 2, 10);
|
|
1046
|
+
const porterResults = this.search(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
|
|
1047
|
+
const trigramResults = this.searchTrigram(query, fetchLimit, source, "OR", contentType, sourceMatchMode);
|
|
1048
|
+
const scoreMap = new Map();
|
|
1049
|
+
const key = (r) => `${r.source}::${r.title}`;
|
|
1050
|
+
for (const [i, r] of porterResults.entries()) {
|
|
1051
|
+
const k = key(r);
|
|
1052
|
+
const existing = scoreMap.get(k);
|
|
1053
|
+
if (existing) {
|
|
1054
|
+
existing.score += 1 / (K + i + 1);
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
scoreMap.set(k, { result: r, score: 1 / (K + i + 1) });
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
for (const [i, r] of trigramResults.entries()) {
|
|
1061
|
+
const k = key(r);
|
|
1062
|
+
const existing = scoreMap.get(k);
|
|
1063
|
+
if (existing) {
|
|
1064
|
+
existing.score += 1 / (K + i + 1);
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
scoreMap.set(k, { result: r, score: 1 / (K + i + 1) });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return Array.from(scoreMap.values())
|
|
1071
|
+
.sort((a, b) => b.score - a.score)
|
|
1072
|
+
.slice(0, limit)
|
|
1073
|
+
.map(({ result, score }) => ({ ...result, rank: -score }));
|
|
1074
|
+
}
|
|
1075
|
+
// ── Proximity Reranking ──
|
|
1076
|
+
#applyProximityReranking(results, query) {
|
|
1077
|
+
const allTerms = query
|
|
1078
|
+
.toLowerCase()
|
|
1079
|
+
.split(/\s+/)
|
|
1080
|
+
.filter((w) => w.length >= 2);
|
|
1081
|
+
// Exclude stopwords from proximity/title scoring — they match everywhere
|
|
1082
|
+
// and inflate boosts for irrelevant chunks. Keep all terms as fallback.
|
|
1083
|
+
const filtered = allTerms.filter((w) => !STOPWORDS.has(w));
|
|
1084
|
+
const terms = filtered.length > 0 ? filtered : allTerms;
|
|
1085
|
+
return results
|
|
1086
|
+
.map((r) => {
|
|
1087
|
+
// Title-match boost: query terms found in the chunk title get a boost.
|
|
1088
|
+
// Code chunks get a stronger title boost (function/class names are high
|
|
1089
|
+
// signal) while prose chunks get a moderate one (headings are useful but
|
|
1090
|
+
// body carries more weight).
|
|
1091
|
+
const titleLower = r.title.toLowerCase();
|
|
1092
|
+
const titleHits = terms.filter((t) => titleLower.includes(t)).length;
|
|
1093
|
+
const titleWeight = r.contentType === "code" ? 0.6 : 0.3;
|
|
1094
|
+
const titleBoost = titleHits > 0 ? titleWeight * (titleHits / terms.length) : 0;
|
|
1095
|
+
// Proximity boost for multi-term queries. minSpan picks the single
|
|
1096
|
+
// tightest window — frequency doesn't move it, so a long doc with one
|
|
1097
|
+
// tight occurrence outranks a short doc with several. Phrase-frequency
|
|
1098
|
+
// reward layers a saturating frequency signal on top: cap 0.5 (below
|
|
1099
|
+
// proximity max ≈1.0, in title-boost range), saturates at 4 hits.
|
|
1100
|
+
let proximityBoost = 0;
|
|
1101
|
+
let phraseBoost = 0;
|
|
1102
|
+
if (terms.length >= 2) {
|
|
1103
|
+
const content = r.content.toLowerCase();
|
|
1104
|
+
const positions = terms.map((t) => findAllPositions(content, t));
|
|
1105
|
+
if (!positions.some((p) => p.length === 0)) {
|
|
1106
|
+
const minSpan = findMinSpan(positions);
|
|
1107
|
+
proximityBoost = 1 / (1 + minSpan / Math.max(content.length, 1));
|
|
1108
|
+
const adjacentPairs = countAdjacentPairs(positions, terms);
|
|
1109
|
+
phraseBoost = 0.5 * Math.min(1, adjacentPairs / 4);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return { result: r, boost: titleBoost + proximityBoost + phraseBoost };
|
|
1113
|
+
})
|
|
1114
|
+
.sort((a, b) => b.boost - a.boost || a.result.rank - b.result.rank)
|
|
1115
|
+
.map(({ result }) => result);
|
|
1116
|
+
}
|
|
1117
|
+
// ── Unified Fallback Search ──
|
|
1118
|
+
searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like", sessionIdAllowSet) {
|
|
1119
|
+
// Step 0: Auto-refresh stale file-backed sources before searching
|
|
1120
|
+
this.#refreshStaleSources();
|
|
1121
|
+
// When a session-id allow-set is in play (issue #737 project filter),
|
|
1122
|
+
// fetch a larger candidate pool from the FTS5 layers so the post-filter
|
|
1123
|
+
// can still deliver `limit` matches even if many candidates are excluded.
|
|
1124
|
+
// The cap is bounded — even at the largest installs the chunk count
|
|
1125
|
+
// dwarfs `limit * 8`, and the surplus is dropped on the post-filter.
|
|
1126
|
+
const fetchLimit = sessionIdAllowSet ? Math.max(limit * 8, 40) : limit;
|
|
1127
|
+
const sessionFilter = this.#makeSessionFilter(sessionIdAllowSet);
|
|
1128
|
+
// Step 1: RRF fusion (porter OR + trigram OR → merge)
|
|
1129
|
+
const rrfResults = this.#rrfSearch(query, fetchLimit, source, contentType, sourceMatchMode);
|
|
1130
|
+
const rrfFiltered = sessionFilter ? rrfResults.filter(sessionFilter) : rrfResults;
|
|
1131
|
+
if (rrfFiltered.length > 0) {
|
|
1132
|
+
const reranked = this.#applyProximityReranking(rrfFiltered.slice(0, limit), query);
|
|
1133
|
+
return reranked.map((r) => ({ ...r, matchLayer: "rrf" }));
|
|
1134
|
+
}
|
|
1135
|
+
// Step 2: Fuzzy correction → RRF re-run
|
|
1136
|
+
// Skip stopwords — they'll be filtered by sanitizeQuery anyway, and each
|
|
1137
|
+
// fuzzyCorrect call hits the vocab DB + runs levenshtein comparisons.
|
|
1138
|
+
const words = query
|
|
1139
|
+
.toLowerCase()
|
|
1140
|
+
.trim()
|
|
1141
|
+
.split(/\s+/)
|
|
1142
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
1143
|
+
const original = words.join(" ");
|
|
1144
|
+
const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
|
|
1145
|
+
const correctedQuery = correctedWords.join(" ");
|
|
1146
|
+
if (correctedQuery !== original) {
|
|
1147
|
+
const fuzzyResults = this.#rrfSearch(correctedQuery, fetchLimit, source, contentType, sourceMatchMode);
|
|
1148
|
+
const fuzzyFiltered = sessionFilter ? fuzzyResults.filter(sessionFilter) : fuzzyResults;
|
|
1149
|
+
if (fuzzyFiltered.length > 0) {
|
|
1150
|
+
const reranked = this.#applyProximityReranking(fuzzyFiltered.slice(0, limit), correctedQuery);
|
|
1151
|
+
return reranked.map((r) => ({ ...r, matchLayer: "rrf-fuzzy" }));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return [];
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Build the session-id post-filter for the FTS5 candidate pool. Legacy
|
|
1158
|
+
* chunks indexed before per-session attribution carry `session_id=''` and
|
|
1159
|
+
* stay visible across projects so user-indexed content remains reachable
|
|
1160
|
+
* after opting into the shared-DB mode (#737).
|
|
1161
|
+
*/
|
|
1162
|
+
#makeSessionFilter(allowSet) {
|
|
1163
|
+
if (!allowSet)
|
|
1164
|
+
return null;
|
|
1165
|
+
return (r) => {
|
|
1166
|
+
const sid = r.sessionId ?? "";
|
|
1167
|
+
return sid === "" || allowSet.has(sid);
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
/** Number of sources auto-refreshed in the last searchWithFallback call. */
|
|
1171
|
+
lastRefreshCount = 0;
|
|
1172
|
+
/**
|
|
1173
|
+
* Check all file-backed sources for staleness and auto re-index changed files.
|
|
1174
|
+
* Uses mtime as a fast gate — only computes SHA-256 when mtime has advanced
|
|
1175
|
+
* past indexed_at. Gracefully skips deleted files and non-file sources.
|
|
1176
|
+
*/
|
|
1177
|
+
#refreshStaleSources() {
|
|
1178
|
+
this.lastRefreshCount = 0;
|
|
1179
|
+
const sources = this.#db.prepare("SELECT label, file_path, content_hash, indexed_at FROM sources WHERE file_path IS NOT NULL").all();
|
|
1180
|
+
for (const src of sources) {
|
|
1181
|
+
try {
|
|
1182
|
+
if (!existsSync(src.file_path))
|
|
1183
|
+
continue; // file deleted — keep cached results
|
|
1184
|
+
// Re-check deny policy before re-reading. The Read deny list may
|
|
1185
|
+
// have been edited after this source was originally indexed; a
|
|
1186
|
+
// file that was allowed then may now be denied. Without this
|
|
1187
|
+
// gate, refresh would happily re-read and re-expose it. #442 r3.
|
|
1188
|
+
if (this.#denyChecker && this.#denyChecker(src.file_path))
|
|
1189
|
+
continue;
|
|
1190
|
+
const mtime = statSync(src.file_path).mtime;
|
|
1191
|
+
const indexedAt = new Date(src.indexed_at + "Z");
|
|
1192
|
+
if (mtime <= indexedAt)
|
|
1193
|
+
continue; // file unchanged — fast path
|
|
1194
|
+
// mtime advanced — fd-bound read for hash + indexing in one go.
|
|
1195
|
+
// Open once, fstat, read from fd. Closes the swap-mid-flight
|
|
1196
|
+
// window between hash read and re-index. #442 round-3.
|
|
1197
|
+
const fd = openSync(src.file_path, "r");
|
|
1198
|
+
let newContent;
|
|
1199
|
+
try {
|
|
1200
|
+
const st = fstatSync(fd);
|
|
1201
|
+
if (!st.isFile())
|
|
1202
|
+
continue; // skip non-regular targets
|
|
1203
|
+
newContent = readFileSync(fd, "utf-8");
|
|
1204
|
+
}
|
|
1205
|
+
finally {
|
|
1206
|
+
closeSync(fd);
|
|
1207
|
+
}
|
|
1208
|
+
const newHash = createHash("sha256").update(newContent).digest("hex");
|
|
1209
|
+
if (newHash === src.content_hash)
|
|
1210
|
+
continue; // content identical — skip
|
|
1211
|
+
// File genuinely changed — re-index using already-read content
|
|
1212
|
+
// (avoids a second open/read race) but preserve file_path/hash
|
|
1213
|
+
// by going through index() which stores them. Since we pass
|
|
1214
|
+
// content, index() does NOT re-read; the bytes hashed above
|
|
1215
|
+
// are exactly the bytes indexed.
|
|
1216
|
+
this.index({ content: newContent, path: src.file_path, source: src.label });
|
|
1217
|
+
this.lastRefreshCount++;
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
// Graceful degradation — never break search for stale detection
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
// ── Sources ──
|
|
1225
|
+
getSourceMeta(label) {
|
|
1226
|
+
const row = this.#stmtSourceMeta.get(label);
|
|
1227
|
+
if (!row)
|
|
1228
|
+
return null;
|
|
1229
|
+
return { label: row.label, chunkCount: row.chunk_count, codeChunkCount: row.code_chunk_count, indexedAt: row.indexed_at, filePath: row.file_path ?? null, contentHash: row.content_hash ?? null };
|
|
1230
|
+
}
|
|
1231
|
+
listSources() {
|
|
1232
|
+
return this.#stmtListSources.all();
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Aggregate snapshot of the persistent content store. Returns total
|
|
1236
|
+
* chunk count, source count, and the most recent indexed_at timestamp.
|
|
1237
|
+
* Used by ctx_stats so callers can see observability state in the same
|
|
1238
|
+
* round trip instead of inferring it from snapshot diffs.
|
|
1239
|
+
*/
|
|
1240
|
+
getIndexState() {
|
|
1241
|
+
const row = this.#db
|
|
1242
|
+
.prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
|
|
1243
|
+
.get();
|
|
1244
|
+
return {
|
|
1245
|
+
totalChunks: row.total_chunks ?? 0,
|
|
1246
|
+
totalSources: row.total_sources ?? 0,
|
|
1247
|
+
lastIndexedAt: row.last_indexed_at ?? undefined,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Get all chunks for a given source by ID — bypasses FTS5 MATCH entirely.
|
|
1252
|
+
* Use this for inventory/listing where you need all sections, not search.
|
|
1253
|
+
*/
|
|
1254
|
+
getChunksBySource(sourceId) {
|
|
1255
|
+
const rows = this.#stmtChunksBySource.all(sourceId);
|
|
1256
|
+
return rows.map((r) => ({
|
|
1257
|
+
title: r.title,
|
|
1258
|
+
content: r.content,
|
|
1259
|
+
source: r.label,
|
|
1260
|
+
rank: 0,
|
|
1261
|
+
contentType: r.content_type,
|
|
1262
|
+
}));
|
|
1263
|
+
}
|
|
1264
|
+
// ── Vocabulary ──
|
|
1265
|
+
getDistinctiveTerms(sourceId, maxTerms = 40) {
|
|
1266
|
+
const stats = this.#stmtSourceChunkCount.get(sourceId);
|
|
1267
|
+
if (!stats || stats.chunk_count < 3)
|
|
1268
|
+
return [];
|
|
1269
|
+
const totalChunks = stats.chunk_count;
|
|
1270
|
+
const minAppearances = 2;
|
|
1271
|
+
const maxAppearances = Math.max(3, Math.ceil(totalChunks * 0.4));
|
|
1272
|
+
// Stream chunks one at a time to avoid loading all content into memory
|
|
1273
|
+
// Count document frequency (how many sections contain each word)
|
|
1274
|
+
const docFreq = new Map();
|
|
1275
|
+
for (const row of this.#stmtChunkContent.iterate(sourceId)) {
|
|
1276
|
+
const words = new Set(row.content
|
|
1277
|
+
.toLowerCase()
|
|
1278
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
1279
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w)));
|
|
1280
|
+
for (const word of words) {
|
|
1281
|
+
docFreq.set(word, (docFreq.get(word) ?? 0) + 1);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
const filtered = Array.from(docFreq.entries())
|
|
1285
|
+
.filter(([, count]) => count >= minAppearances && count <= maxAppearances);
|
|
1286
|
+
// Score: IDF (rarity) + length bonus + identifier bonus (underscore/camelCase)
|
|
1287
|
+
const scored = filtered.map(([word, count]) => {
|
|
1288
|
+
const idf = Math.log(totalChunks / count);
|
|
1289
|
+
const lenBonus = Math.min(word.length / 20, 0.5);
|
|
1290
|
+
const hasSpecialChars = /[_]/.test(word);
|
|
1291
|
+
const isCamelOrLong = word.length >= 12;
|
|
1292
|
+
const identifierBonus = hasSpecialChars ? 1.5 : isCamelOrLong ? 0.8 : 0;
|
|
1293
|
+
return { word, score: idf + lenBonus + identifierBonus };
|
|
1294
|
+
});
|
|
1295
|
+
return scored
|
|
1296
|
+
.sort((a, b) => b.score - a.score)
|
|
1297
|
+
.slice(0, maxTerms)
|
|
1298
|
+
.map((s) => s.word);
|
|
1299
|
+
}
|
|
1300
|
+
// ── Stats ──
|
|
1301
|
+
getStats() {
|
|
1302
|
+
const row = this.#stmtStats.get();
|
|
1303
|
+
return {
|
|
1304
|
+
sources: row?.sources ?? 0,
|
|
1305
|
+
chunks: row?.chunks ?? 0,
|
|
1306
|
+
codeChunks: row?.codeChunks ?? 0,
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
// ── Cleanup ──
|
|
1310
|
+
/**
|
|
1311
|
+
* Delete sources (and their chunks) older than maxAgeDays.
|
|
1312
|
+
* Returns count of deleted sources.
|
|
1313
|
+
*/
|
|
1314
|
+
cleanupStaleSources(maxAgeDays) {
|
|
1315
|
+
const cleanup = this.#db.transaction((days) => {
|
|
1316
|
+
this.#stmtCleanupChunks.run(days);
|
|
1317
|
+
this.#stmtCleanupChunksTrigram.run(days);
|
|
1318
|
+
return this.#stmtCleanupSources.run(days);
|
|
1319
|
+
});
|
|
1320
|
+
const info = cleanup(maxAgeDays);
|
|
1321
|
+
return info.changes;
|
|
1322
|
+
}
|
|
1323
|
+
/** Get DB file size in bytes. */
|
|
1324
|
+
getDBSizeBytes() {
|
|
1325
|
+
try {
|
|
1326
|
+
return statSync(this.#dbPath).size;
|
|
1327
|
+
}
|
|
1328
|
+
catch {
|
|
1329
|
+
return 0;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/** Merge FTS5 b-tree segments for both porter and trigram indexes. */
|
|
1333
|
+
#optimizeFTS() {
|
|
1334
|
+
try {
|
|
1335
|
+
this.#db.exec("INSERT INTO chunks(chunks) VALUES('optimize')");
|
|
1336
|
+
this.#db.exec("INSERT INTO chunks_trigram(chunks_trigram) VALUES('optimize')");
|
|
1337
|
+
}
|
|
1338
|
+
catch { /* best effort — don't block indexing */ }
|
|
1339
|
+
}
|
|
1340
|
+
close() {
|
|
1341
|
+
this.#optimizeFTS(); // defragment before close
|
|
1342
|
+
closeDB(this.#db); // safe close — no close-time checkpoint (cross-process mutation, see db-base.ts closeDB)
|
|
1343
|
+
}
|
|
1344
|
+
// ── Vocabulary Extraction ──
|
|
1345
|
+
#extractAndStoreVocabulary(content) {
|
|
1346
|
+
const words = content
|
|
1347
|
+
.toLowerCase()
|
|
1348
|
+
.split(/[^\p{L}\p{N}_-]+/u)
|
|
1349
|
+
.filter((w) => w.length >= 3 && !STOPWORDS.has(w));
|
|
1350
|
+
const unique = [...new Set(words)];
|
|
1351
|
+
let inserted = 0;
|
|
1352
|
+
this.#db.transaction(() => {
|
|
1353
|
+
for (const word of unique) {
|
|
1354
|
+
const info = this.#stmtInsertVocab.run(word);
|
|
1355
|
+
inserted += info.changes;
|
|
1356
|
+
}
|
|
1357
|
+
})();
|
|
1358
|
+
// Invalidate fuzzy cache when new vocab words actually land. INSERT OR
|
|
1359
|
+
// IGNORE reports changes=0 for duplicates, so re-indexing identical
|
|
1360
|
+
// content does not thrash the cache during iterative workflows.
|
|
1361
|
+
if (inserted > 0)
|
|
1362
|
+
this.#fuzzyCache.clear();
|
|
1363
|
+
}
|
|
1364
|
+
// ── Chunking ──
|
|
1365
|
+
#chunkMarkdown(text, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
1366
|
+
const chunks = [];
|
|
1367
|
+
const lines = text.split("\n");
|
|
1368
|
+
const headingStack = [];
|
|
1369
|
+
let currentContent = [];
|
|
1370
|
+
let currentHeading = "";
|
|
1371
|
+
const flush = () => {
|
|
1372
|
+
const joined = currentContent.join("\n").trim();
|
|
1373
|
+
if (joined.length === 0)
|
|
1374
|
+
return;
|
|
1375
|
+
const title = this.#buildTitle(headingStack, currentHeading);
|
|
1376
|
+
const hasCode = currentContent.some((l) => /^`{3,}/.test(l));
|
|
1377
|
+
// If under the cap, emit as-is (fast path — most chunks hit this)
|
|
1378
|
+
if (Buffer.byteLength(joined) <= maxChunkBytes) {
|
|
1379
|
+
chunks.push({ title, content: joined, hasCode });
|
|
1380
|
+
currentContent = [];
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
// Split oversized chunk at paragraph boundaries (double newlines)
|
|
1384
|
+
const paragraphs = joined.split(/\n\n+/);
|
|
1385
|
+
let accumulator = [];
|
|
1386
|
+
let partIndex = 1;
|
|
1387
|
+
const flushAccumulator = () => {
|
|
1388
|
+
if (accumulator.length === 0)
|
|
1389
|
+
return;
|
|
1390
|
+
const part = accumulator.join("\n\n").trim();
|
|
1391
|
+
if (part.length === 0)
|
|
1392
|
+
return;
|
|
1393
|
+
const partTitle = paragraphs.length > 1 ? `${title} (${partIndex})` : title;
|
|
1394
|
+
partIndex++;
|
|
1395
|
+
chunks.push({
|
|
1396
|
+
title: partTitle,
|
|
1397
|
+
content: part,
|
|
1398
|
+
hasCode: part.includes("```"),
|
|
1399
|
+
});
|
|
1400
|
+
accumulator = [];
|
|
1401
|
+
};
|
|
1402
|
+
for (const para of paragraphs) {
|
|
1403
|
+
accumulator.push(para);
|
|
1404
|
+
const candidate = accumulator.join("\n\n");
|
|
1405
|
+
if (Buffer.byteLength(candidate) > maxChunkBytes && accumulator.length > 1) {
|
|
1406
|
+
accumulator.pop();
|
|
1407
|
+
flushAccumulator();
|
|
1408
|
+
accumulator = [para];
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
flushAccumulator();
|
|
1412
|
+
currentContent = [];
|
|
1413
|
+
};
|
|
1414
|
+
let i = 0;
|
|
1415
|
+
while (i < lines.length) {
|
|
1416
|
+
const line = lines[i];
|
|
1417
|
+
// Horizontal rule separator (Context7 uses long dashes)
|
|
1418
|
+
if (/^[-_*]{3,}\s*$/.test(line)) {
|
|
1419
|
+
flush();
|
|
1420
|
+
i++;
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
// Heading (H1-H4)
|
|
1424
|
+
const headingMatch = line.match(/^(#{1,4})\s+(.+)$/);
|
|
1425
|
+
if (headingMatch) {
|
|
1426
|
+
flush();
|
|
1427
|
+
const level = headingMatch[1].length;
|
|
1428
|
+
const heading = headingMatch[2].trim();
|
|
1429
|
+
// Pop deeper levels from stack
|
|
1430
|
+
while (headingStack.length > 0 &&
|
|
1431
|
+
headingStack[headingStack.length - 1].level >= level) {
|
|
1432
|
+
headingStack.pop();
|
|
1433
|
+
}
|
|
1434
|
+
headingStack.push({ level, text: heading });
|
|
1435
|
+
currentHeading = heading;
|
|
1436
|
+
currentContent.push(line);
|
|
1437
|
+
i++;
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
// Code block — collect entire block as a unit
|
|
1441
|
+
const codeMatch = line.match(/^(`{3,})(.*)?$/);
|
|
1442
|
+
if (codeMatch) {
|
|
1443
|
+
const fence = codeMatch[1];
|
|
1444
|
+
const codeLines = [line];
|
|
1445
|
+
i++;
|
|
1446
|
+
while (i < lines.length) {
|
|
1447
|
+
codeLines.push(lines[i]);
|
|
1448
|
+
if (lines[i].startsWith(fence) && lines[i].trim() === fence) {
|
|
1449
|
+
i++;
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
i++;
|
|
1453
|
+
}
|
|
1454
|
+
currentContent.push(...codeLines);
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
// Regular line
|
|
1458
|
+
currentContent.push(line);
|
|
1459
|
+
i++;
|
|
1460
|
+
}
|
|
1461
|
+
// Flush remaining content
|
|
1462
|
+
flush();
|
|
1463
|
+
return chunks;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Return the largest prefix of `str` whose UTF-8 byte length does not exceed
|
|
1467
|
+
* `maxBytes`, walking by Unicode code point so multibyte sequences (CJK) and
|
|
1468
|
+
* surrogate pairs (emoji) are never cut mid-character. Guarantees forward
|
|
1469
|
+
* progress: if even the first code point exceeds `maxBytes`, it is still
|
|
1470
|
+
* returned whole (a 1-4 byte overshoot beats an infinite loop).
|
|
1471
|
+
*/
|
|
1472
|
+
#byteCappedPrefix(str, maxBytes) {
|
|
1473
|
+
if (Buffer.byteLength(str) <= maxBytes)
|
|
1474
|
+
return str;
|
|
1475
|
+
let prefix = "";
|
|
1476
|
+
let bytes = 0;
|
|
1477
|
+
for (const char of str) {
|
|
1478
|
+
const charBytes = Buffer.byteLength(char);
|
|
1479
|
+
if (bytes + charBytes > maxBytes)
|
|
1480
|
+
break;
|
|
1481
|
+
prefix += char;
|
|
1482
|
+
bytes += charBytes;
|
|
1483
|
+
}
|
|
1484
|
+
// Defensive: a single code point wider than the cap (only possible with a
|
|
1485
|
+
// pathologically small maxBytes) still advances by one character.
|
|
1486
|
+
if (prefix.length === 0)
|
|
1487
|
+
return [...str][0] ?? "";
|
|
1488
|
+
return prefix;
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Split a single oversized plain-text chunk into byte-capped sub-chunks
|
|
1492
|
+
* by accumulating lines until the byte count would exceed maxChunkBytes.
|
|
1493
|
+
* Falls back to byte-accurate splitting for extremely long single lines.
|
|
1494
|
+
*/
|
|
1495
|
+
#splitOversizedPlainChunk(lines, titlePrefix, maxChunkBytes) {
|
|
1496
|
+
const subChunks = [];
|
|
1497
|
+
let accumulator = [];
|
|
1498
|
+
let partIndex = 1;
|
|
1499
|
+
const flushAccumulator = () => {
|
|
1500
|
+
if (accumulator.length === 0)
|
|
1501
|
+
return;
|
|
1502
|
+
const content = accumulator.join("\n");
|
|
1503
|
+
const partTitle = partIndex === 1 ? titlePrefix : `${titlePrefix} (${partIndex})`;
|
|
1504
|
+
subChunks.push({ title: partTitle, content });
|
|
1505
|
+
partIndex++;
|
|
1506
|
+
accumulator = [];
|
|
1507
|
+
};
|
|
1508
|
+
for (const line of lines) {
|
|
1509
|
+
// If a single line itself exceeds the cap (even as first line),
|
|
1510
|
+
// split it by character before accumulating
|
|
1511
|
+
if (Buffer.byteLength(line) > maxChunkBytes) {
|
|
1512
|
+
flushAccumulator();
|
|
1513
|
+
// Split the long line into byte-capped pieces
|
|
1514
|
+
let remaining = line;
|
|
1515
|
+
let linePart = 1;
|
|
1516
|
+
while (remaining.length > 0) {
|
|
1517
|
+
// Byte-accurate slice: never exceeds the cap, never cuts a multibyte
|
|
1518
|
+
// character (CJK) or surrogate pair (emoji) in half.
|
|
1519
|
+
let slice = this.#byteCappedPrefix(remaining, maxChunkBytes);
|
|
1520
|
+
// Try to break at a whitespace boundary near the end for readability,
|
|
1521
|
+
// but only when text remains after this slice.
|
|
1522
|
+
if (slice.length < remaining.length) {
|
|
1523
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
1524
|
+
const lastNewline = slice.lastIndexOf("\n");
|
|
1525
|
+
const breakPoint = Math.max(lastSpace, lastNewline);
|
|
1526
|
+
if (breakPoint > slice.length * WHITESPACE_BREAK_RATIO) {
|
|
1527
|
+
slice = slice.slice(0, breakPoint);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const linePartTitle = partIndex === 1 && linePart === 1
|
|
1531
|
+
? titlePrefix
|
|
1532
|
+
: `${titlePrefix} (${partIndex}.${linePart})`;
|
|
1533
|
+
subChunks.push({ title: linePartTitle, content: slice });
|
|
1534
|
+
remaining = remaining.slice(slice.length);
|
|
1535
|
+
linePart++;
|
|
1536
|
+
partIndex++;
|
|
1537
|
+
}
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
const candidate = accumulator.length > 0
|
|
1541
|
+
? accumulator.join("\n") + "\n" + line
|
|
1542
|
+
: line;
|
|
1543
|
+
// If adding this line would exceed the cap, flush accumulator first
|
|
1544
|
+
if (Buffer.byteLength(candidate) > maxChunkBytes && accumulator.length > 0) {
|
|
1545
|
+
flushAccumulator();
|
|
1546
|
+
}
|
|
1547
|
+
accumulator.push(line);
|
|
1548
|
+
}
|
|
1549
|
+
flushAccumulator();
|
|
1550
|
+
return subChunks;
|
|
1551
|
+
}
|
|
1552
|
+
#chunkPlainText(text, linesPerChunk, maxChunkBytes = MAX_CHUNK_BYTES) {
|
|
1553
|
+
// Try blank-line splitting first for naturally-sectioned output
|
|
1554
|
+
const sections = text.split(/\n\s*\n/);
|
|
1555
|
+
if (sections.length >= MIN_BLANK_LINE_SECTIONS &&
|
|
1556
|
+
sections.length <= MAX_BLANK_LINE_SECTIONS &&
|
|
1557
|
+
sections.every((s) => Buffer.byteLength(s) < BLANK_SECTION_STRATEGY_MAX_BYTES)) {
|
|
1558
|
+
return sections.flatMap((section, i) => {
|
|
1559
|
+
const trimmed = section.trim();
|
|
1560
|
+
if (trimmed.length === 0)
|
|
1561
|
+
return [];
|
|
1562
|
+
const title = trimmed.split("\n")[0].slice(0, CHUNK_TITLE_MAX_CHARS) || `Section ${i + 1}`;
|
|
1563
|
+
// A section may pass the strategy guard yet still exceed the byte cap
|
|
1564
|
+
// (4097–4999B band): sub-split it so no stored chunk breaks the cap.
|
|
1565
|
+
if (Buffer.byteLength(trimmed) <= maxChunkBytes) {
|
|
1566
|
+
return [{ title, content: trimmed }];
|
|
1567
|
+
}
|
|
1568
|
+
return this.#splitOversizedPlainChunk(trimmed.split("\n"), title, maxChunkBytes);
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
const lines = text.split("\n");
|
|
1572
|
+
// Small enough for a single chunk — but still enforce byte cap
|
|
1573
|
+
if (lines.length <= linesPerChunk) {
|
|
1574
|
+
if (Buffer.byteLength(text) <= maxChunkBytes) {
|
|
1575
|
+
return [{ title: "Output", content: text }];
|
|
1576
|
+
}
|
|
1577
|
+
return this.#splitOversizedPlainChunk(lines, "Output", maxChunkBytes);
|
|
1578
|
+
}
|
|
1579
|
+
// Fixed-size line groups with 2-line overlap
|
|
1580
|
+
const chunks = [];
|
|
1581
|
+
const overlap = 2;
|
|
1582
|
+
const step = Math.max(linesPerChunk - overlap, 1);
|
|
1583
|
+
for (let i = 0; i < lines.length; i += step) {
|
|
1584
|
+
const slice = lines.slice(i, i + linesPerChunk);
|
|
1585
|
+
if (slice.length === 0)
|
|
1586
|
+
break;
|
|
1587
|
+
const startLine = i + 1;
|
|
1588
|
+
const endLine = Math.min(i + slice.length, lines.length);
|
|
1589
|
+
const firstLine = slice[0]?.trim().slice(0, CHUNK_TITLE_MAX_CHARS);
|
|
1590
|
+
const joined = slice.join("\n");
|
|
1591
|
+
// Enforce byte cap: sub-split oversized line-group chunks
|
|
1592
|
+
if (Buffer.byteLength(joined) <= maxChunkBytes) {
|
|
1593
|
+
chunks.push({
|
|
1594
|
+
title: firstLine || `Lines ${startLine}-${endLine}`,
|
|
1595
|
+
content: joined,
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
else {
|
|
1599
|
+
const subChunks = this.#splitOversizedPlainChunk(slice, firstLine || `Lines ${startLine}-${endLine}`, maxChunkBytes);
|
|
1600
|
+
chunks.push(...subChunks);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return chunks;
|
|
1604
|
+
}
|
|
1605
|
+
#walkJSON(value, path, chunks, maxChunkBytes) {
|
|
1606
|
+
const title = path.length > 0 ? path.join(" > ") : "(root)";
|
|
1607
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
1608
|
+
// Small enough — emit as a single chunk
|
|
1609
|
+
if (Buffer.byteLength(serialized) <= maxChunkBytes) {
|
|
1610
|
+
// Exception: objects with nested structure (object/array values) always
|
|
1611
|
+
// recurse so that key paths become chunk titles for searchability —
|
|
1612
|
+
// even when the subtree fits in one chunk. Flat objects (all primitive
|
|
1613
|
+
// values) stay as a single chunk since there's no hierarchy to expose.
|
|
1614
|
+
const shouldRecurse = typeof value === "object" &&
|
|
1615
|
+
value !== null &&
|
|
1616
|
+
!Array.isArray(value) &&
|
|
1617
|
+
Object.values(value).some((v) => typeof v === "object" && v !== null);
|
|
1618
|
+
if (!shouldRecurse) {
|
|
1619
|
+
chunks.push({ title, content: serialized, hasCode: true });
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
// Object — recurse into each key
|
|
1624
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1625
|
+
const entries = Object.entries(value);
|
|
1626
|
+
if (entries.length > 0) {
|
|
1627
|
+
for (const [key, val] of entries) {
|
|
1628
|
+
this.#walkJSON(val, [...path, key], chunks, maxChunkBytes);
|
|
1629
|
+
}
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
// Empty object — emit as-is
|
|
1633
|
+
chunks.push({ title, content: serialized, hasCode: true });
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// Array — batch by size with identity-field-aware titles
|
|
1637
|
+
if (Array.isArray(value)) {
|
|
1638
|
+
this.#chunkJSONArray(value, path, chunks, maxChunkBytes);
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
// Primitive that exceeds maxChunkBytes (e.g., very long string)
|
|
1642
|
+
chunks.push({ title, content: serialized, hasCode: false });
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Scan the first element of an array of objects for a recognizable
|
|
1646
|
+
* identity field. Returns the field name or null.
|
|
1647
|
+
*/
|
|
1648
|
+
#findIdentityField(arr) {
|
|
1649
|
+
if (arr.length === 0)
|
|
1650
|
+
return null;
|
|
1651
|
+
const first = arr[0];
|
|
1652
|
+
if (typeof first !== "object" || first === null || Array.isArray(first))
|
|
1653
|
+
return null;
|
|
1654
|
+
const candidates = ["id", "name", "title", "path", "slug", "key", "label"];
|
|
1655
|
+
const obj = first;
|
|
1656
|
+
for (const field of candidates) {
|
|
1657
|
+
if (field in obj && (typeof obj[field] === "string" || typeof obj[field] === "number")) {
|
|
1658
|
+
return field;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
#jsonBatchTitle(prefix, startIdx, endIdx, batch, identityField) {
|
|
1664
|
+
const sep = prefix ? `${prefix} > ` : "";
|
|
1665
|
+
if (!identityField) {
|
|
1666
|
+
return startIdx === endIdx
|
|
1667
|
+
? `${sep}[${startIdx}]`
|
|
1668
|
+
: `${sep}[${startIdx}-${endIdx}]`;
|
|
1669
|
+
}
|
|
1670
|
+
const getId = (item) => String(item[identityField]);
|
|
1671
|
+
if (batch.length === 1) {
|
|
1672
|
+
return `${sep}${getId(batch[0])}`;
|
|
1673
|
+
}
|
|
1674
|
+
if (batch.length <= 3) {
|
|
1675
|
+
return sep + batch.map(getId).join(", ");
|
|
1676
|
+
}
|
|
1677
|
+
return `${sep}${getId(batch[0])}\u2026${getId(batch[batch.length - 1])}`;
|
|
1678
|
+
}
|
|
1679
|
+
#chunkJSONArray(arr, path, chunks, maxChunkBytes) {
|
|
1680
|
+
const prefix = path.length > 0 ? path.join(" > ") : "(root)";
|
|
1681
|
+
const identityField = this.#findIdentityField(arr);
|
|
1682
|
+
let batch = [];
|
|
1683
|
+
let batchStart = 0;
|
|
1684
|
+
const flushBatch = (batchEnd) => {
|
|
1685
|
+
if (batch.length === 0)
|
|
1686
|
+
return;
|
|
1687
|
+
const title = this.#jsonBatchTitle(prefix, batchStart, batchEnd, batch, identityField);
|
|
1688
|
+
chunks.push({
|
|
1689
|
+
title,
|
|
1690
|
+
content: JSON.stringify(batch, null, 2),
|
|
1691
|
+
hasCode: true,
|
|
1692
|
+
});
|
|
1693
|
+
};
|
|
1694
|
+
for (let i = 0; i < arr.length; i++) {
|
|
1695
|
+
batch.push(arr[i]);
|
|
1696
|
+
const candidate = JSON.stringify(batch, null, 2);
|
|
1697
|
+
if (Buffer.byteLength(candidate) > maxChunkBytes && batch.length > 1) {
|
|
1698
|
+
batch.pop();
|
|
1699
|
+
flushBatch(i - 1);
|
|
1700
|
+
batch = [arr[i]];
|
|
1701
|
+
batchStart = i;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
// Flush remaining
|
|
1705
|
+
flushBatch(batchStart + batch.length - 1);
|
|
1706
|
+
}
|
|
1707
|
+
#buildTitle(headingStack, currentHeading) {
|
|
1708
|
+
if (headingStack.length === 0) {
|
|
1709
|
+
return currentHeading || "Untitled";
|
|
1710
|
+
}
|
|
1711
|
+
return headingStack.map((h) => h.text).join(" > ");
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
_a = ContentStore;
|