@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.
Files changed (74) hide show
  1. package/README.md +79 -0
  2. package/dist/api-extractor/extract.js +269 -0
  3. package/dist/api-extractor/language-extractor.js +15 -0
  4. package/dist/api-extractor/python-extractor.js +358 -0
  5. package/dist/api-extractor/render.js +195 -0
  6. package/dist/api-extractor/tree-sitter-extractor.js +1079 -0
  7. package/dist/api-extractor/types.js +11 -0
  8. package/dist/api-extractor/typescript-extractor.js +50 -0
  9. package/dist/api-extractor/walk.js +178 -0
  10. package/dist/api-reference.js +438 -0
  11. package/dist/benchmark-events.js +129 -0
  12. package/dist/benchmark.js +270 -0
  13. package/dist/binder-export.js +381 -0
  14. package/dist/canonical-target.js +168 -0
  15. package/dist/chart-insert.js +377 -0
  16. package/dist/chart-prompts.js +414 -0
  17. package/dist/context-cache.js +98 -0
  18. package/dist/contradicts-shipped-memory.js +232 -0
  19. package/dist/diff-context.js +142 -0
  20. package/dist/doctor.js +220 -0
  21. package/dist/generated-docs.js +219 -0
  22. package/dist/i18n.js +71 -0
  23. package/dist/index.js +49 -0
  24. package/dist/librarian.js +255 -0
  25. package/dist/maintenance-actions.js +244 -0
  26. package/dist/maintenance-inbox.js +842 -0
  27. package/dist/maintenance-runner.js +62 -0
  28. package/dist/page-drift.js +225 -0
  29. package/dist/page-inbox.js +168 -0
  30. package/dist/report-export.js +339 -0
  31. package/dist/review-bridge.js +1386 -0
  32. package/dist/search-index.js +199 -0
  33. package/dist/store.js +1617 -0
  34. package/dist/telemetry-defaults.js +44 -0
  35. package/dist/telemetry-report.js +263 -0
  36. package/dist/telemetry.js +544 -0
  37. package/dist/wiki-synthesis.js +901 -0
  38. package/package.json +35 -0
  39. package/src/api-extractor/extract.ts +333 -0
  40. package/src/api-extractor/language-extractor.ts +37 -0
  41. package/src/api-extractor/python-extractor.ts +380 -0
  42. package/src/api-extractor/render.ts +267 -0
  43. package/src/api-extractor/tree-sitter-extractor.ts +1210 -0
  44. package/src/api-extractor/types.ts +41 -0
  45. package/src/api-extractor/typescript-extractor.ts +56 -0
  46. package/src/api-extractor/walk.ts +209 -0
  47. package/src/api-reference.ts +552 -0
  48. package/src/benchmark-events.ts +216 -0
  49. package/src/benchmark.ts +376 -0
  50. package/src/binder-export.ts +437 -0
  51. package/src/canonical-target.ts +192 -0
  52. package/src/chart-insert.ts +478 -0
  53. package/src/chart-prompts.ts +417 -0
  54. package/src/context-cache.ts +129 -0
  55. package/src/contradicts-shipped-memory.ts +311 -0
  56. package/src/diff-context.ts +187 -0
  57. package/src/doctor.ts +260 -0
  58. package/src/generated-docs.ts +316 -0
  59. package/src/i18n.ts +106 -0
  60. package/src/index.ts +59 -0
  61. package/src/librarian.ts +331 -0
  62. package/src/maintenance-actions.ts +314 -0
  63. package/src/maintenance-inbox.ts +1132 -0
  64. package/src/maintenance-runner.ts +85 -0
  65. package/src/page-drift.ts +292 -0
  66. package/src/page-inbox.ts +254 -0
  67. package/src/report-export.ts +392 -0
  68. package/src/review-bridge.ts +1729 -0
  69. package/src/search-index.ts +266 -0
  70. package/src/store.ts +2171 -0
  71. package/src/telemetry-defaults.ts +50 -0
  72. package/src/telemetry-report.ts +365 -0
  73. package/src/telemetry.ts +757 -0
  74. package/src/wiki-synthesis.ts +1307 -0
