@bahulam/code 0.1.1
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/README.md +93 -0
- package/package.json +56 -0
- package/pulse/app/activity/page.tsx +190 -0
- package/pulse/app/api/activity/route.ts +138 -0
- package/pulse/app/api/benchmark/route.ts +113 -0
- package/pulse/app/api/benchmarks/route.ts +195 -0
- package/pulse/app/api/costs/route.ts +88 -0
- package/pulse/app/api/export/route.ts +77 -0
- package/pulse/app/api/history/route.ts +11 -0
- package/pulse/app/api/import/route.ts +31 -0
- package/pulse/app/api/memory/route.ts +50 -0
- package/pulse/app/api/plans/route.ts +9 -0
- package/pulse/app/api/projects/[slug]/route.ts +96 -0
- package/pulse/app/api/projects/route.ts +121 -0
- package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
- package/pulse/app/api/sessions/[id]/route.ts +31 -0
- package/pulse/app/api/sessions/route.ts +112 -0
- package/pulse/app/api/settings/route.ts +14 -0
- package/pulse/app/api/stats/route.ts +143 -0
- package/pulse/app/api/todos/route.ts +9 -0
- package/pulse/app/api/tools/route.ts +160 -0
- package/pulse/app/benchmarks/page.tsx +224 -0
- package/pulse/app/costs/page.tsx +179 -0
- package/pulse/app/export/page.tsx +465 -0
- package/pulse/app/favicon.ico +0 -0
- package/pulse/app/globals.css +263 -0
- package/pulse/app/help/page.tsx +143 -0
- package/pulse/app/history/page.tsx +157 -0
- package/pulse/app/layout.tsx +46 -0
- package/pulse/app/memory/page.tsx +365 -0
- package/pulse/app/overview-client.tsx +393 -0
- package/pulse/app/page.tsx +14 -0
- package/pulse/app/plans/page.tsx +308 -0
- package/pulse/app/projects/[slug]/page.tsx +390 -0
- package/pulse/app/projects/page.tsx +110 -0
- package/pulse/app/sessions/[id]/page.tsx +243 -0
- package/pulse/app/sessions/page.tsx +39 -0
- package/pulse/app/settings/page.tsx +188 -0
- package/pulse/app/todos/page.tsx +211 -0
- package/pulse/app/tools/page.tsx +249 -0
- package/pulse/cli.js +164 -0
- package/pulse/components/activity/day-of-week-chart.tsx +35 -0
- package/pulse/components/activity/streak-card.tsx +36 -0
- package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
- package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
- package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
- package/pulse/components/costs/model-token-table.tsx +60 -0
- package/pulse/components/global-search.tsx +193 -0
- package/pulse/components/keyboard-nav-provider.tsx +23 -0
- package/pulse/components/layout/bottom-nav.tsx +53 -0
- package/pulse/components/layout/client-layout.tsx +31 -0
- package/pulse/components/layout/sidebar-context.tsx +50 -0
- package/pulse/components/layout/sidebar.tsx +183 -0
- package/pulse/components/layout/top-bar.tsx +121 -0
- package/pulse/components/overview/activity-heatmap.tsx +107 -0
- package/pulse/components/overview/conversation-table.tsx +148 -0
- package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
- package/pulse/components/overview/peak-hours-chart.tsx +87 -0
- package/pulse/components/overview/project-activity-donut.tsx +96 -0
- package/pulse/components/overview/stat-card.tsx +102 -0
- package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
- package/pulse/components/projects/project-card.tsx +175 -0
- package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
- package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
- package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
- package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
- package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
- package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
- package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
- package/pulse/components/sessions/session-badges.tsx +49 -0
- package/pulse/components/sessions/session-table.tsx +299 -0
- package/pulse/components/theme-provider.tsx +44 -0
- package/pulse/components/tools/feature-adoption-table.tsx +58 -0
- package/pulse/components/tools/mcp-server-panel.tsx +45 -0
- package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
- package/pulse/components/tools/version-history-table.tsx +32 -0
- package/pulse/components/ui/alert.tsx +66 -0
- package/pulse/components/ui/badge.tsx +48 -0
- package/pulse/components/ui/breadcrumb.tsx +109 -0
- package/pulse/components/ui/button.tsx +64 -0
- package/pulse/components/ui/calendar.tsx +220 -0
- package/pulse/components/ui/card.tsx +92 -0
- package/pulse/components/ui/command.tsx +158 -0
- package/pulse/components/ui/dialog.tsx +158 -0
- package/pulse/components/ui/input.tsx +21 -0
- package/pulse/components/ui/popover.tsx +89 -0
- package/pulse/components/ui/progress.tsx +31 -0
- package/pulse/components/ui/select.tsx +190 -0
- package/pulse/components/ui/separator.tsx +28 -0
- package/pulse/components/ui/sheet.tsx +143 -0
- package/pulse/components/ui/skeleton.tsx +13 -0
- package/pulse/components/ui/table.tsx +116 -0
- package/pulse/components/ui/tabs.tsx +91 -0
- package/pulse/components/ui/tooltip.tsx +57 -0
- package/pulse/components/use-global-keyboard-nav.ts +79 -0
- package/pulse/components.json +23 -0
- package/pulse/eslint.config.mjs +18 -0
- package/pulse/lib/bahulam-paths.ts +23 -0
- package/pulse/lib/claude-reader.ts +592 -0
- package/pulse/lib/decode.ts +129 -0
- package/pulse/lib/pricing.ts +102 -0
- package/pulse/lib/replay-parser.ts +165 -0
- package/pulse/lib/tool-categories.ts +127 -0
- package/pulse/lib/utils.ts +6 -0
- package/pulse/next-env.d.ts +6 -0
- package/pulse/next.config.ts +16 -0
- package/pulse/package.json +45 -0
- package/pulse/postcss.config.mjs +7 -0
- package/pulse/public/activity.png +0 -0
- package/pulse/public/cc-lens.png +0 -0
- package/pulse/public/command-k.png +0 -0
- package/pulse/public/costs.png +0 -0
- package/pulse/public/dashboard-dark.png +0 -0
- package/pulse/public/dashboard-white.png +0 -0
- package/pulse/public/export.png +0 -0
- package/pulse/public/file.svg +1 -0
- package/pulse/public/globe.svg +1 -0
- package/pulse/public/next.svg +1 -0
- package/pulse/public/projects.png +0 -0
- package/pulse/public/session-chat.png +0 -0
- package/pulse/public/todos.png +0 -0
- package/pulse/public/tools.png +0 -0
- package/pulse/public/vercel.svg +1 -0
- package/pulse/public/window.svg +1 -0
- package/pulse/tsconfig.json +34 -0
- package/pulse/types/claude.ts +294 -0
- package/src/agents/loader.mjs +94 -0
- package/src/agents/multi_workflow_loader.mjs +330 -0
- package/src/agents/parser.mjs +205 -0
- package/src/agents/scaffold.mjs +223 -0
- package/src/agents/teams.mjs +123 -0
- package/src/agents/workflow_loader.mjs +122 -0
- package/src/agents/workflow_scaffold.mjs +249 -0
- package/src/auth/oauth.mjs +220 -0
- package/src/auth/tarang-auth.mjs +312 -0
- package/src/commands/agent.mjs +221 -0
- package/src/commands/workflow.mjs +581 -0
- package/src/config/cli-args.mjs +202 -0
- package/src/config/env.mjs +263 -0
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/model-catalog.mjs +57 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/config/settings.mjs +132 -0
- package/src/context/ast-parser.mjs +298 -0
- package/src/context/bm25.mjs +85 -0
- package/src/context/prose-chunker.mjs +255 -0
- package/src/context/retriever.mjs +425 -0
- package/src/context/skeleton.mjs +134 -0
- package/src/context/symbol-indexer.mjs +375 -0
- package/src/core/agent-history.mjs +111 -0
- package/src/core/agent-loop.mjs +486 -0
- package/src/core/approval-log.mjs +145 -0
- package/src/core/approval.mjs +700 -0
- package/src/core/attachments.mjs +666 -0
- package/src/core/backend-url.mjs +68 -0
- package/src/core/bundled-runtime.mjs +418 -0
- package/src/core/cache-control.mjs +92 -0
- package/src/core/cache.mjs +105 -0
- package/src/core/callback-client.mjs +180 -0
- package/src/core/checkpoints.mjs +142 -0
- package/src/core/compact-history.mjs +127 -0
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/context-manager.mjs +198 -0
- package/src/core/error-guidance.mjs +331 -0
- package/src/core/file-diff.mjs +217 -0
- package/src/core/headless.mjs +460 -0
- package/src/core/hooks-manager.mjs +87 -0
- package/src/core/jsonl-writer.mjs +449 -0
- package/src/core/local-agent.mjs +538 -0
- package/src/core/local-store.mjs +836 -0
- package/src/core/mode-selector.mjs +51 -0
- package/src/core/output-filter.mjs +177 -0
- package/src/core/paths.mjs +190 -0
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/pricing.mjs +336 -0
- package/src/core/project-artifacts.mjs +39 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/providers.mjs +219 -0
- package/src/core/rate-limit-display.mjs +121 -0
- package/src/core/rate-limiter.mjs +119 -0
- package/src/core/resume-mode.mjs +192 -0
- package/src/core/risk-tier.mjs +388 -0
- package/src/core/safety.mjs +260 -0
- package/src/core/scheduler.mjs +173 -0
- package/src/core/session-manager.mjs +360 -0
- package/src/core/session.mjs +143 -0
- package/src/core/settings-sync.mjs +85 -0
- package/src/core/stagnation.mjs +57 -0
- package/src/core/stream-client.mjs +957 -0
- package/src/core/streaming.mjs +182 -0
- package/src/core/system-prompt.mjs +140 -0
- package/src/core/tasks.mjs +196 -0
- package/src/core/tool-executor.mjs +2231 -0
- package/src/core/trust.mjs +160 -0
- package/src/core/work-scope.mjs +248 -0
- package/src/hooks/engine.mjs +162 -0
- package/src/mcp/client.mjs +253 -0
- package/src/mcp/transport-shttp.mjs +130 -0
- package/src/mcp/transport-sse.mjs +131 -0
- package/src/mcp/transport-ws.mjs +134 -0
- package/src/onboarding/preflight.mjs +374 -0
- package/src/permissions/checker.mjs +57 -0
- package/src/permissions/command-classifier.mjs +700 -0
- package/src/permissions/injection-check.mjs +60 -0
- package/src/permissions/path-check.mjs +102 -0
- package/src/permissions/prompt.mjs +73 -0
- package/src/permissions/sandbox.mjs +112 -0
- package/src/plugins/loader.mjs +138 -0
- package/src/skills/installer.mjs +188 -0
- package/src/skills/loader.mjs +252 -0
- package/src/skills/runner.mjs +55 -0
- package/src/state/orbit.mjs +263 -0
- package/src/state/verbosity.mjs +99 -0
- package/src/telemetry/index.mjs +122 -0
- package/src/terminal/agents.mjs +353 -0
- package/src/terminal/analytics.mjs +292 -0
- package/src/terminal/ansi.mjs +695 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +310 -0
- package/src/terminal/repl-ask-form.mjs +120 -0
- package/src/terminal/repl-explore.mjs +44 -0
- package/src/terminal/repl-format.mjs +317 -0
- package/src/terminal/repl-model-form.mjs +132 -0
- package/src/terminal/repl-render.mjs +833 -0
- package/src/terminal/repl-resume.mjs +640 -0
- package/src/terminal/repl-state.mjs +120 -0
- package/src/terminal/repl-utils.mjs +34 -0
- package/src/terminal/repl.mjs +5032 -0
- package/src/terminal/skills.mjs +54 -0
- package/src/terminal/tool-display.mjs +392 -0
- package/src/tools/agent.mjs +137 -0
- package/src/tools/ask-user.mjs +61 -0
- package/src/tools/bash.mjs +231 -0
- package/src/tools/cron-create.mjs +120 -0
- package/src/tools/cron-delete.mjs +49 -0
- package/src/tools/cron-list.mjs +37 -0
- package/src/tools/edit.mjs +82 -0
- package/src/tools/enter-worktree.mjs +69 -0
- package/src/tools/exit-worktree.mjs +57 -0
- package/src/tools/glob.mjs +117 -0
- package/src/tools/grep.mjs +129 -0
- package/src/tools/lint.mjs +71 -0
- package/src/tools/ls.mjs +58 -0
- package/src/tools/lsp.mjs +115 -0
- package/src/tools/multi-edit.mjs +94 -0
- package/src/tools/notebook-edit.mjs +96 -0
- package/src/tools/project-overview.mjs +703 -0
- package/src/tools/read-mcp-resource.mjs +57 -0
- package/src/tools/read.mjs +138 -0
- package/src/tools/registry.mjs +116 -0
- package/src/tools/remote-trigger.mjs +84 -0
- package/src/tools/send-message.mjs +64 -0
- package/src/tools/skill.mjs +52 -0
- package/src/tools/test-runner.mjs +49 -0
- package/src/tools/todo-write.mjs +68 -0
- package/src/tools/tool-search.mjs +77 -0
- package/src/tools/web-fetch.mjs +65 -0
- package/src/tools/web-search.mjs +89 -0
- package/src/tools/write.mjs +55 -0
- package/src/ui/approval.mjs +510 -0
- package/src/ui/banner.mjs +232 -0
- package/src/ui/commands.mjs +537 -0
- package/src/ui/formatter.mjs +409 -0
- package/src/ui/icons.mjs +170 -0
- package/src/ui/input-dock.mjs +772 -0
- package/src/ui/markdown.mjs +278 -0
- package/src/ui/mission-report.mjs +296 -0
- package/src/ui/palette.mjs +189 -0
- package/src/ui/render-queue.mjs +500 -0
- package/src/ui/slash-commands.mjs +257 -0
- package/src/ui/spinner.mjs +116 -0
- package/src/ui/sub-agent.mjs +167 -0
- package/src/ui/term.mjs +174 -0
- package/src/ui/text-layout.mjs +127 -0
- package/src/ui/tool-card.mjs +740 -0
- package/src/ui/tool-details.mjs +504 -0
- package/src/ui/transcript-block.mjs +20 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prose document chunker — JS port of the backend's Python chunker.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `codekepler-backend/app/agent/tools/shared/documents.py` so a
|
|
5
|
+
* file chunked here (CLI local path) and a file chunked server-side
|
|
6
|
+
* (chat upload or server `path` mode) produce byte-comparable chunks
|
|
7
|
+
* with the same page/chunk numbering. Callers can slice/reference
|
|
8
|
+
* chunks by (page_no, chunk_no) across surfaces.
|
|
9
|
+
*
|
|
10
|
+
* Constants come from Django's Phase 1 inline chunker
|
|
11
|
+
* (retail/chat_uploads/services.py) — the single source of truth for
|
|
12
|
+
* the whole platform.
|
|
13
|
+
*
|
|
14
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
* Retriever interface — this module produces chunks; a retriever ranks
|
|
16
|
+
* them. Today the CLI has one retriever (BM25 via
|
|
17
|
+
* src/context/retriever.mjs). Desktop apps and richer offline setups
|
|
18
|
+
* will add an embedding-backed retriever. Both consume the same chunk
|
|
19
|
+
* shape produced here so the swap is transparent to callers:
|
|
20
|
+
*
|
|
21
|
+
* Chunk (produced by this module):
|
|
22
|
+
* { page: number|null, chunk_no: number, text: string, tokens: number }
|
|
23
|
+
*
|
|
24
|
+
* Retriever (any implementation):
|
|
25
|
+
* addSource(sourceId, chunks): void
|
|
26
|
+
* search(query, { topK, sources? }): Array<{
|
|
27
|
+
* sourceId, page, chunk_no, text, score
|
|
28
|
+
* }>
|
|
29
|
+
* removeSource(sourceId): void // invalidate on file change
|
|
30
|
+
*
|
|
31
|
+
* The `read_attachment` tool talks to the retriever interface, never
|
|
32
|
+
* to a specific implementation. Adding embeddings later means writing
|
|
33
|
+
* an `EmbeddingRetriever` that satisfies this contract — no changes to
|
|
34
|
+
* the chunker or the tool.
|
|
35
|
+
* ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
*
|
|
37
|
+
* Divergences from Python (documented):
|
|
38
|
+
* - JS String indexing is UTF-16 code units, Python str is code points.
|
|
39
|
+
* For BMP-only text (nearly all documents) the boundaries match
|
|
40
|
+
* exactly. Non-BMP characters (emoji, some CJK) may fall a code unit
|
|
41
|
+
* off vs Python; not a correctness issue for retrieval.
|
|
42
|
+
* - TEXT_LIKE_MIMES is a JS-only superset of Python's TEXT_MIMES —
|
|
43
|
+
* the CLI has historically supported json/yaml/html/log/rst, and
|
|
44
|
+
* the chunker doesn't care about the surface syntax. Retrieval
|
|
45
|
+
* quality on structured files (json/yaml) will be worse than on
|
|
46
|
+
* prose; document that in the tool description, not here.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import * as fs from 'node:fs';
|
|
50
|
+
import * as path from 'node:path';
|
|
51
|
+
|
|
52
|
+
// Byte-exact match with documents.py:29-31.
|
|
53
|
+
export const CHUNK_TOKENS = 800;
|
|
54
|
+
export const CHUNK_OVERLAP = 100;
|
|
55
|
+
export const CHARS_PER_TOKEN = 4;
|
|
56
|
+
|
|
57
|
+
// Strict server-parity set — same 5 mimes as documents.py.
|
|
58
|
+
export const TEXT_MIMES = new Set([
|
|
59
|
+
'text/plain',
|
|
60
|
+
'text/markdown',
|
|
61
|
+
'text/csv',
|
|
62
|
+
'text/tab-separated-values',
|
|
63
|
+
]);
|
|
64
|
+
export const PDF_MIMES = new Set(['application/pdf']);
|
|
65
|
+
export const DOCUMENT_MIMES = new Set([...TEXT_MIMES, ...PDF_MIMES]);
|
|
66
|
+
|
|
67
|
+
// Wider set the CLI already supports at the `read_attachment` tool layer.
|
|
68
|
+
// Chunking works fine on any UTF-8 text — retrieval quality on structured
|
|
69
|
+
// formats is up to the retriever + query.
|
|
70
|
+
export const TEXT_LIKE_MIMES = new Set([
|
|
71
|
+
...TEXT_MIMES,
|
|
72
|
+
'application/json',
|
|
73
|
+
'application/x-yaml',
|
|
74
|
+
'application/toml',
|
|
75
|
+
'text/yaml',
|
|
76
|
+
'text/html',
|
|
77
|
+
'text/x-log',
|
|
78
|
+
'text/x-rst',
|
|
79
|
+
'text/x-restructuredtext',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// Extension → mime map. Kept small and explicit — Python's
|
|
83
|
+
// mimetypes.guess_type varies by OS registry; this table is stable.
|
|
84
|
+
const EXT_TO_MIME = new Map([
|
|
85
|
+
['.txt', 'text/plain'],
|
|
86
|
+
['.log', 'text/x-log'],
|
|
87
|
+
['.md', 'text/markdown'],
|
|
88
|
+
['.markdown', 'text/markdown'],
|
|
89
|
+
['.mdx', 'text/markdown'], // MDX = markdown + JSX; treat as prose for retrieval
|
|
90
|
+
['.csv', 'text/csv'],
|
|
91
|
+
['.tsv', 'text/tab-separated-values'],
|
|
92
|
+
['.json', 'application/json'],
|
|
93
|
+
['.yaml', 'application/x-yaml'],
|
|
94
|
+
['.yml', 'application/x-yaml'],
|
|
95
|
+
['.toml', 'application/toml'],
|
|
96
|
+
['.html', 'text/html'],
|
|
97
|
+
['.htm', 'text/html'],
|
|
98
|
+
['.rst', 'text/x-rst'],
|
|
99
|
+
['.pdf', 'application/pdf'],
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Best-effort mime for a local file (extension-only, no magic-byte sniff).
|
|
104
|
+
* Files with no matching extension return 'application/octet-stream',
|
|
105
|
+
* consistent with Python's mimetypes.
|
|
106
|
+
*/
|
|
107
|
+
export function guessMime(filePath) {
|
|
108
|
+
const ext = path.extname(String(filePath || '')).toLowerCase();
|
|
109
|
+
return EXT_TO_MIME.get(ext) || 'application/octet-stream';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Chunk a text string with the 800/100 sliding window (character-based).
|
|
114
|
+
* Byte-comparable to Python's chunk_text() — see documents.py:40.
|
|
115
|
+
*
|
|
116
|
+
* @param {string} text
|
|
117
|
+
* @returns {Array<{chunk_no: number, text: string, tokens: number}>}
|
|
118
|
+
*/
|
|
119
|
+
export function chunkText(text) {
|
|
120
|
+
if (!text) return [];
|
|
121
|
+
const stepChars = (CHUNK_TOKENS - CHUNK_OVERLAP) * CHARS_PER_TOKEN;
|
|
122
|
+
const window = CHUNK_TOKENS * CHARS_PER_TOKEN;
|
|
123
|
+
const out = [];
|
|
124
|
+
let i = 0;
|
|
125
|
+
let chunkNo = 0;
|
|
126
|
+
const len = text.length;
|
|
127
|
+
while (i < len) {
|
|
128
|
+
const segment = text.substring(i, i + window);
|
|
129
|
+
const tokens = Math.max(1, Math.floor(segment.length / CHARS_PER_TOKEN));
|
|
130
|
+
out.push({ chunk_no: chunkNo, text: segment, tokens });
|
|
131
|
+
chunkNo += 1;
|
|
132
|
+
i += stepChars;
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Extract per-page text from PDF bytes. Returns [{page, text}] with
|
|
139
|
+
* 1-indexed page numbers. Scanned/OCR-only pages come back with empty
|
|
140
|
+
* text — callers skip those (matches Python behavior).
|
|
141
|
+
*
|
|
142
|
+
* Uses the `pdf-parse` npm dep already in the CLI. Imported from
|
|
143
|
+
* `lib/pdf-parse.js` (not the default entry) to skip the debug-hook
|
|
144
|
+
* that opens a bundled test PDF at load time and fails in production.
|
|
145
|
+
*/
|
|
146
|
+
export async function extractPdfPages(buffer) {
|
|
147
|
+
const { default: pdfParse } = await import('pdf-parse/lib/pdf-parse.js');
|
|
148
|
+
const pageTexts = [];
|
|
149
|
+
try {
|
|
150
|
+
await pdfParse(buffer, {
|
|
151
|
+
// pdf-parse calls this per page in page order (pageIndex is 0-based).
|
|
152
|
+
// We accumulate into an array indexed by pageIndex to be defensive
|
|
153
|
+
// against any out-of-order rendering.
|
|
154
|
+
pagerender: async (pageData) => {
|
|
155
|
+
try {
|
|
156
|
+
const content = await pageData.getTextContent();
|
|
157
|
+
const text = (content.items || [])
|
|
158
|
+
.map(item => (typeof item.str === 'string' ? item.str : ''))
|
|
159
|
+
.join(' ');
|
|
160
|
+
const idx = typeof pageData.pageIndex === 'number' ? pageData.pageIndex : pageTexts.length;
|
|
161
|
+
pageTexts[idx] = text;
|
|
162
|
+
return text;
|
|
163
|
+
} catch {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return pageTexts.map((text, i) => ({ page: i + 1, text: text || '' }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Extract chunks from raw bytes. Returns the same shape as Python's
|
|
176
|
+
* extract_from_bytes(): [{page, chunk_no, text, tokens}].
|
|
177
|
+
*
|
|
178
|
+
* page is null for text mimes, 1-indexed for PDF pages.
|
|
179
|
+
* chunk_no is 0-indexed and monotonic across the whole document
|
|
180
|
+
* (matches Python `global_chunk` behavior at documents.py:109-115).
|
|
181
|
+
* Unsupported mimes return an empty array.
|
|
182
|
+
*
|
|
183
|
+
* @param {Buffer} buffer
|
|
184
|
+
* @param {string} mime
|
|
185
|
+
* @param {{textMimes?: Set<string>}} [opts] override which mimes are
|
|
186
|
+
* treated as chunkable text. Defaults to TEXT_LIKE_MIMES (CLI's
|
|
187
|
+
* permissive set). Pass TEXT_MIMES for strict server parity.
|
|
188
|
+
*/
|
|
189
|
+
export async function extractFromBytes(buffer, mime, opts = {}) {
|
|
190
|
+
const normalizedMime = String(mime || '').toLowerCase();
|
|
191
|
+
const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
|
|
192
|
+
|
|
193
|
+
if (textMimes.has(normalizedMime)) {
|
|
194
|
+
const text = buffer.toString('utf8');
|
|
195
|
+
return chunkText(text).map(c => ({
|
|
196
|
+
page: null,
|
|
197
|
+
chunk_no: c.chunk_no,
|
|
198
|
+
text: c.text,
|
|
199
|
+
tokens: c.tokens,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (PDF_MIMES.has(normalizedMime)) {
|
|
204
|
+
const pages = await extractPdfPages(buffer);
|
|
205
|
+
const out = [];
|
|
206
|
+
let globalChunk = 0;
|
|
207
|
+
for (const { page, text } of pages) {
|
|
208
|
+
if (!text || !text.trim()) continue;
|
|
209
|
+
for (const c of chunkText(text)) {
|
|
210
|
+
out.push({ page, chunk_no: globalChunk, text: c.text, tokens: c.tokens });
|
|
211
|
+
globalChunk += 1;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Read a local file and chunk it. Returns { mime, chunks }; both empty
|
|
222
|
+
* on unresolvable path or unsupported mime — same contract as Python's
|
|
223
|
+
* extract_from_path(). Path resolution is the caller's responsibility
|
|
224
|
+
* (the CLI uses projectRegistry.resolvePath to enforce workspace bounds
|
|
225
|
+
* before calling here).
|
|
226
|
+
*
|
|
227
|
+
* @param {string} absPath absolute, already-resolved file path
|
|
228
|
+
* @param {{textMimes?: Set<string>}} [opts]
|
|
229
|
+
* @returns {Promise<{mime: string, chunks: Array<object>}>}
|
|
230
|
+
*/
|
|
231
|
+
export async function extractFromPath(absPath, opts = {}) {
|
|
232
|
+
let stat;
|
|
233
|
+
try {
|
|
234
|
+
stat = fs.statSync(absPath);
|
|
235
|
+
} catch {
|
|
236
|
+
return { mime: '', chunks: [] };
|
|
237
|
+
}
|
|
238
|
+
if (!stat.isFile()) return { mime: '', chunks: [] };
|
|
239
|
+
|
|
240
|
+
const mime = guessMime(absPath);
|
|
241
|
+
const textMimes = opts.textMimes || TEXT_LIKE_MIMES;
|
|
242
|
+
if (!textMimes.has(mime) && !PDF_MIMES.has(mime)) {
|
|
243
|
+
return { mime, chunks: [] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let buffer;
|
|
247
|
+
try {
|
|
248
|
+
buffer = fs.readFileSync(absPath);
|
|
249
|
+
} catch {
|
|
250
|
+
return { mime, chunks: [] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const chunks = await extractFromBytes(buffer, mime, opts);
|
|
254
|
+
return { mime, chunks };
|
|
255
|
+
}
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Retriever — T20: Unified context retrieval with BM25.
|
|
3
|
+
* Indexes project files and retrieves relevant chunks for LLM context.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { BM25Index } from './bm25.mjs';
|
|
7
|
+
import { SymbolIndexer } from './symbol-indexer.mjs';
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { indexDir as getIndexDir } from '../core/paths.mjs';
|
|
11
|
+
|
|
12
|
+
const IGNORED_DIRS = new Set(['.git', 'node_modules', '.bahulam', '.kepler', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next']);
|
|
13
|
+
const CODE_EXTS = new Set(['.js', '.mjs', '.ts', '.tsx', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.cpp', '.h', '.css', '.html', '.json', '.yaml', '.yml', '.toml', '.md', '.sh']);
|
|
14
|
+
const SYMBOL_EXTS = new Set(['.py', '.js', '.mjs', '.ts', '.tsx', '.jsx', '.go', '.rs']);
|
|
15
|
+
const MAX_FILE_SIZE = 100_000; // 100KB
|
|
16
|
+
const CHUNK_LINES = 50;
|
|
17
|
+
const CHUNK_OVERLAP = 10;
|
|
18
|
+
|
|
19
|
+
export class ContextRetriever {
|
|
20
|
+
constructor(projectDir = process.cwd()) {
|
|
21
|
+
this.projectDir = projectDir;
|
|
22
|
+
this.indexDir = getIndexDir(projectDir);
|
|
23
|
+
this.index = null;
|
|
24
|
+
this.symbolIndexer = null;
|
|
25
|
+
this.chunkTexts = new Map(); // id → original text content
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build or rebuild the search index (BM25 chunks + symbol index). */
|
|
29
|
+
async buildIndex() {
|
|
30
|
+
const files = this._scanFiles(this.projectDir);
|
|
31
|
+
const documents = [];
|
|
32
|
+
|
|
33
|
+
// Symbol indexer for AST-based search
|
|
34
|
+
this.symbolIndexer = new SymbolIndexer();
|
|
35
|
+
await this.symbolIndexer.init();
|
|
36
|
+
|
|
37
|
+
for (const filePath of files) {
|
|
38
|
+
try {
|
|
39
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
40
|
+
const relPath = path.relative(this.projectDir, filePath);
|
|
41
|
+
|
|
42
|
+
// BM25 chunks (existing behavior)
|
|
43
|
+
const chunks = this._chunkFile(content, relPath);
|
|
44
|
+
documents.push(...chunks);
|
|
45
|
+
|
|
46
|
+
// Symbol extraction for code files
|
|
47
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
48
|
+
if (SYMBOL_EXTS.has(ext)) {
|
|
49
|
+
await this.symbolIndexer.indexFile(relPath, content);
|
|
50
|
+
}
|
|
51
|
+
} catch { /* skip unreadable files */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.index = new BM25Index();
|
|
55
|
+
this.index.buildIndex(documents);
|
|
56
|
+
|
|
57
|
+
// Store chunk texts for retrieval
|
|
58
|
+
this.chunkTexts = new Map();
|
|
59
|
+
for (const doc of documents) {
|
|
60
|
+
this.chunkTexts.set(doc.id, doc.text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Persist
|
|
64
|
+
if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
|
|
65
|
+
fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
|
|
66
|
+
fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
|
|
67
|
+
fs.writeFileSync(path.join(this.indexDir, 'symbols.json'), JSON.stringify(this.symbolIndexer.toJSON()));
|
|
68
|
+
|
|
69
|
+
return { fileCount: files.length, chunkCount: documents.length, symbolCount: this.symbolIndexer.symbolCount };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Incrementally update index for a single changed file.
|
|
74
|
+
* Re-chunks the file and replaces its entries in the BM25 index.
|
|
75
|
+
* ~5-50ms per file — safe to call after every edit/write.
|
|
76
|
+
*/
|
|
77
|
+
updateFile(filePath) {
|
|
78
|
+
if (!this.index) {
|
|
79
|
+
if (!this.loadIndex()) return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const absPath = path.resolve(filePath);
|
|
83
|
+
const relPath = path.relative(this.projectDir, absPath);
|
|
84
|
+
|
|
85
|
+
// Remove old chunks for this file
|
|
86
|
+
const oldIds = new Set();
|
|
87
|
+
for (const doc of this.index.docs) {
|
|
88
|
+
if (doc.id === relPath || doc.id.startsWith(relPath + ':')) {
|
|
89
|
+
oldIds.add(doc.id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Collect remaining documents (excluding old chunks for this file)
|
|
94
|
+
const remainingDocs = [];
|
|
95
|
+
for (const doc of this.index.docs) {
|
|
96
|
+
if (!oldIds.has(doc.id)) {
|
|
97
|
+
// Reconstruct text from tf map for rebuild
|
|
98
|
+
remainingDocs.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Remove old chunk texts
|
|
103
|
+
for (const id of oldIds) this.chunkTexts.delete(id);
|
|
104
|
+
|
|
105
|
+
// Re-chunk if file still exists (delete = just remove)
|
|
106
|
+
const newChunks = [];
|
|
107
|
+
if (fs.existsSync(absPath)) {
|
|
108
|
+
try {
|
|
109
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
110
|
+
newChunks.push(...this._chunkFile(content, relPath));
|
|
111
|
+
} catch { /* skip unreadable */ }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Rebuild index from remaining + new chunks
|
|
115
|
+
// This is fast (~5-20ms) because BM25 build is O(n) on token count
|
|
116
|
+
const allDocs = [...remainingDocs, ...newChunks];
|
|
117
|
+
this.index = new BM25Index();
|
|
118
|
+
this.index.buildIndex(allDocs);
|
|
119
|
+
|
|
120
|
+
// Update chunk texts
|
|
121
|
+
for (const chunk of newChunks) {
|
|
122
|
+
this.chunkTexts.set(chunk.id, chunk.text);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Persist updated index
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
|
|
128
|
+
fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
|
|
129
|
+
fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
|
|
130
|
+
} catch { /* best-effort persist */ }
|
|
131
|
+
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Add already-chunked prose content (from prose-chunker.mjs) to the
|
|
137
|
+
* shared BM25 index — no re-read or re-chunking. Used by
|
|
138
|
+
* `read_attachment` to make docs discoverable by `search_code` /
|
|
139
|
+
* future `search_document` without a separate index.
|
|
140
|
+
*
|
|
141
|
+
* Chunk IDs are shaped `${sourceId}#c${chunk_no}` — the `#c`
|
|
142
|
+
* separator makes them distinguishable from code IDs (which use `:`)
|
|
143
|
+
* so `updateFile` for code files won't accidentally drop prose
|
|
144
|
+
* chunks and vice versa.
|
|
145
|
+
*
|
|
146
|
+
* Re-adding the same sourceId replaces its chunks (idempotent —
|
|
147
|
+
* safe to call on every `read_attachment`).
|
|
148
|
+
*
|
|
149
|
+
* @param {string} sourceId stable id, usually a project-relative path
|
|
150
|
+
* @param {Array<{page: (number|null), chunk_no: number, text: string, tokens?: number}>} chunks
|
|
151
|
+
* @returns {number} chunks indexed
|
|
152
|
+
*/
|
|
153
|
+
addProseChunks(sourceId, chunks) {
|
|
154
|
+
if (!Array.isArray(chunks) || chunks.length === 0) return 0;
|
|
155
|
+
if (!sourceId || typeof sourceId !== 'string') return 0;
|
|
156
|
+
|
|
157
|
+
if (!this.index) {
|
|
158
|
+
if (!this.loadIndex()) {
|
|
159
|
+
this.index = new BM25Index();
|
|
160
|
+
this.chunkTexts = new Map();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Drop prior chunks for this source (idempotent).
|
|
165
|
+
const sourcePrefix = `${sourceId}#c`;
|
|
166
|
+
const oldIds = new Set();
|
|
167
|
+
for (const doc of this.index.docs) {
|
|
168
|
+
if (doc.id.startsWith(sourcePrefix)) oldIds.add(doc.id);
|
|
169
|
+
}
|
|
170
|
+
for (const id of oldIds) this.chunkTexts.delete(id);
|
|
171
|
+
|
|
172
|
+
// Collect surviving docs — reconstruct text from stored chunkTexts
|
|
173
|
+
// (BM25Index only stores tf maps + lengths, not raw text).
|
|
174
|
+
const remaining = [];
|
|
175
|
+
for (const doc of this.index.docs) {
|
|
176
|
+
if (!oldIds.has(doc.id)) {
|
|
177
|
+
remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Prep new prose docs — prefix indexed text with sourceId so BM25
|
|
182
|
+
// gets signal from the filename (mirrors how _chunkFile embeds
|
|
183
|
+
// relPath). Page tag included so page-scoped queries can hit it.
|
|
184
|
+
const newDocs = chunks.map(c => {
|
|
185
|
+
const id = `${sourceId}#c${c.chunk_no}`;
|
|
186
|
+
const pageTag = c.page != null ? ` page:${c.page}` : '';
|
|
187
|
+
const indexedText = `${sourceId}${pageTag}\n${c.text}`;
|
|
188
|
+
return { id, text: indexedText };
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Rebuild — BM25 needs IDF recomputed across all docs.
|
|
192
|
+
this.index = new BM25Index();
|
|
193
|
+
this.index.buildIndex([...remaining, ...newDocs]);
|
|
194
|
+
for (const doc of newDocs) {
|
|
195
|
+
this.chunkTexts.set(doc.id, doc.text);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Persist. Best-effort — an unwritable indexDir shouldn't fail
|
|
199
|
+
// the tool call.
|
|
200
|
+
try {
|
|
201
|
+
if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
|
|
203
|
+
fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
|
|
204
|
+
} catch { /* best-effort persist */ }
|
|
205
|
+
|
|
206
|
+
return newDocs.length;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Remove all chunks belonging to a source (either code path or
|
|
211
|
+
* prose sourceId). Called when a file is deleted so stale hits
|
|
212
|
+
* don't linger in the index.
|
|
213
|
+
*/
|
|
214
|
+
removeSource(sourceId) {
|
|
215
|
+
if (!this.index) {
|
|
216
|
+
if (!this.loadIndex()) return 0;
|
|
217
|
+
}
|
|
218
|
+
const oldIds = new Set();
|
|
219
|
+
for (const doc of this.index.docs) {
|
|
220
|
+
// Code IDs: exact match or `sourceId:` prefix (line/AST chunks).
|
|
221
|
+
// Prose IDs: `sourceId#c` prefix.
|
|
222
|
+
if (
|
|
223
|
+
doc.id === sourceId
|
|
224
|
+
|| doc.id.startsWith(`${sourceId}:`)
|
|
225
|
+
|| doc.id.startsWith(`${sourceId}#c`)
|
|
226
|
+
) {
|
|
227
|
+
oldIds.add(doc.id);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (oldIds.size === 0) return 0;
|
|
231
|
+
for (const id of oldIds) this.chunkTexts.delete(id);
|
|
232
|
+
|
|
233
|
+
const remaining = [];
|
|
234
|
+
for (const doc of this.index.docs) {
|
|
235
|
+
if (!oldIds.has(doc.id)) {
|
|
236
|
+
remaining.push({ id: doc.id, text: this.chunkTexts.get(doc.id) || '' });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.index = new BM25Index();
|
|
241
|
+
this.index.buildIndex(remaining);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
if (!fs.existsSync(this.indexDir)) fs.mkdirSync(this.indexDir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(path.join(this.indexDir, 'bm25.json'), JSON.stringify(this.index.toJSON()));
|
|
246
|
+
fs.writeFileSync(path.join(this.indexDir, 'chunks.json'), JSON.stringify(Object.fromEntries(this.chunkTexts)));
|
|
247
|
+
} catch { /* best-effort */ }
|
|
248
|
+
|
|
249
|
+
return oldIds.size;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Load persisted index. */
|
|
253
|
+
loadIndex() {
|
|
254
|
+
const indexPath = path.join(this.indexDir, 'bm25.json');
|
|
255
|
+
const chunksPath = path.join(this.indexDir, 'chunks.json');
|
|
256
|
+
const symbolsPath = path.join(this.indexDir, 'symbols.json');
|
|
257
|
+
if (!fs.existsSync(indexPath)) return false;
|
|
258
|
+
try {
|
|
259
|
+
const data = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
260
|
+
this.index = BM25Index.fromJSON(data);
|
|
261
|
+
|
|
262
|
+
if (fs.existsSync(chunksPath)) {
|
|
263
|
+
const chunks = JSON.parse(fs.readFileSync(chunksPath, 'utf-8'));
|
|
264
|
+
this.chunkTexts = new Map(Object.entries(chunks));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (fs.existsSync(symbolsPath)) {
|
|
268
|
+
const symData = JSON.parse(fs.readFileSync(symbolsPath, 'utf-8'));
|
|
269
|
+
this.symbolIndexer = SymbolIndexer.fromJSON(symData);
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
} catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Search symbols (functions, classes, methods) by query.
|
|
279
|
+
* Returns structured results with file:line, signature, parent class.
|
|
280
|
+
*/
|
|
281
|
+
searchSymbols(query, topK = 5) {
|
|
282
|
+
if (!this.symbolIndexer) return [];
|
|
283
|
+
return this.symbolIndexer.search(query, topK);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Format symbol search results for the agent.
|
|
288
|
+
*/
|
|
289
|
+
formatSymbolResults(results) {
|
|
290
|
+
if (!this.symbolIndexer || !results.length) return '';
|
|
291
|
+
return this.symbolIndexer.formatResults(results);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Retrieve relevant context chunks for a query, with full text. */
|
|
295
|
+
retrieve(query, topK = 10) {
|
|
296
|
+
if (!this.index) {
|
|
297
|
+
if (!this.loadIndex()) return [];
|
|
298
|
+
}
|
|
299
|
+
const results = this.index.search(query, topK);
|
|
300
|
+
// Attach chunk text to results
|
|
301
|
+
return results.map(r => ({
|
|
302
|
+
...r,
|
|
303
|
+
text: this.chunkTexts.get(r.id) || `[File: ${r.id}]`,
|
|
304
|
+
}));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Scan project files respecting .gitignore-like patterns. */
|
|
308
|
+
_scanFiles(dir, depth = 0) {
|
|
309
|
+
if (depth > 15) return [];
|
|
310
|
+
const results = [];
|
|
311
|
+
let entries;
|
|
312
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; }
|
|
313
|
+
|
|
314
|
+
for (const entry of entries) {
|
|
315
|
+
if (entry.name.startsWith('.') && IGNORED_DIRS.has(entry.name)) continue;
|
|
316
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
317
|
+
|
|
318
|
+
const fullPath = path.join(dir, entry.name);
|
|
319
|
+
if (entry.isDirectory()) {
|
|
320
|
+
results.push(...this._scanFiles(fullPath, depth + 1));
|
|
321
|
+
} else if (entry.isFile()) {
|
|
322
|
+
const ext = path.extname(entry.name);
|
|
323
|
+
if (!CODE_EXTS.has(ext)) continue;
|
|
324
|
+
try {
|
|
325
|
+
const stat = fs.statSync(fullPath);
|
|
326
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
327
|
+
} catch { continue; }
|
|
328
|
+
results.push(fullPath);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return results;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Chunk a file by AST boundaries (functions/classes) with line-based fallback.
|
|
336
|
+
* AST-aware chunks contain complete functions/classes — not arbitrary 50-line blocks.
|
|
337
|
+
*/
|
|
338
|
+
_chunkFile(content, relPath) {
|
|
339
|
+
const lines = content.split('\n');
|
|
340
|
+
|
|
341
|
+
// Small files: single chunk
|
|
342
|
+
if (lines.length <= CHUNK_LINES) {
|
|
343
|
+
return [{ id: relPath, text: `${relPath}\n${content}` }];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Try AST-aware chunking: split at function/class boundaries
|
|
347
|
+
const boundaries = this._findASTBoundaries(lines);
|
|
348
|
+
|
|
349
|
+
if (boundaries.length > 1) {
|
|
350
|
+
// AST-aware: chunk at function/class boundaries
|
|
351
|
+
const chunks = [];
|
|
352
|
+
for (const { name, startLine, endLine } of boundaries) {
|
|
353
|
+
const chunk = lines.slice(startLine, endLine).join('\n');
|
|
354
|
+
const id = `${relPath}:${startLine + 1}:${name}`;
|
|
355
|
+
chunks.push({ id, text: `${relPath}:${startLine + 1} (${name})\n${chunk}` });
|
|
356
|
+
}
|
|
357
|
+
return chunks;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Fallback: line-based chunking for non-code files
|
|
361
|
+
const chunks = [];
|
|
362
|
+
for (let i = 0; i < lines.length; i += (CHUNK_LINES - CHUNK_OVERLAP)) {
|
|
363
|
+
const chunk = lines.slice(i, i + CHUNK_LINES).join('\n');
|
|
364
|
+
chunks.push({ id: `${relPath}:${i + 1}`, text: `${relPath}:${i + 1}\n${chunk}` });
|
|
365
|
+
}
|
|
366
|
+
return chunks;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Find function/class boundaries using regex patterns.
|
|
371
|
+
* Returns array of { name, startLine, endLine }.
|
|
372
|
+
*/
|
|
373
|
+
_findASTBoundaries(lines) {
|
|
374
|
+
const boundaries = [];
|
|
375
|
+
const patterns = [
|
|
376
|
+
// Python: def/class (indentation-based)
|
|
377
|
+
/^(?:async\s+)?def\s+(\w+)\s*\(/,
|
|
378
|
+
/^class\s+(\w+)/,
|
|
379
|
+
// JS/TS: function, class, export function
|
|
380
|
+
/^(?:export\s+)?(?:async\s+)?function\s+(\w+)/,
|
|
381
|
+
/^(?:export\s+)?class\s+(\w+)/,
|
|
382
|
+
// JS/TS: const fn = arrow
|
|
383
|
+
/^(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(/,
|
|
384
|
+
// Go: func
|
|
385
|
+
/^func\s+(?:\([^)]*\)\s+)?(\w+)\s*\(/,
|
|
386
|
+
// Rust: fn, struct, impl
|
|
387
|
+
/^(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/,
|
|
388
|
+
/^(?:pub\s+)?struct\s+(\w+)/,
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
let currentStart = 0;
|
|
392
|
+
let currentName = '_top_level';
|
|
393
|
+
|
|
394
|
+
for (let i = 0; i < lines.length; i++) {
|
|
395
|
+
const line = lines[i].trimStart();
|
|
396
|
+
for (const pattern of patterns) {
|
|
397
|
+
const match = line.match(pattern);
|
|
398
|
+
if (match) {
|
|
399
|
+
// Close previous boundary
|
|
400
|
+
if (i > currentStart) {
|
|
401
|
+
boundaries.push({
|
|
402
|
+
name: currentName,
|
|
403
|
+
startLine: currentStart,
|
|
404
|
+
endLine: i,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
currentStart = i;
|
|
408
|
+
currentName = match[1] || match[2] || 'unknown';
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Close last boundary
|
|
415
|
+
if (lines.length > currentStart) {
|
|
416
|
+
boundaries.push({
|
|
417
|
+
name: currentName,
|
|
418
|
+
startLine: currentStart,
|
|
419
|
+
endLine: lines.length,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return boundaries;
|
|
424
|
+
}
|
|
425
|
+
}
|