hyperstack-core 1.5.1 → 1.5.3

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 (4) hide show
  1. package/README.md +64 -12
  2. package/cli.js +112 -1
  3. package/package.json +1 -1
  4. package/src/parser.js +307 -305
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # hyperstack-core
2
2
 
3
- The Memory Hub for AI agents. Typed graph memory with episodic/semantic/working APIs, decision replay, utility-weighted edges, git-style branching, and agent identity. The only memory layer where agents can verify what they know, trace why they know it, and coordinate without an LLM in the loop. $0 per operation at any scale.
3
+ **The Agent Provenance Graph for AI agents** the only memory layer where agents can prove what they knew, trace why they knew it, and coordinate without an LLM in the loop. $0 per operation at any scale.
4
+
5
+ *Timestamped facts. Auditable decisions. Deterministic trust. Build agents you can trust at $0/operation.*
6
+
7
+ Current version: **v1.5.2**
4
8
 
5
9
  ```
6
10
  npm i hyperstack-core
@@ -31,11 +35,12 @@ import { HyperStackClient } from "hyperstack-core";
31
35
  const hs = new HyperStackClient({ apiKey: "hs_..." });
32
36
 
33
37
  // Store a decision with typed relations
34
- await hs.decide({
38
+ await hs.store({
35
39
  slug: "use-clerk",
36
40
  title: "Use Clerk for auth",
37
41
  body: "Better DX, lower cost, native Next.js support",
38
- affects: ["auth-api"],
42
+ cardType: "decision",
43
+ links: [{ target: "auth-api", relation: "affects" }],
39
44
  });
40
45
 
41
46
  // Store a blocker
@@ -53,10 +58,35 @@ const result = await hs.blockers("deploy-prod");
53
58
  // What breaks if auth changes?
54
59
  const impact = await hs.impact("use-clerk");
55
60
  // → [auth-api, deploy-prod, billing-v2]
61
+
62
+ // Batch store
63
+ await hs.bulkStore([{ slug: "p1", title: "Project A", body: "..." }, { slug: "p2", title: "Project B", body: "..." }]);
64
+
65
+ // Agentic routing (deterministic, no LLM)
66
+ const canDo = await hs.can({ query: "what breaks if auth changes?", slug: "auth-api" });
67
+ const steps = await hs.plan({ goal: "add 2FA to auth-api" });
68
+
69
+ // Parse markdown/logs into cards (CLI: npx hyperstack-core ingest)
70
+ await hs.parse("# DECISIONS.md content", { source: "decisions" });
71
+
72
+ // Ingest conversation transcript
73
+ await hs.autoRemember("Alice is senior engineer. We decided Clerk for auth.");
74
+
75
+ // Memory hub: working / semantic / episodic
76
+ const cards = await hs.hsMemory({ surface: "semantic" });
56
77
  ```
57
78
 
58
79
  Typed relations, not text blobs. `task→blocks→deploy` is queryable. A paragraph in DECISIONS.md is not.
59
80
 
81
+ **REST API:** Always use `X-API-Key`, never `Authorization: Bearer`:
82
+
83
+ ```bash
84
+ curl -X POST "https://hyperstack-cloud.vercel.app/api/cards" \
85
+ -H "X-API-Key: hs_your_key" \
86
+ -H "Content-Type: application/json" \
87
+ -d '{"slug":"use-clerk","title":"Use Clerk","body":"Auth decision"}'
88
+ ```
89
+
60
90
  ---
61
91
 
62
92
  ## MCP — Works in Cursor, Claude Desktop, VS Code, Windsurf
@@ -77,7 +107,7 @@ Typed relations, not text blobs. `task→blocks→deploy` is queryable. A paragr
77
107
  }
78
108
  ```
79
109
 
80
- 15 MCP tools: `hs_store`, `hs_search`, `hs_smart_search`, `hs_decide`, `hs_commit`, `hs_feedback`, `hs_blockers`, `hs_graph`, `hs_impact`, `hs_recommend`, `hs_fork`, `hs_diff`, `hs_merge`, `hs_discard`, `hs_identify`, `hs_profile`, `hs_prune`, `hs_ingest`, `hs_inbox`, `hs_stats`
110
+ 10 MCP tools: `hs_store`, `hs_search`, `hs_get`, `hs_list`, `hs_graph`, `hs_blockers`, `hs_impact`, `hs_feedback`, `hs_fork`, `hs_identify`
81
111
 
82
112
  ---
83
113
 
@@ -112,6 +142,28 @@ GET /api/cards?workspace=X&memoryType=working&includeExpired=true
112
142
 
113
143
  ---
114
144
 
145
+ ## Card Fields
146
+
147
+ | Field | Description |
148
+ |-------|-------------|
149
+ | `confidence` | 0.0–1.0 confidence score |
150
+ | `truthStratum` | `draft` \| `hypothesis` \| `confirmed` |
151
+ | `verifiedBy` | e.g. `"human:deeq"` |
152
+ | `verifiedAt` | Auto-set server-side |
153
+ | `memoryType` | `working` \| `semantic` \| `episodic` |
154
+ | `ttl` | Working memory expiry (seconds) |
155
+ | `sourceAgent` | Auto-stamped after `identify()` |
156
+
157
+ ---
158
+
159
+ ## Conflict Detection & Staleness
160
+
161
+ **Conflict detection** — structural, no LLM. Auto-detects contradicting cards (e.g. same slug, conflicting `truthStratum`).
162
+
163
+ **Staleness cascade** — upstream changes mark dependents stale. When a card is updated, linked cards get `isStale` until refreshed.
164
+
165
+ ---
166
+
115
167
  ## Decision Replay
116
168
 
117
169
  Reconstruct exactly what an agent knew when a decision was made.
@@ -135,7 +187,7 @@ Use cases: compliance audits, agent debugging, post-mortems.
135
187
  The graph gets smarter the more you use it. Report success/failure after every agent task.
136
188
 
137
189
  ```javascript
