@mainahq/core 1.0.3 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ai/__tests__/delegation.test.ts +55 -1
- package/src/ai/delegation.ts +5 -3
- package/src/context/__tests__/budget.test.ts +29 -6
- package/src/context/__tests__/engine.test.ts +1 -0
- package/src/context/__tests__/selector.test.ts +23 -3
- package/src/context/__tests__/wiki.test.ts +349 -0
- package/src/context/budget.ts +12 -8
- package/src/context/engine.ts +37 -0
- package/src/context/selector.ts +30 -4
- package/src/context/wiki.ts +296 -0
- package/src/db/index.ts +12 -0
- package/src/feedback/__tests__/capture.test.ts +166 -0
- package/src/feedback/__tests__/signals.test.ts +144 -0
- package/src/feedback/__tests__/tmp-capture-1775575256633-lah0etnzlj/feedback.db +0 -0
- package/src/feedback/__tests__/tmp-capture-1775575256640-2xmjme4qraa/feedback.db +0 -0
- package/src/feedback/capture.ts +102 -0
- package/src/feedback/signals.ts +68 -0
- package/src/index.ts +104 -0
- package/src/init/__tests__/init.test.ts +400 -3
- package/src/init/index.ts +368 -12
- package/src/language/__tests__/__fixtures__/detect/composer.lock +1 -0
- package/src/prompts/defaults/index.ts +3 -1
- package/src/prompts/defaults/wiki-compile.md +20 -0
- package/src/prompts/defaults/wiki-query.md +18 -0
- package/src/stats/__tests__/tool-usage.test.ts +133 -0
- package/src/stats/tracker.ts +92 -0
- package/src/verify/__tests__/pipeline.test.ts +11 -8
- package/src/verify/pipeline.ts +13 -1
- package/src/verify/tools/__tests__/wiki-lint.test.ts +784 -0
- package/src/verify/tools/wiki-lint-runner.ts +38 -0
- package/src/verify/tools/wiki-lint.ts +898 -0
- package/src/wiki/__tests__/compiler.test.ts +389 -0
- package/src/wiki/__tests__/extractors/code.test.ts +99 -0
- package/src/wiki/__tests__/extractors/decision.test.ts +323 -0
- package/src/wiki/__tests__/extractors/feature.test.ts +186 -0
- package/src/wiki/__tests__/extractors/workflow.test.ts +131 -0
- package/src/wiki/__tests__/graph.test.ts +344 -0
- package/src/wiki/__tests__/hooks.test.ts +119 -0
- package/src/wiki/__tests__/indexer.test.ts +285 -0
- package/src/wiki/__tests__/linker.test.ts +230 -0
- package/src/wiki/__tests__/louvain.test.ts +229 -0
- package/src/wiki/__tests__/query.test.ts +316 -0
- package/src/wiki/__tests__/schema.test.ts +114 -0
- package/src/wiki/__tests__/signals.test.ts +474 -0
- package/src/wiki/__tests__/state.test.ts +168 -0
- package/src/wiki/__tests__/tracking.test.ts +118 -0
- package/src/wiki/__tests__/types.test.ts +387 -0
- package/src/wiki/compiler.ts +1075 -0
- package/src/wiki/extractors/code.ts +90 -0
- package/src/wiki/extractors/decision.ts +217 -0
- package/src/wiki/extractors/feature.ts +206 -0
- package/src/wiki/extractors/workflow.ts +112 -0
- package/src/wiki/graph.ts +445 -0
- package/src/wiki/hooks.ts +49 -0
- package/src/wiki/indexer.ts +105 -0
- package/src/wiki/linker.ts +117 -0
- package/src/wiki/louvain.ts +190 -0
- package/src/wiki/prompts/compile-architecture.md +59 -0
- package/src/wiki/prompts/compile-decision.md +66 -0
- package/src/wiki/prompts/compile-entity.md +56 -0
- package/src/wiki/prompts/compile-feature.md +60 -0
- package/src/wiki/prompts/compile-module.md +42 -0
- package/src/wiki/prompts/wiki-query.md +25 -0
- package/src/wiki/query.ts +338 -0
- package/src/wiki/schema.ts +111 -0
- package/src/wiki/signals.ts +368 -0
- package/src/wiki/state.ts +89 -0
- package/src/wiki/tracking.ts +30 -0
- package/src/wiki/types.ts +169 -0
- package/src/workflow/context.ts +26 -0
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
type DelegationRequest,
|
|
4
4
|
formatDelegationRequest,
|
|
5
|
+
outputDelegationRequest,
|
|
5
6
|
parseDelegationRequest,
|
|
6
7
|
} from "../delegation";
|
|
7
8
|
|
|
@@ -103,3 +104,56 @@ Some output after`;
|
|
|
103
104
|
expect(parsed?.prompt).toBe("hello");
|
|
104
105
|
});
|
|
105
106
|
});
|
|
107
|
+
|
|
108
|
+
describe("outputDelegationRequest", () => {
|
|
109
|
+
let stderrChunks: string[];
|
|
110
|
+
let stdoutChunks: string[];
|
|
111
|
+
let originalStderrWrite: typeof process.stderr.write;
|
|
112
|
+
let originalStdoutWrite: typeof process.stdout.write;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
stderrChunks = [];
|
|
116
|
+
stdoutChunks = [];
|
|
117
|
+
originalStderrWrite = process.stderr.write;
|
|
118
|
+
originalStdoutWrite = process.stdout.write;
|
|
119
|
+
|
|
120
|
+
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
121
|
+
stderrChunks.push(
|
|
122
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
|
|
123
|
+
);
|
|
124
|
+
return true;
|
|
125
|
+
}) as typeof process.stderr.write;
|
|
126
|
+
|
|
127
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
128
|
+
stdoutChunks.push(
|
|
129
|
+
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
|
|
130
|
+
);
|
|
131
|
+
return true;
|
|
132
|
+
}) as typeof process.stdout.write;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
process.stderr.write = originalStderrWrite;
|
|
137
|
+
process.stdout.write = originalStdoutWrite;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("writes to stderr, not stdout (prevents MCP protocol corruption)", () => {
|
|
141
|
+
const req: DelegationRequest = {
|
|
142
|
+
task: "review",
|
|
143
|
+
context: "test context",
|
|
144
|
+
prompt: "test prompt",
|
|
145
|
+
expectedFormat: "text",
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
outputDelegationRequest(req);
|
|
149
|
+
|
|
150
|
+
// Must write to stderr
|
|
151
|
+
expect(stderrChunks.length).toBeGreaterThan(0);
|
|
152
|
+
const stderrOutput = stderrChunks.join("");
|
|
153
|
+
expect(stderrOutput).toContain("---MAINA_AI_REQUEST---");
|
|
154
|
+
expect(stderrOutput).toContain("task: review");
|
|
155
|
+
|
|
156
|
+
// Must NOT write to stdout (stdout is reserved for JSON-RPC in MCP)
|
|
157
|
+
expect(stdoutChunks.length).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/src/ai/delegation.ts
CHANGED
|
@@ -102,10 +102,12 @@ export function parseDelegationRequest(text: string): DelegationRequest | null {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Output a delegation request to
|
|
106
|
-
*
|
|
105
|
+
* Output a delegation request to stderr.
|
|
106
|
+
* Uses stderr so that MCP stdio transport (which uses stdout for JSON-RPC)
|
|
107
|
+
* is never corrupted by delegation text.
|
|
108
|
+
* In CLI mode, stderr is still visible in the terminal.
|
|
107
109
|
*/
|
|
108
110
|
export function outputDelegationRequest(req: DelegationRequest): void {
|
|
109
111
|
const formatted = formatDelegationRequest(req);
|
|
110
|
-
process.
|
|
112
|
+
process.stderr.write(`\n${formatted}\n`);
|
|
111
113
|
}
|
|
@@ -42,12 +42,13 @@ describe("getBudgetRatio", () => {
|
|
|
42
42
|
describe("assembleBudget", () => {
|
|
43
43
|
test("allocations sum correctly for default mode with default context window", () => {
|
|
44
44
|
const allocation = assembleBudget("default");
|
|
45
|
-
// working + episodic + semantic + retrieval + headroom === total
|
|
45
|
+
// working + episodic + semantic + retrieval + wiki + headroom === total
|
|
46
46
|
const layerSum =
|
|
47
47
|
allocation.working +
|
|
48
48
|
allocation.episodic +
|
|
49
49
|
allocation.semantic +
|
|
50
50
|
allocation.retrieval +
|
|
51
|
+
allocation.wiki +
|
|
51
52
|
allocation.headroom;
|
|
52
53
|
expect(layerSum).toBe(allocation.total);
|
|
53
54
|
});
|
|
@@ -75,7 +76,8 @@ describe("assembleBudget", () => {
|
|
|
75
76
|
allocation.working +
|
|
76
77
|
allocation.episodic +
|
|
77
78
|
allocation.semantic +
|
|
78
|
-
allocation.retrieval
|
|
79
|
+
allocation.retrieval +
|
|
80
|
+
allocation.wiki;
|
|
79
81
|
expect(layerSum).toBe(budget);
|
|
80
82
|
});
|
|
81
83
|
|
|
@@ -83,15 +85,35 @@ describe("assembleBudget", () => {
|
|
|
83
85
|
const modelContext = 200_000;
|
|
84
86
|
const allocationDefault = assembleBudget("default", modelContext);
|
|
85
87
|
const allocationFocused = assembleBudget("focused", modelContext);
|
|
86
|
-
// working = ~
|
|
87
|
-
// working = floor(80_000 * 0.
|
|
88
|
+
// working = ~12% of budget; for focused: budget = 0.4 * 200_000 = 80_000
|
|
89
|
+
// working = floor(80_000 * 0.12) = 9_600
|
|
88
90
|
expect(allocationFocused.working).toBe(
|
|
89
|
-
Math.floor(Math.floor(modelContext * 0.4) * 0.
|
|
91
|
+
Math.floor(Math.floor(modelContext * 0.4) * 0.12),
|
|
90
92
|
);
|
|
91
93
|
expect(allocationDefault.working).toBe(
|
|
92
|
-
Math.floor(Math.floor(modelContext * 0.6) * 0.
|
|
94
|
+
Math.floor(Math.floor(modelContext * 0.6) * 0.12),
|
|
93
95
|
);
|
|
94
96
|
});
|
|
97
|
+
|
|
98
|
+
test("wiki allocation is ~12% of usable budget", () => {
|
|
99
|
+
const modelContext = 200_000;
|
|
100
|
+
const allocation = assembleBudget("default", modelContext);
|
|
101
|
+
const budget = Math.floor(modelContext * 0.6);
|
|
102
|
+
expect(allocation.wiki).toBe(Math.floor(budget * 0.12));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("all 5 layers sum to budget for default mode", () => {
|
|
106
|
+
const modelContext = 200_000;
|
|
107
|
+
const allocation = assembleBudget("default", modelContext);
|
|
108
|
+
const budget = Math.floor(modelContext * 0.6);
|
|
109
|
+
const layerSum =
|
|
110
|
+
allocation.working +
|
|
111
|
+
allocation.episodic +
|
|
112
|
+
allocation.semantic +
|
|
113
|
+
allocation.retrieval +
|
|
114
|
+
allocation.wiki;
|
|
115
|
+
expect(layerSum).toBe(budget);
|
|
116
|
+
});
|
|
95
117
|
});
|
|
96
118
|
|
|
97
119
|
describe("truncateToFit", () => {
|
|
@@ -109,6 +131,7 @@ describe("truncateToFit", () => {
|
|
|
109
131
|
semantic: 200,
|
|
110
132
|
episodic: 300,
|
|
111
133
|
retrieval: 400,
|
|
134
|
+
wiki: 0,
|
|
112
135
|
headroom: 0,
|
|
113
136
|
total: 1000,
|
|
114
137
|
...overrides,
|
|
@@ -156,6 +156,7 @@ describe("assembleContext", () => {
|
|
|
156
156
|
expect(typeof result.budget.episodic).toBe("number");
|
|
157
157
|
expect(typeof result.budget.semantic).toBe("number");
|
|
158
158
|
expect(typeof result.budget.retrieval).toBe("number");
|
|
159
|
+
expect(typeof result.budget.wiki).toBe("number");
|
|
159
160
|
expect(typeof result.budget.total).toBe("number");
|
|
160
161
|
expect(typeof result.budget.headroom).toBe("number");
|
|
161
162
|
expect(result.budget.total).toBeGreaterThan(0);
|
|
@@ -3,28 +3,31 @@ import type { BudgetMode } from "../budget.ts";
|
|
|
3
3
|
import { getBudgetMode, getContextNeeds, needsLayer } from "../selector.ts";
|
|
4
4
|
|
|
5
5
|
describe("getContextNeeds", () => {
|
|
6
|
-
it("commit needs only working + conventions", () => {
|
|
6
|
+
it("commit needs only working + conventions + wiki", () => {
|
|
7
7
|
const needs = getContextNeeds("commit");
|
|
8
8
|
expect(needs.working).toBe(true);
|
|
9
9
|
expect(needs.episodic).toBe(false);
|
|
10
10
|
expect(needs.semantic).toEqual(["conventions"]);
|
|
11
11
|
expect(needs.retrieval).toBe(false);
|
|
12
|
+
expect(needs.wiki).toBe(true);
|
|
12
13
|
});
|
|
13
14
|
|
|
14
|
-
it("context command needs all
|
|
15
|
+
it("context command needs all 5 layers", () => {
|
|
15
16
|
const needs = getContextNeeds("context");
|
|
16
17
|
expect(needs.working).toBe(true);
|
|
17
18
|
expect(needs.episodic).toBe(true);
|
|
18
19
|
expect(needs.semantic).toBe(true);
|
|
19
20
|
expect(needs.retrieval).toBe(true);
|
|
21
|
+
expect(needs.wiki).toBe(true);
|
|
20
22
|
});
|
|
21
23
|
|
|
22
|
-
it("verify needs working + recent-reviews + adrs + conventions", () => {
|
|
24
|
+
it("verify needs working + recent-reviews + adrs + conventions + wiki", () => {
|
|
23
25
|
const needs = getContextNeeds("verify");
|
|
24
26
|
expect(needs.working).toBe(true);
|
|
25
27
|
expect(needs.episodic).toEqual(["recent-reviews"]);
|
|
26
28
|
expect(needs.semantic).toEqual(["adrs", "conventions"]);
|
|
27
29
|
expect(needs.retrieval).toBe(false);
|
|
30
|
+
expect(needs.wiki).toBe(true);
|
|
28
31
|
});
|
|
29
32
|
|
|
30
33
|
it("review returns correct context needs", () => {
|
|
@@ -33,6 +36,7 @@ describe("getContextNeeds", () => {
|
|
|
33
36
|
expect(needs.episodic).toEqual(["past-reviews"]);
|
|
34
37
|
expect(needs.semantic).toEqual(["adrs"]);
|
|
35
38
|
expect(needs.retrieval).toBe(false);
|
|
39
|
+
expect(needs.wiki).toBe(true);
|
|
36
40
|
});
|
|
37
41
|
|
|
38
42
|
it("plan returns correct context needs", () => {
|
|
@@ -41,6 +45,7 @@ describe("getContextNeeds", () => {
|
|
|
41
45
|
expect(needs.semantic).toEqual(["adrs", "conventions"]);
|
|
42
46
|
expect(needs.episodic).toBe(false);
|
|
43
47
|
expect(needs.retrieval).toBe(false);
|
|
48
|
+
expect(needs.wiki).toBe(true);
|
|
44
49
|
});
|
|
45
50
|
|
|
46
51
|
it("explain returns correct context needs", () => {
|
|
@@ -49,6 +54,7 @@ describe("getContextNeeds", () => {
|
|
|
49
54
|
expect(needs.episodic).toBe(false);
|
|
50
55
|
expect(needs.semantic).toBe(true);
|
|
51
56
|
expect(needs.retrieval).toBe(true);
|
|
57
|
+
expect(needs.wiki).toBe(true);
|
|
52
58
|
});
|
|
53
59
|
|
|
54
60
|
it("design returns correct context needs", () => {
|
|
@@ -57,6 +63,7 @@ describe("getContextNeeds", () => {
|
|
|
57
63
|
expect(needs.episodic).toBe(false);
|
|
58
64
|
expect(needs.semantic).toEqual(["adrs"]);
|
|
59
65
|
expect(needs.retrieval).toBe(false);
|
|
66
|
+
expect(needs.wiki).toBe(true);
|
|
60
67
|
});
|
|
61
68
|
|
|
62
69
|
it("ticket returns correct context needs", () => {
|
|
@@ -65,6 +72,7 @@ describe("getContextNeeds", () => {
|
|
|
65
72
|
expect(needs.episodic).toBe(false);
|
|
66
73
|
expect(needs.semantic).toEqual(["modules"]);
|
|
67
74
|
expect(needs.retrieval).toBe(false);
|
|
75
|
+
expect(needs.wiki).toBe(false);
|
|
68
76
|
});
|
|
69
77
|
|
|
70
78
|
it("analyze returns correct context needs", () => {
|
|
@@ -73,6 +81,7 @@ describe("getContextNeeds", () => {
|
|
|
73
81
|
expect(needs.episodic).toBe(true);
|
|
74
82
|
expect(needs.semantic).toBe(true);
|
|
75
83
|
expect(needs.retrieval).toBe(false);
|
|
84
|
+
expect(needs.wiki).toBe(true);
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
it("pr returns correct context needs", () => {
|
|
@@ -81,6 +90,7 @@ describe("getContextNeeds", () => {
|
|
|
81
90
|
expect(needs.episodic).toEqual(["past-reviews"]);
|
|
82
91
|
expect(needs.semantic).toBe(true);
|
|
83
92
|
expect(needs.retrieval).toBe(true);
|
|
93
|
+
expect(needs.wiki).toBe(true);
|
|
84
94
|
});
|
|
85
95
|
});
|
|
86
96
|
|
|
@@ -129,6 +139,16 @@ describe("needsLayer", () => {
|
|
|
129
139
|
const needs = getContextNeeds("commit");
|
|
130
140
|
expect(needsLayer(needs, "retrieval")).toBe(false);
|
|
131
141
|
});
|
|
142
|
+
|
|
143
|
+
it("correctly identifies when wiki layer is needed", () => {
|
|
144
|
+
const needs = getContextNeeds("commit");
|
|
145
|
+
expect(needsLayer(needs, "wiki")).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("correctly identifies when wiki layer is not needed", () => {
|
|
149
|
+
const needs = getContextNeeds("ticket");
|
|
150
|
+
expect(needsLayer(needs, "wiki")).toBe(false);
|
|
151
|
+
});
|
|
132
152
|
});
|
|
133
153
|
|
|
134
154
|
describe("getBudgetMode", () => {
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { calculateTokens } from "../budget";
|
|
12
|
+
import { assembleWikiText, loadWikiContext } from "../wiki";
|
|
13
|
+
|
|
14
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function makeTempDir(): string {
|
|
17
|
+
const dir = join(
|
|
18
|
+
tmpdir(),
|
|
19
|
+
`maina-wiki-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
20
|
+
);
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeWikiFile(
|
|
26
|
+
wikiDir: string,
|
|
27
|
+
relPath: string,
|
|
28
|
+
content: string,
|
|
29
|
+
): void {
|
|
30
|
+
const fullPath = join(wikiDir, relPath);
|
|
31
|
+
const dir = fullPath.substring(0, fullPath.lastIndexOf("/"));
|
|
32
|
+
mkdirSync(dir, { recursive: true });
|
|
33
|
+
writeFileSync(fullPath, content, "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe("loadWikiContext", () => {
|
|
39
|
+
let wikiDir: string;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
wikiDir = makeTempDir();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
try {
|
|
47
|
+
rmSync(wikiDir, { recursive: true, force: true });
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore cleanup errors
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when wiki directory does not exist", () => {
|
|
54
|
+
const result = loadWikiContext({
|
|
55
|
+
wikiDir: join(tmpdir(), "nonexistent-wiki-dir-xyz"),
|
|
56
|
+
});
|
|
57
|
+
expect(result).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns null when wiki directory is empty", () => {
|
|
61
|
+
// wikiDir exists but has no files
|
|
62
|
+
const result = loadWikiContext({ wikiDir });
|
|
63
|
+
expect(result).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("loads index.md when wiki exists", () => {
|
|
67
|
+
writeWikiFile(wikiDir, "index.md", "# Wiki Index\nOverview of articles.");
|
|
68
|
+
|
|
69
|
+
const result = loadWikiContext({ wikiDir });
|
|
70
|
+
expect(result).not.toBeNull();
|
|
71
|
+
expect(result?.text).toContain("Wiki Index");
|
|
72
|
+
expect(result?.text).toContain("Overview of articles");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("loads decision articles for review command", () => {
|
|
76
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
77
|
+
writeWikiFile(
|
|
78
|
+
wikiDir,
|
|
79
|
+
"decisions/adr-001.md",
|
|
80
|
+
"# ADR-001: Use TypeScript\nWe chose TypeScript for type safety.",
|
|
81
|
+
);
|
|
82
|
+
writeWikiFile(
|
|
83
|
+
wikiDir,
|
|
84
|
+
"features/feature-auth.md",
|
|
85
|
+
"# Auth Feature\nAuthentication implementation details.",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const result = loadWikiContext({ wikiDir, command: "review" });
|
|
89
|
+
expect(result).not.toBeNull();
|
|
90
|
+
expect(result?.text).toContain("ADR-001");
|
|
91
|
+
// review loads decisions and modules, not features
|
|
92
|
+
expect(result?.text).not.toContain("Auth Feature");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("loads feature articles for commit command", () => {
|
|
96
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
97
|
+
writeWikiFile(
|
|
98
|
+
wikiDir,
|
|
99
|
+
"features/feature-wiki.md",
|
|
100
|
+
"# Wiki Feature\nWiki context layer details.",
|
|
101
|
+
);
|
|
102
|
+
writeWikiFile(
|
|
103
|
+
wikiDir,
|
|
104
|
+
"decisions/adr-001.md",
|
|
105
|
+
"# ADR-001: TypeScript\nDecision content.",
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const result = loadWikiContext({ wikiDir, command: "commit" });
|
|
109
|
+
expect(result).not.toBeNull();
|
|
110
|
+
expect(result?.text).toContain("Wiki Feature");
|
|
111
|
+
// commit loads features and architecture, not decisions
|
|
112
|
+
expect(result?.text).not.toContain("ADR-001");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("loads all article types for explain command (exploration mode)", () => {
|
|
116
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
117
|
+
writeWikiFile(
|
|
118
|
+
wikiDir,
|
|
119
|
+
"decisions/adr-001.md",
|
|
120
|
+
"# ADR-001\nDecision content.",
|
|
121
|
+
);
|
|
122
|
+
writeWikiFile(
|
|
123
|
+
wikiDir,
|
|
124
|
+
"features/feature-auth.md",
|
|
125
|
+
"# Auth Feature\nAuth details.",
|
|
126
|
+
);
|
|
127
|
+
writeWikiFile(
|
|
128
|
+
wikiDir,
|
|
129
|
+
"modules/context-engine.md",
|
|
130
|
+
"# Context Engine\nEngine docs.",
|
|
131
|
+
);
|
|
132
|
+
writeWikiFile(
|
|
133
|
+
wikiDir,
|
|
134
|
+
"architecture/overview.md",
|
|
135
|
+
"# Architecture Overview\nSystem design.",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const result = loadWikiContext({ wikiDir, command: "explain" });
|
|
139
|
+
expect(result).not.toBeNull();
|
|
140
|
+
expect(result?.text).toContain("ADR-001");
|
|
141
|
+
expect(result?.text).toContain("Auth Feature");
|
|
142
|
+
expect(result?.text).toContain("Context Engine");
|
|
143
|
+
expect(result?.text).toContain("Architecture Overview");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("loads decision articles for design command (conflict detection)", () => {
|
|
147
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
148
|
+
writeWikiFile(
|
|
149
|
+
wikiDir,
|
|
150
|
+
"decisions/adr-001.md",
|
|
151
|
+
"# ADR-001\nDecision content.",
|
|
152
|
+
);
|
|
153
|
+
writeWikiFile(
|
|
154
|
+
wikiDir,
|
|
155
|
+
"features/feature-auth.md",
|
|
156
|
+
"# Auth Feature\nShould not be loaded.",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const result = loadWikiContext({ wikiDir, command: "design" });
|
|
160
|
+
expect(result).not.toBeNull();
|
|
161
|
+
expect(result?.text).toContain("ADR-001");
|
|
162
|
+
expect(result?.text).not.toContain("Auth Feature");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("respects token budget (returns content within reasonable bounds)", () => {
|
|
166
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
167
|
+
// Create several articles
|
|
168
|
+
for (let i = 0; i < 10; i++) {
|
|
169
|
+
writeWikiFile(
|
|
170
|
+
wikiDir,
|
|
171
|
+
`decisions/adr-${String(i).padStart(3, "0")}.md`,
|
|
172
|
+
`# ADR-${i}\n${"Content line. ".repeat(100)}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = loadWikiContext({ wikiDir, command: "review" });
|
|
177
|
+
expect(result).not.toBeNull();
|
|
178
|
+
expect(result?.tokens).toBeGreaterThan(0);
|
|
179
|
+
// Tokens should match calculateTokens of the text
|
|
180
|
+
expect(result?.tokens).toBe(calculateTokens(result?.text ?? ""));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns valid LayerContent with correct name and priority", () => {
|
|
184
|
+
writeWikiFile(wikiDir, "index.md", "# Wiki Index");
|
|
185
|
+
|
|
186
|
+
const result = loadWikiContext({ wikiDir });
|
|
187
|
+
expect(result).not.toBeNull();
|
|
188
|
+
expect(result?.name).toBe("wiki");
|
|
189
|
+
expect(result?.priority).toBe(4);
|
|
190
|
+
expect(typeof result?.text).toBe("string");
|
|
191
|
+
expect(typeof result?.tokens).toBe("number");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("handles empty wiki directory gracefully", () => {
|
|
195
|
+
// Create a subdirectory but no files
|
|
196
|
+
mkdirSync(join(wikiDir, "decisions"), { recursive: true });
|
|
197
|
+
|
|
198
|
+
const result = loadWikiContext({ wikiDir });
|
|
199
|
+
// Directory has an entry (subdirectory) but no actual content
|
|
200
|
+
expect(result).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("boosts articles relevant to working files", () => {
|
|
204
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
205
|
+
writeWikiFile(
|
|
206
|
+
wikiDir,
|
|
207
|
+
"modules/context-engine.md",
|
|
208
|
+
"# Context Engine\nRelevant to working files.",
|
|
209
|
+
);
|
|
210
|
+
writeWikiFile(
|
|
211
|
+
wikiDir,
|
|
212
|
+
"modules/git-ops.md",
|
|
213
|
+
"# Git Operations\nNot relevant to working files.",
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const result = loadWikiContext({
|
|
217
|
+
wikiDir,
|
|
218
|
+
workingFiles: ["packages/core/src/context/engine.ts"],
|
|
219
|
+
command: "review",
|
|
220
|
+
});
|
|
221
|
+
expect(result).not.toBeNull();
|
|
222
|
+
// Both should be loaded since both are modules and command is review
|
|
223
|
+
expect(result?.text).toContain("Context Engine");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("returns null when wiki dir has only empty subdirectories", () => {
|
|
227
|
+
mkdirSync(join(wikiDir, "decisions"), { recursive: true });
|
|
228
|
+
mkdirSync(join(wikiDir, "modules"), { recursive: true });
|
|
229
|
+
|
|
230
|
+
const result = loadWikiContext({ wikiDir });
|
|
231
|
+
expect(result).toBeNull();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("includes index content in output even without other articles", () => {
|
|
235
|
+
writeWikiFile(wikiDir, "index.md", "# Project Wiki\nThis is the index.");
|
|
236
|
+
|
|
237
|
+
const result = loadWikiContext({ wikiDir });
|
|
238
|
+
expect(result).not.toBeNull();
|
|
239
|
+
expect(result?.text).toContain("## Wiki Knowledge (Layer 5)");
|
|
240
|
+
expect(result?.text).toContain("### Index");
|
|
241
|
+
expect(result?.text).toContain("Project Wiki");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("formats articles with wiki/ path prefix", () => {
|
|
245
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
246
|
+
writeWikiFile(
|
|
247
|
+
wikiDir,
|
|
248
|
+
"decisions/adr-001.md",
|
|
249
|
+
"# ADR-001: Use Bun\nBun is faster.",
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const result = loadWikiContext({ wikiDir, command: "review" });
|
|
253
|
+
expect(result).not.toBeNull();
|
|
254
|
+
expect(result?.text).toContain("wiki/decisions/adr-001.md");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("loads all articles when no command is specified (default)", () => {
|
|
258
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
259
|
+
writeWikiFile(
|
|
260
|
+
wikiDir,
|
|
261
|
+
"decisions/adr-001.md",
|
|
262
|
+
"# Decision\nDecision content.",
|
|
263
|
+
);
|
|
264
|
+
writeWikiFile(
|
|
265
|
+
wikiDir,
|
|
266
|
+
"features/feature.md",
|
|
267
|
+
"# Feature\nFeature content.",
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const result = loadWikiContext({ wikiDir });
|
|
271
|
+
expect(result).not.toBeNull();
|
|
272
|
+
expect(result?.text).toContain("Decision");
|
|
273
|
+
expect(result?.text).toContain("Feature");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("records loaded articles to signals file for RL tracking", () => {
|
|
277
|
+
writeWikiFile(wikiDir, "index.md", "# Index");
|
|
278
|
+
writeWikiFile(
|
|
279
|
+
wikiDir,
|
|
280
|
+
"decisions/adr-001.md",
|
|
281
|
+
"# ADR-001\nDecision content.",
|
|
282
|
+
);
|
|
283
|
+
writeWikiFile(
|
|
284
|
+
wikiDir,
|
|
285
|
+
"modules/context-engine.md",
|
|
286
|
+
"# Context Engine\nEngine docs.",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const result = loadWikiContext({ wikiDir, command: "review" });
|
|
290
|
+
expect(result).not.toBeNull();
|
|
291
|
+
|
|
292
|
+
// Check that the signals file was created
|
|
293
|
+
const signalsPath = join(wikiDir, ".signals.json");
|
|
294
|
+
expect(existsSync(signalsPath)).toBe(true);
|
|
295
|
+
|
|
296
|
+
const raw = readFileSync(signalsPath, "utf-8");
|
|
297
|
+
const parsed = JSON.parse(raw);
|
|
298
|
+
expect(parsed.loadSignals).toBeDefined();
|
|
299
|
+
expect(parsed.loadSignals.length).toBeGreaterThan(0);
|
|
300
|
+
expect(parsed.loadSignals[0].command).toBe("review");
|
|
301
|
+
expect(Array.isArray(parsed.loadSignals[0].articles)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("assembleWikiText", () => {
|
|
306
|
+
it("formats with correct headers", () => {
|
|
307
|
+
const text = assembleWikiText("# Index content", [
|
|
308
|
+
{
|
|
309
|
+
path: "decisions/adr-001.md",
|
|
310
|
+
title: "ADR-001",
|
|
311
|
+
content: "Decision content.",
|
|
312
|
+
category: "decision",
|
|
313
|
+
score: 0.9,
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
expect(text).toContain("## Wiki Knowledge (Layer 5)");
|
|
318
|
+
expect(text).toContain("### Index");
|
|
319
|
+
expect(text).toContain("# Index content");
|
|
320
|
+
expect(text).toContain("### Relevant Articles");
|
|
321
|
+
expect(text).toContain("#### ADR-001 (wiki/decisions/adr-001.md)");
|
|
322
|
+
expect(text).toContain("Decision content.");
|
|
323
|
+
expect(text).toContain("---");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("handles empty index", () => {
|
|
327
|
+
const text = assembleWikiText("", [
|
|
328
|
+
{
|
|
329
|
+
path: "decisions/adr-001.md",
|
|
330
|
+
title: "ADR-001",
|
|
331
|
+
content: "Content.",
|
|
332
|
+
category: "decision",
|
|
333
|
+
score: 0.9,
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
|
|
337
|
+
expect(text).toContain("## Wiki Knowledge (Layer 5)");
|
|
338
|
+
expect(text).not.toContain("### Index");
|
|
339
|
+
expect(text).toContain("### Relevant Articles");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("handles empty articles", () => {
|
|
343
|
+
const text = assembleWikiText("# Index", []);
|
|
344
|
+
|
|
345
|
+
expect(text).toContain("## Wiki Knowledge (Layer 5)");
|
|
346
|
+
expect(text).toContain("### Index");
|
|
347
|
+
expect(text).not.toContain("### Relevant Articles");
|
|
348
|
+
});
|
|
349
|
+
});
|
package/src/context/budget.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface BudgetAllocation {
|
|
|
5
5
|
episodic: number;
|
|
6
6
|
semantic: number;
|
|
7
7
|
retrieval: number;
|
|
8
|
+
wiki: number;
|
|
8
9
|
total: number;
|
|
9
10
|
headroom: number; // reserved for AI reasoning
|
|
10
11
|
}
|
|
@@ -45,10 +46,11 @@ export function getBudgetRatio(mode: BudgetMode): number {
|
|
|
45
46
|
* Calculates per-layer token allocations for a given mode and model context window.
|
|
46
47
|
*
|
|
47
48
|
* Layer proportions within the usable budget:
|
|
48
|
-
* working: ~
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* retrieval: ~
|
|
49
|
+
* working: ~12%
|
|
50
|
+
* episodic: ~12%
|
|
51
|
+
* semantic: ~16%
|
|
52
|
+
* retrieval: ~8% (remainder after the above four)
|
|
53
|
+
* wiki: ~12%
|
|
52
54
|
*/
|
|
53
55
|
export function assembleBudget(
|
|
54
56
|
mode: BudgetMode,
|
|
@@ -58,17 +60,19 @@ export function assembleBudget(
|
|
|
58
60
|
const budget = Math.floor(modelContextWindow * ratio);
|
|
59
61
|
const headroom = modelContextWindow - budget;
|
|
60
62
|
|
|
61
|
-
const working = Math.floor(budget * 0.
|
|
62
|
-
const
|
|
63
|
-
const
|
|
63
|
+
const working = Math.floor(budget * 0.12);
|
|
64
|
+
const episodic = Math.floor(budget * 0.12);
|
|
65
|
+
const semantic = Math.floor(budget * 0.16);
|
|
66
|
+
const wiki = Math.floor(budget * 0.12);
|
|
64
67
|
// retrieval gets the exact remainder so all layer tokens sum to budget
|
|
65
|
-
const retrieval = budget - working - semantic -
|
|
68
|
+
const retrieval = budget - working - episodic - semantic - wiki;
|
|
66
69
|
|
|
67
70
|
return {
|
|
68
71
|
working,
|
|
69
72
|
semantic,
|
|
70
73
|
episodic,
|
|
71
74
|
retrieval,
|
|
75
|
+
wiki,
|
|
72
76
|
headroom,
|
|
73
77
|
total: modelContextWindow,
|
|
74
78
|
};
|