@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level driver that runs a maintenance action end-to-end.
|
|
3
|
+
*
|
|
4
|
+
* Wraps `executeMaintenanceAction` with the surrounding scaffolding the Review Board
|
|
5
|
+
* needs: refresh the generated wiki views before and after the action so the inbox
|
|
6
|
+
* reflects the new state, persist a latest-action artifact at
|
|
7
|
+
* `docs/public/maintenance-action-result.json` for the Review Board's "Done" overlay
|
|
8
|
+
* polling, and append a project-log entry summarizing the action so the change appears
|
|
9
|
+
* in `git log` next to the page it touched. Used by both the CLI (`dendrite-wiki
|
|
10
|
+
* wiki:action`) and the review bridge HTTP endpoint.
|
|
11
|
+
*/
|
|
12
|
+
import { executeMaintenanceAction } from './maintenance-actions.js';
|
|
13
|
+
import {
|
|
14
|
+
refreshGeneratedWikiDocs,
|
|
15
|
+
writeLatestMaintenanceActionArtifact,
|
|
16
|
+
type MaintenanceActionArtifact
|
|
17
|
+
} from './generated-docs.js';
|
|
18
|
+
import { appendProjectLog } from './store.js';
|
|
19
|
+
|
|
20
|
+
export interface RunMaintenanceActionOptions {
|
|
21
|
+
// Only consumed by the edit-page-summary action — bridges the operator-supplied
|
|
22
|
+
// textarea draft from the review board into the action handler. Pass-through-only here.
|
|
23
|
+
summaryDraft?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function runMaintenanceActionAndRefresh(
|
|
27
|
+
actionId: string,
|
|
28
|
+
options: RunMaintenanceActionOptions = {}
|
|
29
|
+
): Promise<MaintenanceActionArtifact> {
|
|
30
|
+
const execution = await executeMaintenanceAction(actionId, options);
|
|
31
|
+
const changedPaths = extractChangedPaths(execution.result);
|
|
32
|
+
const projectLogEntry = buildProjectLogEntry(execution, changedPaths);
|
|
33
|
+
|
|
34
|
+
if (projectLogEntry) {
|
|
35
|
+
await appendProjectLog(projectLogEntry);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const refresh = await refreshGeneratedWikiDocs();
|
|
39
|
+
|
|
40
|
+
const artifact: MaintenanceActionArtifact = {
|
|
41
|
+
ranAt: new Date().toISOString(),
|
|
42
|
+
refreshedPageCount: refresh.pageCount,
|
|
43
|
+
audit: {
|
|
44
|
+
artifactPath: 'docs/public/maintenance-action-result.json',
|
|
45
|
+
changedPaths,
|
|
46
|
+
projectLogEntry,
|
|
47
|
+
undoPath: buildUndoPath(changedPaths)
|
|
48
|
+
},
|
|
49
|
+
execution
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await writeLatestMaintenanceActionArtifact(artifact);
|
|
53
|
+
return artifact;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractChangedPaths(result: unknown): string[] {
|
|
57
|
+
const updatedPaths = (result as { updatedPaths?: unknown }).updatedPaths;
|
|
58
|
+
return Array.isArray(updatedPaths) ? updatedPaths.filter((path): path is string => typeof path === 'string') : [];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildProjectLogEntry(execution: Awaited<ReturnType<typeof executeMaintenanceAction>>, changedPaths: string[]): string | undefined {
|
|
62
|
+
// Log the action kinds that mutate canonical (committed) project state. We deliberately
|
|
63
|
+
// skip 'snoozed-page-drift' (local-data only — operator workflow noise) and the read-only
|
|
64
|
+
// result kinds; logging those would bloat the project log without adding signal.
|
|
65
|
+
const loggedResultKinds: ReadonlySet<string> = new Set([
|
|
66
|
+
'applied-proposal',
|
|
67
|
+
'inserted-h1',
|
|
68
|
+
'archived-guidance-file',
|
|
69
|
+
'edited-page-summary'
|
|
70
|
+
]);
|
|
71
|
+
if (!loggedResultKinds.has(execution.resultKind)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const changedSummary = changedPaths.length > 0 ? ` Changed paths: ${changedPaths.join(', ')}.` : '';
|
|
76
|
+
return `Accepted maintenance action ${execution.actionId}. ${execution.resultSummary}${changedSummary}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildUndoPath(changedPaths: string[]): string {
|
|
80
|
+
if (changedPaths.length === 0) {
|
|
81
|
+
return 'No project files were changed, so no undo path is needed.';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `Before committing, inspect git diff for ${changedPaths.join(', ')} and restore those paths from version control if the accepted maintenance action should be undone.`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page drift detection.
|
|
3
|
+
*
|
|
4
|
+
* Computes Jaccard token overlap between a page's stated intent (its title + first
|
|
5
|
+
* paragraph) and the recent project-log entries that mention this page's slug. Low overlap
|
|
6
|
+
* means the page declares one purpose but recent activity has been about something else —
|
|
7
|
+
* the page is drifting away from its title.
|
|
8
|
+
*
|
|
9
|
+
* Ported from dendrite-mcp's drift-detection-via-Jaccard pattern. Pure deterministic, no
|
|
10
|
+
* LLM, no embeddings — just token sets compared by intersection-over-union. Surfaces as a
|
|
11
|
+
* `page-drift` wiki lint finding so it lands in the existing maintenance review board
|
|
12
|
+
* without needing new UI. The operator can suppress false-positive drifts via the snooze
|
|
13
|
+
* store in `./page-drift-snoozes.ts` rather than being forced to perform a fake edit on
|
|
14
|
+
* the page just to clear the finding.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Tunable defaults. After the first dogfood pass on this repo flagged 14 of 32 pages
|
|
18
|
+
// (a busy session pushed the project log full of campaign-specific entries that legitimately
|
|
19
|
+
// don't share vocabulary with the abstract page intents), we made two adjustments:
|
|
20
|
+
//
|
|
21
|
+
// 1. Threshold dropped from 0.5 to 0.35 so only genuinely-divergent pages flag.
|
|
22
|
+
// 2. A 7-day recency filter on log entries means a single busy week doesn't accumulate
|
|
23
|
+
// enough activity-side tokens to swamp the intent-side tokens.
|
|
24
|
+
//
|
|
25
|
+
// Both are overridable via detectPageDrift({ thresholdSimilarity, maxLogEntryAgeDays })
|
|
26
|
+
// so the lint can be retuned per project as real usage signals emerge.
|
|
27
|
+
|
|
28
|
+
const DEFAULT_SIMILARITY_THRESHOLD = 0.35;
|
|
29
|
+
const DEFAULT_MAX_LOG_ENTRY_AGE_DAYS = 7;
|
|
30
|
+
const MIN_INTENT_TOKEN_COUNT = 4;
|
|
31
|
+
const MIN_ACTIVITY_TOKEN_COUNT = 4;
|
|
32
|
+
const MAX_RECENT_LOG_ENTRIES_FOR_PAGE = 8;
|
|
33
|
+
const MIN_RECENT_LOG_ENTRIES_FOR_DRIFT_CHECK = 2;
|
|
34
|
+
// Drift requires activity to recur across at least this many distinct date headings within
|
|
35
|
+
// the recency window. A single busy day's burst — even if it produces many log entries —
|
|
36
|
+
// is not enough to signal drift; that's session noise, not divergence. Real drift means
|
|
37
|
+
// the project has been talking about the page in different terms across multiple days.
|
|
38
|
+
const MIN_DISTINCT_DAYS_FOR_DRIFT_CHECK = 2;
|
|
39
|
+
|
|
40
|
+
const STOP_TOKENS = new Set([
|
|
41
|
+
'the',
|
|
42
|
+
'and',
|
|
43
|
+
'for',
|
|
44
|
+
'from',
|
|
45
|
+
'into',
|
|
46
|
+
'this',
|
|
47
|
+
'that',
|
|
48
|
+
'with',
|
|
49
|
+
'have',
|
|
50
|
+
'been',
|
|
51
|
+
'should',
|
|
52
|
+
'must',
|
|
53
|
+
'when',
|
|
54
|
+
'than',
|
|
55
|
+
'these',
|
|
56
|
+
'those',
|
|
57
|
+
'their',
|
|
58
|
+
'what',
|
|
59
|
+
'about',
|
|
60
|
+
'where',
|
|
61
|
+
'page',
|
|
62
|
+
'wiki',
|
|
63
|
+
'project'
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
export interface PageDriftSignal {
|
|
67
|
+
similarity: number;
|
|
68
|
+
intentTokens: string[];
|
|
69
|
+
activityTokens: string[];
|
|
70
|
+
matchedLogEntries: number;
|
|
71
|
+
matchedDistinctDays: number;
|
|
72
|
+
sampleIntent: string;
|
|
73
|
+
sampleActivity: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PageDriftDetectorOptions {
|
|
77
|
+
thresholdSimilarity?: number;
|
|
78
|
+
maxLogEntryAgeDays?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Minimum number of distinct date headings (## YYYY-MM-DD) the page must be mentioned
|
|
81
|
+
* under for drift detection to fire. Defaults to 2 — drift means activity is *recurring
|
|
82
|
+
* across days* with off-topic vocabulary, not just bursting in a single session.
|
|
83
|
+
*/
|
|
84
|
+
minDistinctDays?: number;
|
|
85
|
+
/** ISO timestamp used as "now" for date arithmetic. Tests pass this so fixed-date fixtures don't decay. */
|
|
86
|
+
referenceDate?: Date;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RecentLogEntriesMatch {
|
|
90
|
+
entries: string[];
|
|
91
|
+
distinctDays: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function detectPageDrift(
|
|
95
|
+
pageContent: string,
|
|
96
|
+
pageSlug: string,
|
|
97
|
+
recentProjectLogText: string,
|
|
98
|
+
options: PageDriftDetectorOptions = {}
|
|
99
|
+
): PageDriftSignal | undefined {
|
|
100
|
+
const intent = extractPageIntent(pageContent);
|
|
101
|
+
if (!intent) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const intentTokens = tokenize(intent);
|
|
106
|
+
if (intentTokens.size < MIN_INTENT_TOKEN_COUNT) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const threshold = options.thresholdSimilarity ?? DEFAULT_SIMILARITY_THRESHOLD;
|
|
111
|
+
const maxAgeDays = options.maxLogEntryAgeDays ?? DEFAULT_MAX_LOG_ENTRY_AGE_DAYS;
|
|
112
|
+
const minDistinctDays = options.minDistinctDays ?? MIN_DISTINCT_DAYS_FOR_DRIFT_CHECK;
|
|
113
|
+
|
|
114
|
+
const match = extractRecentEntriesMentioningPage(
|
|
115
|
+
recentProjectLogText,
|
|
116
|
+
pageSlug,
|
|
117
|
+
MAX_RECENT_LOG_ENTRIES_FOR_PAGE,
|
|
118
|
+
maxAgeDays,
|
|
119
|
+
options.referenceDate ?? new Date()
|
|
120
|
+
);
|
|
121
|
+
if (match.entries.length < MIN_RECENT_LOG_ENTRIES_FOR_DRIFT_CHECK) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
// Distinct-days gate: a single concentrated burst of activity (even if it produces many
|
|
125
|
+
// log entries under one date heading) is session noise, not drift. Drift requires the
|
|
126
|
+
// page to keep getting off-topic mentions across multiple working days.
|
|
127
|
+
if (match.distinctDays < minDistinctDays) {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const activityText = match.entries.join(' ');
|
|
132
|
+
const activityTokens = tokenize(activityText);
|
|
133
|
+
if (activityTokens.size < MIN_ACTIVITY_TOKEN_COUNT) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const similarity = jaccardSimilarity(intentTokens, activityTokens);
|
|
138
|
+
if (similarity >= threshold) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
similarity,
|
|
144
|
+
intentTokens: [...intentTokens].sort(),
|
|
145
|
+
activityTokens: [...activityTokens].sort(),
|
|
146
|
+
matchedLogEntries: match.entries.length,
|
|
147
|
+
matchedDistinctDays: match.distinctDays,
|
|
148
|
+
sampleIntent: truncate(intent, 120),
|
|
149
|
+
sampleActivity: truncate(match.entries[match.entries.length - 1] ?? '', 120)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function extractPageIntent(pageContent: string): string {
|
|
154
|
+
const withoutFrontmatter = pageContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
|
155
|
+
const lines = withoutFrontmatter.split(/\r?\n/);
|
|
156
|
+
let title = '';
|
|
157
|
+
const paragraphLines: string[] = [];
|
|
158
|
+
let foundTitle = false;
|
|
159
|
+
let collectingParagraph = false;
|
|
160
|
+
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const trimmed = line.trim();
|
|
163
|
+
if (!foundTitle) {
|
|
164
|
+
const titleMatch = trimmed.match(/^#\s+(.+)$/);
|
|
165
|
+
if (titleMatch) {
|
|
166
|
+
title = titleMatch[1].trim();
|
|
167
|
+
foundTitle = true;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// After the H1, look for the first non-empty, non-heading paragraph.
|
|
174
|
+
if (!collectingParagraph) {
|
|
175
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
collectingParagraph = true;
|
|
179
|
+
paragraphLines.push(trimmed);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Stop the paragraph at the next blank line or heading.
|
|
184
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
paragraphLines.push(trimmed);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!title) {
|
|
191
|
+
return '';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const paragraph = paragraphLines.join(' ');
|
|
195
|
+
return paragraph ? `${title}. ${paragraph}` : title;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function extractRecentEntriesMentioningPage(
|
|
199
|
+
projectLogText: string,
|
|
200
|
+
pageSlug: string,
|
|
201
|
+
maxEntries: number,
|
|
202
|
+
maxAgeDays: number = DEFAULT_MAX_LOG_ENTRY_AGE_DAYS,
|
|
203
|
+
referenceDate: Date = new Date()
|
|
204
|
+
): RecentLogEntriesMatch {
|
|
205
|
+
if (!projectLogText) {
|
|
206
|
+
return { entries: [], distinctDays: 0 };
|
|
207
|
+
}
|
|
208
|
+
// Project log entries are bullet lines under date headings of the form `## YYYY-MM-DD`.
|
|
209
|
+
// We forward-scan tracking the current date heading; entries are kept only when their
|
|
210
|
+
// associated date is within maxAgeDays of referenceDate. Entries that appear before any
|
|
211
|
+
// date heading or after a heading older than the cutoff are skipped.
|
|
212
|
+
//
|
|
213
|
+
// We also track which distinct date headings produced matches, so the caller can tell a
|
|
214
|
+
// single-day burst (session noise) apart from multi-day drift (real divergence).
|
|
215
|
+
//
|
|
216
|
+
// Note: appendProjectLog only adds a date heading once per day, so multiple entries from
|
|
217
|
+
// the same day all live under that single heading. Old entries in the file may have
|
|
218
|
+
// stale headings that fail the recency check, which is exactly what we want — drift
|
|
219
|
+
// detection should reflect *recent* activity, not the project's entire history.
|
|
220
|
+
const lines = projectLogText.split(/\r?\n/);
|
|
221
|
+
const slugTokens = [
|
|
222
|
+
pageSlug.toLowerCase(),
|
|
223
|
+
pageSlug.toLowerCase().replace(/-/g, ' '),
|
|
224
|
+
pageSlug.toLowerCase().replace(/-/g, '_')
|
|
225
|
+
];
|
|
226
|
+
const cutoffMs = referenceDate.getTime() - maxAgeDays * 86_400_000;
|
|
227
|
+
const matches: string[] = [];
|
|
228
|
+
const matchedDateHeadings = new Set<string>();
|
|
229
|
+
let currentHeadingIsRecent = false;
|
|
230
|
+
let currentHeadingDate = '';
|
|
231
|
+
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
const trimmed = line.trim();
|
|
234
|
+
const dateHeading = trimmed.match(/^##\s+(\d{4}-\d{2}-\d{2})/);
|
|
235
|
+
if (dateHeading) {
|
|
236
|
+
const headingMs = Date.parse(dateHeading[1]);
|
|
237
|
+
currentHeadingIsRecent = Number.isFinite(headingMs) && headingMs >= cutoffMs;
|
|
238
|
+
currentHeadingDate = dateHeading[1];
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (!currentHeadingIsRecent) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!trimmed.startsWith('- ')) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const lowered = trimmed.toLowerCase();
|
|
248
|
+
if (slugTokens.some((token) => lowered.includes(token))) {
|
|
249
|
+
matches.push(trimmed.replace(/^-\s*/, ''));
|
|
250
|
+
matchedDateHeadings.add(currentHeadingDate);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Project log appends newest entries at the file end. We walked forward, so `matches`
|
|
254
|
+
// is in document order (oldest → newest). Reverse and cap so callers see newest-first,
|
|
255
|
+
// matching the original recency-biased ordering before the date-window filter landed.
|
|
256
|
+
return {
|
|
257
|
+
entries: matches.slice(-maxEntries).reverse(),
|
|
258
|
+
distinctDays: matchedDateHeadings.size
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function tokenize(text: string): Set<string> {
|
|
263
|
+
return new Set(
|
|
264
|
+
text
|
|
265
|
+
.toLowerCase()
|
|
266
|
+
.split(/[^a-z0-9]+/i)
|
|
267
|
+
.map((token) => token.trim())
|
|
268
|
+
.filter((token) => token.length >= 4 && !STOP_TOKENS.has(token))
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
|
273
|
+
if (a.size === 0 || b.size === 0) {
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
let intersection = 0;
|
|
277
|
+
for (const token of a) {
|
|
278
|
+
if (b.has(token)) intersection += 1;
|
|
279
|
+
}
|
|
280
|
+
const union = a.size + b.size - intersection;
|
|
281
|
+
return union === 0 ? 0 : intersection / union;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function truncate(text: string, max: number): string {
|
|
285
|
+
if (text.length <= max) return text;
|
|
286
|
+
return `${text.slice(0, max - 1).trimEnd()}…`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function buildPageDriftMessage(signal: PageDriftSignal): string {
|
|
290
|
+
const pct = Math.round(signal.similarity * 100);
|
|
291
|
+
return `Page drift suspected: only ${pct}% token overlap between page intent and ${signal.matchedLogEntries} recent project-log entries mentioning this page. Page may have outgrown its stated purpose.`;
|
|
292
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-page maintenance projection — the data behind the in-page "memory pending" badge.
|
|
3
|
+
*
|
|
4
|
+
* The central Maintenance Review Board is the right surface for an operator triaging the
|
|
5
|
+
* whole backlog, but it forces a context switch off the page they were actually reading.
|
|
6
|
+
* This module answers a narrower question: "for THIS specific wiki page, what memories
|
|
7
|
+
* want to land on it, and what lint findings does it have right now?" The browser-side
|
|
8
|
+
* `PageMemoryBadge` Vue component renders the result as a small floating pill that
|
|
9
|
+
* expands into an inline action panel — apply a pending promotion without leaving the
|
|
10
|
+
* page.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is a projection of the same `reviewProjectMemories` + `lintWikiPages`
|
|
13
|
+
* pipelines the central board uses. The action ids are byte-identical to the ones
|
|
14
|
+
* `buildMaintenanceInboxSnapshot` emits, so `/actions/execute` accepts them unchanged.
|
|
15
|
+
* That keeps the audit story intact: apply still writes through `maintenance-runner.ts`,
|
|
16
|
+
* still appends a project-log entry, still marks the source memory superseded.
|
|
17
|
+
*/
|
|
18
|
+
// Side-effect import: registers WikiCanonicalTarget on the brain DI surface.
|
|
19
|
+
import './canonical-target.js';
|
|
20
|
+
import { previewProjectMemoryPromotion, resolvePromotionTargetSlug } from '@rarusoft/dendrite-memory';
|
|
21
|
+
import {
|
|
22
|
+
reviewProjectMemories,
|
|
23
|
+
type ProjectMemoryRecord,
|
|
24
|
+
type ProjectMemoryReviewFinding,
|
|
25
|
+
type ProjectMemoryReviewKind
|
|
26
|
+
} from '@rarusoft/dendrite-memory';
|
|
27
|
+
import { lintWikiPages, readWikiPage, type WikiLintFinding, type WikiLintRule } from './store.js';
|
|
28
|
+
|
|
29
|
+
export interface PageInboxMemoryRecord {
|
|
30
|
+
id: string;
|
|
31
|
+
kind: ProjectMemoryRecord['kind'];
|
|
32
|
+
summary: string;
|
|
33
|
+
text: string;
|
|
34
|
+
recallCount: number;
|
|
35
|
+
sources: string[];
|
|
36
|
+
relatedFiles: string[];
|
|
37
|
+
relatedPages: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PageInboxMemoryItem {
|
|
41
|
+
kind: 'memory-promotion';
|
|
42
|
+
reviewKind: ProjectMemoryReviewKind;
|
|
43
|
+
/** Action id that `/actions/execute` will accept to apply the promotion. */
|
|
44
|
+
applyActionId: string;
|
|
45
|
+
/** Action id for the read-only draft preview (no writes). */
|
|
46
|
+
draftActionId: string;
|
|
47
|
+
summary: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
memoryIds: string[];
|
|
50
|
+
records: PageInboxMemoryRecord[];
|
|
51
|
+
/** Anchor (slug-of-heading) the promotion would inject under; '' when appended at end. */
|
|
52
|
+
proposedSectionAnchor: string;
|
|
53
|
+
proposedHeading: string;
|
|
54
|
+
/** A 1-2 sentence preview of the markdown that would be inserted. */
|
|
55
|
+
proposedTextPreview: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PageInboxLintItem {
|
|
59
|
+
kind: 'lint';
|
|
60
|
+
rule: WikiLintRule;
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PageInboxSnapshot {
|
|
65
|
+
slug: string;
|
|
66
|
+
pageExists: boolean;
|
|
67
|
+
memoryItems: PageInboxMemoryItem[];
|
|
68
|
+
lintItems: PageInboxLintItem[];
|
|
69
|
+
/** memoryItems.length + lintItems.length — what the badge surfaces as a count. */
|
|
70
|
+
total: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PROPOSED_TEXT_PREVIEW_CHARS = 320;
|
|
74
|
+
|
|
75
|
+
export interface PageInboxSummaryEntry {
|
|
76
|
+
slug: string;
|
|
77
|
+
/** Sum of pending memory promotions + lint findings targeted at this slug. */
|
|
78
|
+
total: number;
|
|
79
|
+
/** Count of pending memory promotions (subset of total). */
|
|
80
|
+
memoryCount: number;
|
|
81
|
+
/** Count of lint findings (subset of total). */
|
|
82
|
+
lintCount: number;
|
|
83
|
+
/** True when any lint finding on this page is in the urgent bucket
|
|
84
|
+
* (contradicts-shipped-memory, page-drift, etc.). Drives the sidebar
|
|
85
|
+
* badge's tone so operators see the rot signals at a glance. */
|
|
86
|
+
hasUrgent: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* One-shot projection across every page: which slugs have any pending memory promotions
|
|
91
|
+
* or lint findings, and how many. Powers the sidebar-link decoration so the operator
|
|
92
|
+
* sees pending counts on every link in the wiki nav without having to visit each page.
|
|
93
|
+
*/
|
|
94
|
+
export async function buildPageInboxSummary(): Promise<PageInboxSummaryEntry[]> {
|
|
95
|
+
const [memoryReview, lintFindings] = await Promise.all([
|
|
96
|
+
reviewProjectMemories(),
|
|
97
|
+
lintWikiPages()
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
type Bucket = { memoryCount: number; lintCount: number; hasUrgent: boolean };
|
|
101
|
+
const counts = new Map<string, Bucket>();
|
|
102
|
+
const bump = (slug: string): Bucket => {
|
|
103
|
+
let entry = counts.get(slug);
|
|
104
|
+
if (!entry) {
|
|
105
|
+
entry = { memoryCount: 0, lintCount: 0, hasUrgent: false };
|
|
106
|
+
counts.set(slug, entry);
|
|
107
|
+
}
|
|
108
|
+
return entry;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (const finding of memoryReview.findings) {
|
|
112
|
+
if (finding.kind !== 'promotion-ready') continue;
|
|
113
|
+
const target = resolvePromotionTargetSlug(finding.records);
|
|
114
|
+
bump(target).memoryCount += 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const finding of lintFindings) {
|
|
118
|
+
const entry = bump(finding.slug);
|
|
119
|
+
entry.lintCount += 1;
|
|
120
|
+
if (finding.rule === 'contradicts-shipped-memory' || finding.rule === 'page-drift') {
|
|
121
|
+
entry.hasUrgent = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Array.from(counts.entries())
|
|
126
|
+
.map(([slug, entry]) => ({
|
|
127
|
+
slug,
|
|
128
|
+
total: entry.memoryCount + entry.lintCount,
|
|
129
|
+
memoryCount: entry.memoryCount,
|
|
130
|
+
lintCount: entry.lintCount,
|
|
131
|
+
hasUrgent: entry.hasUrgent
|
|
132
|
+
}))
|
|
133
|
+
.filter((entry) => entry.total > 0)
|
|
134
|
+
.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function buildPageInboxSnapshot(slug: string): Promise<PageInboxSnapshot> {
|
|
138
|
+
const trimmed = slug.trim();
|
|
139
|
+
if (!trimmed) {
|
|
140
|
+
throw new Error('page-inbox requires a non-empty slug.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const [memoryReview, lintFindings, pageContent] = await Promise.all([
|
|
144
|
+
reviewProjectMemories(),
|
|
145
|
+
lintWikiPages(),
|
|
146
|
+
readWikiPage(trimmed).catch(() => '')
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
const memoryItems = await collectPageMemoryItems(trimmed, memoryReview.findings);
|
|
150
|
+
const lintItems = lintFindings
|
|
151
|
+
.filter((finding) => finding.slug === trimmed)
|
|
152
|
+
.map((finding): PageInboxLintItem => ({
|
|
153
|
+
kind: 'lint',
|
|
154
|
+
rule: finding.rule,
|
|
155
|
+
message: finding.message
|
|
156
|
+
}));
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
slug: trimmed,
|
|
160
|
+
pageExists: pageContent !== '',
|
|
161
|
+
memoryItems,
|
|
162
|
+
lintItems,
|
|
163
|
+
total: memoryItems.length + lintItems.length
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function collectPageMemoryItems(
|
|
168
|
+
slug: string,
|
|
169
|
+
findings: ProjectMemoryReviewFinding[]
|
|
170
|
+
): Promise<PageInboxMemoryItem[]> {
|
|
171
|
+
const items: PageInboxMemoryItem[] = [];
|
|
172
|
+
for (const finding of findings) {
|
|
173
|
+
if (finding.kind !== 'promotion-ready') {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
// resolvePromotionTargetSlug mirrors what previewProjectMemoryPromotion does when
|
|
177
|
+
// no explicit target is passed — so the badge filter exactly matches the page the
|
|
178
|
+
// apply would actually write to. If the operator picks a different target via the
|
|
179
|
+
// central board's modal, that's outside the badge's view, which is fine.
|
|
180
|
+
const resolvedTarget = resolvePromotionTargetSlug(finding.records);
|
|
181
|
+
if (resolvedTarget !== slug) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const memoryIds = finding.memoryIds;
|
|
186
|
+
const applyActionId = buildMemoryActionId('promotion-ready', memoryIds, 'apply-memory-promotion');
|
|
187
|
+
const draftActionId = buildMemoryActionId('promotion-ready', memoryIds, 'draft-memory-promotion');
|
|
188
|
+
|
|
189
|
+
let proposedSectionAnchor = '';
|
|
190
|
+
let proposedHeading = '';
|
|
191
|
+
let proposedTextPreview = '';
|
|
192
|
+
try {
|
|
193
|
+
// Reusing the existing preview avoids drift between "what the badge promises" and
|
|
194
|
+
// "what apply does" — the same module computes both. The cost is one preview build
|
|
195
|
+
// per pending item, which is cheap for the small numbers a single page sees.
|
|
196
|
+
const preview = await previewProjectMemoryPromotion(memoryIds, { targetPage: slug });
|
|
197
|
+
proposedSectionAnchor = preview.proposedSectionAnchor;
|
|
198
|
+
proposedHeading = preview.sectionHeading;
|
|
199
|
+
proposedTextPreview = truncatePreview(preview.proposedText);
|
|
200
|
+
} catch {
|
|
201
|
+
// Promotion preview can throw if the memory id resolves to a record that's been
|
|
202
|
+
// archived between the review and the preview call. Skip the preview text in that
|
|
203
|
+
// case; the action ids are still valid for the central board.
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
items.push({
|
|
207
|
+
kind: 'memory-promotion',
|
|
208
|
+
reviewKind: finding.kind,
|
|
209
|
+
applyActionId,
|
|
210
|
+
draftActionId,
|
|
211
|
+
summary: finding.summary,
|
|
212
|
+
reason: finding.reason,
|
|
213
|
+
memoryIds,
|
|
214
|
+
records: finding.records.map(toInboxRecord),
|
|
215
|
+
proposedSectionAnchor,
|
|
216
|
+
proposedHeading,
|
|
217
|
+
proposedTextPreview
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return items;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toInboxRecord(record: ProjectMemoryRecord): PageInboxMemoryRecord {
|
|
224
|
+
return {
|
|
225
|
+
id: record.id,
|
|
226
|
+
kind: record.kind,
|
|
227
|
+
summary: record.summary,
|
|
228
|
+
text: record.text,
|
|
229
|
+
recallCount: record.recallCount,
|
|
230
|
+
sources: record.sources.map((source) => `${source.kind}:${source.slug}`),
|
|
231
|
+
relatedFiles: record.relatedFiles,
|
|
232
|
+
relatedPages: record.relatedPages
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Mirrors `buildMemoryActionId` from maintenance-inbox.ts. Kept inline here rather than
|
|
237
|
+
// imported because the inbox module is the heavy lifter; this surface only needs the id
|
|
238
|
+
// shape and a single review kind. If the id format ever changes, both call sites have to
|
|
239
|
+
// move in lockstep, and the maintenance-inbox tests already lock the shape.
|
|
240
|
+
function buildMemoryActionId(
|
|
241
|
+
reviewKind: ProjectMemoryReviewKind,
|
|
242
|
+
memoryIds: string[],
|
|
243
|
+
actionKind: 'apply-memory-promotion' | 'draft-memory-promotion'
|
|
244
|
+
): string {
|
|
245
|
+
return `memory:${reviewKind}:${memoryIds.join('+')}:${actionKind}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function truncatePreview(proposedText: string): string {
|
|
249
|
+
const normalized = proposedText.replace(/\s+/g, ' ').trim();
|
|
250
|
+
if (normalized.length <= PROPOSED_TEXT_PREVIEW_CHARS) {
|
|
251
|
+
return normalized;
|
|
252
|
+
}
|
|
253
|
+
return `${normalized.slice(0, PROPOSED_TEXT_PREVIEW_CHARS - 1).trimEnd()}…`;
|
|
254
|
+
}
|