@rarusoft/dendrite-wiki 0.1.0-alpha.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 +79 -0
- package/dist/api-extractor/extract.js +269 -0
- package/dist/api-extractor/language-extractor.js +15 -0
- package/dist/api-extractor/python-extractor.js +358 -0
- package/dist/api-extractor/render.js +195 -0
- package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
- package/dist/api-extractor/types.js +11 -0
- package/dist/api-extractor/typescript-extractor.js +50 -0
- package/dist/api-extractor/walk.js +178 -0
- package/dist/api-reference.js +438 -0
- package/dist/benchmark-events.js +129 -0
- package/dist/benchmark.js +270 -0
- package/dist/binder-export.js +381 -0
- package/dist/canonical-target.js +168 -0
- package/dist/chart-insert.js +377 -0
- package/dist/chart-prompts.js +414 -0
- package/dist/context-cache.js +98 -0
- package/dist/contradicts-shipped-memory.js +232 -0
- package/dist/diff-context.js +142 -0
- package/dist/doctor.js +220 -0
- package/dist/generated-docs.js +219 -0
- package/dist/i18n.js +71 -0
- package/dist/index.js +49 -0
- package/dist/librarian.js +255 -0
- package/dist/maintenance-actions.js +244 -0
- package/dist/maintenance-inbox.js +842 -0
- package/dist/maintenance-runner.js +62 -0
- package/dist/page-drift.js +225 -0
- package/dist/page-inbox.js +168 -0
- package/dist/report-export.js +339 -0
- package/dist/review-bridge.js +1386 -0
- package/dist/search-index.js +199 -0
- package/dist/store.js +1617 -0
- package/dist/telemetry-defaults.js +44 -0
- package/dist/telemetry-report.js +263 -0
- package/dist/telemetry.js +544 -0
- package/dist/wiki-synthesis.js +901 -0
- package/package.json +35 -0
- package/src/api-extractor/extract.ts +333 -0
- package/src/api-extractor/language-extractor.ts +37 -0
- package/src/api-extractor/python-extractor.ts +380 -0
- package/src/api-extractor/render.ts +267 -0
- package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
- package/src/api-extractor/types.ts +41 -0
- package/src/api-extractor/typescript-extractor.ts +56 -0
- package/src/api-extractor/walk.ts +209 -0
- package/src/api-reference.ts +552 -0
- package/src/benchmark-events.ts +216 -0
- package/src/benchmark.ts +376 -0
- package/src/binder-export.ts +437 -0
- package/src/canonical-target.ts +192 -0
- package/src/chart-insert.ts +478 -0
- package/src/chart-prompts.ts +417 -0
- package/src/context-cache.ts +129 -0
- package/src/contradicts-shipped-memory.ts +311 -0
- package/src/diff-context.ts +187 -0
- package/src/doctor.ts +260 -0
- package/src/generated-docs.ts +316 -0
- package/src/i18n.ts +106 -0
- package/src/index.ts +59 -0
- package/src/librarian.ts +331 -0
- package/src/maintenance-actions.ts +314 -0
- package/src/maintenance-inbox.ts +1132 -0
- package/src/maintenance-runner.ts +85 -0
- package/src/page-drift.ts +292 -0
- package/src/page-inbox.ts +254 -0
- package/src/report-export.ts +392 -0
- package/src/review-bridge.ts +1729 -0
- package/src/search-index.ts +266 -0
- package/src/store.ts +2171 -0
- package/src/telemetry-defaults.ts +50 -0
- package/src/telemetry-report.ts +365 -0
- package/src/telemetry.ts +757 -0
- package/src/wiki-synthesis.ts +1307 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only benchmark event log — the per-tool-call activity stream behind snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Every meaningful MCP tool call (`session_started`, `context_requested`, `wiki_updated`,
|
|
5
|
+
* `maintenance_state_changed`, `session_snapshot`) writes one event line to the local
|
|
6
|
+
* benchmark events JSONL. The full snapshot writer in `./benchmark.ts` aggregates these
|
|
7
|
+
* into the daily trend; the recall-quality and dashboard surfaces in the Review Board
|
|
8
|
+
* read recent events to render live activity.
|
|
9
|
+
*
|
|
10
|
+
* Strictly local — events never leave the machine unless the operator has explicitly
|
|
11
|
+
* opted into telemetry via `./telemetry.ts` AND configured a destination URL/token. The
|
|
12
|
+
* default install never sends event data anywhere.
|
|
13
|
+
*/
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
export type DendriteBenchmarkEventName =
|
|
18
|
+
| 'session_started'
|
|
19
|
+
| 'context_requested'
|
|
20
|
+
| 'wiki_updated'
|
|
21
|
+
| 'maintenance_state_changed'
|
|
22
|
+
| 'session_snapshot';
|
|
23
|
+
|
|
24
|
+
export type DendriteBenchmarkEventTrigger =
|
|
25
|
+
| 'server'
|
|
26
|
+
| 'wiki_context'
|
|
27
|
+
| 'wiki_write'
|
|
28
|
+
| 'wiki_log'
|
|
29
|
+
| 'wiki_write_proposals'
|
|
30
|
+
| 'wiki_apply_proposal'
|
|
31
|
+
| 'wiki_execute_maintenance_action'
|
|
32
|
+
| 'browser-editor'
|
|
33
|
+
| 'wiki_insert_chart'
|
|
34
|
+
| 'wiki_replace_chart';
|
|
35
|
+
|
|
36
|
+
export interface DendriteBenchmarkEvent {
|
|
37
|
+
schemaVersion: 1;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
event: DendriteBenchmarkEventName;
|
|
40
|
+
trigger: DendriteBenchmarkEventTrigger;
|
|
41
|
+
metrics?: Record<string, number>;
|
|
42
|
+
detail?: Record<string, boolean | number | string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DendriteBenchmarkEventSummary {
|
|
46
|
+
schemaVersion: 1;
|
|
47
|
+
generatedAt: string;
|
|
48
|
+
eventCount: number;
|
|
49
|
+
logPath: string;
|
|
50
|
+
byType: Record<DendriteBenchmarkEventName, number>;
|
|
51
|
+
usage: {
|
|
52
|
+
sessionStartedCount: number;
|
|
53
|
+
contextRequestCount: number;
|
|
54
|
+
wikiUpdateCount: number;
|
|
55
|
+
maintenanceStateChangeCount: number;
|
|
56
|
+
sessionSnapshotCount: number;
|
|
57
|
+
};
|
|
58
|
+
orientation: {
|
|
59
|
+
latestContextPageCount: number | null;
|
|
60
|
+
latestContextOmittedPageCount: number | null;
|
|
61
|
+
latestOpenQuestionCount: number | null;
|
|
62
|
+
};
|
|
63
|
+
maintenance: {
|
|
64
|
+
acceptedProposalCount: number;
|
|
65
|
+
latestLintFindingCount: number | null;
|
|
66
|
+
latestProposalCount: number | null;
|
|
67
|
+
};
|
|
68
|
+
recentEvents: Array<Pick<DendriteBenchmarkEvent, 'timestamp' | 'event' | 'trigger'>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface BenchmarkEventWriteOptions {
|
|
72
|
+
root?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface DendriteBenchmarkEventInput {
|
|
76
|
+
event: DendriteBenchmarkEventName;
|
|
77
|
+
trigger: DendriteBenchmarkEventTrigger;
|
|
78
|
+
metrics?: Record<string, number>;
|
|
79
|
+
detail?: Record<string, boolean | number | string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const eventLogRelativePath = path.join('local-data', 'benchmark-events.jsonl');
|
|
83
|
+
const summaryRelativePath = path.join('docs', 'public', 'dendrite-benchmark-events-summary.json');
|
|
84
|
+
|
|
85
|
+
export async function appendBenchmarkEvent(
|
|
86
|
+
input: DendriteBenchmarkEventInput,
|
|
87
|
+
options: BenchmarkEventWriteOptions = {}
|
|
88
|
+
): Promise<DendriteBenchmarkEvent> {
|
|
89
|
+
const event: DendriteBenchmarkEvent = {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
timestamp: new Date().toISOString(),
|
|
92
|
+
event: input.event,
|
|
93
|
+
trigger: input.trigger,
|
|
94
|
+
metrics: input.metrics,
|
|
95
|
+
detail: input.detail
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (isBenchmarkEventCaptureDisabled()) {
|
|
99
|
+
return event;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
103
|
+
const eventLogPath = path.join(root, eventLogRelativePath);
|
|
104
|
+
const summaryPath = path.join(root, summaryRelativePath);
|
|
105
|
+
|
|
106
|
+
await fs.mkdir(path.dirname(eventLogPath), { recursive: true });
|
|
107
|
+
await fs.mkdir(path.dirname(summaryPath), { recursive: true });
|
|
108
|
+
await fs.appendFile(eventLogPath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
109
|
+
|
|
110
|
+
const summary = await buildBenchmarkEventSummary(eventLogPath);
|
|
111
|
+
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
|
112
|
+
|
|
113
|
+
return event;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function captureBenchmarkEvent(
|
|
117
|
+
input: DendriteBenchmarkEventInput,
|
|
118
|
+
options: BenchmarkEventWriteOptions = {}
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
if (isBenchmarkEventCaptureDisabled()) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await appendBenchmarkEvent(input, options);
|
|
126
|
+
} catch {
|
|
127
|
+
// Benchmark event capture is advisory and should not block MCP usage.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function buildBenchmarkEventSummary(eventLogPath: string): Promise<DendriteBenchmarkEventSummary> {
|
|
132
|
+
const events = await readBenchmarkEvents(eventLogPath);
|
|
133
|
+
const byType = createEmptyEventCounts();
|
|
134
|
+
let latestContextPageCount: number | null = null;
|
|
135
|
+
let latestContextOmittedPageCount: number | null = null;
|
|
136
|
+
let latestOpenQuestionCount: number | null = null;
|
|
137
|
+
let latestLintFindingCount: number | null = null;
|
|
138
|
+
let latestProposalCount: number | null = null;
|
|
139
|
+
let acceptedProposalCount = 0;
|
|
140
|
+
|
|
141
|
+
for (const event of events) {
|
|
142
|
+
byType[event.event] += 1;
|
|
143
|
+
|
|
144
|
+
if (event.event === 'context_requested') {
|
|
145
|
+
latestContextPageCount = event.metrics?.contextPageCount ?? latestContextPageCount;
|
|
146
|
+
latestContextOmittedPageCount = event.metrics?.contextOmittedPageCount ?? latestContextOmittedPageCount;
|
|
147
|
+
latestOpenQuestionCount = event.metrics?.openQuestionCount ?? latestOpenQuestionCount;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (event.event === 'maintenance_state_changed') {
|
|
151
|
+
latestLintFindingCount = event.metrics?.lintFindingCount ?? latestLintFindingCount;
|
|
152
|
+
latestProposalCount = event.metrics?.proposalCount ?? latestProposalCount;
|
|
153
|
+
if (event.detail?.acceptedProposal === true) {
|
|
154
|
+
acceptedProposalCount += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
generatedAt: new Date().toISOString(),
|
|
162
|
+
eventCount: events.length,
|
|
163
|
+
logPath: eventLogRelativePath.replace(/\\/g, '/'),
|
|
164
|
+
byType,
|
|
165
|
+
usage: {
|
|
166
|
+
sessionStartedCount: byType.session_started,
|
|
167
|
+
contextRequestCount: byType.context_requested,
|
|
168
|
+
wikiUpdateCount: byType.wiki_updated,
|
|
169
|
+
maintenanceStateChangeCount: byType.maintenance_state_changed,
|
|
170
|
+
sessionSnapshotCount: byType.session_snapshot
|
|
171
|
+
},
|
|
172
|
+
orientation: {
|
|
173
|
+
latestContextPageCount,
|
|
174
|
+
latestContextOmittedPageCount,
|
|
175
|
+
latestOpenQuestionCount
|
|
176
|
+
},
|
|
177
|
+
maintenance: {
|
|
178
|
+
acceptedProposalCount,
|
|
179
|
+
latestLintFindingCount,
|
|
180
|
+
latestProposalCount
|
|
181
|
+
},
|
|
182
|
+
recentEvents: events.slice(-8).map(({ timestamp, event, trigger }) => ({ timestamp, event, trigger }))
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readBenchmarkEvents(eventLogPath: string): Promise<DendriteBenchmarkEvent[]> {
|
|
187
|
+
const content = await fs.readFile(eventLogPath, 'utf8').catch(() => '');
|
|
188
|
+
if (content.trim().length === 0) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return content
|
|
193
|
+
.split(/\r?\n/u)
|
|
194
|
+
.filter((line) => line.trim().length > 0)
|
|
195
|
+
.flatMap((line) => {
|
|
196
|
+
try {
|
|
197
|
+
return [JSON.parse(line) as DendriteBenchmarkEvent];
|
|
198
|
+
} catch {
|
|
199
|
+
return [];
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function createEmptyEventCounts(): Record<DendriteBenchmarkEventName, number> {
|
|
205
|
+
return {
|
|
206
|
+
session_started: 0,
|
|
207
|
+
context_requested: 0,
|
|
208
|
+
wiki_updated: 0,
|
|
209
|
+
maintenance_state_changed: 0,
|
|
210
|
+
session_snapshot: 0
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isBenchmarkEventCaptureDisabled(): boolean {
|
|
215
|
+
return process.env.DENDRITE_WIKI_DISABLE_BENCHMARK_EVENTS === '1';
|
|
216
|
+
}
|
package/src/benchmark.ts
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Benchmark snapshot writer — captures a single point-in-time view of project health.
|
|
3
|
+
*
|
|
4
|
+
* Each snapshot records page counts, lint findings, claim counts, memory counts, recall-
|
|
5
|
+
* benchmark scores (top-1, top-5, MRR), maintenance inbox depth, and git HEAD. Written to
|
|
6
|
+
* `docs/public/dendrite-benchmark-latest.json` (the latest) and appended to
|
|
7
|
+
* `docs/public/dendrite-benchmark-history.json` (the trend). The wiki's Benchmark Report
|
|
8
|
+
* page renders the trend in the browser; CI runs and `npm run check` produce snapshots
|
|
9
|
+
* labeled `docs-build` and `session-start` so trend lines have meaningful x-axis points.
|
|
10
|
+
*
|
|
11
|
+
* Snapshots are the kill-switch metric the project uses to validate behavior changes:
|
|
12
|
+
* if a refactor's snapshot regresses recall numbers, the change is reverted before it
|
|
13
|
+
* ships. Local-first by default — no telemetry leaves the machine unless the operator
|
|
14
|
+
* explicitly opts in via `./telemetry.ts`.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { promisify } from 'node:util';
|
|
20
|
+
import { buildMaintenanceInboxSnapshot } from './maintenance-inbox.js';
|
|
21
|
+
import { reviewProjectMemories } from '@rarusoft/dendrite-memory';
|
|
22
|
+
import { runRecallBenchmark, type RecallBenchmarkResult } from '@rarusoft/dendrite-memory';
|
|
23
|
+
import {
|
|
24
|
+
buildWikiContext,
|
|
25
|
+
buildWikiGraphSnapshot,
|
|
26
|
+
lintWikiPages,
|
|
27
|
+
listGuidanceLifecycle,
|
|
28
|
+
listWikiPages,
|
|
29
|
+
listWikiProposals
|
|
30
|
+
} from './store.js';
|
|
31
|
+
|
|
32
|
+
const execFileAsync = promisify(execFile);
|
|
33
|
+
|
|
34
|
+
export interface DendriteBenchmarkOptions {
|
|
35
|
+
root?: string;
|
|
36
|
+
label?: string;
|
|
37
|
+
query?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DendriteBenchmarkSnapshot {
|
|
41
|
+
schemaVersion: 1;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
label: string;
|
|
44
|
+
query: string;
|
|
45
|
+
git: {
|
|
46
|
+
commit: string;
|
|
47
|
+
branch: string;
|
|
48
|
+
dirty: boolean;
|
|
49
|
+
};
|
|
50
|
+
metrics: {
|
|
51
|
+
pageCount: number;
|
|
52
|
+
metadataCoverage: number;
|
|
53
|
+
claimCount: number;
|
|
54
|
+
staleClaimCount: number;
|
|
55
|
+
lintFindingCount: number;
|
|
56
|
+
proposalCount: number;
|
|
57
|
+
guidanceCount: number;
|
|
58
|
+
activeGuidanceCount: number;
|
|
59
|
+
graphNodeCount: number;
|
|
60
|
+
graphEdgeCount: number;
|
|
61
|
+
contextPageCount: number;
|
|
62
|
+
contextOmittedPageCount: number;
|
|
63
|
+
};
|
|
64
|
+
context: {
|
|
65
|
+
selectedSlugs: string[];
|
|
66
|
+
omittedSlugs: string[];
|
|
67
|
+
openQuestionCount: number;
|
|
68
|
+
};
|
|
69
|
+
recall: {
|
|
70
|
+
probesSource: RecallBenchmarkResult['probesSource'];
|
|
71
|
+
probesPath: string | null;
|
|
72
|
+
probeCount: number;
|
|
73
|
+
evaluatedProbeCount: number;
|
|
74
|
+
top1HitCount: number;
|
|
75
|
+
top5HitCount: number;
|
|
76
|
+
missCount: number;
|
|
77
|
+
meanReciprocalRank: number;
|
|
78
|
+
averageReasonCount: number;
|
|
79
|
+
shadowBipartiteSeenProbeCount: number;
|
|
80
|
+
shadowBipartiteAverageBonus: number;
|
|
81
|
+
shadowBipartitePotentialRankChangeCount: number;
|
|
82
|
+
shadowSemanticSeenProbeCount: number;
|
|
83
|
+
shadowSemanticAverageCosine: number;
|
|
84
|
+
shadowSemanticAverageTopCosine: number;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface DendriteBenchmarkHistoryArtifact {
|
|
89
|
+
schemaVersion: 1;
|
|
90
|
+
generatedAt: string;
|
|
91
|
+
latest: DendriteBenchmarkSnapshot;
|
|
92
|
+
snapshots: DendriteBenchmarkSnapshot[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const defaultBenchmarkQuery = 'What is the current project status, what changed recently, and what should the operator decide next?';
|
|
96
|
+
|
|
97
|
+
export async function collectBenchmarkSnapshot(options: DendriteBenchmarkOptions = {}): Promise<DendriteBenchmarkSnapshot> {
|
|
98
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
99
|
+
const [pages, findings, proposals, graph, context, guidance, memoryReview, recall] = await Promise.all([
|
|
100
|
+
listWikiPages(),
|
|
101
|
+
lintWikiPages(),
|
|
102
|
+
listWikiProposals(),
|
|
103
|
+
buildWikiGraphSnapshot(),
|
|
104
|
+
buildWikiContext(options.query ?? defaultBenchmarkQuery, { maxPages: 5, includeLint: true }),
|
|
105
|
+
listGuidanceLifecycle(),
|
|
106
|
+
reviewProjectMemories(),
|
|
107
|
+
runRecallBenchmark(root)
|
|
108
|
+
]);
|
|
109
|
+
const inbox = await buildMaintenanceInboxSnapshot(findings, proposals, { memoryFindings: memoryReview.findings });
|
|
110
|
+
const git = await readGitState(root);
|
|
111
|
+
const claimCount = context.claims.length;
|
|
112
|
+
const staleClaimCount = context.claims.filter((claim) => claim.status !== 'current').length;
|
|
113
|
+
const graphEdgeCount = graph.nodes.reduce((total, node) => total + node.outgoingLinks.length, 0);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
schemaVersion: 1,
|
|
117
|
+
timestamp: new Date().toISOString(),
|
|
118
|
+
label: options.label ?? 'manual',
|
|
119
|
+
query: options.query ?? defaultBenchmarkQuery,
|
|
120
|
+
git,
|
|
121
|
+
metrics: {
|
|
122
|
+
pageCount: pages.length,
|
|
123
|
+
metadataCoverage: pages.length === 0 ? 0 : pages.filter((page) => page.metadata !== undefined).length / pages.length,
|
|
124
|
+
claimCount,
|
|
125
|
+
staleClaimCount,
|
|
126
|
+
lintFindingCount: inbox.status.lintFindingCount,
|
|
127
|
+
proposalCount: inbox.status.proposalCount,
|
|
128
|
+
guidanceCount: guidance.length,
|
|
129
|
+
activeGuidanceCount: guidance.filter((item) => item.status === 'active').length,
|
|
130
|
+
graphNodeCount: graph.nodes.length,
|
|
131
|
+
graphEdgeCount,
|
|
132
|
+
contextPageCount: context.pages.length,
|
|
133
|
+
contextOmittedPageCount: context.omittedPageReasons.length
|
|
134
|
+
},
|
|
135
|
+
context: {
|
|
136
|
+
selectedSlugs: context.pages.map((page) => page.slug),
|
|
137
|
+
omittedSlugs: context.omittedPageReasons.map((page) => page.slug),
|
|
138
|
+
openQuestionCount: context.openQuestions.length
|
|
139
|
+
},
|
|
140
|
+
recall: {
|
|
141
|
+
probesSource: recall.probesSource,
|
|
142
|
+
probesPath: recall.probesPath,
|
|
143
|
+
probeCount: recall.probeCount,
|
|
144
|
+
evaluatedProbeCount: recall.evaluatedProbeCount,
|
|
145
|
+
top1HitCount: recall.top1HitCount,
|
|
146
|
+
top5HitCount: recall.top5HitCount,
|
|
147
|
+
missCount: recall.missCount,
|
|
148
|
+
meanReciprocalRank: recall.meanReciprocalRank,
|
|
149
|
+
averageReasonCount: recall.averageReasonCount,
|
|
150
|
+
shadowBipartiteSeenProbeCount: recall.shadowBipartiteSeenProbeCount,
|
|
151
|
+
shadowBipartiteAverageBonus: recall.shadowBipartiteAverageBonus,
|
|
152
|
+
shadowBipartitePotentialRankChangeCount: recall.shadowBipartitePotentialRankChangeCount,
|
|
153
|
+
shadowSemanticSeenProbeCount: recall.shadowSemanticSeenProbeCount,
|
|
154
|
+
shadowSemanticAverageCosine: recall.shadowSemanticAverageCosine,
|
|
155
|
+
shadowSemanticAverageTopCosine: recall.shadowSemanticAverageTopCosine
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function writeBenchmarkSnapshot(options: DendriteBenchmarkOptions = {}): Promise<DendriteBenchmarkSnapshot> {
|
|
161
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
162
|
+
const artifactPath = path.join(root, 'docs', 'public', 'dendrite-benchmark-latest.json');
|
|
163
|
+
const historyArtifactPath = path.join(root, 'docs', 'public', 'dendrite-benchmark-history.json');
|
|
164
|
+
const logPath = path.join(root, 'docs', 'wiki', 'benchmark-log.md');
|
|
165
|
+
const previousLatestSnapshot = await readLatestBenchmarkSnapshot(artifactPath);
|
|
166
|
+
const snapshot = await collectBenchmarkSnapshot({ ...options, root });
|
|
167
|
+
|
|
168
|
+
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
|
|
169
|
+
await fs.writeFile(artifactPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
|
|
170
|
+
await fs.writeFile(
|
|
171
|
+
historyArtifactPath,
|
|
172
|
+
`${JSON.stringify(await buildBenchmarkHistoryArtifact(historyArtifactPath, previousLatestSnapshot, snapshot), null, 2)}\n`,
|
|
173
|
+
'utf8'
|
|
174
|
+
);
|
|
175
|
+
await ensureBenchmarkLog(logPath);
|
|
176
|
+
await fs.appendFile(logPath, renderBenchmarkRow(snapshot), 'utf8');
|
|
177
|
+
|
|
178
|
+
return snapshot;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function buildBenchmarkHistoryArtifact(
|
|
182
|
+
historyArtifactPath: string,
|
|
183
|
+
previousLatestSnapshot: DendriteBenchmarkSnapshot | null,
|
|
184
|
+
snapshot: DendriteBenchmarkSnapshot
|
|
185
|
+
): Promise<DendriteBenchmarkHistoryArtifact> {
|
|
186
|
+
const existing = await readBenchmarkHistoryArtifact(historyArtifactPath);
|
|
187
|
+
const latestSeed = existing.snapshots.length === 0 ? previousLatestSnapshot : null;
|
|
188
|
+
const snapshots = [...existing.snapshots];
|
|
189
|
+
|
|
190
|
+
if (latestSeed && latestSeed.timestamp !== snapshot.timestamp) {
|
|
191
|
+
snapshots.push(latestSeed);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
snapshots.push(snapshot);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
schemaVersion: 1,
|
|
198
|
+
generatedAt: snapshot.timestamp,
|
|
199
|
+
latest: snapshot,
|
|
200
|
+
snapshots
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function readBenchmarkHistory(root?: string): Promise<DendriteBenchmarkHistoryArtifact> {
|
|
205
|
+
const resolvedRoot = path.resolve(root ?? process.cwd());
|
|
206
|
+
const historyArtifactPath = path.join(resolvedRoot, 'docs', 'public', 'dendrite-benchmark-history.json');
|
|
207
|
+
return readBenchmarkHistoryArtifact(historyArtifactPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function readBenchmarkHistoryArtifact(historyArtifactPath: string): Promise<DendriteBenchmarkHistoryArtifact> {
|
|
211
|
+
const existing = await fs.readFile(historyArtifactPath, 'utf8').catch(() => undefined);
|
|
212
|
+
if (!existing) {
|
|
213
|
+
return emptyBenchmarkHistoryArtifact();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(existing) as Partial<DendriteBenchmarkHistoryArtifact>;
|
|
218
|
+
if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.snapshots)) {
|
|
219
|
+
return emptyBenchmarkHistoryArtifact();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const snapshots = parsed.snapshots.filter(isBenchmarkSnapshot).map(normalizeStoredBenchmarkSnapshot);
|
|
223
|
+
return {
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
generatedAt: typeof parsed.generatedAt === 'string' ? parsed.generatedAt : '',
|
|
226
|
+
latest: isBenchmarkSnapshot(parsed.latest) ? normalizeStoredBenchmarkSnapshot(parsed.latest) : snapshots.at(-1) ?? emptyBenchmarkSnapshot(),
|
|
227
|
+
snapshots
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return emptyBenchmarkHistoryArtifact();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readLatestBenchmarkSnapshot(latestArtifactPath: string): Promise<DendriteBenchmarkSnapshot | null> {
|
|
235
|
+
const existing = await fs.readFile(latestArtifactPath, 'utf8').catch(() => undefined);
|
|
236
|
+
if (!existing) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const parsed = JSON.parse(existing) as unknown;
|
|
242
|
+
return isBenchmarkSnapshot(parsed) ? normalizeStoredBenchmarkSnapshot(parsed) : null;
|
|
243
|
+
} catch {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeStoredBenchmarkSnapshot(snapshot: DendriteBenchmarkSnapshot): DendriteBenchmarkSnapshot {
|
|
249
|
+
if (snapshot.recall) {
|
|
250
|
+
return snapshot;
|
|
251
|
+
}
|
|
252
|
+
const empty = emptyBenchmarkSnapshot();
|
|
253
|
+
return { ...snapshot, recall: empty.recall };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function emptyBenchmarkHistoryArtifact(): DendriteBenchmarkHistoryArtifact {
|
|
257
|
+
return {
|
|
258
|
+
schemaVersion: 1,
|
|
259
|
+
generatedAt: '',
|
|
260
|
+
latest: emptyBenchmarkSnapshot(),
|
|
261
|
+
snapshots: []
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function emptyBenchmarkSnapshot(): DendriteBenchmarkSnapshot {
|
|
266
|
+
return {
|
|
267
|
+
schemaVersion: 1,
|
|
268
|
+
timestamp: '',
|
|
269
|
+
label: '',
|
|
270
|
+
query: '',
|
|
271
|
+
git: {
|
|
272
|
+
commit: 'unknown',
|
|
273
|
+
branch: 'unknown',
|
|
274
|
+
dirty: false
|
|
275
|
+
},
|
|
276
|
+
metrics: {
|
|
277
|
+
pageCount: 0,
|
|
278
|
+
metadataCoverage: 0,
|
|
279
|
+
claimCount: 0,
|
|
280
|
+
staleClaimCount: 0,
|
|
281
|
+
lintFindingCount: 0,
|
|
282
|
+
proposalCount: 0,
|
|
283
|
+
guidanceCount: 0,
|
|
284
|
+
activeGuidanceCount: 0,
|
|
285
|
+
graphNodeCount: 0,
|
|
286
|
+
graphEdgeCount: 0,
|
|
287
|
+
contextPageCount: 0,
|
|
288
|
+
contextOmittedPageCount: 0
|
|
289
|
+
},
|
|
290
|
+
context: {
|
|
291
|
+
selectedSlugs: [],
|
|
292
|
+
omittedSlugs: [],
|
|
293
|
+
openQuestionCount: 0
|
|
294
|
+
},
|
|
295
|
+
recall: {
|
|
296
|
+
probesSource: 'auto-derived',
|
|
297
|
+
probesPath: null,
|
|
298
|
+
probeCount: 0,
|
|
299
|
+
evaluatedProbeCount: 0,
|
|
300
|
+
top1HitCount: 0,
|
|
301
|
+
top5HitCount: 0,
|
|
302
|
+
missCount: 0,
|
|
303
|
+
meanReciprocalRank: 0,
|
|
304
|
+
averageReasonCount: 0,
|
|
305
|
+
shadowBipartiteSeenProbeCount: 0,
|
|
306
|
+
shadowBipartiteAverageBonus: 0,
|
|
307
|
+
shadowBipartitePotentialRankChangeCount: 0,
|
|
308
|
+
shadowSemanticSeenProbeCount: 0,
|
|
309
|
+
shadowSemanticAverageCosine: 0,
|
|
310
|
+
shadowSemanticAverageTopCosine: 0
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isBenchmarkSnapshot(value: unknown): value is DendriteBenchmarkSnapshot {
|
|
316
|
+
if (!value || typeof value !== 'object') {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const snapshot = value as Partial<DendriteBenchmarkSnapshot>;
|
|
321
|
+
return snapshot.schemaVersion === 1 && typeof snapshot.timestamp === 'string' && typeof snapshot.label === 'string';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function ensureBenchmarkLog(logPath: string): Promise<void> {
|
|
325
|
+
const existing = await fs.readFile(logPath, 'utf8').catch(() => undefined);
|
|
326
|
+
if (existing) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
await fs.mkdir(path.dirname(logPath), { recursive: true });
|
|
331
|
+
await fs.writeFile(
|
|
332
|
+
logPath,
|
|
333
|
+
[
|
|
334
|
+
'# Benchmark Log',
|
|
335
|
+
'',
|
|
336
|
+
'This page records Dendrite Wiki MCP benchmark snapshots for this project.',
|
|
337
|
+
'',
|
|
338
|
+
'## Snapshots',
|
|
339
|
+
'',
|
|
340
|
+
'| Timestamp | Label | Pages | Claims | Lint Findings | Proposals | Context Pages | Git Commit |',
|
|
341
|
+
'|---|---|---:|---:|---:|---:|---:|---|'
|
|
342
|
+
].join('\n') + '\n',
|
|
343
|
+
'utf8'
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function renderBenchmarkRow(snapshot: DendriteBenchmarkSnapshot): string {
|
|
348
|
+
return `| ${snapshot.timestamp} | ${escapeTableCell(snapshot.label)} | ${snapshot.metrics.pageCount} | ${snapshot.metrics.claimCount} | ${snapshot.metrics.lintFindingCount} | ${snapshot.metrics.proposalCount} | ${snapshot.metrics.contextPageCount} | ${snapshot.git.commit} |\n`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function escapeTableCell(value: string): string {
|
|
352
|
+
return value.replace(/\|/g, '\\|');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readGitState(root: string): Promise<DendriteBenchmarkSnapshot['git']> {
|
|
356
|
+
const [commit, branch, status] = await Promise.all([
|
|
357
|
+
readGitOutput(root, ['rev-parse', '--short', 'HEAD']),
|
|
358
|
+
readGitOutput(root, ['branch', '--show-current']),
|
|
359
|
+
readGitOutput(root, ['status', '--short'])
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
commit: commit || 'unknown',
|
|
364
|
+
branch: branch || 'unknown',
|
|
365
|
+
dirty: status.length > 0
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
async function readGitOutput(root: string, args: string[]): Promise<string> {
|
|
370
|
+
try {
|
|
371
|
+
const { stdout } = await execFileAsync('git', args, { cwd: root });
|
|
372
|
+
return stdout.trim();
|
|
373
|
+
} catch {
|
|
374
|
+
return '';
|
|
375
|
+
}
|
|
376
|
+
}
|