heyspex 0.2.5 → 0.2.7

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 (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -1,523 +1,523 @@
1
- // heyspex_recall_memories and the memory-scoring helpers that the preflight,
2
- // on-deck brief and claim tools also use (tag ranking, amendment detection,
3
- // design-decision memories). Extracted from cli.mjs under
4
- // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
- //
6
- // ─────────────────────────────────────────────────────────────────────────────
7
- // Memory scorer for runtime injection ([[HS-TASK-IMMF]]).
8
- // Shared by enrichOnDeckWithRecalledMemories + handleRunPreflight so /hs-next
9
- // and heyspex_preflight surface the same top-K memories. Tag-overlap scoring
10
- // with a baseline pool ensures rule-placement-class memories (the kind missed
11
- // in the IMMF incident) always rank as candidates.
12
- // ─────────────────────────────────────────────────────────────────────────────
13
-
14
- import { consoleReadErrorFrom, readViaSelector, resolveReadTier } from './read-path.mjs';
15
- import { consoleFetch } from './console-transport.mjs';
16
- import { join, relative } from 'node:path';
17
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
- import { parseFrontmatter } from './vault-io.mjs';
19
- import { effectiveConsoleUrl } from './mcp-config.mjs';
20
- import { InvalidListCursorError, LIST_SPEC_TOOLS, finishPagedListToolResult, pageListToolResult, paginateRowsKeyset, resolvePageLimit } from './list-tools.mjs';
21
-
22
- /**
23
- * Page + byte-budget the recall result ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
24
- * Sort key is the memory's `name` with `id` as the tie-breaker — the same
25
- * name order recall has always used, now as a keyset cursor. Never a total
26
- * count: the response says what it returned and where the next page starts.
27
- */
28
- function finishRecallPage(memories, args, filtersApplied) {
29
- const paged = pageListToolResult('heyspex_recall_memories', memories, args, {
30
- keyOf: (m) => m.name,
31
- idOf: (m) => m.id,
32
- extra: { filters_applied: filtersApplied },
33
- });
34
- if (paged.error) {
35
- return { isError: true, content: [{ type: 'text', text: JSON.stringify(paged.error, null, 2) }] };
36
- }
37
- return { content: [{ type: 'text', text: paged.text }] };
38
- }
39
-
40
- // UI-related tags that trigger design context injection
41
- export const UI_TAGS = new Set(['ui', 'console', 'companion', 'design-system']);
42
-
43
- // Key design decisions to enforce (spec basename → requirements)
44
- export const DESIGN_DECISIONS = {
45
- 'scrollbar-transparent-track': {
46
- summary: 'Use <ScrollArea> for ALL scrollable areas, not native overflow',
47
- checklist: [
48
- 'Search for overflow, overflowY, maxHeight — each needs ScrollArea',
49
- 'Import ScrollArea from @/components/scroll-area',
50
- ],
51
- },
52
- 'supabase-design-system-reference': {
53
- summary: 'Use CSS variables from globals.css, not hardcoded colors',
54
- checklist: [
55
- 'Use --bg-canvas, --text-primary, etc. from globals.css',
56
- 'No #fff, #000, or other hex colors in TSX files',
57
- ],
58
- },
59
- 'quick-panel-visual-language-windows11-alignment': {
60
- summary: 'Square corners, 2px borders, mint accents for overlays',
61
- checklist: [
62
- 'No borderRadius or rounded-* classes',
63
- 'Use 2px solid borders, not 1px',
64
- ],
65
- },
66
- 'console-agent-quick-start-box-styling': {
67
- summary: 'Blue/green callout variants with documented tokens',
68
- checklist: [
69
- 'Use #60a5fa (blue-400) or emerald variants for callouts',
70
- 'Apply color-mix for background tints',
71
- ],
72
- },
73
- };
74
-
75
- /**
76
- * Check if a spec has UI-related tags that should trigger design context.
77
- * @param {Object} frontmatter - Parsed frontmatter
78
- * @returns {string[]} - Array of matched UI tags
79
- */
80
- export function detectUITags(frontmatter) {
81
- const tags = frontmatter.tags || [];
82
- return tags.filter(tag => UI_TAGS.has(tag.toLowerCase()));
83
- }
84
-
85
- /**
86
- * Load design-related memories from the vault based on tags.
87
- *
88
- * Untouched — see {@link loadDesignMemoriesViaTier} below for the cloud-tier
89
- * sibling ([[HS-DJWP-MCPC-TASK]] round 2, best-effort wiring).
90
- *
91
- * @param {string} vaultRoot - Root of the vault
92
- * @param {string[]} triggerTags - UI tags that triggered this lookup
93
- * @returns {Array<{id: string, summary: string, checklist: string[]}>}
94
- */
95
- export function loadDesignMemories(vaultRoot, triggerTags) {
96
- const memories = [];
97
- const scanDirs = [
98
- join(vaultRoot, 'specs', 'memory', 'decisions'),
99
- join(vaultRoot, 'specs', 'memory', 'ui-patterns'),
100
- ];
101
-
102
- for (const memoryDir of scanDirs) {
103
- if (!existsSync(memoryDir)) continue;
104
-
105
- try {
106
- const files = readdirSync(memoryDir).filter(f => f.endsWith('.md'));
107
-
108
- for (const file of files) {
109
- const basename = file.replace('.md', '');
110
-
111
- // Check if this is a known design decision
112
- if (DESIGN_DECISIONS[basename]) {
113
- memories.push({
114
- id: basename,
115
- ...DESIGN_DECISIONS[basename],
116
- });
117
- continue;
118
- }
119
-
120
- // Or check file tags for ui/design-system
121
- const filepath = join(memoryDir, file);
122
- try {
123
- const content = readFileSync(filepath, 'utf8');
124
- const { frontmatter } = parseFrontmatter(content);
125
- const fileTags = (frontmatter.tags || []).map(t => t.toLowerCase());
126
-
127
- // Include if file has any of our trigger tags
128
- const hasUITag = fileTags.some(t => UI_TAGS.has(t));
129
- if (hasUITag) {
130
- // Extract summary from body
131
- const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
132
- const summary = lines[0]?.slice(0, 200) || frontmatter.name || basename;
133
-
134
- memories.push({
135
- id: basename,
136
- summary,
137
- checklist: [],
138
- });
139
- }
140
- } catch {
141
- // Skip files that can't be parsed
142
- }
143
- }
144
- } catch {
145
- // Skip if directory can't be read
146
- }
147
- }
148
-
149
- return memories;
150
- }
151
-
152
- /**
153
- * Recover the slug `DESIGN_DECISIONS` is keyed by from a `MemoryDoc.id`
154
- * ([[HS-DJWP-MCPC-TASK]] round 2 best-effort wiring). `GET /api/memories`
155
- * has no basename field, but the modern memory-doc id convention is
156
- * `HS-<TYPE>-<slug>` (e.g. `HS-DEC-scrollbar-transparent-track`, `TYPE` being
157
- * the 2-4 letter memory-kind code) — the slug is byte-identical to the
158
- * file's basename in every checked case. Stripping that prefix recovers the
159
- * DESIGN_DECISIONS key. A handful of legacy memory docs predate this
160
- * convention (their `id:` is a bare slug with no `HS-` prefix, sometimes
161
- * drifted from the current filename/dict key entirely) — those simply miss
162
- * the exact-key lookup and fall through to the tag-overlap branch below,
163
- * same as disk tier's own fallback for anything not in DESIGN_DECISIONS.
164
- */
165
- /** The tool-error code for a console page that was a permission verdict, not a memory set. */
166
- export const VAULT_SCOPE_UNAUTHORIZED = 'VAULT_SCOPE_UNAUTHORIZED';
167
-
168
- /**
169
- * The denial reason carried by a `GET /api/memories` page the console could
170
- * NOT authorize — `{ memories: [], vaultScope: { authorized: false, reason } }`
171
- * — or `null` for a normal page ([[HS-FIA6-WW9R-TASK-reads-never-answer-empty-from-an]]
172
- * for [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 4).
173
- * That page is a verdict about the CALLER's vault scope, not about the vault's
174
- * contents: during the 2026-10-10 incident `heyspex_recall_memories` rendered
175
- * it as "no memories" for an hour while the console was answering from the
176
- * wrong vault. Pure; `body` may be anything the transport parsed.
177
- * @param {unknown} body
178
- * @returns {string | null}
179
- */
180
- export function unauthorizedVaultScopeReason(body) {
181
- const vaultScope = body && typeof body === 'object' ? body.vaultScope : null;
182
- if (!vaultScope || typeof vaultScope !== 'object' || vaultScope.authorized !== false) return null;
183
- return typeof vaultScope.reason === 'string' && vaultScope.reason ? vaultScope.reason : 'unknown';
184
- }
185
-
186
- /**
187
- * The `isError` tool result for an unauthorized page, or `null` when the page
188
- * is a real memory set (same shape `search-specs.mjs` answers for its empty
189
- * unauthorized page). Pure.
190
- * @param {unknown} body
191
- * @param {string} tool the MCP tool name, for the agent's error text
192
- */
193
- export function unauthorizedVaultScopeResult(body, tool) {
194
- const reason = unauthorizedVaultScopeReason(body);
195
- if (reason === null) return null;
196
- return {
197
- isError: true,
198
- content: [{
199
- type: 'text',
200
- text: JSON.stringify({
201
- error: VAULT_SCOPE_UNAUTHORIZED,
202
- tool,
203
- reason,
204
- message:
205
- `The console could not authorize a vault for this caller (${reason}), so the empty page it `
206
- + 'returned says nothing about the vault\'s memories. Do NOT read this as "no memories". '
207
- + 'Check that this repo\'s vault is registered and that the request named it '
208
- + '(`x-heyspex-vault-id`); `hs connect --check` shows the binding.',
209
- }, null, 2),
210
- }],
211
- };
212
- }
213
-
214
- export function designDecisionKeyFromMemoryDocId(id) {
215
- const trimmed = typeof id === 'string' ? id.trim() : '';
216
- return trimmed.replace(/^HS-[A-Z0-9]+-/, '') || trimmed;
217
- }
218
-
219
- /**
220
- * Cloud-reliant sibling of `loadDesignMemories`
221
- * ([[HS-DJWP-MCPC-TASK]] round 2, best-effort per the coordinator's
222
- * instruction to check whether `id`/`name` can serve as the DESIGN_DECISIONS
223
- * key). `loadDesignMemories` stays untouched (the `diskRead` branch).
224
- *
225
- * In `neon` tier: `GET /api/memories?category=decisions` +
226
- * `?category=ui-patterns` (the same two dirs the disk scan walks), then per
227
- * doc — exact DESIGN_DECISIONS hit via {@link designDecisionKeyFromMemoryDocId},
228
- * else the same UI_TAGS tag-overlap fallback the disk path uses (using the
229
- * console's own `summary` — already body-derived — rather than replicating
230
- * disk tier's frontmatter-adjacent `content.split('\n')[0]` heuristic, which
231
- * this task does not attempt to reproduce byte-for-byte since nothing tests
232
- * its exact text).
233
- */
234
- export async function loadDesignMemoriesViaTier(vaultRoot, triggerTags, { tier, consoleUrl }) {
235
- return readViaSelector({
236
- tier,
237
- diskRead: () => loadDesignMemories(vaultRoot, triggerTags),
238
- neonRead: async () => {
239
- const [decisionsRes, patternsRes] = await Promise.all([
240
- consoleFetch('/api/memories?category=decisions'),
241
- consoleFetch('/api/memories?category=ui-patterns'),
242
- ]);
243
- if (!decisionsRes.ok) {
244
- throw consoleReadErrorFrom(decisionsRes, `GET /api/memories?category=decisions returned ${decisionsRes.status}: ${decisionsRes.error || ''}`, null);
245
- }
246
- if (!patternsRes.ok) {
247
- throw consoleReadErrorFrom(patternsRes, `GET /api/memories?category=ui-patterns returned ${patternsRes.status}: ${patternsRes.error || ''}`, null);
248
- }
249
- // An unauthorized empty page is a scope verdict, not "no design
250
- // memories" — fail the same way a non-OK status does ([[HS-FIA6]]).
251
- for (const [label, res] of [['decisions', decisionsRes], ['ui-patterns', patternsRes]]) {
252
- const reason = unauthorizedVaultScopeReason(res.body);
253
- if (reason !== null) {
254
- throw new Error(`GET /api/memories?category=${label} answered an unauthorized empty page (${VAULT_SCOPE_UNAUTHORIZED}: ${reason})`);
255
- }
256
- }
257
- const docs = [
258
- ...(Array.isArray(decisionsRes.body?.memories) ? decisionsRes.body.memories : []),
259
- ...(Array.isArray(patternsRes.body?.memories) ? patternsRes.body.memories : []),
260
- ];
261
-
262
- const memories = [];
263
- for (const doc of docs) {
264
- const key = designDecisionKeyFromMemoryDocId(doc.id);
265
- if (DESIGN_DECISIONS[key]) {
266
- memories.push({ id: key, ...DESIGN_DECISIONS[key] });
267
- continue;
268
- }
269
- const tags = (Array.isArray(doc.tags) ? doc.tags : []).map((t) => String(t).toLowerCase());
270
- if (tags.some((t) => UI_TAGS.has(t))) {
271
- const summary =
272
- (typeof doc.summary === 'string' && doc.summary) || doc.name || doc.id;
273
- memories.push({ id: doc.id, summary, checklist: [] });
274
- }
275
- }
276
- return memories;
277
- },
278
- routeLabel: 'GET /api/memories',
279
- consoleUrl,
280
- });
281
- }
282
-
283
- export function handleRecallMemories(vaultRoot, args) {
284
- const { category = 'all', related_to, tags = [], compact = true } = args;
285
-
286
- const memoryDir = join(vaultRoot, 'specs', 'memory');
287
- if (!existsSync(memoryDir)) {
288
- return {
289
- content: [{
290
- type: 'text',
291
- text: JSON.stringify({
292
- memories: [],
293
- next_cursor: null,
294
- message: 'No memories found (specs/memory/ does not exist)',
295
- }, null, 2),
296
- }],
297
- };
298
- }
299
-
300
- // Determine which subdirectories to scan
301
- const categories = category === 'all'
302
- ? ['lessons', 'decisions', 'references', 'ui-patterns']
303
- : [category];
304
-
305
- const memories = [];
306
-
307
- for (const cat of categories) {
308
- const catDir = join(memoryDir, cat);
309
- if (!existsSync(catDir)) continue;
310
-
311
- const files = readdirSync(catDir).filter(f => f.endsWith('.md'));
312
-
313
- for (const file of files) {
314
- const filepath = join(catDir, file);
315
- try {
316
- const content = readFileSync(filepath, 'utf8');
317
- const { frontmatter, body } = parseFrontmatter(content);
318
-
319
- // Build memory entry — compact mode omits body_preview
320
- const memory = {
321
- id: frontmatter.id || file.replace('.md', ''),
322
- name: frontmatter.name || file.replace('.md', ''),
323
- type: frontmatter.type || (cat === 'ui-patterns' ? 'memory-ui-pattern' : `memory-${cat.replace(/s$/, '')}`),
324
- category: cat,
325
- tags: frontmatter.tags || [],
326
- summary: extractSummary(body),
327
- };
328
-
329
- // Include extra fields only in non-compact mode
330
- if (!compact) {
331
- memory.status = frontmatter.status || 'open';
332
- memory.related = frontmatter.related || [];
333
- memory.filepath = relative(vaultRoot, filepath);
334
- memory.body_preview = body.slice(0, 500) + (body.length > 500 ? '...' : '');
335
- }
336
-
337
- // Apply filters
338
- if (related_to) {
339
- const relatedIds = (frontmatter.related || []).map(r =>
340
- String(r).replace(/^\[\[|\]\]$/g, '').toLowerCase()
341
- );
342
- if (!relatedIds.some(r => r.includes(related_to.toLowerCase()))) {
343
- continue;
344
- }
345
- }
346
-
347
- if (tags.length > 0) {
348
- const memoryTags = (memory.tags || []).map(t => t.toLowerCase());
349
- if (!tags.some(t => memoryTags.includes(t.toLowerCase()))) {
350
- continue;
351
- }
352
- }
353
-
354
- memories.push(memory);
355
- } catch (e) {
356
- // Skip files that can't be parsed
357
- }
358
- }
359
- }
360
-
361
- // One page (name, id keyset) under the byte budget — never the whole set.
362
- return finishRecallPage(memories, args, {
363
- category,
364
- related_to: related_to || null,
365
- tags: tags.length > 0 ? tags : null,
366
- compact,
367
- });
368
- }
369
-
370
- /**
371
- * Cloud-reliant sibling of `handleRecallMemories` ([[HS-DJWP-MCPC-TASK]]
372
- * "Memories" family, backing `heyspex_recall_memories`). `handleRecallMemories`
373
- * itself is left completely untouched (it is the `diskRead` branch, byte for
374
- * byte); in `neon` tier this answers from `GET /api/memories` instead of
375
- * scanning `specs/memory/**`.
376
- *
377
- * Known fidelity gap (documented, not silently dropped): the console's
378
- * `MemoryDoc` shape carries `{id, name, type, category, tags, summary, body}` —
379
- * it does not project `status` / `related` / a filesystem `filepath`, so
380
- * `compact: false` in `neon` tier returns those three fields as `null`/`[]`
381
- * instead of the real frontmatter values. `related_to` filtering is therefore
382
- * also unavailable over HTTP today (degrades to "no match" rather than
383
- * silently ignoring the filter) — extending `MemoryDoc` to carry `related`
384
- * would need a spex-console edit, out of scope for this task.
385
- *
386
- * Two-fetch shape ([[HS-LKV0-FUU4-DIAG-cloud-mode-recall-times-out-the]]): the
387
- * first `GET /api/memories` call gets the DEFAULT summary-only listing (no
388
- * `body`) — filtering + sorting run on tags/name, which never need body text.
389
- * Only AFTER slicing to the handful actually being returned (`effectiveLimit`,
390
- * capped at 30) does a second call fetch bodies, narrowed to exactly those ids
391
- * via `?ids=...&fields=body`. This is what keeps recall off the console's full
392
- * 896-doc body scan while still returning full fidelity (real `summary`
393
- * fallback + `body_preview`) for every doc it hands back — never a degraded
394
- * body on a returned doc.
395
- */
396
- export async function handleRecallMemoriesViaTier(vaultRoot, args) {
397
- const tier = await resolveReadTier();
398
- return readViaSelector({
399
- tier,
400
- diskRead: () => handleRecallMemories(vaultRoot, args),
401
- neonRead: async () => {
402
- const { category = 'all', related_to, tags = [], compact = true } = args;
403
-
404
- const qs = new URLSearchParams();
405
- if (category && category !== 'all') qs.set('category', category);
406
- const path = `/api/memories${qs.toString() ? `?${qs}` : ''}`;
407
- const res = await consoleFetch(path);
408
- if (!res.ok) {
409
- throw consoleReadErrorFrom(res, `GET ${path} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
410
- }
411
- // A 200 whose `vaultScope.authorized` is false is a permission verdict,
412
- // never an empty memory set ([[HS-FIA6-WW9R-TASK-reads-never-answer-empty-from-an]]).
413
- const unauthorized = unauthorizedVaultScopeResult(res.body, 'heyspex_recall_memories');
414
- if (unauthorized) return unauthorized;
415
- let docs = Array.isArray(res.body?.memories) ? res.body.memories : [];
416
-
417
- // `related_to` cannot be honored over HTTP (MemoryDoc carries no
418
- // `related` field) — documented gap above; a caller relying on it in
419
- // cloud reliance gets zero results rather than a silently-ignored filter.
420
- if (related_to) docs = [];
421
-
422
- if (tags.length > 0) {
423
- const wanted = tags.map((t) => String(t).toLowerCase());
424
- docs = docs.filter((d) => {
425
- const memoryTags = (Array.isArray(d.tags) ? d.tags : []).map((t) => String(t).toLowerCase());
426
- return wanted.some((t) => memoryTags.includes(t));
427
- });
428
- }
429
-
430
- // Page on tags/name ONLY — no body needed yet — so the body fetch below
431
- // touches exactly the docs being returned. Same `(name, id)` keyset
432
- // cursor as the disk path ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]);
433
- // the byte-budget fit runs once bodies are attached, below.
434
- const pageLimit = resolvePageLimit(args.limit, LIST_SPEC_TOOLS.heyspex_recall_memories);
435
- let firstPage;
436
- try {
437
- firstPage = paginateRowsKeyset(docs, {
438
- limit: pageLimit,
439
- after: args.after,
440
- keyOf: (d) => String(d.name ?? ''),
441
- idOf: (d) => String(d.id ?? ''),
442
- });
443
- } catch (err) {
444
- if (err instanceof InvalidListCursorError) {
445
- return {
446
- isError: true,
447
- content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_CURSOR', message: err.message, tool: 'heyspex_recall_memories' }, null, 2) }],
448
- };
449
- }
450
- throw err;
451
- }
452
- const limitedDocs = firstPage.rows;
453
-
454
- const bodyById = new Map();
455
- if (limitedDocs.length > 0) {
456
- const idsQs = new URLSearchParams({
457
- ids: limitedDocs.map((d) => d.id).join(','),
458
- fields: 'body',
459
- });
460
- const bodyPath = `/api/memories?${idsQs}`;
461
- const bodyRes = await consoleFetch(bodyPath);
462
- if (!bodyRes.ok) {
463
- throw consoleReadErrorFrom(bodyRes, `GET ${bodyPath} returned ${bodyRes.status}: ${bodyRes.error || JSON.stringify(bodyRes.body ?? {})}`, null);
464
- }
465
- const bodyDocs = Array.isArray(bodyRes.body?.memories) ? bodyRes.body.memories : [];
466
- for (const bd of bodyDocs) bodyById.set(bd.id, typeof bd.body === 'string' ? bd.body : '');
467
- }
468
-
469
- const memories = limitedDocs.map((d) => {
470
- const body = bodyById.get(d.id) || '';
471
- const memory = {
472
- id: d.id,
473
- name: d.name,
474
- type: d.type,
475
- category: d.category,
476
- tags: Array.isArray(d.tags) ? d.tags : [],
477
- summary: typeof d.summary === 'string' && d.summary ? d.summary : extractSummary(body),
478
- };
479
- if (!compact) {
480
- memory.status = null; // not carried by GET /api/memories — see doc comment above.
481
- memory.related = [];
482
- memory.filepath = null;
483
- memory.body_preview = body.slice(0, 500) + (body.length > 500 ? '...' : '');
484
- }
485
- return memory;
486
- });
487
-
488
- // Already paged above (before the body fetch) — attach the page envelope
489
- // and fit to the byte budget without re-paging, which would report
490
- // `next_cursor: null` for a set that now fits in one page.
491
- const paged = finishPagedListToolResult(
492
- 'heyspex_recall_memories',
493
- { rows: memories, next_cursor: firstPage.next_cursor, limit: pageLimit },
494
- {
495
- keyOf: (m) => m.name,
496
- idOf: (m) => m.id,
497
- extra: {
498
- filters_applied: {
499
- category,
500
- related_to: related_to || null,
501
- tags: tags.length > 0 ? tags : null,
502
- compact,
503
- },
504
- },
505
- },
506
- );
507
- return { content: [{ type: 'text', text: paged.text }] };
508
- },
509
- routeLabel: 'GET /api/memories',
510
- consoleUrl: effectiveConsoleUrl(),
511
- });
512
- }
513
-
514
- export function extractSummary(body) {
515
- // Try to extract first paragraph or first few sentences
516
- const lines = body.split('\n').filter(l => l.trim() && !l.startsWith('#'));
517
- if (lines.length === 0) return '';
518
-
519
- // Get first non-empty, non-heading line
520
- const firstPara = lines[0].trim();
521
- if (firstPara.length <= 200) return firstPara;
522
- return firstPara.slice(0, 200) + '...';
523
- }
1
+ // heyspex_recall_memories and the memory-scoring helpers that the preflight,
2
+ // on-deck brief and claim tools also use (tag ranking, amendment detection,
3
+ // design-decision memories). Extracted from cli.mjs under
4
+ // [[HS-BLNF-6W61-DIAG-cli-mjs-has-grown-into-a-13-665-line]] (no behavior change).
5
+ //
6
+ // ─────────────────────────────────────────────────────────────────────────────
7
+ // Memory scorer for runtime injection ([[HS-TASK-IMMF]]).
8
+ // Shared by enrichOnDeckWithRecalledMemories + handleRunPreflight so /hs-next
9
+ // and heyspex_preflight surface the same top-K memories. Tag-overlap scoring
10
+ // with a baseline pool ensures rule-placement-class memories (the kind missed
11
+ // in the IMMF incident) always rank as candidates.
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+
14
+ import { consoleReadErrorFrom, readViaSelector, resolveReadTier } from './read-path.mjs';
15
+ import { consoleFetch } from './console-transport.mjs';
16
+ import { join, relative } from 'node:path';
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
+ import { parseFrontmatter } from './vault-io.mjs';
19
+ import { effectiveConsoleUrl } from './mcp-config.mjs';
20
+ import { InvalidListCursorError, LIST_SPEC_TOOLS, finishPagedListToolResult, pageListToolResult, paginateRowsKeyset, resolvePageLimit } from './list-tools.mjs';
21
+
22
+ /**
23
+ * Page + byte-budget the recall result ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]).
24
+ * Sort key is the memory's `name` with `id` as the tie-breaker — the same
25
+ * name order recall has always used, now as a keyset cursor. Never a total
26
+ * count: the response says what it returned and where the next page starts.
27
+ */
28
+ function finishRecallPage(memories, args, filtersApplied) {
29
+ const paged = pageListToolResult('heyspex_recall_memories', memories, args, {
30
+ keyOf: (m) => m.name,
31
+ idOf: (m) => m.id,
32
+ extra: { filters_applied: filtersApplied },
33
+ });
34
+ if (paged.error) {
35
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(paged.error, null, 2) }] };
36
+ }
37
+ return { content: [{ type: 'text', text: paged.text }] };
38
+ }
39
+
40
+ // UI-related tags that trigger design context injection
41
+ export const UI_TAGS = new Set(['ui', 'console', 'companion', 'design-system']);
42
+
43
+ // Key design decisions to enforce (spec basename → requirements)
44
+ export const DESIGN_DECISIONS = {
45
+ 'scrollbar-transparent-track': {
46
+ summary: 'Use <ScrollArea> for ALL scrollable areas, not native overflow',
47
+ checklist: [
48
+ 'Search for overflow, overflowY, maxHeight — each needs ScrollArea',
49
+ 'Import ScrollArea from @/components/scroll-area',
50
+ ],
51
+ },
52
+ 'supabase-design-system-reference': {
53
+ summary: 'Use CSS variables from globals.css, not hardcoded colors',
54
+ checklist: [
55
+ 'Use --bg-canvas, --text-primary, etc. from globals.css',
56
+ 'No #fff, #000, or other hex colors in TSX files',
57
+ ],
58
+ },
59
+ 'quick-panel-visual-language-windows11-alignment': {
60
+ summary: 'Square corners, 2px borders, mint accents for overlays',
61
+ checklist: [
62
+ 'No borderRadius or rounded-* classes',
63
+ 'Use 2px solid borders, not 1px',
64
+ ],
65
+ },
66
+ 'console-agent-quick-start-box-styling': {
67
+ summary: 'Blue/green callout variants with documented tokens',
68
+ checklist: [
69
+ 'Use #60a5fa (blue-400) or emerald variants for callouts',
70
+ 'Apply color-mix for background tints',
71
+ ],
72
+ },
73
+ };
74
+
75
+ /**
76
+ * Check if a spec has UI-related tags that should trigger design context.
77
+ * @param {Object} frontmatter - Parsed frontmatter
78
+ * @returns {string[]} - Array of matched UI tags
79
+ */
80
+ export function detectUITags(frontmatter) {
81
+ const tags = frontmatter.tags || [];
82
+ return tags.filter(tag => UI_TAGS.has(tag.toLowerCase()));
83
+ }
84
+
85
+ /**
86
+ * Load design-related memories from the vault based on tags.
87
+ *
88
+ * Untouched — see {@link loadDesignMemoriesViaTier} below for the cloud-tier
89
+ * sibling ([[HS-DJWP-MCPC-TASK]] round 2, best-effort wiring).
90
+ *
91
+ * @param {string} vaultRoot - Root of the vault
92
+ * @param {string[]} triggerTags - UI tags that triggered this lookup
93
+ * @returns {Array<{id: string, summary: string, checklist: string[]}>}
94
+ */
95
+ export function loadDesignMemories(vaultRoot, triggerTags) {
96
+ const memories = [];
97
+ const scanDirs = [
98
+ join(vaultRoot, 'specs', 'memory', 'decisions'),
99
+ join(vaultRoot, 'specs', 'memory', 'ui-patterns'),
100
+ ];
101
+
102
+ for (const memoryDir of scanDirs) {
103
+ if (!existsSync(memoryDir)) continue;
104
+
105
+ try {
106
+ const files = readdirSync(memoryDir).filter(f => f.endsWith('.md'));
107
+
108
+ for (const file of files) {
109
+ const basename = file.replace('.md', '');
110
+
111
+ // Check if this is a known design decision
112
+ if (DESIGN_DECISIONS[basename]) {
113
+ memories.push({
114
+ id: basename,
115
+ ...DESIGN_DECISIONS[basename],
116
+ });
117
+ continue;
118
+ }
119
+
120
+ // Or check file tags for ui/design-system
121
+ const filepath = join(memoryDir, file);
122
+ try {
123
+ const content = readFileSync(filepath, 'utf8');
124
+ const { frontmatter } = parseFrontmatter(content);
125
+ const fileTags = (frontmatter.tags || []).map(t => t.toLowerCase());
126
+
127
+ // Include if file has any of our trigger tags
128
+ const hasUITag = fileTags.some(t => UI_TAGS.has(t));
129
+ if (hasUITag) {
130
+ // Extract summary from body
131
+ const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
132
+ const summary = lines[0]?.slice(0, 200) || frontmatter.name || basename;
133
+
134
+ memories.push({
135
+ id: basename,
136
+ summary,
137
+ checklist: [],
138
+ });
139
+ }
140
+ } catch {
141
+ // Skip files that can't be parsed
142
+ }
143
+ }
144
+ } catch {
145
+ // Skip if directory can't be read
146
+ }
147
+ }
148
+
149
+ return memories;
150
+ }
151
+
152
+ /**
153
+ * Recover the slug `DESIGN_DECISIONS` is keyed by from a `MemoryDoc.id`
154
+ * ([[HS-DJWP-MCPC-TASK]] round 2 best-effort wiring). `GET /api/memories`
155
+ * has no basename field, but the modern memory-doc id convention is
156
+ * `HS-<TYPE>-<slug>` (e.g. `HS-DEC-scrollbar-transparent-track`, `TYPE` being
157
+ * the 2-4 letter memory-kind code) — the slug is byte-identical to the
158
+ * file's basename in every checked case. Stripping that prefix recovers the
159
+ * DESIGN_DECISIONS key. A handful of legacy memory docs predate this
160
+ * convention (their `id:` is a bare slug with no `HS-` prefix, sometimes
161
+ * drifted from the current filename/dict key entirely) — those simply miss
162
+ * the exact-key lookup and fall through to the tag-overlap branch below,
163
+ * same as disk tier's own fallback for anything not in DESIGN_DECISIONS.
164
+ */
165
+ /** The tool-error code for a console page that was a permission verdict, not a memory set. */
166
+ export const VAULT_SCOPE_UNAUTHORIZED = 'VAULT_SCOPE_UNAUTHORIZED';
167
+
168
+ /**
169
+ * The denial reason carried by a `GET /api/memories` page the console could
170
+ * NOT authorize — `{ memories: [], vaultScope: { authorized: false, reason } }`
171
+ * — or `null` for a normal page ([[HS-FIA6-WW9R-TASK-reads-never-answer-empty-from-an]]
172
+ * for [[HS-W3JF-PEGM-DIAG-one-account-wide-active-vault-pointer]] fix 4).
173
+ * That page is a verdict about the CALLER's vault scope, not about the vault's
174
+ * contents: during the 2026-10-10 incident `heyspex_recall_memories` rendered
175
+ * it as "no memories" for an hour while the console was answering from the
176
+ * wrong vault. Pure; `body` may be anything the transport parsed.
177
+ * @param {unknown} body
178
+ * @returns {string | null}
179
+ */
180
+ export function unauthorizedVaultScopeReason(body) {
181
+ const vaultScope = body && typeof body === 'object' ? body.vaultScope : null;
182
+ if (!vaultScope || typeof vaultScope !== 'object' || vaultScope.authorized !== false) return null;
183
+ return typeof vaultScope.reason === 'string' && vaultScope.reason ? vaultScope.reason : 'unknown';
184
+ }
185
+
186
+ /**
187
+ * The `isError` tool result for an unauthorized page, or `null` when the page
188
+ * is a real memory set (same shape `search-specs.mjs` answers for its empty
189
+ * unauthorized page). Pure.
190
+ * @param {unknown} body
191
+ * @param {string} tool the MCP tool name, for the agent's error text
192
+ */
193
+ export function unauthorizedVaultScopeResult(body, tool) {
194
+ const reason = unauthorizedVaultScopeReason(body);
195
+ if (reason === null) return null;
196
+ return {
197
+ isError: true,
198
+ content: [{
199
+ type: 'text',
200
+ text: JSON.stringify({
201
+ error: VAULT_SCOPE_UNAUTHORIZED,
202
+ tool,
203
+ reason,
204
+ message:
205
+ `The console could not authorize a vault for this caller (${reason}), so the empty page it `
206
+ + 'returned says nothing about the vault\'s memories. Do NOT read this as "no memories". '
207
+ + 'Check that this repo\'s vault is registered and that the request named it '
208
+ + '(`x-heyspex-vault-id`); `hs connect --check` shows the binding.',
209
+ }, null, 2),
210
+ }],
211
+ };
212
+ }
213
+
214
+ export function designDecisionKeyFromMemoryDocId(id) {
215
+ const trimmed = typeof id === 'string' ? id.trim() : '';
216
+ return trimmed.replace(/^HS-[A-Z0-9]+-/, '') || trimmed;
217
+ }
218
+
219
+ /**
220
+ * Cloud-reliant sibling of `loadDesignMemories`
221
+ * ([[HS-DJWP-MCPC-TASK]] round 2, best-effort per the coordinator's
222
+ * instruction to check whether `id`/`name` can serve as the DESIGN_DECISIONS
223
+ * key). `loadDesignMemories` stays untouched (the `diskRead` branch).
224
+ *
225
+ * In `neon` tier: `GET /api/memories?category=decisions` +
226
+ * `?category=ui-patterns` (the same two dirs the disk scan walks), then per
227
+ * doc — exact DESIGN_DECISIONS hit via {@link designDecisionKeyFromMemoryDocId},
228
+ * else the same UI_TAGS tag-overlap fallback the disk path uses (using the
229
+ * console's own `summary` — already body-derived — rather than replicating
230
+ * disk tier's frontmatter-adjacent `content.split('\n')[0]` heuristic, which
231
+ * this task does not attempt to reproduce byte-for-byte since nothing tests
232
+ * its exact text).
233
+ */
234
+ export async function loadDesignMemoriesViaTier(vaultRoot, triggerTags, { tier, consoleUrl }) {
235
+ return readViaSelector({
236
+ tier,
237
+ diskRead: () => loadDesignMemories(vaultRoot, triggerTags),
238
+ neonRead: async () => {
239
+ const [decisionsRes, patternsRes] = await Promise.all([
240
+ consoleFetch('/api/memories?category=decisions'),
241
+ consoleFetch('/api/memories?category=ui-patterns'),
242
+ ]);
243
+ if (!decisionsRes.ok) {
244
+ throw consoleReadErrorFrom(decisionsRes, `GET /api/memories?category=decisions returned ${decisionsRes.status}: ${decisionsRes.error || ''}`, null);
245
+ }
246
+ if (!patternsRes.ok) {
247
+ throw consoleReadErrorFrom(patternsRes, `GET /api/memories?category=ui-patterns returned ${patternsRes.status}: ${patternsRes.error || ''}`, null);
248
+ }
249
+ // An unauthorized empty page is a scope verdict, not "no design
250
+ // memories" — fail the same way a non-OK status does ([[HS-FIA6]]).
251
+ for (const [label, res] of [['decisions', decisionsRes], ['ui-patterns', patternsRes]]) {
252
+ const reason = unauthorizedVaultScopeReason(res.body);
253
+ if (reason !== null) {
254
+ throw new Error(`GET /api/memories?category=${label} answered an unauthorized empty page (${VAULT_SCOPE_UNAUTHORIZED}: ${reason})`);
255
+ }
256
+ }
257
+ const docs = [
258
+ ...(Array.isArray(decisionsRes.body?.memories) ? decisionsRes.body.memories : []),
259
+ ...(Array.isArray(patternsRes.body?.memories) ? patternsRes.body.memories : []),
260
+ ];
261
+
262
+ const memories = [];
263
+ for (const doc of docs) {
264
+ const key = designDecisionKeyFromMemoryDocId(doc.id);
265
+ if (DESIGN_DECISIONS[key]) {
266
+ memories.push({ id: key, ...DESIGN_DECISIONS[key] });
267
+ continue;
268
+ }
269
+ const tags = (Array.isArray(doc.tags) ? doc.tags : []).map((t) => String(t).toLowerCase());
270
+ if (tags.some((t) => UI_TAGS.has(t))) {
271
+ const summary =
272
+ (typeof doc.summary === 'string' && doc.summary) || doc.name || doc.id;
273
+ memories.push({ id: doc.id, summary, checklist: [] });
274
+ }
275
+ }
276
+ return memories;
277
+ },
278
+ routeLabel: 'GET /api/memories',
279
+ consoleUrl,
280
+ });
281
+ }
282
+
283
+ export function handleRecallMemories(vaultRoot, args) {
284
+ const { category = 'all', related_to, tags = [], compact = true } = args;
285
+
286
+ const memoryDir = join(vaultRoot, 'specs', 'memory');
287
+ if (!existsSync(memoryDir)) {
288
+ return {
289
+ content: [{
290
+ type: 'text',
291
+ text: JSON.stringify({
292
+ memories: [],
293
+ next_cursor: null,
294
+ message: 'No memories found (specs/memory/ does not exist)',
295
+ }, null, 2),
296
+ }],
297
+ };
298
+ }
299
+
300
+ // Determine which subdirectories to scan
301
+ const categories = category === 'all'
302
+ ? ['lessons', 'decisions', 'references', 'ui-patterns']
303
+ : [category];
304
+
305
+ const memories = [];
306
+
307
+ for (const cat of categories) {
308
+ const catDir = join(memoryDir, cat);
309
+ if (!existsSync(catDir)) continue;
310
+
311
+ const files = readdirSync(catDir).filter(f => f.endsWith('.md'));
312
+
313
+ for (const file of files) {
314
+ const filepath = join(catDir, file);
315
+ try {
316
+ const content = readFileSync(filepath, 'utf8');
317
+ const { frontmatter, body } = parseFrontmatter(content);
318
+
319
+ // Build memory entry — compact mode omits body_preview
320
+ const memory = {
321
+ id: frontmatter.id || file.replace('.md', ''),
322
+ name: frontmatter.name || file.replace('.md', ''),
323
+ type: frontmatter.type || (cat === 'ui-patterns' ? 'memory-ui-pattern' : `memory-${cat.replace(/s$/, '')}`),
324
+ category: cat,
325
+ tags: frontmatter.tags || [],
326
+ summary: extractSummary(body),
327
+ };
328
+
329
+ // Include extra fields only in non-compact mode
330
+ if (!compact) {
331
+ memory.status = frontmatter.status || 'open';
332
+ memory.related = frontmatter.related || [];
333
+ memory.filepath = relative(vaultRoot, filepath);
334
+ memory.body_preview = body.slice(0, 500) + (body.length > 500 ? '...' : '');
335
+ }
336
+
337
+ // Apply filters
338
+ if (related_to) {
339
+ const relatedIds = (frontmatter.related || []).map(r =>
340
+ String(r).replace(/^\[\[|\]\]$/g, '').toLowerCase()
341
+ );
342
+ if (!relatedIds.some(r => r.includes(related_to.toLowerCase()))) {
343
+ continue;
344
+ }
345
+ }
346
+
347
+ if (tags.length > 0) {
348
+ const memoryTags = (memory.tags || []).map(t => t.toLowerCase());
349
+ if (!tags.some(t => memoryTags.includes(t.toLowerCase()))) {
350
+ continue;
351
+ }
352
+ }
353
+
354
+ memories.push(memory);
355
+ } catch (e) {
356
+ // Skip files that can't be parsed
357
+ }
358
+ }
359
+ }
360
+
361
+ // One page (name, id keyset) under the byte budget — never the whole set.
362
+ return finishRecallPage(memories, args, {
363
+ category,
364
+ related_to: related_to || null,
365
+ tags: tags.length > 0 ? tags : null,
366
+ compact,
367
+ });
368
+ }
369
+
370
+ /**
371
+ * Cloud-reliant sibling of `handleRecallMemories` ([[HS-DJWP-MCPC-TASK]]
372
+ * "Memories" family, backing `heyspex_recall_memories`). `handleRecallMemories`
373
+ * itself is left completely untouched (it is the `diskRead` branch, byte for
374
+ * byte); in `neon` tier this answers from `GET /api/memories` instead of
375
+ * scanning `specs/memory/**`.
376
+ *
377
+ * Known fidelity gap (documented, not silently dropped): the console's
378
+ * `MemoryDoc` shape carries `{id, name, type, category, tags, summary, body}` —
379
+ * it does not project `status` / `related` / a filesystem `filepath`, so
380
+ * `compact: false` in `neon` tier returns those three fields as `null`/`[]`
381
+ * instead of the real frontmatter values. `related_to` filtering is therefore
382
+ * also unavailable over HTTP today (degrades to "no match" rather than
383
+ * silently ignoring the filter) — extending `MemoryDoc` to carry `related`
384
+ * would need a spex-console edit, out of scope for this task.
385
+ *
386
+ * Two-fetch shape ([[HS-LKV0-FUU4-DIAG-cloud-mode-recall-times-out-the]]): the
387
+ * first `GET /api/memories` call gets the DEFAULT summary-only listing (no
388
+ * `body`) — filtering + sorting run on tags/name, which never need body text.
389
+ * Only AFTER slicing to the handful actually being returned (`effectiveLimit`,
390
+ * capped at 30) does a second call fetch bodies, narrowed to exactly those ids
391
+ * via `?ids=...&fields=body`. This is what keeps recall off the console's full
392
+ * 896-doc body scan while still returning full fidelity (real `summary`
393
+ * fallback + `body_preview`) for every doc it hands back — never a degraded
394
+ * body on a returned doc.
395
+ */
396
+ export async function handleRecallMemoriesViaTier(vaultRoot, args) {
397
+ const tier = await resolveReadTier();
398
+ return readViaSelector({
399
+ tier,
400
+ diskRead: () => handleRecallMemories(vaultRoot, args),
401
+ neonRead: async () => {
402
+ const { category = 'all', related_to, tags = [], compact = true } = args;
403
+
404
+ const qs = new URLSearchParams();
405
+ if (category && category !== 'all') qs.set('category', category);
406
+ const path = `/api/memories${qs.toString() ? `?${qs}` : ''}`;
407
+ const res = await consoleFetch(path);
408
+ if (!res.ok) {
409
+ throw consoleReadErrorFrom(res, `GET ${path} returned ${res.status}: ${res.error || JSON.stringify(res.body ?? {})}`, null);
410
+ }
411
+ // A 200 whose `vaultScope.authorized` is false is a permission verdict,
412
+ // never an empty memory set ([[HS-FIA6-WW9R-TASK-reads-never-answer-empty-from-an]]).
413
+ const unauthorized = unauthorizedVaultScopeResult(res.body, 'heyspex_recall_memories');
414
+ if (unauthorized) return unauthorized;
415
+ let docs = Array.isArray(res.body?.memories) ? res.body.memories : [];
416
+
417
+ // `related_to` cannot be honored over HTTP (MemoryDoc carries no
418
+ // `related` field) — documented gap above; a caller relying on it in
419
+ // cloud reliance gets zero results rather than a silently-ignored filter.
420
+ if (related_to) docs = [];
421
+
422
+ if (tags.length > 0) {
423
+ const wanted = tags.map((t) => String(t).toLowerCase());
424
+ docs = docs.filter((d) => {
425
+ const memoryTags = (Array.isArray(d.tags) ? d.tags : []).map((t) => String(t).toLowerCase());
426
+ return wanted.some((t) => memoryTags.includes(t));
427
+ });
428
+ }
429
+
430
+ // Page on tags/name ONLY — no body needed yet — so the body fetch below
431
+ // touches exactly the docs being returned. Same `(name, id)` keyset
432
+ // cursor as the disk path ([[HS-E66W-8KK5-TASK-mcp-list-tools-page-with-a-cursor-and-a]]);
433
+ // the byte-budget fit runs once bodies are attached, below.
434
+ const pageLimit = resolvePageLimit(args.limit, LIST_SPEC_TOOLS.heyspex_recall_memories);
435
+ let firstPage;
436
+ try {
437
+ firstPage = paginateRowsKeyset(docs, {
438
+ limit: pageLimit,
439
+ after: args.after,
440
+ keyOf: (d) => String(d.name ?? ''),
441
+ idOf: (d) => String(d.id ?? ''),
442
+ });
443
+ } catch (err) {
444
+ if (err instanceof InvalidListCursorError) {
445
+ return {
446
+ isError: true,
447
+ content: [{ type: 'text', text: JSON.stringify({ error: 'INVALID_CURSOR', message: err.message, tool: 'heyspex_recall_memories' }, null, 2) }],
448
+ };
449
+ }
450
+ throw err;
451
+ }
452
+ const limitedDocs = firstPage.rows;
453
+
454
+ const bodyById = new Map();
455
+ if (limitedDocs.length > 0) {
456
+ const idsQs = new URLSearchParams({
457
+ ids: limitedDocs.map((d) => d.id).join(','),
458
+ fields: 'body',
459
+ });
460
+ const bodyPath = `/api/memories?${idsQs}`;
461
+ const bodyRes = await consoleFetch(bodyPath);
462
+ if (!bodyRes.ok) {
463
+ throw consoleReadErrorFrom(bodyRes, `GET ${bodyPath} returned ${bodyRes.status}: ${bodyRes.error || JSON.stringify(bodyRes.body ?? {})}`, null);
464
+ }
465
+ const bodyDocs = Array.isArray(bodyRes.body?.memories) ? bodyRes.body.memories : [];
466
+ for (const bd of bodyDocs) bodyById.set(bd.id, typeof bd.body === 'string' ? bd.body : '');
467
+ }
468
+
469
+ const memories = limitedDocs.map((d) => {
470
+ const body = bodyById.get(d.id) || '';
471
+ const memory = {
472
+ id: d.id,
473
+ name: d.name,
474
+ type: d.type,
475
+ category: d.category,
476
+ tags: Array.isArray(d.tags) ? d.tags : [],
477
+ summary: typeof d.summary === 'string' && d.summary ? d.summary : extractSummary(body),
478
+ };
479
+ if (!compact) {
480
+ memory.status = null; // not carried by GET /api/memories — see doc comment above.
481
+ memory.related = [];
482
+ memory.filepath = null;
483
+ memory.body_preview = body.slice(0, 500) + (body.length > 500 ? '...' : '');
484
+ }
485
+ return memory;
486
+ });
487
+
488
+ // Already paged above (before the body fetch) — attach the page envelope
489
+ // and fit to the byte budget without re-paging, which would report
490
+ // `next_cursor: null` for a set that now fits in one page.
491
+ const paged = finishPagedListToolResult(
492
+ 'heyspex_recall_memories',
493
+ { rows: memories, next_cursor: firstPage.next_cursor, limit: pageLimit },
494
+ {
495
+ keyOf: (m) => m.name,
496
+ idOf: (m) => m.id,
497
+ extra: {
498
+ filters_applied: {
499
+ category,
500
+ related_to: related_to || null,
501
+ tags: tags.length > 0 ? tags : null,
502
+ compact,
503
+ },
504
+ },
505
+ },
506
+ );
507
+ return { content: [{ type: 'text', text: paged.text }] };
508
+ },
509
+ routeLabel: 'GET /api/memories',
510
+ consoleUrl: effectiveConsoleUrl(),
511
+ });
512
+ }
513
+
514
+ export function extractSummary(body) {
515
+ // Try to extract first paragraph or first few sentences
516
+ const lines = body.split('\n').filter(l => l.trim() && !l.startsWith('#'));
517
+ if (lines.length === 0) return '';
518
+
519
+ // Get first non-empty, non-heading line
520
+ const firstPara = lines[0].trim();
521
+ if (firstPara.length <= 200) return firstPara;
522
+ return firstPara.slice(0, 200) + '...';
523
+ }