@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getChangedFiles, getStagedFiles } from "../git/index";
|
|
4
|
+
import {
|
|
5
|
+
assembleBudget,
|
|
6
|
+
type BudgetAllocation,
|
|
7
|
+
type BudgetMode,
|
|
8
|
+
calculateTokens,
|
|
9
|
+
type LayerContent,
|
|
10
|
+
truncateToFit,
|
|
11
|
+
} from "./budget";
|
|
12
|
+
import { assembleEpisodicText, decayAllEntries, getEntries } from "./episodic";
|
|
13
|
+
import {
|
|
14
|
+
assembleRetrievalText,
|
|
15
|
+
type RetrievalOptions,
|
|
16
|
+
search,
|
|
17
|
+
} from "./retrieval";
|
|
18
|
+
import type { MainaCommand } from "./selector";
|
|
19
|
+
import { getBudgetMode, getContextNeeds, needsLayer } from "./selector";
|
|
20
|
+
import { assembleWorkingText, loadWorkingContext } from "./working";
|
|
21
|
+
|
|
22
|
+
// ── Public types ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface LayerReport {
|
|
25
|
+
name: string;
|
|
26
|
+
tokens: number;
|
|
27
|
+
entries: number;
|
|
28
|
+
included: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AssembledContext {
|
|
32
|
+
text: string;
|
|
33
|
+
tokens: number;
|
|
34
|
+
layers: LayerReport[];
|
|
35
|
+
mode: BudgetMode;
|
|
36
|
+
budget: BudgetAllocation;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContextOptions {
|
|
40
|
+
repoRoot?: string; // defaults to process.cwd()
|
|
41
|
+
mainaDir?: string; // defaults to join(repoRoot, '.maina')
|
|
42
|
+
searchQuery?: string; // for retrieval layer
|
|
43
|
+
scope?: string; // limit to specific directory
|
|
44
|
+
modeOverride?: BudgetMode; // override the command-derived budget mode
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Internal helpers ──────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Escape regex metacharacters in a string so it can be safely used
|
|
51
|
+
* as a literal term inside a ripgrep/grep alternation pattern.
|
|
52
|
+
*/
|
|
53
|
+
function escapeRegex(s: string): string {
|
|
54
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Try to read a file as text. Returns empty string on any failure.
|
|
59
|
+
*/
|
|
60
|
+
function tryReadFile(filePath: string): string {
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(filePath)) {
|
|
63
|
+
return readFileSync(filePath, "utf8");
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// intentionally swallowed
|
|
67
|
+
}
|
|
68
|
+
return "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Minimal fallback for the semantic layer: constitution.md + conventions.md if present.
|
|
73
|
+
*/
|
|
74
|
+
function fallbackSemanticText(mainaDir: string): string {
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
|
|
77
|
+
const constitutionPath = join(mainaDir, "constitution.md");
|
|
78
|
+
const constitutionText = tryReadFile(constitutionPath);
|
|
79
|
+
if (constitutionText) {
|
|
80
|
+
parts.push("## Constitution\n");
|
|
81
|
+
parts.push(constitutionText);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const conventionsPath = join(mainaDir, "conventions.md");
|
|
85
|
+
const conventionsText = tryReadFile(conventionsPath);
|
|
86
|
+
if (conventionsText) {
|
|
87
|
+
parts.push("## Conventions\n");
|
|
88
|
+
parts.push(conventionsText);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return parts.join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Attempt to load and assemble the semantic layer.
|
|
96
|
+
* Uses dynamic import so the engine works even if semantic.ts is not yet built.
|
|
97
|
+
*/
|
|
98
|
+
async function loadSemanticLayer(
|
|
99
|
+
repoRoot: string,
|
|
100
|
+
mainaDir: string,
|
|
101
|
+
filter?: string[],
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
try {
|
|
104
|
+
const {
|
|
105
|
+
buildSemanticContext,
|
|
106
|
+
assembleSemanticText,
|
|
107
|
+
persistSemanticContext,
|
|
108
|
+
} = await import("./semantic");
|
|
109
|
+
|
|
110
|
+
// Populate task context from git for PageRank personalization
|
|
111
|
+
const [staged, changed] = await Promise.all([
|
|
112
|
+
getStagedFiles(repoRoot),
|
|
113
|
+
getChangedFiles("HEAD~5", repoRoot),
|
|
114
|
+
]);
|
|
115
|
+
const touchedRelative = [...new Set([...staged, ...changed])];
|
|
116
|
+
// Graph uses absolute paths, so convert for personalization lookup
|
|
117
|
+
const touchedAbsolute = touchedRelative.map((f) => join(repoRoot, f));
|
|
118
|
+
|
|
119
|
+
const taskContext = {
|
|
120
|
+
touchedFiles: touchedAbsolute,
|
|
121
|
+
mentionedFiles: [],
|
|
122
|
+
currentTicketTerms: filter ?? [],
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const semanticContext = await buildSemanticContext(
|
|
126
|
+
repoRoot,
|
|
127
|
+
mainaDir,
|
|
128
|
+
taskContext,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Persist entities + dependency graph to DB for cross-session recall
|
|
132
|
+
persistSemanticContext(mainaDir, semanticContext, repoRoot);
|
|
133
|
+
|
|
134
|
+
return assembleSemanticText(semanticContext, filter);
|
|
135
|
+
} catch {
|
|
136
|
+
// semantic module not available or failed — use minimal fallback
|
|
137
|
+
return fallbackSemanticText(mainaDir);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build the working layer content. Never throws.
|
|
143
|
+
*/
|
|
144
|
+
async function buildWorkingLayer(
|
|
145
|
+
mainaDir: string,
|
|
146
|
+
repoRoot: string,
|
|
147
|
+
): Promise<LayerContent> {
|
|
148
|
+
try {
|
|
149
|
+
const context = await loadWorkingContext(mainaDir, repoRoot);
|
|
150
|
+
|
|
151
|
+
// Backfill touchedFiles from git if empty (so working layer is useful
|
|
152
|
+
// even without explicit trackFile() calls)
|
|
153
|
+
if (context.touchedFiles.length === 0) {
|
|
154
|
+
const [staged, changed] = await Promise.all([
|
|
155
|
+
getStagedFiles(repoRoot),
|
|
156
|
+
getChangedFiles("HEAD~3", repoRoot),
|
|
157
|
+
]);
|
|
158
|
+
context.touchedFiles = [...new Set([...staged, ...changed])];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const text = assembleWorkingText(context);
|
|
162
|
+
const tokens = calculateTokens(text);
|
|
163
|
+
return { name: "working", text, tokens, priority: 0 };
|
|
164
|
+
} catch {
|
|
165
|
+
const text = "Working context unavailable.";
|
|
166
|
+
return {
|
|
167
|
+
name: "working",
|
|
168
|
+
text,
|
|
169
|
+
tokens: calculateTokens(text),
|
|
170
|
+
priority: 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build the episodic layer content. Never throws.
|
|
177
|
+
*/
|
|
178
|
+
function buildEpisodicLayer(mainaDir: string, filter?: string[]): LayerContent {
|
|
179
|
+
try {
|
|
180
|
+
decayAllEntries(mainaDir);
|
|
181
|
+
|
|
182
|
+
let entries: import("./episodic").EpisodicEntry[];
|
|
183
|
+
if (filter !== undefined && filter.length > 0) {
|
|
184
|
+
// When filter is a string[], fetch entries for each type and merge
|
|
185
|
+
const allEntries = filter.flatMap((type) => getEntries(mainaDir, type));
|
|
186
|
+
// Deduplicate by id
|
|
187
|
+
const seen = new Set<string>();
|
|
188
|
+
entries = allEntries.filter((e) => {
|
|
189
|
+
if (seen.has(e.id)) return false;
|
|
190
|
+
seen.add(e.id);
|
|
191
|
+
return true;
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
entries = getEntries(mainaDir);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const text = assembleEpisodicText(entries);
|
|
198
|
+
return {
|
|
199
|
+
name: "episodic",
|
|
200
|
+
text,
|
|
201
|
+
tokens: calculateTokens(text),
|
|
202
|
+
priority: 2,
|
|
203
|
+
};
|
|
204
|
+
} catch {
|
|
205
|
+
return { name: "episodic", text: "", tokens: 0, priority: 2 };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Build the semantic layer content. Never throws.
|
|
211
|
+
*/
|
|
212
|
+
async function buildSemanticLayer(
|
|
213
|
+
repoRoot: string,
|
|
214
|
+
mainaDir: string,
|
|
215
|
+
filter?: string[],
|
|
216
|
+
): Promise<LayerContent> {
|
|
217
|
+
try {
|
|
218
|
+
const text = await loadSemanticLayer(repoRoot, mainaDir, filter);
|
|
219
|
+
return {
|
|
220
|
+
name: "semantic",
|
|
221
|
+
text,
|
|
222
|
+
tokens: calculateTokens(text),
|
|
223
|
+
priority: 1,
|
|
224
|
+
};
|
|
225
|
+
} catch {
|
|
226
|
+
return { name: "semantic", text: "", tokens: 0, priority: 1 };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Build the retrieval layer content. Never throws.
|
|
232
|
+
*/
|
|
233
|
+
async function buildRetrievalLayer(
|
|
234
|
+
query: string,
|
|
235
|
+
options: RetrievalOptions,
|
|
236
|
+
): Promise<LayerContent> {
|
|
237
|
+
try {
|
|
238
|
+
const results = await search(query, options);
|
|
239
|
+
const text = assembleRetrievalText(results);
|
|
240
|
+
return {
|
|
241
|
+
name: "retrieval",
|
|
242
|
+
text,
|
|
243
|
+
tokens: calculateTokens(text),
|
|
244
|
+
priority: 3,
|
|
245
|
+
};
|
|
246
|
+
} catch {
|
|
247
|
+
return { name: "retrieval", text: "", tokens: 0, priority: 3 };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Assemble context for a Maina command.
|
|
255
|
+
*
|
|
256
|
+
* 1. Determines which layers are needed via the selector.
|
|
257
|
+
* 2. Builds a budget allocation for the command's mode.
|
|
258
|
+
* 3. Loads each needed layer in parallel (resilient — failures produce empty layers).
|
|
259
|
+
* 4. Runs truncateToFit to stay within token budget.
|
|
260
|
+
* 5. Returns the combined text plus reporting metadata.
|
|
261
|
+
*/
|
|
262
|
+
export async function assembleContext(
|
|
263
|
+
command: MainaCommand,
|
|
264
|
+
options: ContextOptions = {},
|
|
265
|
+
): Promise<AssembledContext> {
|
|
266
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
267
|
+
const mainaDir = options.mainaDir ?? join(repoRoot, ".maina");
|
|
268
|
+
|
|
269
|
+
const needs = getContextNeeds(command);
|
|
270
|
+
const mode = options.modeOverride ?? getBudgetMode(command);
|
|
271
|
+
const budget = assembleBudget(mode);
|
|
272
|
+
|
|
273
|
+
// Determine which layers to build
|
|
274
|
+
const layerPromises: Promise<LayerContent>[] = [];
|
|
275
|
+
|
|
276
|
+
// Working layer
|
|
277
|
+
if (needsLayer(needs, "working")) {
|
|
278
|
+
layerPromises.push(buildWorkingLayer(mainaDir, repoRoot));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Semantic layer — filter may be a string[]
|
|
282
|
+
if (needsLayer(needs, "semantic")) {
|
|
283
|
+
const semanticFilter = Array.isArray(needs.semantic)
|
|
284
|
+
? needs.semantic
|
|
285
|
+
: undefined;
|
|
286
|
+
layerPromises.push(buildSemanticLayer(repoRoot, mainaDir, semanticFilter));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Episodic layer — filter may be a string[]
|
|
290
|
+
if (needsLayer(needs, "episodic")) {
|
|
291
|
+
const episodicFilter = Array.isArray(needs.episodic)
|
|
292
|
+
? needs.episodic
|
|
293
|
+
: undefined;
|
|
294
|
+
layerPromises.push(
|
|
295
|
+
Promise.resolve(buildEpisodicLayer(mainaDir, episodicFilter)),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Retrieval layer — auto-generates search query from staged/changed files if not provided
|
|
300
|
+
if (needsLayer(needs, "retrieval")) {
|
|
301
|
+
let query = options.searchQuery;
|
|
302
|
+
|
|
303
|
+
// Auto-generate query from recent changes if none provided
|
|
304
|
+
if (!query) {
|
|
305
|
+
try {
|
|
306
|
+
const [staged, changed] = await Promise.all([
|
|
307
|
+
getStagedFiles(repoRoot),
|
|
308
|
+
getChangedFiles("HEAD~3", repoRoot),
|
|
309
|
+
]);
|
|
310
|
+
const recentFiles = [...new Set([...staged, ...changed])];
|
|
311
|
+
if (recentFiles.length > 0) {
|
|
312
|
+
// Extract meaningful terms, escape regex metacharacters in each,
|
|
313
|
+
// then join with | for ripgrep alternation
|
|
314
|
+
const terms = recentFiles
|
|
315
|
+
.flatMap(
|
|
316
|
+
(f) =>
|
|
317
|
+
f
|
|
318
|
+
.split("/")
|
|
319
|
+
.pop()
|
|
320
|
+
?.replace(/\.\w+$/, "")
|
|
321
|
+
.split(/[-_.]/) ?? [],
|
|
322
|
+
)
|
|
323
|
+
.filter((t) => t.length > 3)
|
|
324
|
+
.map(escapeRegex)
|
|
325
|
+
.slice(0, 5);
|
|
326
|
+
if (terms.length > 0) {
|
|
327
|
+
query = terms.join("|");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Failed to auto-generate — leave as empty
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (query) {
|
|
336
|
+
const retrievalOptions: RetrievalOptions = {
|
|
337
|
+
cwd: options.scope ?? repoRoot,
|
|
338
|
+
tokenBudget: budget.retrieval,
|
|
339
|
+
};
|
|
340
|
+
layerPromises.push(buildRetrievalLayer(query, retrievalOptions));
|
|
341
|
+
} else {
|
|
342
|
+
// No query possible — add empty placeholder so it appears in reports
|
|
343
|
+
layerPromises.push(
|
|
344
|
+
Promise.resolve({
|
|
345
|
+
name: "retrieval",
|
|
346
|
+
text: "",
|
|
347
|
+
tokens: 0,
|
|
348
|
+
priority: 3,
|
|
349
|
+
}),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Build all layers in parallel
|
|
355
|
+
const builtLayers = await Promise.all(layerPromises);
|
|
356
|
+
|
|
357
|
+
// Filter out completely empty non-working layers before truncation
|
|
358
|
+
// (keep working even if empty so it's always reported)
|
|
359
|
+
const nonEmptyOrWorking = builtLayers.filter(
|
|
360
|
+
(l) => l.name === "working" || l.tokens > 0,
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// Also keep retrieval in reports even if empty (for test expectations)
|
|
364
|
+
const includedInTruncation =
|
|
365
|
+
nonEmptyOrWorking.length > 0 ? nonEmptyOrWorking : builtLayers;
|
|
366
|
+
|
|
367
|
+
// Apply budget truncation
|
|
368
|
+
const surviving = truncateToFit(includedInTruncation, budget);
|
|
369
|
+
const survivingNames = new Set(surviving.map((l) => l.name));
|
|
370
|
+
|
|
371
|
+
// Build layer reports (all built layers, marked included/excluded)
|
|
372
|
+
const layerReports: LayerReport[] = builtLayers.map((layer) => ({
|
|
373
|
+
name: layer.name,
|
|
374
|
+
tokens: layer.tokens,
|
|
375
|
+
entries: layer.text.split("\n").filter((line) => line.trim()).length,
|
|
376
|
+
included: survivingNames.has(layer.name),
|
|
377
|
+
}));
|
|
378
|
+
|
|
379
|
+
// Combine surviving layer texts
|
|
380
|
+
const combinedParts = surviving
|
|
381
|
+
.filter((l) => l.text.length > 0)
|
|
382
|
+
.map((l) => l.text);
|
|
383
|
+
|
|
384
|
+
const text = combinedParts.join("\n\n");
|
|
385
|
+
const tokens = calculateTokens(text);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
text,
|
|
389
|
+
tokens,
|
|
390
|
+
layers: layerReports,
|
|
391
|
+
mode,
|
|
392
|
+
budget,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { getContextDb } from "../db/index.ts";
|
|
2
|
+
|
|
3
|
+
export interface EpisodicEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
content: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
relevance: number;
|
|
8
|
+
accessCount: number;
|
|
9
|
+
createdAt: string; // ISO timestamp
|
|
10
|
+
lastAccessedAt: string; // ISO timestamp
|
|
11
|
+
type: string; // "review" | "session" | "commit" | "feedback"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Raw row shape returned from SQLite
|
|
15
|
+
interface EpisodicRow {
|
|
16
|
+
id: string;
|
|
17
|
+
content: string;
|
|
18
|
+
summary: string | null;
|
|
19
|
+
relevance: number | null;
|
|
20
|
+
access_count: number | null;
|
|
21
|
+
created_at: string;
|
|
22
|
+
last_accessed_at: string | null;
|
|
23
|
+
type: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rowToEntry(row: EpisodicRow): EpisodicEntry {
|
|
27
|
+
return {
|
|
28
|
+
id: row.id,
|
|
29
|
+
content: row.content,
|
|
30
|
+
summary: row.summary ?? "",
|
|
31
|
+
relevance: row.relevance ?? 1.0,
|
|
32
|
+
accessCount: row.access_count ?? 0,
|
|
33
|
+
createdAt: row.created_at,
|
|
34
|
+
lastAccessedAt: row.last_accessed_at ?? row.created_at,
|
|
35
|
+
type: row.type,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Opens (or creates) the context database for the given mainaDir.
|
|
41
|
+
* Returns the raw bun:sqlite Database instance.
|
|
42
|
+
* Never throws — returns null on failure.
|
|
43
|
+
*/
|
|
44
|
+
function openDb(mainaDir: string) {
|
|
45
|
+
const result = getContextDb(mainaDir);
|
|
46
|
+
if (!result.ok) return null;
|
|
47
|
+
return result.value.db;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Implements the Ebbinghaus forgetting curve for relevance decay.
|
|
52
|
+
* Formula: exp(-0.1 * daysSinceAccess) + 0.1 * min(accessCount, 5)
|
|
53
|
+
* Clamped to [0, 1].
|
|
54
|
+
*/
|
|
55
|
+
export function calculateDecay(
|
|
56
|
+
daysSinceAccess: number,
|
|
57
|
+
accessCount: number,
|
|
58
|
+
): number {
|
|
59
|
+
const raw = Math.exp(-0.1 * daysSinceAccess) + 0.1 * Math.min(accessCount, 5);
|
|
60
|
+
return Math.min(1, Math.max(0, raw));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Adds a new episodic entry to the database.
|
|
65
|
+
* Sets id (crypto.randomUUID), relevance=1.0, accessCount=0, timestamps=now.
|
|
66
|
+
*/
|
|
67
|
+
export function addEntry(
|
|
68
|
+
mainaDir: string,
|
|
69
|
+
entry: Omit<
|
|
70
|
+
EpisodicEntry,
|
|
71
|
+
"id" | "relevance" | "accessCount" | "createdAt" | "lastAccessedAt"
|
|
72
|
+
>,
|
|
73
|
+
): EpisodicEntry {
|
|
74
|
+
const db = openDb(mainaDir);
|
|
75
|
+
const now = new Date().toISOString();
|
|
76
|
+
const id = crypto.randomUUID();
|
|
77
|
+
|
|
78
|
+
const newEntry: EpisodicEntry = {
|
|
79
|
+
id,
|
|
80
|
+
content: entry.content,
|
|
81
|
+
summary: entry.summary,
|
|
82
|
+
type: entry.type,
|
|
83
|
+
relevance: 1.0,
|
|
84
|
+
accessCount: 0,
|
|
85
|
+
createdAt: now,
|
|
86
|
+
lastAccessedAt: now,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (db) {
|
|
90
|
+
db.prepare(
|
|
91
|
+
`INSERT INTO episodic_entries
|
|
92
|
+
(id, content, summary, relevance, access_count, created_at, last_accessed_at, type)
|
|
93
|
+
VALUES
|
|
94
|
+
(?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
95
|
+
).run(
|
|
96
|
+
newEntry.id,
|
|
97
|
+
newEntry.content,
|
|
98
|
+
newEntry.summary,
|
|
99
|
+
newEntry.relevance,
|
|
100
|
+
newEntry.accessCount,
|
|
101
|
+
newEntry.createdAt,
|
|
102
|
+
newEntry.lastAccessedAt,
|
|
103
|
+
newEntry.type,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return newEntry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Finds an entry by ID, increments accessCount, updates lastAccessedAt,
|
|
112
|
+
* recalculates relevance, saves, and returns the updated entry.
|
|
113
|
+
* Returns null if the entry is not found or db is unavailable.
|
|
114
|
+
*/
|
|
115
|
+
export function accessEntry(
|
|
116
|
+
mainaDir: string,
|
|
117
|
+
id: string,
|
|
118
|
+
): EpisodicEntry | null {
|
|
119
|
+
const db = openDb(mainaDir);
|
|
120
|
+
if (!db) return null;
|
|
121
|
+
|
|
122
|
+
const row = db
|
|
123
|
+
.prepare("SELECT * FROM episodic_entries WHERE id = ?")
|
|
124
|
+
.get(id) as EpisodicRow | undefined;
|
|
125
|
+
|
|
126
|
+
if (!row) return null;
|
|
127
|
+
|
|
128
|
+
const entry = rowToEntry(row);
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
const newAccessCount = entry.accessCount + 1;
|
|
131
|
+
|
|
132
|
+
// Recalculate relevance: days since last accessed = 0 (we're accessing now)
|
|
133
|
+
const newRelevance = calculateDecay(0, newAccessCount);
|
|
134
|
+
|
|
135
|
+
db.prepare(
|
|
136
|
+
`UPDATE episodic_entries
|
|
137
|
+
SET access_count = ?, last_accessed_at = ?, relevance = ?
|
|
138
|
+
WHERE id = ?`,
|
|
139
|
+
).run(newAccessCount, now, newRelevance, id);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
...entry,
|
|
143
|
+
accessCount: newAccessCount,
|
|
144
|
+
lastAccessedAt: now,
|
|
145
|
+
relevance: newRelevance,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Returns all entries (optionally filtered by type), sorted by relevance descending.
|
|
151
|
+
*/
|
|
152
|
+
export function getEntries(mainaDir: string, type?: string): EpisodicEntry[] {
|
|
153
|
+
const db = openDb(mainaDir);
|
|
154
|
+
if (!db) return [];
|
|
155
|
+
|
|
156
|
+
let rows: EpisodicRow[];
|
|
157
|
+
if (type !== undefined) {
|
|
158
|
+
rows = db
|
|
159
|
+
.prepare(
|
|
160
|
+
"SELECT * FROM episodic_entries WHERE type = ? ORDER BY relevance DESC",
|
|
161
|
+
)
|
|
162
|
+
.all(type) as EpisodicRow[];
|
|
163
|
+
} else {
|
|
164
|
+
rows = db
|
|
165
|
+
.prepare("SELECT * FROM episodic_entries ORDER BY relevance DESC")
|
|
166
|
+
.all() as EpisodicRow[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return rows.map(rowToEntry);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Removes entries with relevance < 0.1, then enforces a max of 100 entries
|
|
174
|
+
* by removing the lowest-relevance entries above that count.
|
|
175
|
+
* Returns the total count of pruned entries.
|
|
176
|
+
*/
|
|
177
|
+
export function pruneEntries(mainaDir: string): number {
|
|
178
|
+
const db = openDb(mainaDir);
|
|
179
|
+
if (!db) return 0;
|
|
180
|
+
|
|
181
|
+
// Remove entries below relevance threshold
|
|
182
|
+
const lowRelevance = db
|
|
183
|
+
.prepare("DELETE FROM episodic_entries WHERE relevance < 0.1")
|
|
184
|
+
.run();
|
|
185
|
+
let pruned = lowRelevance.changes;
|
|
186
|
+
|
|
187
|
+
// Count remaining entries
|
|
188
|
+
const countRow = db
|
|
189
|
+
.prepare("SELECT COUNT(*) as count FROM episodic_entries")
|
|
190
|
+
.get() as { count: number };
|
|
191
|
+
const total = countRow.count;
|
|
192
|
+
|
|
193
|
+
if (total > 100) {
|
|
194
|
+
const excess = total - 100;
|
|
195
|
+
// Delete the lowest-relevance entries beyond the top 100
|
|
196
|
+
const overLimit = db
|
|
197
|
+
.prepare(
|
|
198
|
+
`DELETE FROM episodic_entries WHERE id IN (
|
|
199
|
+
SELECT id FROM episodic_entries ORDER BY relevance ASC LIMIT ?
|
|
200
|
+
)`,
|
|
201
|
+
)
|
|
202
|
+
.run(excess);
|
|
203
|
+
pruned += overLimit.changes;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return pruned;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Recalculates relevance for all entries based on current time.
|
|
211
|
+
*/
|
|
212
|
+
export function decayAllEntries(mainaDir: string): void {
|
|
213
|
+
const db = openDb(mainaDir);
|
|
214
|
+
if (!db) return;
|
|
215
|
+
|
|
216
|
+
const rows = db
|
|
217
|
+
.prepare("SELECT * FROM episodic_entries")
|
|
218
|
+
.all() as EpisodicRow[];
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
|
|
221
|
+
const update = db.prepare(
|
|
222
|
+
"UPDATE episodic_entries SET relevance = ? WHERE id = ?",
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
for (const row of rows) {
|
|
226
|
+
const entry = rowToEntry(row);
|
|
227
|
+
const lastAccessed = new Date(entry.lastAccessedAt).getTime();
|
|
228
|
+
const daysSinceAccess = (now - lastAccessed) / (1000 * 60 * 60 * 24);
|
|
229
|
+
const newRelevance = calculateDecay(daysSinceAccess, entry.accessCount);
|
|
230
|
+
update.run(newRelevance, entry.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Formats a list of episodic entries as text for LLM consumption.
|
|
236
|
+
*/
|
|
237
|
+
export function assembleEpisodicText(entries: EpisodicEntry[]): string {
|
|
238
|
+
if (entries.length === 0) return "";
|
|
239
|
+
|
|
240
|
+
const lines: string[] = ["## Episodic Context\n"];
|
|
241
|
+
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
lines.push(
|
|
244
|
+
`### [${entry.type}] ${entry.summary} (relevance: ${entry.relevance.toFixed(2)})`,
|
|
245
|
+
);
|
|
246
|
+
lines.push(entry.content);
|
|
247
|
+
lines.push("");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return lines.join("\n");
|
|
251
|
+
}
|