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.
- package/README.md +64 -12
- package/cli.js +112 -1
- package/package.json +1 -1
- package/src/parser.js +307 -305
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# hyperstack-core
|
|
2
2
|
|
|
3
|
-
The
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
304
|
-
|
|
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
|
-
|
|
|
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 |
|
|
338
|
-
| Pro | $29/mo |
|
|
339
|
-
| Team | $59/mo | 500 |
|
|
340
|
-
| Business | $149/mo | 2,000 |
|
|
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
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
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* @param {string}
|
|
276
|
-
* @param {
|
|
277
|
-
* @
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
result =
|
|
291
|
-
} else {
|
|
292
|
-
result =
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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} content — raw 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;
|