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.
- package/.claude-plugin/marketplace.json +1 -1
- package/bin/hs.mjs +349 -38
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +40 -3
- package/dist/cli/hs-defer.mjs +3817 -3741
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +228 -37
- package/dist/cli/hs-index-reconcile.mjs +231 -40
- package/dist/cli/hs-init.mjs +22 -3
- package/dist/cli/hs-install.mjs +22 -3
- package/dist/cli/hs-join.mjs +40 -3
- package/dist/cli/hs-next-launch.mjs +2777 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12403 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +233 -42
- package/dist/cli/hs-vault-push.mjs +231 -40
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +9 -2
- package/docs/mcp-client-config.md +6 -0
- package/package.json +266 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +41 -3
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +53 -3
- package/scripts/lib/hs-companion-settings.mjs +26 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/scripts/record-grounding.mjs +38 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
- 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
|
+
}
|