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.
Files changed (137) hide show
  1. package/README.md +118 -56
  2. package/bin/install.ts +48 -128
  3. package/package.json +11 -3
  4. package/skills/code-review/SKILL.md +137 -40
  5. package/skills/context-mode/SKILL.md +67 -56
  6. package/skills/creating-supi-agents/SKILL.md +204 -0
  7. package/skills/debugging/SKILL.md +86 -40
  8. package/skills/fix-pr/SKILL.md +96 -65
  9. package/skills/planning/SKILL.md +103 -46
  10. package/skills/qa-strategy/SKILL.md +68 -46
  11. package/skills/receiving-code-review/SKILL.md +60 -53
  12. package/skills/release/SKILL.md +111 -39
  13. package/skills/tdd/SKILL.md +118 -67
  14. package/skills/verification/SKILL.md +71 -37
  15. package/src/bootstrap.ts +27 -5
  16. package/src/commands/agents.ts +249 -0
  17. package/src/commands/ai-review.ts +1113 -0
  18. package/src/commands/config.ts +224 -95
  19. package/src/commands/doctor.ts +19 -13
  20. package/src/commands/fix-pr.ts +8 -11
  21. package/src/commands/generate.ts +200 -0
  22. package/src/commands/model-picker.ts +5 -15
  23. package/src/commands/model.ts +4 -5
  24. package/src/commands/optimize-context.ts +202 -0
  25. package/src/commands/plan.ts +148 -92
  26. package/src/commands/qa.ts +14 -23
  27. package/src/commands/release.ts +504 -275
  28. package/src/commands/review.ts +643 -86
  29. package/src/commands/status.ts +44 -17
  30. package/src/commands/supi.ts +69 -41
  31. package/src/commands/update.ts +57 -2
  32. package/src/config/defaults.ts +6 -39
  33. package/src/config/loader.ts +388 -40
  34. package/src/config/model-resolver.ts +26 -22
  35. package/src/config/schema.ts +113 -48
  36. package/src/context/analyzer.ts +61 -2
  37. package/src/context/optimizer.ts +199 -0
  38. package/src/context-mode/compressor.ts +14 -11
  39. package/src/context-mode/detector.ts +16 -54
  40. package/src/context-mode/event-extractor.ts +45 -16
  41. package/src/context-mode/event-store.ts +225 -16
  42. package/src/context-mode/hooks.ts +195 -22
  43. package/src/context-mode/knowledge/chunker.ts +235 -0
  44. package/src/context-mode/knowledge/store.ts +187 -0
  45. package/src/context-mode/routing.ts +12 -23
  46. package/src/context-mode/sandbox/executor.ts +183 -0
  47. package/src/context-mode/sandbox/runners.ts +40 -0
  48. package/src/context-mode/snapshot-builder.ts +243 -7
  49. package/src/context-mode/tools.ts +440 -0
  50. package/src/context-mode/web/fetcher.ts +117 -0
  51. package/src/context-mode/web/html-to-md.ts +293 -0
  52. package/src/debug/logger.ts +107 -0
  53. package/src/deps/registry.ts +0 -20
  54. package/src/docs/drift.ts +454 -0
  55. package/src/fix-pr/fetch-comments.ts +66 -0
  56. package/src/git/commit-msg.ts +2 -1
  57. package/src/git/commit.ts +123 -141
  58. package/src/git/conventions.ts +2 -2
  59. package/src/git/status.ts +4 -1
  60. package/src/lsp/bridge.ts +138 -12
  61. package/src/planning/approval-flow.ts +125 -19
  62. package/src/planning/plan-writer-prompt.ts +4 -11
  63. package/src/planning/planning-ask-tool.ts +81 -0
  64. package/src/planning/prompt-builder.ts +9 -169
  65. package/src/planning/system-prompt.ts +290 -0
  66. package/src/platform/omp.ts +50 -4
  67. package/src/platform/progress.ts +182 -0
  68. package/src/platform/test-utils.ts +4 -1
  69. package/src/platform/tui-colors.ts +30 -0
  70. package/src/platform/types.ts +1 -0
  71. package/src/qa/detect-app-type.ts +102 -0
  72. package/src/qa/discover-routes.ts +353 -0
  73. package/src/quality/ai-session.ts +96 -0
  74. package/src/quality/ai-setup.ts +86 -0
  75. package/src/quality/gates/ai-review.ts +129 -0
  76. package/src/quality/gates/build.ts +8 -0
  77. package/src/quality/gates/command.ts +150 -0
  78. package/src/quality/gates/format.ts +28 -0
  79. package/src/quality/gates/lint.ts +22 -0
  80. package/src/quality/gates/lsp-diagnostics.ts +84 -0
  81. package/src/quality/gates/test-suite.ts +8 -0
  82. package/src/quality/gates/typecheck.ts +22 -0
  83. package/src/quality/registry.ts +25 -0
  84. package/src/quality/review-gates.ts +33 -0
  85. package/src/quality/runner.ts +268 -0
  86. package/src/quality/schemas.ts +48 -0
  87. package/src/quality/setup.ts +227 -0
  88. package/src/release/changelog.ts +7 -3
  89. package/src/release/channels/custom.ts +43 -0
  90. package/src/release/channels/gitea.ts +35 -0
  91. package/src/release/channels/github.ts +35 -0
  92. package/src/release/channels/gitlab.ts +35 -0
  93. package/src/release/channels/registry.ts +52 -0
  94. package/src/release/channels/types.ts +27 -0
  95. package/src/release/detector.ts +10 -63
  96. package/src/release/executor.ts +61 -51
  97. package/src/release/prompt.ts +38 -38
  98. package/src/release/version.ts +129 -10
  99. package/src/review/agent-loader.ts +331 -0
  100. package/src/review/consolidator.ts +180 -0
  101. package/src/review/default-agents/correctness.md +72 -0
  102. package/src/review/default-agents/maintainability.md +64 -0
  103. package/src/review/default-agents/security.md +67 -0
  104. package/src/review/fixer.ts +219 -0
  105. package/src/review/multi-agent-runner.ts +135 -0
  106. package/src/review/output.ts +147 -0
  107. package/src/review/prompts/agent-review-wrapper.md +36 -0
  108. package/src/review/prompts/fix-findings.md +32 -0
  109. package/src/review/prompts/fix-output-schema.md +18 -0
  110. package/src/review/prompts/invalid-output-retry.md +22 -0
  111. package/src/review/prompts/output-instructions.md +14 -0
  112. package/src/review/prompts/review-output-schema.md +38 -0
  113. package/src/review/prompts/single-review.md +53 -0
  114. package/src/review/prompts/validation-review.md +30 -0
  115. package/src/review/runner.ts +128 -0
  116. package/src/review/scope.ts +353 -0
  117. package/src/review/template.ts +15 -0
  118. package/src/review/types.ts +296 -0
  119. package/src/review/validator.ts +160 -0
  120. package/src/storage/plans.ts +5 -3
  121. package/src/storage/reports.ts +50 -7
  122. package/src/storage/review-sessions.ts +117 -0
  123. package/src/text.ts +19 -0
  124. package/src/types.ts +336 -26
  125. package/src/utils/paths.ts +39 -0
  126. package/src/visual/companion.ts +5 -3
  127. package/src/visual/start-server.ts +101 -0
  128. package/src/visual/stop-server.ts +39 -0
  129. package/bin/ctx-mode-wrapper.mjs +0 -66
  130. package/src/config/profiles.ts +0 -64
  131. package/src/context-mode/installer.ts +0 -38
  132. package/src/quality/ai-review-gate.ts +0 -43
  133. package/src/quality/gate-runner.ts +0 -67
  134. package/src/quality/lsp-gate.ts +0 -24
  135. package/src/quality/test-gate.ts +0 -39
  136. package/src/visual/scripts/start-server.sh +0 -98
  137. 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
- /** Register context-mode hooks on the platform */
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 sessionId = `session-${Date.now()}`;
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
- const dbDir = platform.paths.project(process.cwd(), "sessions");
38
- mkdirSync(dbDir, { recursive: true });
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
- (platform as any).logger?.error?.("context-mode: failed to initialize event store", e);
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
- if (config.contextMode.compaction && eventStore) {
109
- let pendingSnapshot: string | null = null;
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
- pendingSnapshot = buildResumeSnapshot(eventStore!, sessionId);
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
- pendingSnapshot = null;
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
- if (!pendingSnapshot) return undefined;
123
- const snapshot = pendingSnapshot;
124
- pendingSnapshot = null;
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
+ }