hyperstack-core 1.5.2 → 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/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/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;
|