138
- // Report which cards helped the agent succeed
190
+ // Report success/failure updates utility scores on edges
139
191
  await hs.feedback({
140
192
  cardSlugs: ["use-clerk", "auth-api"],
141
193
  outcome: "success",
@@ -300,8 +352,8 @@ hs.merge(branch_workspace_id=branch["branchWorkspaceId"], strategy="branch-wins"
300
352
  ```
301
353
 
302
354
  ```python
303
- from hyperstack_langgraph import HyperStackMemory
304
- memory = HyperStackMemory(api_key="hs_...", workspace="my-project")
355
+ from hyperstack_langgraph import HyperStackClient
356
+ client = HyperStackClient(api_key="hs_...", workspace="my-project")
305
357
  ```
306
358
 
307
359
  ---
@@ -317,7 +369,7 @@ memory = HyperStackMemory(api_key="hs_...", workspace="my-project")
317
369
  | Provenance layer | ✅ | ❌ | ❌ | ❌ |
318
370
  | Time-travel | ✅ | ❌ | ❌ | ❌ |
319
371
  | Decision replay | ✅ | ❌ | ❌ | ❌ |
320
- | Memory Hub segmentation | ✅ | ❌ | ❌ | ❌ |
372
+ | Three memory surfaces | ✅ | ❌ | ❌ | ❌ |
321
373
  | Self-hosted Docker | ✅ 1 command | ✅ complex | ✅ | ✅ |
322
374
  | Cross-tool MCP | ✅ Cursor+Claude | ❌ | ❌ | ❌ |
323
375
  | Cost per retrieval | **$0** | ~$0.002 LLM | ~$0.002 LLM | usage-based |
@@ -334,10 +386,10 @@ Mem0 finds "similar" cards. HyperStack finds **exactly** what blocks task #42.
334
386
 
335
387
  | Plan | Price | Cards | Features |
336
388
  |------|-------|-------|---------|
337
- | Free | $0 | 10 | Search only |
338
- | Pro | $29/mo | 100 | All modes + branching + identity + Memory Hub |
339
- | Team | $59/mo | 500 | All modes + webhooks + agent tokens |
340
- | Business | $149/mo | 2,000 | All modes + SSO + 20 members |
389
+ | Free | $0 | 50 | ALL features — graph, impact, replay (no gate) |
390
+ | Pro | $29/mo | 500+ | Branching, priority support |
391
+ | Team | $59/mo | 500, 5 API keys | Everything in Pro + collaboration |
392
+ | Business | $149/mo | 2,000, 20 members | Everything in Team + scale |
341
393
  | Self-hosted | $0 | Unlimited | Full feature parity |
342
394
 
343
395
  ---
package/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  /**
4
4
  * hyperstack-core CLI
@@ -37,6 +37,7 @@ hyperstack-core — Typed graph memory for AI agents
37
37
  Commands:
38
38
  login Authenticate via browser (OAuth device flow)
39
39
  logout Remove saved credentials
40
+ quickstart Scan current dir, ingest docs, show graph — 90 seconds to value
40
41
  init <template> Initialize a project with a template
41
42
  ingest <path> Auto-parse files into cards + edges (markdown/text/logs)
42
43
  search <query> Search the knowledge graph
@@ -387,6 +388,116 @@ async function run() {
387
388
  return;
388
389
  }
389
390
 
391
+ // ─── Quickstart command ─────────────────────────────
392
+
393
+ if (command === "quickstart") {
394
+ console.log("\n🧠 HyperStack Quickstart\n");
395
+
396
+ // 1. Auth check
397
+ const qsApiKey = getApiKey();
398
+ if (!qsApiKey) {
399
+ console.log(" Not authenticated.\n");
400
+ console.log(" Option A: npx hyperstack-core login");
401
+ console.log(" Option B: export HYPERSTACK_API_KEY=hs_your_key");
402
+ console.log(" Get a free key: https://cascadeai.dev/hyperstack\n");
403
+ process.exit(1);
404
+ }
405
+ console.log(" ✅ Authenticated\n");
406
+
407
+ // 2. Scan current directory for ingestable files
408
+ const cwd = process.cwd();
409
+ const scanDirs = [cwd];
410
+ const docsDir = join(cwd, "docs");
411
+ if (existsSync(docsDir) && statSync(docsDir).isDirectory()) scanDirs.push(docsDir);
412
+
413
+ const found = [];
414
+ for (const dir of scanDirs) {
415
+ const entries = readdirSync(dir).filter((f) => /\.(md|txt)$/i.test(f));
416
+ for (const f of entries) {
417
+ const full = join(dir, f);
418
+ const stat = statSync(full);
419
+ const rel = dir === cwd ? f : `docs/${f}`;
420
+ found.push({ path: full, name: rel, size: stat.size });
421
+ }
422
+ }
423
+
424
+ if (!found.length) {
425
+ console.log(" No .md or .txt files found in current directory.\n");
426
+ console.log(" Try: npx hyperstack-core ingest <file>");
427
+ process.exit(0);
428
+ }
429
+
430
+ console.log(" Found files:\n");
431
+ for (const f of found) {
432
+ const kb = (f.size / 1024).toFixed(1);
433
+ console.log(` 📄 ${f.name} (${kb} KB)`);
434
+ }
435
+
436
+ const workspace = getFlag("workspace", "default");
437
+ console.log(`\n Workspace: ${workspace}`);
438
+ console.log(` Files: ${found.length}\n`);
439
+
440
+ // 3. Confirm (skip with --yes)
441
+ if (!args.includes("--yes") && !args.includes("-y")) {
442
+ const { createInterface } = await import("readline");
443
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
444
+ const answer = await new Promise((res) => {
445
+ rl.question(" Ingest these files? [Y/n] ", (a) => { rl.close(); res(a.trim().toLowerCase()); });
446
+ });
447
+ if (answer === "n" || answer === "no") {
448
+ console.log("\n Cancelled.\n");
449
+ return;
450
+ }
451
+ }
452
+
453
+ // 4. Parse all files
454
+ console.log("\n Parsing...\n");
455
+ let allCards = [];
456
+ let totalEdges = 0;
457
+
458
+ for (const f of found) {
459
+ const content = readFileSync(f.path, "utf-8");
460
+ const prefix = slugify(f.name.replace(/\.[^.]+$/, ""));
461
+ const result = parse(content, { prefix });
462
+ allCards.push(...result.cards);
463
+ totalEdges += result.edgeCount;
464
+ console.log(` 📄 ${f.name} → ${result.cards.length} cards, ${result.edgeCount} edges (${result.format})`);
465
+ }
466
+
467
+ if (!allCards.length) {
468
+ console.log("\n No cards extracted. Files may be empty.\n");
469
+ return;
470
+ }
471
+
472
+ // 5. Store
473
+ console.log(`\n Storing ${allCards.length} cards...`);
474
+ const qsClient = new HyperStackClient({ apiKey: qsApiKey, workspace });
475
+
476
+ const result = await qsClient.ingest(allCards, {
477
+ onProgress: () => process.stdout.write("."),
478
+ });
479
+
480
+ console.log("\n");
481
+ if (result.failed) {
482
+ console.log(` ✅ ${result.stored} cards stored, ${result.failed} failed`);
483
+ for (const e of result.errors) console.log(` ❌ [${e.slug}] ${e.error}`);
484
+ } else {
485
+ console.log(` ✅ ${result.stored} cards stored, ${totalEdges} edges from ${found.length} file(s)`);
486
+ }
487
+
488
+ // 6. Next steps
489
+ const firstSlug = allCards[0]?.slug || "";
490
+ const searchTerm = allCards[0]?.title?.split(" ").slice(0, 3).join(" ") || "getting started";
491
+ console.log(`
492
+ Next steps:
493
+
494
+ npx hyperstack-core search "${searchTerm}"
495
+ npx hyperstack-core graph ${firstSlug} --depth 2 --reverse
496
+ npx hyperstack-core list
497
+ `);
498
+ return;
499
+ }
500
+
390
501
  // ─── Ingest command ─────────────────────────────────
391
502
 
392
503
  if (command === "ingest") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperstack-core",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "Typed graph memory for AI agents. Replace GOALS.md with queryable cards + relations. Works with OpenClaw, Claude Desktop, Cursor.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/parser.js CHANGED
@@ -1,305 +1,307 @@
1
- /**
2
- * hyperstack-core parser
3
- *
4
- * Zero-dependency markdown/text/conversation → HyperStack cards + edges.
5
- * No LLM. Pure regex. Preserves document structure as typed graph edges.
6
- */
7
-
8
- function slugify(text) {
9
- return text
10
- .toLowerCase()
11
- .replace(/[^a-z0-9]+/g, "-")
12
- .replace(/^-+|-+$/g, "")
13
- .slice(0, 80);
14
- }
15
-
16
- function inferCardType(title, body) {
17
- const text = `${title} ${body}`.toLowerCase();
18
- if (/\b(decided?|chose|picked|selected|agreed|ruling)\b/.test(text)) return "decision";
19
- if (/\b(todo|task|sprint|milestone|epic|ticket|backlog)\b/.test(text)) return "project";
20
- if (/\b(always|never|prefer|convention|standard|rule|must)\b/.test(text)) return "preference";
21
- if (/\b(step|process|how.to|guide|install|setup|deploy|workflow|tutorial)\b/.test(text)) return "workflow";
22
- if (/\b(bug|fix|error|issue|patch|regression|incident)\b/.test(text)) return "event";
23
- if (/\b(person|team|role|engineer|manager|founder)\b/.test(text)) return "person";
24
- return "general";
25
- }
26
-
27
- function extractKeywords(title, body) {
28
- const kw = new Set();
29
- let m;
30
- const backticks = /`([^`]+)`/g;
31
- while ((m = backticks.exec(body)) !== null) kw.add(m[1].toLowerCase());
32
- const mentions = /@(\w+)/g;
33
- while ((m = mentions.exec(body)) !== null) kw.add(m[1].toLowerCase());
34
- return [...kw].slice(0, 15);
35
- }
36
-
37
- function extractLinksFromBody(body) {
38
- const links = [];
39
-
40
- // Markdown links → related edges for internal refs
41
- const mdLink = /\[([^\]]+)\]\(([^)]+)\)/g;
42
- let m;
43
- while ((m = mdLink.exec(body)) !== null) {
44
- const href = m[2];
45
- if (href.startsWith("#")) {
46
- links.push({ target: slugify(href.slice(1)), relation: "related" });
47
- } else if (/\.md(#|$)/.test(href)) {
48
- const slug = slugify(href.replace(/\.md.*$/, "").replace(/^.*\//, ""));
49
- if (slug) links.push({ target: slug, relation: "related" });
50
- }
51
- }
52
-
53
- // Semantic patterns → typed edges
54
- const patterns = [
55
- { re: /depends\s+on\s+["`']?([\w][\w-]*)["`']?/gi, relation: "depends_on" },
56
- { re: /blocked?\s+by\s+["`']?([\w][\w-]*)["`']?/gi, relation: "blocked_by" },
57
- { re: /blocks?\s+["`']?([\w][\w-]*)["`']?/gi, relation: "blocks" },
58
- { re: /decided\s+by\s+@?([\w][\w-]*)/gi, relation: "decided" },
59
- { re: /assigned\s+to\s+@?([\w][\w-]*)/gi, relation: "assigned_to" },
60
- { re: /owned?\s+by\s+@?([\w][\w-]*)/gi, relation: "owns" },
61
- ];
62
- for (const { re, relation } of patterns) {
63
- while ((m = re.exec(body)) !== null) {
64
- const target = slugify(m[1]);
65
- if (target && target.length > 1) links.push({ target, relation });
66
- }
67
- }
68
-
69
- return links;
70
- }
71
-
72
- // ─── Markdown Parser ──────────────────────────────────
73
-
74
- function parseMarkdown(content, prefix) {
75
- const cards = [];
76
- const headingRe = /^(#{1,6})\s+(.+)$/gm;
77
- const headings = [];
78
- let m;
79
-
80
- while ((m = headingRe.exec(content)) !== null) {
81
- headings.push({
82
- level: m[1].length,
83
- title: m[2].trim(),
84
- pos: m.index,
85
- contentStart: m.index + m[0].length,
86
- });
87
- }
88
-
89
- if (headings.length === 0) return parsePlainText(content, prefix);
90
-
91
- const usedSlugs = new Set();
92
- function uniqueSlug(base) {
93
- let slug = base;
94
- let i = 2;
95
- while (usedSlugs.has(slug)) { slug = `${base}-${i}`; i++; }
96
- usedSlugs.add(slug);
97
- return slug;
98
- }
99
-
100
- // Preamble before first heading
101
- const preamble = content.slice(0, headings[0].pos).trim();
102
- if (preamble.length > 30) {
103
- const slug = uniqueSlug(prefix ? `${prefix}/intro` : "intro");
104
- cards.push({
105
- slug,
106
- title: prefix ? `${prefix} — Introduction` : "Introduction",
107
- body: preamble.slice(0, 1000),
108
- cardType: "general",
109
- keywords: extractKeywords("intro", preamble),
110
- links: [],
111
- });
112
- }
113
-
114
- // Parent stack for hierarchy: [{level, slug}]
115
- const stack = [];
116
- let edgeCount = 0;
117
-
118
- for (let i = 0; i < headings.length; i++) {
119
- const h = headings[i];
120
- const bodyEnd = i + 1 < headings.length ? headings[i + 1].pos : content.length;
121
- const body = content.slice(h.contentStart, bodyEnd).trim();
122
-
123
- const baseSlug = slugify(h.title);
124
- if (!baseSlug) continue;
125
- const slug = uniqueSlug(prefix ? `${prefix}/${baseSlug}` : baseSlug);
126
- const links = [];
127
-
128
- // Hierarchy edge
129
- while (stack.length > 0 && stack[stack.length - 1].level >= h.level) stack.pop();
130
- if (stack.length > 0) {
131
- links.push({ target: stack[stack.length - 1].slug, relation: "subtask_of" });
132
- edgeCount++;
133
- }
134
- stack.push({ level: h.level, slug });
135
-
136
- // Content-derived edges
137
- const bodyLinks = extractLinksFromBody(body);
138
- for (const l of bodyLinks) {
139
- const target = prefix && !l.target.includes("/") ? `${prefix}/${l.target}` : l.target;
140
- links.push({ target, relation: l.relation });
141
- edgeCount++;
142
- }
143
-
144
- cards.push({
145
- slug,
146
- title: h.title,
147
- body: body.slice(0, 1000),
148
- cardType: inferCardType(h.title, body),
149
- keywords: extractKeywords(h.title, body),
150
- links,
151
- });
152
- }
153
-
154
- return { cards, edgeCount, format: "markdown" };
155
- }
156
-
157
- // ─── Conversation Parser ──────────────────────────────
158
-
159
- const SPEAKER_RE = /^(User|Human|Assistant|AI|System|Bot)\s*:/i;
160
-
161
- function isConversationLog(text) {
162
- const lines = text.split("\n");
163
- let hits = 0;
164
- for (const line of lines) {
165
- if (SPEAKER_RE.test(line.trim())) hits++;
166
- if (hits >= 2) return true;
167
- }
168
- return false;
169
- }
170
-
171
- function parseConversation(content, prefix) {
172
- const cards = [];
173
- const turnRe = /^(User|Human|Assistant|AI|System|Bot)\s*:/gim;
174
- const starts = [];
175
- let m;
176
- while ((m = turnRe.exec(content)) !== null) {
177
- starts.push({ index: m.index, speaker: m[1].toLowerCase(), after: m.index + m[0].length });
178
- }
179
-
180
- if (starts.length === 0) return parsePlainText(content, prefix);
181
-
182
- let prevSlug = null;
183
- let edgeCount = 0;
184
-
185
- for (let i = 0; i < starts.length; i++) {
186
- const t = starts[i];
187
- const end = i + 1 < starts.length ? starts[i + 1].index : content.length;
188
- const message = content.slice(t.after, end).trim();
189
- if (!message) continue;
190
-
191
- const idx = i + 1;
192
- const slug = prefix ? `${prefix}/turn-${idx}` : `turn-${idx}`;
193
- const snippet = message.slice(0, 60) + (message.length > 60 ? "..." : "");
194
- const links = [];
195
-
196
- if (prevSlug) {
197
- links.push({ target: prevSlug, relation: "related" });
198
- edgeCount++;
199
- }
200
-
201
- cards.push({
202
- slug,
203
- title: `[${t.speaker}] ${snippet}`,
204
- body: message.slice(0, 1000),
205
- cardType: "event",
206
- keywords: [t.speaker, `turn-${idx}`],
207
- links,
208
- meta: { speaker: t.speaker, turnIndex: idx },
209
- });
210
-
211
- prevSlug = slug;
212
- }
213
-
214
- return { cards, edgeCount, format: "conversation" };
215
- }
216
-
217
- // ─── Plain Text Parser ────────────────────────────────
218
-
219
- function parsePlainText(content, prefix) {
220
- const cards = [];
221
- const paragraphs = content
222
- .split(/\n\s*\n/)
223
- .map((p) => p.trim())
224
- .filter((p) => p.length > 20);
225
-
226
- if (paragraphs.length === 0) {
227
- return { cards: [], edgeCount: 0, format: "text" };
228
- }
229
-
230
- const usedSlugs = new Set();
231
- let prevSlug = null;
232
- let edgeCount = 0;
233
-
234
- for (let i = 0; i < paragraphs.length; i++) {
235
- const para = paragraphs[i];
236
- const firstLine = para.split(/[.\n]/)[0].trim().slice(0, 80) || `Section ${i + 1}`;
237
- let slug = slugify(firstLine);
238
- if (!slug) slug = `section-${i + 1}`;
239
-
240
- if (prefix) slug = `${prefix}/${slug}`;
241
- let unique = slug;
242
- let n = 2;
243
- while (usedSlugs.has(unique)) { unique = `${slug}-${n}`; n++; }
244
- usedSlugs.add(unique);
245
- slug = unique;
246
-
247
- const links = [];
248
- if (prevSlug) {
249
- links.push({ target: prevSlug, relation: "related" });
250
- edgeCount++;
251
- }
252
-
253
- cards.push({
254
- slug,
255
- title: firstLine,
256
- body: para.slice(0, 1000),
257
- cardType: inferCardType(firstLine, para),
258
- keywords: extractKeywords(firstLine, para),
259
- links,
260
- });
261
- prevSlug = slug;
262
- }
263
-
264
- return { cards, edgeCount, format: "text" };
265
- }
266
-
267
- // ─── Main Entry ───────────────────────────────────────
268
-
269
- /**
270
- * Parse any text into HyperStack cards + edges.
271
- * Auto-detects: markdown, conversation log, or plain text.
272
- *
273
- * @param {string} content raw input
274
- * @param {object} [opts]
275
- * @param {string} [opts.prefix]slug prefix (e.g. "readme", "docs/api")
276
- * @param {string} [opts.defaultType] — override cardType for "general" cards
277
- * @returns {{ cards: Array, edgeCount: number, format: string }}
278
- */
279
- function parse(content, opts = {}) {
280
- if (!content || typeof content !== "string") {
281
- return { cards: [], edgeCount: 0, format: "empty" };
282
- }
283
-
284
- const prefix = opts.prefix ? slugify(opts.prefix) : "";
285
-
286
- let result;
287
- if (isConversationLog(content)) {
288
- result = parseConversation(content, prefix);
289
- } else if (/^#{1,6}\s+/m.test(content)) {
290
- result = parseMarkdown(content, prefix);
291
- } else {
292
- result = parsePlainText(content, prefix);
293
- }
294
-
295
- if (opts.defaultType) {
296
- for (const card of result.cards) {
297
- if (card.cardType === "general") card.cardType = opts.defaultType;
298
- }
299
- }
300
-
301
- return result;
302
- }
303
-
304
- export { parse, parseMarkdown, parseConversation, parsePlainText, slugify, inferCardType };
305
- export default parse;
1
+ /**
2
+ * hyperstack-core parser
3
+ *
4
+ * Zero-dependency markdown/text/conversation → HyperStack cards + edges.
5
+ * No LLM. Pure regex. Preserves document structure as typed graph edges.
6
+ */
7
+
8
+ function slugify(text) {
9
+ return text
10
+ .toLowerCase()
11
+ .replace(/[^a-z0-9]+/g, "-")
12
+ .replace(/^-+|-+$/g, "")
13
+ .slice(0, 80);
14
+ }
15
+
16
+ function inferCardType(title, body) {
17
+ const text = `${title} ${body}`.toLowerCase();
18
+ if (/\b(decided?|chose|picked|selected|agreed|ruling)\b/.test(text)) return "decision";
19
+ if (/\b(todo|task|sprint|milestone|epic|ticket|backlog)\b/.test(text)) return "project";
20
+ if (/\b(always|never|prefer|convention|standard|rule|must)\b/.test(text)) return "preference";
21
+ if (/\b(step|process|how.to|guide|install|setup|deploy|workflow|tutorial)\b/.test(text)) return "workflow";
22
+ if (/\b(bug|fix|error|issue|patch|regression|incident)\b/.test(text)) return "event";
23
+ if (/\b(person|team|role|engineer|manager|founder)\b/.test(text)) return "person";
24
+ return "general";
25
+ }
26
+
27
+ function extractKeywords(title, body) {
28
+ const kw = new Set();
29
+ let m;
30
+ const backticks = /`([^`]+)`/g;
31
+ while ((m = backticks.exec(body)) !== null) kw.add(m[1].toLowerCase());
32
+ const mentions = /@(\w+)/g;
33
+ while ((m = mentions.exec(body)) !== null) kw.add(m[1].toLowerCase());
34
+ return [...kw].slice(0, 15);
35
+ }
36
+
37
+ function extractLinksFromBody(body) {
38
+ const links = [];
39
+
40
+ // Markdown links → related edges for internal refs
41
+ const mdLink = /\[([^\]]+)\]\(([^)]+)\)/g;
42
+ let m;
43
+ while ((m = mdLink.exec(body)) !== null) {
44
+ const href = m[2];
45
+ if (href.startsWith("#")) {
46
+ links.push({ target: slugify(href.slice(1)), relation: "related" });
47
+ } else if (/\.md(#|$)/.test(href)) {
48
+ const slug = slugify(href.replace(/\.md.*$/, "").replace(/^.*\//, ""));
49
+ if (slug) links.push({ target: slug, relation: "related" });
50
+ }
51
+ }
52
+
53
+ // Semantic patterns → typed edges
54
+ const patterns = [
55
+ { re: /depends\s+on\s+["`']?([\w][\w-]*)["`']?/gi, relation: "depends_on" },
56
+ { re: /blocked?\s+by\s+["`']?([\w][\w-]*)["`']?/gi, relation: "blocked_by" },
57
+ { re: /blocks?\s+["`']?([\w][\w-]*)["`']?/gi, relation: "blocks" },
58
+ { re: /decided\s+by\s+@?([\w][\w-]*)/gi, relation: "decided" },
59
+ { re: /assigned\s+to\s+@?([\w][\w-]*)/gi, relation: "assigned_to" },
60
+ { re: /owned?\s+by\s+@?([\w][\w-]*)/gi, relation: "owns" },
61
+ ];
62
+ for (const { re, relation } of patterns) {
63
+ while ((m = re.exec(body)) !== null) {
64
+ const target = slugify(m[1]);
65
+ if (target && target.length > 1) links.push({ target, relation });
66
+ }
67
+ }
68
+
69
+ return links;
70
+ }
71
+
72
+ // ─── Markdown Parser ──────────────────────────────────────────────
73
+
74
+ function parseMarkdown(content, prefix) {
75
+ const cards = [];
76
+ const headingRe = /^(#{1,6})\s+(.+)$/gm;
77
+ const headings = [];
78
+ let m;
79
+
80
+ while ((m = headingRe.exec(content)) !== null) {
81
+ headings.push({
82
+ level: m[1].length,
83
+ title: m[2].trim(),
84
+ pos: m.index,
85
+ contentStart: m.index + m[0].length,
86
+ });
87
+ }
88
+
89
+ if (headings.length === 0) return parsePlainText(content, prefix);
90
+
91
+ const usedSlugs = new Set();
92
+ function uniqueSlug(base) {
93
+ let slug = base;
94
+ let i = 2;
95
+ while (usedSlugs.has(slug)) { slug = `${base}-${i}`; i++; }
96
+ usedSlugs.add(slug);
97
+ return slug;
98
+ }
99
+
100
+ // Preamble before first heading
101
+ const preamble = content.slice(0, headings[0].pos).trim();
102
+ if (preamble.length > 30) {
103
+ const slug = uniqueSlug(prefix ? `${prefix}/intro` : "intro");
104
+ cards.push({
105
+ slug,
106
+ title: prefix ? `${prefix} — Introduction` : "Introduction",
107
+ body: preamble.slice(0, 1000),
108
+ cardType: "general",
109
+ keywords: extractKeywords("intro", preamble),
110
+ links: [],
111
+ });
112
+ }
113
+
114
+ // Parent stack for hierarchy: [{level, slug}]
115
+ const stack = [];
116
+ let edgeCount = 0;
117
+
118
+ for (let i = 0; i < headings.length; i++) {
119
+ const h = headings[i];
120
+ const bodyEnd = i + 1 < headings.length ? headings[i + 1].pos : content.length;
121
+ const rawBody = content.slice(h.contentStart, bodyEnd).trim();
122
+ // FIX: fallback to title when heading has no body content
123
+ const body = rawBody.length > 0 ? rawBody : h.title;
124
+
125
+ const baseSlug = slugify(h.title);
126
+ if (!baseSlug) continue;
127
+ const slug = uniqueSlug(prefix ? `${prefix}/${baseSlug}` : baseSlug);
128
+ const links = [];
129
+
130
+ // Hierarchy edge
131
+ while (stack.length > 0 && stack[stack.length - 1].level >= h.level) stack.pop();
132
+ if (stack.length > 0) {
133
+ links.push({ target: stack[stack.length - 1].slug, relation: "subtask_of" });
134
+ edgeCount++;
135
+ }
136
+ stack.push({ level: h.level, slug });
137
+
138
+ // Content-derived edges
139
+ const bodyLinks = extractLinksFromBody(body);
140
+ for (const l of bodyLinks) {
141
+ const target = prefix && !l.target.includes("/") ? `${prefix}/${l.target}` : l.target;
142
+ links.push({ target, relation: l.relation });
143
+ edgeCount++;
144
+ }
145
+
146
+ cards.push({
147
+ slug,
148
+ title: h.title,
149
+ body: body.slice(0, 1000),
150
+ cardType: inferCardType(h.title, body),
151
+ keywords: extractKeywords(h.title, body),
152
+ links,
153
+ });
154
+ }
155
+
156
+ return { cards, edgeCount, format: "markdown" };
157
+ }
158
+
159
+ // ─── Conversation Parser ──────────────────────────────────────────────
160
+
161
+ const SPEAKER_RE = /^(User|Human|Assistant|AI|System|Bot)\s*:/i;
162
+
163
+ function isConversationLog(text) {
164
+ const lines = text.split("\n");
165
+ let hits = 0;
166
+ for (const line of lines) {
167
+ if (SPEAKER_RE.test(line.trim())) hits++;
168
+ if (hits >= 2) return true;
169
+ }
170
+ return false;
171
+ }
172
+
173
+ function parseConversation(content, prefix) {
174
+ const cards = [];
175
+ const turnRe = /^(User|Human|Assistant|AI|System|Bot)\s*:/gim;
176
+ const starts = [];
177
+ let m;
178
+ while ((m = turnRe.exec(content)) !== null) {
179
+ starts.push({ index: m.index, speaker: m[1].toLowerCase(), after: m.index + m[0].length });
180
+ }
181
+
182
+ if (starts.length === 0) return parsePlainText(content, prefix);
183
+
184
+ let prevSlug = null;
185
+ let edgeCount = 0;
186
+
187
+ for (let i = 0; i < starts.length; i++) {
188
+ const t = starts[i];
189
+ const end = i + 1 < starts.length ? starts[i + 1].index : content.length;
190
+ const message = content.slice(t.after, end).trim();
191
+ if (!message) continue;
192
+
193
+ const idx = i + 1;
194
+ const slug = prefix ? `${prefix}/turn-${idx}` : `turn-${idx}`;
195
+ const snippet = message.slice(0, 60) + (message.length > 60 ? "..." : "");
196
+ const links = [];
197
+
198
+ if (prevSlug) {
199
+ links.push({ target: prevSlug, relation: "related" });
200
+ edgeCount++;
201
+ }
202
+
203
+ cards.push({
204
+ slug,
205
+ title: `[${t.speaker}] ${snippet}`,
206
+ body: message.slice(0, 1000),
207
+ cardType: "event",
208
+ keywords: [t.speaker, `turn-${idx}`],
209
+ links,
210
+ meta: { speaker: t.speaker, turnIndex: idx },
211
+ });
212
+
213
+ prevSlug = slug;
214
+ }
215
+
216
+ return { cards, edgeCount, format: "conversation" };
217
+ }
218
+
219
+ // ─── Plain Text Parser ──────────────────────────────────────────────
220
+
221
+ function parsePlainText(content, prefix) {
222
+ const cards = [];
223
+ const paragraphs = content
224
+ .split(/\n\s*\n/)
225
+ .map((p) => p.trim())
226
+ .filter((p) => p.length > 20);
227
+
228
+ if (paragraphs.length === 0) {
229
+ return { cards: [], edgeCount: 0, format: "text" };
230
+ }
231
+
232
+ const usedSlugs = new Set();
233
+ let prevSlug = null;
234
+ let edgeCount = 0;
235
+
236
+ for (let i = 0; i < paragraphs.length; i++) {
237
+ const para = paragraphs[i];
238
+ const firstLine = para.split(/[.\n]/)[0].trim().slice(0, 80) || `Section ${i + 1}`;
239
+ let slug = slugify(firstLine);
240
+ if (!slug) slug = `section-${i + 1}`;
241
+
242
+ if (prefix) slug = `${prefix}/${slug}`;
243
+ let unique = slug;
244
+ let n = 2;
245
+ while (usedSlugs.has(unique)) { unique = `${slug}-${n}`; n++; }
246
+ usedSlugs.add(unique);
247
+ slug = unique;
248
+
249
+ const links = [];
250
+ if (prevSlug) {
251
+ links.push({ target: prevSlug, relation: "related" });
252
+ edgeCount++;
253
+ }
254
+
255
+ cards.push({
256
+ slug,
257
+ title: firstLine,
258
+ body: para.slice(0, 1000),
259
+ cardType: inferCardType(firstLine, para),
260
+ keywords: extractKeywords(firstLine, para),
261
+ links,
262
+ });
263
+ prevSlug = slug;
264
+ }
265
+
266
+ return { cards, edgeCount, format: "text" };
267
+ }
268
+
269
+ // ─── Main Entry ───────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * Parse any text into HyperStack cards + edges.
273
+ * Auto-detects: markdown, conversation log, or plain text.
274
+ *
275
+ * @param {string} contentraw input
276
+ * @param {object} [opts]
277
+ * @param {string} [opts.prefix] slug prefix (e.g. "readme", "docs/api")
278
+ * @param {string} [opts.defaultType] — override cardType for "general" cards
279
+ * @returns {{ cards: Array, edgeCount: number, format: string }}
280
+ */
281
+ function parse(content, opts = {}) {
282
+ if (!content || typeof content !== "string") {
283
+ return { cards: [], edgeCount: 0, format: "empty" };
284
+ }
285
+
286
+ const prefix = opts.prefix ? slugify(opts.prefix) : "";
287
+
288
+ let result;
289
+ if (isConversationLog(content)) {
290
+ result = parseConversation(content, prefix);
291
+ } else if (/^#{1,6}\s+/m.test(content)) {
292
+ result = parseMarkdown(content, prefix);
293
+ } else {
294
+ result = parsePlainText(content, prefix);
295
+ }
296
+
297
+ if (opts.defaultType) {
298
+ for (const card of result.cards) {
299
+ if (card.cardType === "general") card.cardType = opts.defaultType;
300
+ }
301
+ }
302
+
303
+ return result;
304
+ }
305
+
306
+ export { parse, parseMarkdown, parseConversation, parsePlainText, slugify, inferCardType };
307
+ export default parse;