supipowers 1.2.6 → 1.5.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/README.md +118 -56
- package/bin/install.ts +48 -128
- package/package.json +11 -3
- package/skills/code-review/SKILL.md +137 -40
- package/skills/context-mode/SKILL.md +67 -56
- package/skills/creating-supi-agents/SKILL.md +204 -0
- package/skills/debugging/SKILL.md +86 -40
- package/skills/fix-pr/SKILL.md +96 -65
- package/skills/planning/SKILL.md +103 -46
- package/skills/qa-strategy/SKILL.md +68 -46
- package/skills/receiving-code-review/SKILL.md +60 -53
- package/skills/release/SKILL.md +111 -39
- package/skills/tdd/SKILL.md +118 -67
- package/skills/verification/SKILL.md +71 -37
- package/src/bootstrap.ts +27 -5
- package/src/commands/agents.ts +249 -0
- package/src/commands/ai-review.ts +1113 -0
- package/src/commands/config.ts +224 -95
- package/src/commands/doctor.ts +19 -13
- package/src/commands/fix-pr.ts +8 -11
- package/src/commands/generate.ts +200 -0
- package/src/commands/model-picker.ts +5 -15
- package/src/commands/model.ts +4 -5
- package/src/commands/optimize-context.ts +202 -0
- package/src/commands/plan.ts +148 -92
- package/src/commands/qa.ts +14 -23
- package/src/commands/release.ts +504 -275
- package/src/commands/review.ts +643 -86
- package/src/commands/status.ts +44 -17
- package/src/commands/supi.ts +69 -41
- package/src/commands/update.ts +57 -2
- package/src/config/defaults.ts +6 -39
- package/src/config/loader.ts +388 -40
- package/src/config/model-resolver.ts +26 -22
- package/src/config/schema.ts +113 -48
- package/src/context/analyzer.ts +61 -2
- package/src/context/optimizer.ts +199 -0
- package/src/context-mode/compressor.ts +14 -11
- package/src/context-mode/detector.ts +16 -54
- package/src/context-mode/event-extractor.ts +45 -16
- package/src/context-mode/event-store.ts +225 -16
- package/src/context-mode/hooks.ts +195 -22
- package/src/context-mode/knowledge/chunker.ts +235 -0
- package/src/context-mode/knowledge/store.ts +187 -0
- package/src/context-mode/routing.ts +12 -23
- package/src/context-mode/sandbox/executor.ts +183 -0
- package/src/context-mode/sandbox/runners.ts +40 -0
- package/src/context-mode/snapshot-builder.ts +243 -7
- package/src/context-mode/tools.ts +440 -0
- package/src/context-mode/web/fetcher.ts +117 -0
- package/src/context-mode/web/html-to-md.ts +293 -0
- package/src/debug/logger.ts +107 -0
- package/src/deps/registry.ts +0 -20
- package/src/docs/drift.ts +454 -0
- package/src/fix-pr/fetch-comments.ts +66 -0
- package/src/git/commit-msg.ts +2 -1
- package/src/git/commit.ts +123 -141
- package/src/git/conventions.ts +2 -2
- package/src/git/status.ts +4 -1
- package/src/lsp/bridge.ts +138 -12
- package/src/planning/approval-flow.ts +125 -19
- package/src/planning/plan-writer-prompt.ts +4 -11
- package/src/planning/planning-ask-tool.ts +81 -0
- package/src/planning/prompt-builder.ts +9 -169
- package/src/planning/system-prompt.ts +290 -0
- package/src/platform/omp.ts +50 -4
- package/src/platform/progress.ts +182 -0
- package/src/platform/test-utils.ts +4 -1
- package/src/platform/tui-colors.ts +30 -0
- package/src/platform/types.ts +1 -0
- package/src/qa/detect-app-type.ts +102 -0
- package/src/qa/discover-routes.ts +353 -0
- package/src/quality/ai-session.ts +96 -0
- package/src/quality/ai-setup.ts +86 -0
- package/src/quality/gates/ai-review.ts +129 -0
- package/src/quality/gates/build.ts +8 -0
- package/src/quality/gates/command.ts +150 -0
- package/src/quality/gates/format.ts +28 -0
- package/src/quality/gates/lint.ts +22 -0
- package/src/quality/gates/lsp-diagnostics.ts +84 -0
- package/src/quality/gates/test-suite.ts +8 -0
- package/src/quality/gates/typecheck.ts +22 -0
- package/src/quality/registry.ts +25 -0
- package/src/quality/review-gates.ts +33 -0
- package/src/quality/runner.ts +268 -0
- package/src/quality/schemas.ts +48 -0
- package/src/quality/setup.ts +227 -0
- package/src/release/changelog.ts +7 -3
- package/src/release/channels/custom.ts +43 -0
- package/src/release/channels/gitea.ts +35 -0
- package/src/release/channels/github.ts +35 -0
- package/src/release/channels/gitlab.ts +35 -0
- package/src/release/channels/registry.ts +52 -0
- package/src/release/channels/types.ts +27 -0
- package/src/release/detector.ts +10 -63
- package/src/release/executor.ts +61 -51
- package/src/release/prompt.ts +38 -38
- package/src/release/version.ts +129 -10
- package/src/review/agent-loader.ts +331 -0
- package/src/review/consolidator.ts +180 -0
- package/src/review/default-agents/correctness.md +72 -0
- package/src/review/default-agents/maintainability.md +64 -0
- package/src/review/default-agents/security.md +67 -0
- package/src/review/fixer.ts +219 -0
- package/src/review/multi-agent-runner.ts +135 -0
- package/src/review/output.ts +147 -0
- package/src/review/prompts/agent-review-wrapper.md +36 -0
- package/src/review/prompts/fix-findings.md +32 -0
- package/src/review/prompts/fix-output-schema.md +18 -0
- package/src/review/prompts/invalid-output-retry.md +22 -0
- package/src/review/prompts/output-instructions.md +14 -0
- package/src/review/prompts/review-output-schema.md +38 -0
- package/src/review/prompts/single-review.md +53 -0
- package/src/review/prompts/validation-review.md +30 -0
- package/src/review/runner.ts +128 -0
- package/src/review/scope.ts +353 -0
- package/src/review/template.ts +15 -0
- package/src/review/types.ts +296 -0
- package/src/review/validator.ts +160 -0
- package/src/storage/plans.ts +5 -3
- package/src/storage/reports.ts +50 -7
- package/src/storage/review-sessions.ts +117 -0
- package/src/text.ts +19 -0
- package/src/types.ts +336 -26
- package/src/utils/paths.ts +39 -0
- package/src/visual/companion.ts +5 -3
- package/src/visual/start-server.ts +101 -0
- package/src/visual/stop-server.ts +39 -0
- package/bin/ctx-mode-wrapper.mjs +0 -66
- package/src/config/profiles.ts +0 -64
- package/src/context-mode/installer.ts +0 -38
- package/src/quality/ai-review-gate.ts +0 -43
- package/src/quality/gate-runner.ts +0 -67
- package/src/quality/lsp-gate.ts +0 -24
- package/src/quality/test-gate.ts +0 -39
- package/src/visual/scripts/start-server.sh +0 -98
- package/src/visual/scripts/stop-server.sh +0 -21
|
@@ -7,10 +7,18 @@ import { EventStore } from "./event-store.js";
|
|
|
7
7
|
import { extractEvents, extractPromptEvents } from "./event-extractor.js";
|
|
8
8
|
import { buildResumeSnapshot } from "./snapshot-builder.js";
|
|
9
9
|
import { routeToolCall } from "./routing.js";
|
|
10
|
+
import { KnowledgeStore } from "./knowledge/store.js";
|
|
11
|
+
import { registerContextModeTools } from "./tools.js";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
10
13
|
import { readFileSync, mkdirSync } from "node:fs";
|
|
11
14
|
import { join, dirname } from "node:path";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
13
16
|
|
|
17
|
+
type SessionContextLike = {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
sessionManager?: { getSessionFile?: () => string } | null;
|
|
20
|
+
};
|
|
21
|
+
|
|
14
22
|
// Cached detection result
|
|
15
23
|
let cachedStatus: ContextModeStatus | null = null;
|
|
16
24
|
|
|
@@ -24,29 +32,135 @@ function loadRoutingSkill(): string | null {
|
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
function resolveSessionCwd(ctx?: SessionContextLike): string {
|
|
36
|
+
return typeof ctx?.cwd === "string" && ctx.cwd.length > 0 ? ctx.cwd : process.cwd();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deriveSessionId(ctx?: SessionContextLike): string {
|
|
40
|
+
try {
|
|
41
|
+
const sessionFile = ctx?.sessionManager?.getSessionFile?.();
|
|
42
|
+
if (typeof sessionFile === "string" && sessionFile.length > 0) {
|
|
43
|
+
return createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Best effort only — session lifecycle must never fail on ID derivation.
|
|
47
|
+
}
|
|
48
|
+
return `session-${Date.now()}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getSessionDbPath(platform: Platform, cwd: string): string {
|
|
52
|
+
return join(platform.paths.project(cwd, "sessions"), "events.db");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Register supi-context-mode hooks on the platform */
|
|
28
56
|
export function registerContextModeHooks(platform: Platform, config: SupipowersConfig): void {
|
|
29
57
|
if (!config.contextMode.enabled) return;
|
|
30
58
|
|
|
31
|
-
// Phase 2: Event store initialization
|
|
32
59
|
let eventStore: EventStore | null = null;
|
|
33
|
-
let
|
|
60
|
+
let eventStorePath: string | null = null;
|
|
61
|
+
let sessionCwd = process.cwd();
|
|
62
|
+
let sessionId = deriveSessionId();
|
|
63
|
+
|
|
64
|
+
const ensureEventStore = (cwd: string): EventStore | null => {
|
|
65
|
+
if (!config.contextMode.eventTracking) return null;
|
|
66
|
+
|
|
67
|
+
const dbPath = getSessionDbPath(platform, cwd);
|
|
68
|
+
if (eventStore && eventStorePath === dbPath) return eventStore;
|
|
69
|
+
|
|
70
|
+
if (eventStore) {
|
|
71
|
+
try {
|
|
72
|
+
eventStore.close();
|
|
73
|
+
} catch {
|
|
74
|
+
// Best effort — we are about to reopen against the active session path.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
34
77
|
|
|
35
|
-
if (config.contextMode.eventTracking) {
|
|
36
78
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
eventStore = new EventStore(join(dbDir, "events.db"));
|
|
79
|
+
mkdirSync(platform.paths.project(cwd, "sessions"), { recursive: true });
|
|
80
|
+
eventStore = new EventStore(dbPath);
|
|
40
81
|
eventStore.init();
|
|
82
|
+
eventStore.pruneOldSessions(7);
|
|
83
|
+
eventStorePath = dbPath;
|
|
84
|
+
_eventStoreRef = eventStore;
|
|
85
|
+
return eventStore;
|
|
41
86
|
} catch (e) {
|
|
42
|
-
|
|
87
|
+
eventStore = null;
|
|
88
|
+
eventStorePath = null;
|
|
89
|
+
_eventStoreRef = null;
|
|
90
|
+
(platform as any).logger?.error?.("supi-context-mode: failed to initialize event store", e);
|
|
91
|
+
return null;
|
|
43
92
|
}
|
|
44
|
-
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
ensureEventStore(sessionCwd);
|
|
45
96
|
|
|
46
|
-
// Update module-level refs for compaction hooks
|
|
47
|
-
_eventStoreRef = eventStore;
|
|
48
97
|
_sessionIdRef = sessionId;
|
|
49
98
|
|
|
99
|
+
// Initialize knowledge store for native ctx_* tools (independent of event tracking)
|
|
100
|
+
let knowledgeStore: KnowledgeStore | null = null;
|
|
101
|
+
try {
|
|
102
|
+
const sessionsDir = platform.paths.project(sessionCwd, "sessions");
|
|
103
|
+
mkdirSync(sessionsDir, { recursive: true });
|
|
104
|
+
const kdbPath = join(sessionsDir, "knowledge.db");
|
|
105
|
+
knowledgeStore = new KnowledgeStore(kdbPath);
|
|
106
|
+
knowledgeStore.init();
|
|
107
|
+
_knowledgeStoreRef = knowledgeStore;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
(platform as any).logger?.error?.("supi-context-mode: failed to initialize knowledge store", e);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Register native context-mode tools
|
|
113
|
+
if (knowledgeStore) {
|
|
114
|
+
registerContextModeTools(platform, knowledgeStore);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
platform.on("session_start", (_event, ctx) => {
|
|
118
|
+
sessionCwd = resolveSessionCwd(ctx as SessionContextLike | undefined);
|
|
119
|
+
sessionId = deriveSessionId(ctx as SessionContextLike | undefined);
|
|
120
|
+
_sessionIdRef = sessionId;
|
|
121
|
+
|
|
122
|
+
const store = ensureEventStore(sessionCwd);
|
|
123
|
+
if (!store) return;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
store.upsertMeta(sessionId, sessionCwd);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
(platform as any).logger?.warn?.("supi-context-mode: failed to initialize session metadata", e);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
platform.on("session_shutdown", () => {
|
|
133
|
+
// Close knowledge store
|
|
134
|
+
if (knowledgeStore) {
|
|
135
|
+
try {
|
|
136
|
+
knowledgeStore.close();
|
|
137
|
+
} catch {
|
|
138
|
+
// Best effort
|
|
139
|
+
} finally {
|
|
140
|
+
knowledgeStore = null;
|
|
141
|
+
_knowledgeStoreRef = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!eventStore) {
|
|
146
|
+
_eventStoreRef = null;
|
|
147
|
+
_sessionIdRef = "";
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
eventStore.pruneOldSessions(7);
|
|
153
|
+
eventStore.close();
|
|
154
|
+
} catch (e) {
|
|
155
|
+
(platform as any).logger?.warn?.("supi-context-mode: failed to close event store", e);
|
|
156
|
+
} finally {
|
|
157
|
+
eventStore = null;
|
|
158
|
+
eventStorePath = null;
|
|
159
|
+
_eventStoreRef = null;
|
|
160
|
+
_sessionIdRef = "";
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
50
164
|
// Phase 1: Result compression + Phase 2: Event extraction
|
|
51
165
|
platform.on("tool_result", (event) => {
|
|
52
166
|
// Phase 1: compression
|
|
@@ -58,7 +172,7 @@ export function registerContextModeHooks(platform: Platform, config: SupipowersC
|
|
|
58
172
|
const events = extractEvents(event, sessionId);
|
|
59
173
|
if (events.length > 0) eventStore.writeEvents(events);
|
|
60
174
|
} catch (e) {
|
|
61
|
-
(platform as any).logger?.warn?.("context-mode: event extraction failed", e);
|
|
175
|
+
(platform as any).logger?.warn?.("supi-context-mode: event extraction failed", e);
|
|
62
176
|
}
|
|
63
177
|
}
|
|
64
178
|
|
|
@@ -88,7 +202,7 @@ export function registerContextModeHooks(platform: Platform, config: SupipowersC
|
|
|
88
202
|
if (events.length > 0) eventStore.writeEvents(events);
|
|
89
203
|
}
|
|
90
204
|
} catch (e) {
|
|
91
|
-
(platform as any).logger?.warn?.("context-mode: prompt event extraction failed", e);
|
|
205
|
+
(platform as any).logger?.warn?.("supi-context-mode: prompt event extraction failed", e);
|
|
92
206
|
}
|
|
93
207
|
}
|
|
94
208
|
|
|
@@ -105,23 +219,80 @@ export function registerContextModeHooks(platform: Platform, config: SupipowersC
|
|
|
105
219
|
});
|
|
106
220
|
|
|
107
221
|
// Phase 3: Compaction integration
|
|
108
|
-
|
|
109
|
-
|
|
222
|
+
const compactionStore = config.contextMode.compaction ? ensureEventStore(sessionCwd) : null;
|
|
223
|
+
if (compactionStore) {
|
|
224
|
+
|
|
225
|
+
// Initialize fallback session metadata for sessions that never emit session_start in tests.
|
|
226
|
+
try {
|
|
227
|
+
compactionStore.upsertMeta(sessionId, sessionCwd);
|
|
228
|
+
} catch {
|
|
229
|
+
// Non-fatal: metadata is supplementary
|
|
230
|
+
}
|
|
110
231
|
|
|
111
232
|
platform.on("session_before_compact", () => {
|
|
233
|
+
// Re-detect MCP tools: they may have loaded since init
|
|
234
|
+
const status = cachedStatus ?? detectContextMode(platform.getActiveTools());
|
|
235
|
+
const searchAvailable = status.tools.ctxSearch;
|
|
236
|
+
|
|
237
|
+
// Determine the search tool name for reference-based snapshots
|
|
238
|
+
let searchTool: string | undefined;
|
|
239
|
+
if (searchAvailable) {
|
|
240
|
+
const tools = platform.getActiveTools();
|
|
241
|
+
searchTool = tools.find((t) => t.includes("ctx_search"));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Read compact count from metadata
|
|
245
|
+
let compactCount = 0;
|
|
112
246
|
try {
|
|
113
|
-
|
|
247
|
+
const meta = eventStore!.getMeta(sessionId);
|
|
248
|
+
compactCount = meta?.compactCount ?? 0;
|
|
249
|
+
} catch {
|
|
250
|
+
// Non-fatal
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const snapshot = buildResumeSnapshot(eventStore!, sessionId, {
|
|
255
|
+
compactCount,
|
|
256
|
+
searchTool,
|
|
257
|
+
searchAvailable,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Persist to DB so it survives crashes
|
|
261
|
+
if (snapshot) {
|
|
262
|
+
const eventCount = Object.values(eventStore!.getEventCounts(sessionId))
|
|
263
|
+
.reduce((a, b) => a + b, 0);
|
|
264
|
+
eventStore!.upsertResume(sessionId, snapshot, eventCount);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return undefined; // don't cancel or replace compaction
|
|
114
268
|
} catch (e) {
|
|
115
269
|
(platform as any).logger?.warn?.("context-mode: snapshot build failed", e);
|
|
116
|
-
|
|
270
|
+
return undefined;
|
|
117
271
|
}
|
|
118
|
-
return undefined; // don't cancel or replace compaction
|
|
119
272
|
});
|
|
120
273
|
|
|
121
274
|
platform.on("session_compact", () => {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
275
|
+
// Try resume from DB first, fall back to in-memory
|
|
276
|
+
let snapshot: string | null = null;
|
|
277
|
+
try {
|
|
278
|
+
const resume = eventStore!.getResume(sessionId);
|
|
279
|
+
if (resume) {
|
|
280
|
+
snapshot = resume.snapshot;
|
|
281
|
+
eventStore!.consumeResume(sessionId);
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
// Non-fatal: fall through
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!snapshot) return undefined;
|
|
288
|
+
|
|
289
|
+
// Track compaction count in session metadata
|
|
290
|
+
try {
|
|
291
|
+
eventStore!.incrementCompactCount(sessionId);
|
|
292
|
+
} catch {
|
|
293
|
+
// Non-fatal
|
|
294
|
+
}
|
|
295
|
+
|
|
125
296
|
return {
|
|
126
297
|
context: snapshot.split("\n"),
|
|
127
298
|
preserveData: {
|
|
@@ -145,11 +316,13 @@ export function getSessionId(): string {
|
|
|
145
316
|
|
|
146
317
|
// Module-level refs updated by registerContextModeHooks
|
|
147
318
|
let _eventStoreRef: EventStore | null = null;
|
|
319
|
+
let _knowledgeStoreRef: KnowledgeStore | null = null;
|
|
148
320
|
let _sessionIdRef = "";
|
|
149
321
|
|
|
150
322
|
/** Reset cached state (for testing) */
|
|
151
323
|
export function _resetCache(): void {
|
|
152
324
|
cachedStatus = null;
|
|
153
325
|
_eventStoreRef = null;
|
|
326
|
+
_knowledgeStoreRef = null;
|
|
154
327
|
_sessionIdRef = "";
|
|
155
|
-
}
|
|
328
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
export interface Chunk {
|
|
2
|
+
title: string;
|
|
3
|
+
body: string;
|
|
4
|
+
contentType: "code" | "prose";
|
|
5
|
+
source: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)/;
|
|
9
|
+
const FENCE_RE = /^(`{3,})/;
|
|
10
|
+
const MAX_CHUNK_SIZE = 4096;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Split markdown into searchable chunks by heading boundaries.
|
|
14
|
+
* Never splits inside fenced code blocks. Oversized chunks split at paragraph boundaries.
|
|
15
|
+
*/
|
|
16
|
+
export function chunkMarkdown(text: string, source: string): Chunk[] {
|
|
17
|
+
if (!text) return [];
|
|
18
|
+
|
|
19
|
+
const sections = splitByHeadings(text, source);
|
|
20
|
+
const chunks: Chunk[] = [];
|
|
21
|
+
|
|
22
|
+
for (const section of sections) {
|
|
23
|
+
const body = section.body.trim();
|
|
24
|
+
if (!body) continue;
|
|
25
|
+
|
|
26
|
+
if (body.length <= MAX_CHUNK_SIZE) {
|
|
27
|
+
chunks.push({
|
|
28
|
+
title: section.title,
|
|
29
|
+
body,
|
|
30
|
+
contentType: classifyContent(body),
|
|
31
|
+
source,
|
|
32
|
+
});
|
|
33
|
+
} else {
|
|
34
|
+
const parts = splitOversized(body);
|
|
35
|
+
for (let i = 0; i < parts.length; i++) {
|
|
36
|
+
const partBody = parts[i].trim();
|
|
37
|
+
if (!partBody) continue;
|
|
38
|
+
chunks.push({
|
|
39
|
+
title: `${section.title} (part ${i + 1})`,
|
|
40
|
+
body: partBody,
|
|
41
|
+
contentType: classifyContent(partBody),
|
|
42
|
+
source,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return chunks;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RawSection {
|
|
52
|
+
title: string;
|
|
53
|
+
body: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Split text into sections at heading boundaries, respecting fenced code blocks. */
|
|
57
|
+
function splitByHeadings(text: string, source: string): RawSection[] {
|
|
58
|
+
const lines = text.split("\n");
|
|
59
|
+
const sections: RawSection[] = [];
|
|
60
|
+
let currentTitle = source;
|
|
61
|
+
let currentLines: string[] = [];
|
|
62
|
+
let inFence = false;
|
|
63
|
+
let fenceMarker = "";
|
|
64
|
+
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
if (inFence) {
|
|
67
|
+
currentLines.push(line);
|
|
68
|
+
// Check if this line closes the fence
|
|
69
|
+
const closeMatch = line.match(FENCE_RE);
|
|
70
|
+
if (closeMatch && line.trimStart().startsWith(fenceMarker) && !line.trimStart().slice(fenceMarker.length).match(/\S/)) {
|
|
71
|
+
inFence = false;
|
|
72
|
+
fenceMarker = "";
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check for opening fence
|
|
78
|
+
const fenceMatch = line.match(FENCE_RE);
|
|
79
|
+
if (fenceMatch) {
|
|
80
|
+
// Opening fence: backticks followed optionally by a language hint
|
|
81
|
+
inFence = true;
|
|
82
|
+
fenceMarker = fenceMatch[1];
|
|
83
|
+
currentLines.push(line);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const headingMatch = line.match(HEADING_RE);
|
|
88
|
+
if (headingMatch) {
|
|
89
|
+
// Flush previous section
|
|
90
|
+
sections.push({ title: currentTitle, body: currentLines.join("\n") });
|
|
91
|
+
currentTitle = headingMatch[2];
|
|
92
|
+
currentLines = [];
|
|
93
|
+
} else {
|
|
94
|
+
currentLines.push(line);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Flush final section
|
|
99
|
+
sections.push({ title: currentTitle, body: currentLines.join("\n") });
|
|
100
|
+
|
|
101
|
+
return sections;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Classify body as "code" or "prose" based on fenced code block character ratio. */
|
|
105
|
+
function classifyContent(body: string): "code" | "prose" {
|
|
106
|
+
let codeChars = 0;
|
|
107
|
+
let inFence = false;
|
|
108
|
+
let fenceMarker = "";
|
|
109
|
+
let blockLines: string[] = [];
|
|
110
|
+
|
|
111
|
+
for (const line of body.split("\n")) {
|
|
112
|
+
if (inFence) {
|
|
113
|
+
const closeMatch = line.match(FENCE_RE);
|
|
114
|
+
if (closeMatch && line.trimStart().startsWith(fenceMarker) && !line.trimStart().slice(fenceMarker.length).match(/\S/)) {
|
|
115
|
+
inFence = false;
|
|
116
|
+
codeChars += blockLines.join("\n").length;
|
|
117
|
+
blockLines = [];
|
|
118
|
+
fenceMarker = "";
|
|
119
|
+
} else {
|
|
120
|
+
blockLines.push(line);
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const fenceMatch = line.match(FENCE_RE);
|
|
126
|
+
if (fenceMatch) {
|
|
127
|
+
inFence = true;
|
|
128
|
+
fenceMarker = fenceMatch[1];
|
|
129
|
+
blockLines = [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// If still in fence (unclosed), count accumulated lines
|
|
134
|
+
if (inFence) {
|
|
135
|
+
codeChars += blockLines.join("\n").length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return codeChars > body.length * 0.5 ? "code" : "prose";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Split oversized body at paragraph boundaries (\n\n), never breaking inside code fences.
|
|
143
|
+
* Returns parts each ≤ MAX_CHUNK_SIZE (best effort — a single paragraph exceeding the limit is kept whole).
|
|
144
|
+
*/
|
|
145
|
+
function splitOversized(body: string): string[] {
|
|
146
|
+
// Identify paragraph boundaries that are outside code fences
|
|
147
|
+
const splitPoints = findSafeSplitPoints(body);
|
|
148
|
+
|
|
149
|
+
if (splitPoints.length === 0) {
|
|
150
|
+
// No safe split points — return as single part
|
|
151
|
+
return [body];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
let start = 0;
|
|
156
|
+
|
|
157
|
+
for (const point of splitPoints) {
|
|
158
|
+
const candidate = body.slice(start, point).trim();
|
|
159
|
+
if (!candidate) {
|
|
160
|
+
start = point;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if adding this segment to the current accumulation would exceed the limit
|
|
165
|
+
if (parts.length > 0) {
|
|
166
|
+
const last = parts[parts.length - 1];
|
|
167
|
+
if (last.length + candidate.length + 2 <= MAX_CHUNK_SIZE) {
|
|
168
|
+
parts[parts.length - 1] = last + "\n\n" + candidate;
|
|
169
|
+
start = point;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Start a new part, greedily accumulating paragraphs up to the limit
|
|
175
|
+
if (parts.length === 0 || parts[parts.length - 1].length > 0) {
|
|
176
|
+
parts.push(candidate);
|
|
177
|
+
}
|
|
178
|
+
start = point;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Handle remaining text after last split point
|
|
182
|
+
const remaining = body.slice(start).trim();
|
|
183
|
+
if (remaining) {
|
|
184
|
+
if (parts.length > 0) {
|
|
185
|
+
const last = parts[parts.length - 1];
|
|
186
|
+
if (last.length + remaining.length + 2 <= MAX_CHUNK_SIZE) {
|
|
187
|
+
parts[parts.length - 1] = last + "\n\n" + remaining;
|
|
188
|
+
} else {
|
|
189
|
+
parts.push(remaining);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
parts.push(remaining);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return parts;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Find byte offsets of `\n\n` boundaries that are NOT inside fenced code blocks. */
|
|
200
|
+
function findSafeSplitPoints(body: string): number[] {
|
|
201
|
+
const points: number[] = [];
|
|
202
|
+
let inFence = false;
|
|
203
|
+
let fenceMarker = "";
|
|
204
|
+
let i = 0;
|
|
205
|
+
|
|
206
|
+
const lines = body.split("\n");
|
|
207
|
+
let offset = 0;
|
|
208
|
+
|
|
209
|
+
for (let li = 0; li < lines.length; li++) {
|
|
210
|
+
const line = lines[li];
|
|
211
|
+
|
|
212
|
+
if (inFence) {
|
|
213
|
+
const closeMatch = line.match(FENCE_RE);
|
|
214
|
+
if (closeMatch && line.trimStart().startsWith(fenceMarker) && !line.trimStart().slice(fenceMarker.length).match(/\S/)) {
|
|
215
|
+
inFence = false;
|
|
216
|
+
fenceMarker = "";
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const fenceMatch = line.match(FENCE_RE);
|
|
220
|
+
if (fenceMatch) {
|
|
221
|
+
inFence = true;
|
|
222
|
+
fenceMarker = fenceMatch[1];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// A blank line outside a fence is a paragraph boundary (\n\n in the original text)
|
|
227
|
+
if (!inFence && line === "") {
|
|
228
|
+
points.push(offset + 1); // offset after the blank line's \n
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
offset += line.length + 1; // +1 for the \n
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return points;
|
|
235
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import type { Chunk } from "./chunker.js";
|
|
4
|
+
|
|
5
|
+
export interface SearchOptions {
|
|
6
|
+
source?: string;
|
|
7
|
+
contentType?: "code" | "prose";
|
|
8
|
+
limit?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SearchResult {
|
|
12
|
+
title: string;
|
|
13
|
+
body: string;
|
|
14
|
+
source: string;
|
|
15
|
+
contentType: string;
|
|
16
|
+
score: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QueryGroupedResults {
|
|
20
|
+
query: string;
|
|
21
|
+
results: SearchResult[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StoreStats {
|
|
25
|
+
totalChunks: number;
|
|
26
|
+
sources: string[];
|
|
27
|
+
dbSizeBytes: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SCHEMA = `
|
|
31
|
+
CREATE TABLE IF NOT EXISTS content_chunks (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
33
|
+
source TEXT NOT NULL,
|
|
34
|
+
title TEXT NOT NULL DEFAULT '',
|
|
35
|
+
body TEXT NOT NULL,
|
|
36
|
+
content_type TEXT NOT NULL DEFAULT 'prose'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS content_chunks_fts USING fts5(
|
|
40
|
+
title,
|
|
41
|
+
body,
|
|
42
|
+
content='content_chunks',
|
|
43
|
+
content_rowid='id',
|
|
44
|
+
tokenize='porter'
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TRIGGER IF NOT EXISTS content_chunks_ai AFTER INSERT ON content_chunks BEGIN
|
|
48
|
+
INSERT INTO content_chunks_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
|
|
49
|
+
END;
|
|
50
|
+
|
|
51
|
+
CREATE TRIGGER IF NOT EXISTS content_chunks_ad AFTER DELETE ON content_chunks BEGIN
|
|
52
|
+
INSERT INTO content_chunks_fts(content_chunks_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
|
|
53
|
+
END;
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS url_cache (
|
|
56
|
+
url TEXT NOT NULL,
|
|
57
|
+
source TEXT NOT NULL,
|
|
58
|
+
fetched_at INTEGER NOT NULL,
|
|
59
|
+
PRIMARY KEY (url, source)
|
|
60
|
+
);
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export class KnowledgeStore {
|
|
64
|
+
private _db: Database;
|
|
65
|
+
private dbPath: string;
|
|
66
|
+
|
|
67
|
+
/** Public accessor for direct SQL on extension tables (e.g. url_cache). */
|
|
68
|
+
get db(): Database {
|
|
69
|
+
return this._db;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
constructor(dbPath: string) {
|
|
73
|
+
this.dbPath = dbPath;
|
|
74
|
+
this._db = new Database(dbPath);
|
|
75
|
+
this._db.exec("PRAGMA journal_mode=WAL");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
init(): void {
|
|
79
|
+
this._db.exec(SCHEMA);
|
|
80
|
+
}
|
|
81
|
+
index(chunks: Chunk[], source: string): void {
|
|
82
|
+
const del = this._db.prepare("DELETE FROM content_chunks WHERE source = ?");
|
|
83
|
+
const ins = this._db.prepare(
|
|
84
|
+
"INSERT INTO content_chunks (source, title, body, content_type) VALUES (?, ?, ?, ?)",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
this._db.transaction(() => {
|
|
88
|
+
del.run(source);
|
|
89
|
+
for (const chunk of chunks) {
|
|
90
|
+
ins.run(source, chunk.title, chunk.body, chunk.contentType);
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
search(queries: string[], options?: SearchOptions): QueryGroupedResults[] {
|
|
96
|
+
if (!queries.length) return [];
|
|
97
|
+
|
|
98
|
+
const limit = options?.limit ?? 3;
|
|
99
|
+
const results: QueryGroupedResults[] = [];
|
|
100
|
+
|
|
101
|
+
for (const query of queries) {
|
|
102
|
+
const sanitized = sanitizeFtsQuery(query);
|
|
103
|
+
if (!sanitized) {
|
|
104
|
+
results.push({ query, results: [] });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let sql = `
|
|
109
|
+
SELECT c.title, c.body, c.source, c.content_type AS contentType,
|
|
110
|
+
bm25(content_chunks_fts, 5.0, 1.0) AS score
|
|
111
|
+
FROM content_chunks_fts f
|
|
112
|
+
JOIN content_chunks c ON c.id = f.rowid
|
|
113
|
+
WHERE content_chunks_fts MATCH ?
|
|
114
|
+
`;
|
|
115
|
+
const params: (string | number)[] = [sanitized];
|
|
116
|
+
|
|
117
|
+
if (options?.source) {
|
|
118
|
+
sql += " AND c.source LIKE '%' || ? || '%'";
|
|
119
|
+
params.push(options.source);
|
|
120
|
+
}
|
|
121
|
+
if (options?.contentType) {
|
|
122
|
+
sql += " AND c.content_type = ?";
|
|
123
|
+
params.push(options.contentType);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sql += " ORDER BY score LIMIT ?";
|
|
127
|
+
params.push(limit);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const rows = this._db.prepare(sql).all(...params) as SearchResult[];
|
|
131
|
+
results.push({ query, results: rows });
|
|
132
|
+
} catch {
|
|
133
|
+
// FTS5 query syntax error — return empty for this query
|
|
134
|
+
results.push({ query, results: [] });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return results;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
purge(): number {
|
|
142
|
+
const row = this._db.prepare("SELECT COUNT(*) AS cnt FROM content_chunks").get() as {
|
|
143
|
+
cnt: number;
|
|
144
|
+
};
|
|
145
|
+
const count = row.cnt;
|
|
146
|
+
this._db.exec("DELETE FROM content_chunks");
|
|
147
|
+
this._db.exec("INSERT INTO content_chunks_fts(content_chunks_fts) VALUES('rebuild')");
|
|
148
|
+
this._db.exec("DELETE FROM url_cache");
|
|
149
|
+
return count;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
getStats(): StoreStats {
|
|
153
|
+
const countRow = this._db.prepare("SELECT COUNT(*) AS cnt FROM content_chunks").get() as {
|
|
154
|
+
cnt: number;
|
|
155
|
+
};
|
|
156
|
+
const sourceRows = this._db
|
|
157
|
+
.prepare("SELECT DISTINCT source FROM content_chunks ORDER BY source")
|
|
158
|
+
.all() as { source: string }[];
|
|
159
|
+
const dbSizeBytes = fs.statSync(this.dbPath).size;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
totalChunks: countRow.cnt,
|
|
163
|
+
sources: sourceRows.map((r) => r.source),
|
|
164
|
+
dbSizeBytes,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
pruneExpiredUrls(ttlHours = 24): number {
|
|
169
|
+
const cutoff = Math.floor(Date.now() / 1000) - ttlHours * 3600;
|
|
170
|
+
const result = this._db.prepare("DELETE FROM url_cache WHERE fetched_at < ?").run(cutoff);
|
|
171
|
+
return result.changes;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
close(): void {
|
|
175
|
+
this._db.close();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Strip FTS5 special operators to prevent syntax errors. Keep alphanumeric + spaces. */
|
|
180
|
+
function sanitizeFtsQuery(query: string): string {
|
|
181
|
+
// Remove characters that have special meaning in FTS5: ^, *, ", (, ), {, }, +, -
|
|
182
|
+
// Keep words separated by spaces for implicit AND matching
|
|
183
|
+
return query
|
|
184
|
+
.replace(/[^\p{L}\p{N}\s]/gu, " ")
|
|
185
|
+
.replace(/\s+/g, " ")
|
|
186
|
+
.trim();
|
|
187
|
+
}
|