@@ -0,0 +1,62 @@
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 { refreshGeneratedWikiDocs, writeLatestMaintenanceActionArtifact } from './generated-docs.js';
14
+ import { appendProjectLog } from './store.js';
15
+ export async function runMaintenanceActionAndRefresh(actionId, options = {}) {
16
+ const execution = await executeMaintenanceAction(actionId, options);
17
+ const changedPaths = extractChangedPaths(execution.result);
18
+ const projectLogEntry = buildProjectLogEntry(execution, changedPaths);
19
+ if (projectLogEntry) {
20
+ await appendProjectLog(projectLogEntry);
21
+ }
22
+ const refresh = await refreshGeneratedWikiDocs();
23
+ const artifact = {
24
+ ranAt: new Date().toISOString(),
25
+ refreshedPageCount: refresh.pageCount,
26
+ audit: {
27
+ artifactPath: 'docs/public/maintenance-action-result.json',
28
+ changedPaths,
29
+ projectLogEntry,
30
+ undoPath: buildUndoPath(changedPaths)
31
+ },
32
+ execution
33
+ };
34
+ await writeLatestMaintenanceActionArtifact(artifact);
35
+ return artifact;
36
+ }
37
+ function extractChangedPaths(result) {
38
+ const updatedPaths = result.updatedPaths;
39
+ return Array.isArray(updatedPaths) ? updatedPaths.filter((path) => typeof path === 'string') : [];
40
+ }
41
+ function buildProjectLogEntry(execution, changedPaths) {
42
+ // Log the action kinds that mutate canonical (committed) project state. We deliberately
43
+ // skip 'snoozed-page-drift' (local-data only — operator workflow noise) and the read-only
44
+ // result kinds; logging those would bloat the project log without adding signal.
45
+ const loggedResultKinds = new Set([
46
+ 'applied-proposal',
47
+ 'inserted-h1',
48
+ 'archived-guidance-file',
49
+ 'edited-page-summary'
50
+ ]);
51
+ if (!loggedResultKinds.has(execution.resultKind)) {
52
+ return undefined;
53
+ }
54
+ const changedSummary = changedPaths.length > 0 ? ` Changed paths: ${changedPaths.join(', ')}.` : '';
55
+ return `Accepted maintenance action ${execution.actionId}. ${execution.resultSummary}${changedSummary}`;
56
+ }
57
+ function buildUndoPath(changedPaths) {
58
+ if (changedPaths.length === 0) {
59
+ return 'No project files were changed, so no undo path is needed.';
60
+ }
61
+ return `Before committing, inspect git diff for ${changedPaths.join(', ')} and restore those paths from version control if the accepted maintenance action should be undone.`;
62
+ }
@@ -0,0 +1,225 @@
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
+ // Tunable defaults. After the first dogfood pass on this repo flagged 14 of 32 pages
17
+ // (a busy session pushed the project log full of campaign-specific entries that legitimately
18
+ // don't share vocabulary with the abstract page intents), we made two adjustments:
19
+ //
20
+ // 1. Threshold dropped from 0.5 to 0.35 so only genuinely-divergent pages flag.
21
+ // 2. A 7-day recency filter on log entries means a single busy week doesn't accumulate
22
+ // enough activity-side tokens to swamp the intent-side tokens.
23
+ //
24
+ // Both are overridable via detectPageDrift({ thresholdSimilarity, maxLogEntryAgeDays })
25
+ // so the lint can be retuned per project as real usage signals emerge.
26
+ const DEFAULT_SIMILARITY_THRESHOLD = 0.35;
27
+ const DEFAULT_MAX_LOG_ENTRY_AGE_DAYS = 7;
28
+ const MIN_INTENT_TOKEN_COUNT = 4;
29
+ const MIN_ACTIVITY_TOKEN_COUNT = 4;
30
+ const MAX_RECENT_LOG_ENTRIES_FOR_PAGE = 8;
31
+ const MIN_RECENT_LOG_ENTRIES_FOR_DRIFT_CHECK = 2;
32
+ // Drift requires activity to recur across at least this many distinct date headings within
33
+ // the recency window. A single busy day's burst — even if it produces many log entries —
34
+ // is not enough to signal drift; that's session noise, not divergence. Real drift means
35
+ // the project has been talking about the page in different terms across multiple days.
36
+ const MIN_DISTINCT_DAYS_FOR_DRIFT_CHECK = 2;
37
+ const STOP_TOKENS = new Set([
38
+ 'the',
39
+ 'and',
40
+ 'for',
41
+ 'from',
42
+ 'into',
43
+ 'this',
44
+ 'that',
45
+ 'with',
46
+ 'have',
47
+ 'been',
48
+ 'should',
49
+ 'must',
50
+ 'when',
51
+ 'than',
52
+ 'these',
53
+ 'those',
54
+ 'their',
55
+ 'what',
56
+ 'about',
57
+ 'where',
58
+ 'page',
59
+ 'wiki',
60
+ 'project'
61
+ ]);
62
+ export function detectPageDrift(pageContent, pageSlug, recentProjectLogText, options = {}) {
63
+ const intent = extractPageIntent(pageContent);
64
+ if (!intent) {
65
+ return undefined;
66
+ }
67
+ const intentTokens = tokenize(intent);
68
+ if (intentTokens.size < MIN_INTENT_TOKEN_COUNT) {
69
+ return undefined;
70
+ }
71
+ const threshold = options.thresholdSimilarity ?? DEFAULT_SIMILARITY_THRESHOLD;
72
+ const maxAgeDays = options.maxLogEntryAgeDays ?? DEFAULT_MAX_LOG_ENTRY_AGE_DAYS;
73
+ const minDistinctDays = options.minDistinctDays ?? MIN_DISTINCT_DAYS_FOR_DRIFT_CHECK;
74
+ const match = extractRecentEntriesMentioningPage(recentProjectLogText, pageSlug, MAX_RECENT_LOG_ENTRIES_FOR_PAGE, maxAgeDays, options.referenceDate ?? new Date());
75
+ if (match.entries.length < MIN_RECENT_LOG_ENTRIES_FOR_DRIFT_CHECK) {
76
+ return undefined;
77
+ }
78
+ // Distinct-days gate: a single concentrated burst of activity (even if it produces many
79
+ // log entries under one date heading) is session noise, not drift. Drift requires the
80
+ // page to keep getting off-topic mentions across multiple working days.
81
+ if (match.distinctDays < minDistinctDays) {
82
+ return undefined;
83
+ }
84
+ const activityText = match.entries.join(' ');
85
+ const activityTokens = tokenize(activityText);
86
+ if (activityTokens.size < MIN_ACTIVITY_TOKEN_COUNT) {
87
+ return undefined;
88
+ }
89
+ const similarity = jaccardSimilarity(intentTokens, activityTokens);
90
+ if (similarity >= threshold) {
91
+ return undefined;
92
+ }
93
+ return {
94
+ similarity,
95
+ intentTokens: [...intentTokens].sort(),
96
+ activityTokens: [...activityTokens].sort(),
97
+ matchedLogEntries: match.entries.length,
98
+ matchedDistinctDays: match.distinctDays,
99
+ sampleIntent: truncate(intent, 120),
100
+ sampleActivity: truncate(match.entries[match.entries.length - 1] ?? '', 120)
101
+ };
102
+ }
103
+ export function extractPageIntent(pageContent) {
104
+ const withoutFrontmatter = pageContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
105
+ const lines = withoutFrontmatter.split(/\r?\n/);
106
+ let title = '';
107
+ const paragraphLines = [];
108
+ let foundTitle = false;
109
+ let collectingParagraph = false;
110
+ for (const line of lines) {
111
+ const trimmed = line.trim();
112
+ if (!foundTitle) {
113
+ const titleMatch = trimmed.match(/^#\s+(.+)$/);
114
+ if (titleMatch) {
115
+ title = titleMatch[1].trim();
116
+ foundTitle = true;
117
+ continue;
118
+ }
119
+ continue;
120
+ }
121
+ // After the H1, look for the first non-empty, non-heading paragraph.
122
+ if (!collectingParagraph) {
123
+ if (trimmed === '' || trimmed.startsWith('#')) {
124
+ continue;
125
+ }
126
+ collectingParagraph = true;
127
+ paragraphLines.push(trimmed);
128
+ continue;
129
+ }
130
+ // Stop the paragraph at the next blank line or heading.
131
+ if (trimmed === '' || trimmed.startsWith('#')) {
132
+ break;
133
+ }
134
+ paragraphLines.push(trimmed);
135
+ }
136
+ if (!title) {
137
+ return '';
138
+ }
139
+ const paragraph = paragraphLines.join(' ');
140
+ return paragraph ? `${title}. ${paragraph}` : title;
141
+ }
142
+ export function extractRecentEntriesMentioningPage(projectLogText, pageSlug, maxEntries, maxAgeDays = DEFAULT_MAX_LOG_ENTRY_AGE_DAYS, referenceDate = new Date()) {
143
+ if (!projectLogText) {
144
+ return { entries: [], distinctDays: 0 };
145
+ }
146
+ // Project log entries are bullet lines under date headings of the form `## YYYY-MM-DD`.
147
+ // We forward-scan tracking the current date heading; entries are kept only when their
148
+ // associated date is within maxAgeDays of referenceDate. Entries that appear before any
149
+ // date heading or after a heading older than the cutoff are skipped.
150
+ //
151
+ // We also track which distinct date headings produced matches, so the caller can tell a
152
+ // single-day burst (session noise) apart from multi-day drift (real divergence).
153
+ //
154
+ // Note: appendProjectLog only adds a date heading once per day, so multiple entries from
155
+ // the same day all live under that single heading. Old entries in the file may have
156
+ // stale headings that fail the recency check, which is exactly what we want — drift
157
+ // detection should reflect *recent* activity, not the project's entire history.
158
+ const lines = projectLogText.split(/\r?\n/);
159
+ const slugTokens = [
160
+ pageSlug.toLowerCase(),
161
+ pageSlug.toLowerCase().replace(/-/g, ' '),
162
+ pageSlug.toLowerCase().replace(/-/g, '_')
163
+ ];
164
+ const cutoffMs = referenceDate.getTime() - maxAgeDays * 86_400_000;
165
+ const matches = [];
166
+ const matchedDateHeadings = new Set();
167
+ let currentHeadingIsRecent = false;
168
+ let currentHeadingDate = '';
169
+ for (const line of lines) {
170
+ const trimmed = line.trim();
171
+ const dateHeading = trimmed.match(/^##\s+(\d{4}-\d{2}-\d{2})/);
172
+ if (dateHeading) {
173
+ const headingMs = Date.parse(dateHeading[1]);
174
+ currentHeadingIsRecent = Number.isFinite(headingMs) && headingMs >= cutoffMs;
175
+ currentHeadingDate = dateHeading[1];
176
+ continue;
177
+ }
178
+ if (!currentHeadingIsRecent) {
179
+ continue;
180
+ }
181
+ if (!trimmed.startsWith('- ')) {
182
+ continue;
183
+ }
184
+ const lowered = trimmed.toLowerCase();
185
+ if (slugTokens.some((token) => lowered.includes(token))) {
186
+ matches.push(trimmed.replace(/^-\s*/, ''));
187
+ matchedDateHeadings.add(currentHeadingDate);
188
+ }
189
+ }
190
+ // Project log appends newest entries at the file end. We walked forward, so `matches`
191
+ // is in document order (oldest → newest). Reverse and cap so callers see newest-first,
192
+ // matching the original recency-biased ordering before the date-window filter landed.
193
+ return {
194
+ entries: matches.slice(-maxEntries).reverse(),
195
+ distinctDays: matchedDateHeadings.size
196
+ };
197
+ }
198
+ function tokenize(text) {
199
+ return new Set(text
200
+ .toLowerCase()
201
+ .split(/[^a-z0-9]+/i)
202
+ .map((token) => token.trim())
203
+ .filter((token) => token.length >= 4 && !STOP_TOKENS.has(token)));
204
+ }
205
+ function jaccardSimilarity(a, b) {
206
+ if (a.size === 0 || b.size === 0) {
207
+ return 0;
208
+ }
209
+ let intersection = 0;
210
+ for (const token of a) {
211
+ if (b.has(token))
212
+ intersection += 1;
213
+ }
214
+ const union = a.size + b.size - intersection;
215
+ return union === 0 ? 0 : intersection / union;
216
+ }
217
+ function truncate(text, max) {
218
+ if (text.length <= max)
219
+ return text;
220
+ return `${text.slice(0, max - 1).trimEnd()}…`;
221
+ }
222
+ export function buildPageDriftMessage(signal) {
223
+ const pct = Math.round(signal.similarity * 100);
224
+ 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.`;
225
+ }
@@ -0,0 +1,168 @@
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 { reviewProjectMemories } from '@rarusoft/dendrite-memory';
22
+ import { lintWikiPages, readWikiPage } from './store.js';
23
+ const PROPOSED_TEXT_PREVIEW_CHARS = 320;
24
+ /**
25
+ * One-shot projection across every page: which slugs have any pending memory promotions
26
+ * or lint findings, and how many. Powers the sidebar-link decoration so the operator
27
+ * sees pending counts on every link in the wiki nav without having to visit each page.
28
+ */
29
+ export async function buildPageInboxSummary() {
30
+ const [memoryReview, lintFindings] = await Promise.all([
31
+ reviewProjectMemories(),
32
+ lintWikiPages()
33
+ ]);
34
+ const counts = new Map();
35
+ const bump = (slug) => {
36
+ let entry = counts.get(slug);
37
+ if (!entry) {
38
+ entry = { memoryCount: 0, lintCount: 0, hasUrgent: false };
39
+ counts.set(slug, entry);
40
+ }
41
+ return entry;
42
+ };
43
+ for (const finding of memoryReview.findings) {
44
+ if (finding.kind !== 'promotion-ready')
45
+ continue;
46
+ const target = resolvePromotionTargetSlug(finding.records);
47
+ bump(target).memoryCount += 1;
48
+ }
49
+ for (const finding of lintFindings) {
50
+ const entry = bump(finding.slug);
51
+ entry.lintCount += 1;
52
+ if (finding.rule === 'contradicts-shipped-memory' || finding.rule === 'page-drift') {
53
+ entry.hasUrgent = true;
54
+ }
55
+ }
56
+ return Array.from(counts.entries())
57
+ .map(([slug, entry]) => ({
58
+ slug,
59
+ total: entry.memoryCount + entry.lintCount,
60
+ memoryCount: entry.memoryCount,
61
+ lintCount: entry.lintCount,
62
+ hasUrgent: entry.hasUrgent
63
+ }))
64
+ .filter((entry) => entry.total > 0)
65
+ .sort((a, b) => a.slug.localeCompare(b.slug));
66
+ }
67
+ export async function buildPageInboxSnapshot(slug) {
68
+ const trimmed = slug.trim();
69
+ if (!trimmed) {
70
+ throw new Error('page-inbox requires a non-empty slug.');
71
+ }
72
+ const [memoryReview, lintFindings, pageContent] = await Promise.all([
73
+ reviewProjectMemories(),
74
+ lintWikiPages(),
75
+ readWikiPage(trimmed).catch(() => '')
76
+ ]);
77
+ const memoryItems = await collectPageMemoryItems(trimmed, memoryReview.findings);
78
+ const lintItems = lintFindings
79
+ .filter((finding) => finding.slug === trimmed)
80
+ .map((finding) => ({
81
+ kind: 'lint',
82
+ rule: finding.rule,
83
+ message: finding.message
84
+ }));
85
+ return {
86
+ slug: trimmed,
87
+ pageExists: pageContent !== '',
88
+ memoryItems,
89
+ lintItems,
90
+ total: memoryItems.length + lintItems.length
91
+ };
92
+ }
93
+ async function collectPageMemoryItems(slug, findings) {
94
+ const items = [];
95
+ for (const finding of findings) {
96
+ if (finding.kind !== 'promotion-ready') {
97
+ continue;
98
+ }
99
+ // resolvePromotionTargetSlug mirrors what previewProjectMemoryPromotion does when
100
+ // no explicit target is passed — so the badge filter exactly matches the page the
101
+ // apply would actually write to. If the operator picks a different target via the
102
+ // central board's modal, that's outside the badge's view, which is fine.
103
+ const resolvedTarget = resolvePromotionTargetSlug(finding.records);
104
+ if (resolvedTarget !== slug) {
105
+ continue;
106
+ }
107
+ const memoryIds = finding.memoryIds;
108
+ const applyActionId = buildMemoryActionId('promotion-ready', memoryIds, 'apply-memory-promotion');
109
+ const draftActionId = buildMemoryActionId('promotion-ready', memoryIds, 'draft-memory-promotion');
110
+ let proposedSectionAnchor = '';
111
+ let proposedHeading = '';
112
+ let proposedTextPreview = '';
113
+ try {
114
+ // Reusing the existing preview avoids drift between "what the badge promises" and
115
+ // "what apply does" — the same module computes both. The cost is one preview build
116
+ // per pending item, which is cheap for the small numbers a single page sees.
117
+ const preview = await previewProjectMemoryPromotion(memoryIds, { targetPage: slug });
118
+ proposedSectionAnchor = preview.proposedSectionAnchor;
119
+ proposedHeading = preview.sectionHeading;
120
+ proposedTextPreview = truncatePreview(preview.proposedText);
121
+ }
122
+ catch {
123
+ // Promotion preview can throw if the memory id resolves to a record that's been
124
+ // archived between the review and the preview call. Skip the preview text in that
125
+ // case; the action ids are still valid for the central board.
126
+ }
127
+ items.push({
128
+ kind: 'memory-promotion',
129
+ reviewKind: finding.kind,
130
+ applyActionId,
131
+ draftActionId,
132
+ summary: finding.summary,
133
+ reason: finding.reason,
134
+ memoryIds,
135
+ records: finding.records.map(toInboxRecord),
136
+ proposedSectionAnchor,
137
+ proposedHeading,
138
+ proposedTextPreview
139
+ });
140
+ }
141
+ return items;
142
+ }
143
+ function toInboxRecord(record) {
144
+ return {
145
+ id: record.id,
146
+ kind: record.kind,
147
+ summary: record.summary,
148
+ text: record.text,
149
+ recallCount: record.recallCount,
150
+ sources: record.sources.map((source) => `${source.kind}:${source.slug}`),
151
+ relatedFiles: record.relatedFiles,
152
+ relatedPages: record.relatedPages
153
+ };
154
+ }
155
+ // Mirrors `buildMemoryActionId` from maintenance-inbox.ts. Kept inline here rather than
156
+ // imported because the inbox module is the heavy lifter; this surface only needs the id
157
+ // shape and a single review kind. If the id format ever changes, both call sites have to
158
+ // move in lockstep, and the maintenance-inbox tests already lock the shape.
159
+ function buildMemoryActionId(reviewKind, memoryIds, actionKind) {
160
+ return `memory:${reviewKind}:${memoryIds.join('+')}:${actionKind}`;
161
+ }
162
+ function truncatePreview(proposedText) {
163
+ const normalized = proposedText.replace(/\s+/g, ' ').trim();
164
+ if (normalized.length <= PROPOSED_TEXT_PREVIEW_CHARS) {
165
+ return normalized;
166
+ }
167
+ return `${normalized.slice(0, PROPOSED_TEXT_PREVIEW_CHARS - 1).trimEnd()}…`;
168
+ }