@stackmemoryai/stackmemory 1.10.3 → 1.10.5
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/dist/src/cli/commands/wiki.js +298 -0
- package/dist/src/cli/index.js +2 -0
- package/dist/src/core/storage/obsidian-vault-adapter.js +19 -1
- package/dist/src/core/wiki/wiki-compiler.js +1011 -0
- package/dist/src/utils/hook-installer.js +15 -0
- package/package.json +1 -1
- package/templates/claude-hooks/doc-ingest.js +76 -0
- package/templates/claude-hooks/wiki-update.js +373 -0
|
@@ -82,6 +82,21 @@ const CANONICAL_HOOKS = [
|
|
|
82
82
|
timeout: 2,
|
|
83
83
|
commandPrefix: "node",
|
|
84
84
|
required: false
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
scriptName: "wiki-update.js",
|
|
88
|
+
eventType: "Stop",
|
|
89
|
+
timeout: 10,
|
|
90
|
+
commandPrefix: "node",
|
|
91
|
+
required: false
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
scriptName: "doc-ingest.js",
|
|
95
|
+
eventType: "PostToolUse",
|
|
96
|
+
matcher: "WebFetch",
|
|
97
|
+
timeout: 15,
|
|
98
|
+
commandPrefix: "node",
|
|
99
|
+
required: false
|
|
85
100
|
}
|
|
86
101
|
];
|
|
87
102
|
const DEAD_HOOKS = ["sms-response-handler.js"];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.5",
|
|
4
4
|
"description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Doc Ingest Hook
|
|
5
|
+
*
|
|
6
|
+
* Fires on PostToolUse when WebFetch is used. If the fetched URL looks like
|
|
7
|
+
* documentation (docs.*, /api/, /reference/, /guide/), auto-ingests it into
|
|
8
|
+
* the project wiki via stackmemory wiki ingest.
|
|
9
|
+
*
|
|
10
|
+
* Lightweight: only runs if .stackmemory/config.yaml has obsidian.vaultPath.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { execSync } = require('child_process');
|
|
16
|
+
|
|
17
|
+
// Doc URL patterns
|
|
18
|
+
const DOC_PATTERNS = [
|
|
19
|
+
/^https?:\/\/docs\./i,
|
|
20
|
+
/^https?:\/\/[^/]+\/docs\//i,
|
|
21
|
+
/^https?:\/\/[^/]+\/api\//i,
|
|
22
|
+
/^https?:\/\/[^/]+\/reference\//i,
|
|
23
|
+
/^https?:\/\/[^/]+\/guide/i,
|
|
24
|
+
/^https?:\/\/[^/]+\/tutorial/i,
|
|
25
|
+
/^https?:\/\/developer\./i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Rate limit: max 1 ingest per URL per session
|
|
29
|
+
const seen = new Set();
|
|
30
|
+
|
|
31
|
+
function main() {
|
|
32
|
+
try {
|
|
33
|
+
// Only fire on WebFetch tool
|
|
34
|
+
const toolName = process.env.TOOL_NAME || '';
|
|
35
|
+
if (toolName !== 'WebFetch') return;
|
|
36
|
+
|
|
37
|
+
// Check wiki is configured
|
|
38
|
+
const configPath = path.join(process.cwd(), '.stackmemory', 'config.yaml');
|
|
39
|
+
if (!fs.existsSync(configPath)) return;
|
|
40
|
+
const config = fs.readFileSync(configPath, 'utf-8');
|
|
41
|
+
if (!config.includes('vaultPath:')) return;
|
|
42
|
+
|
|
43
|
+
// Extract URL from tool input
|
|
44
|
+
const input = process.env.TOOL_INPUT || '';
|
|
45
|
+
let url;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(input);
|
|
48
|
+
url = parsed.url;
|
|
49
|
+
} catch {
|
|
50
|
+
// Try regex fallback
|
|
51
|
+
const match = input.match(/https?:\/\/[^\s"']+/);
|
|
52
|
+
url = match ? match[0] : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!url) return;
|
|
56
|
+
|
|
57
|
+
// Check if URL matches doc patterns
|
|
58
|
+
const isDoc = DOC_PATTERNS.some((p) => p.test(url));
|
|
59
|
+
if (!isDoc) return;
|
|
60
|
+
|
|
61
|
+
// Dedupe per session
|
|
62
|
+
const host = new URL(url).hostname;
|
|
63
|
+
if (seen.has(host)) return;
|
|
64
|
+
seen.add(host);
|
|
65
|
+
|
|
66
|
+
// Run ingest (fire-and-forget, max 1 page for auto-ingest)
|
|
67
|
+
execSync(`stackmemory wiki ingest "${url}" -n 5`, {
|
|
68
|
+
timeout: 15000,
|
|
69
|
+
stdio: 'ignore',
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// Silent fail
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main();
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wiki Update Hook
|
|
5
|
+
*
|
|
6
|
+
* Fires on session stop. Incrementally compiles new context (frames,
|
|
7
|
+
* entity states, anchors) into the wiki since the last compile.
|
|
8
|
+
*
|
|
9
|
+
* Works in any repo with StackMemory initialized + Obsidian configured:
|
|
10
|
+
* - .stackmemory/context.db must exist
|
|
11
|
+
* - .stackmemory/config.yaml must have obsidian.vaultPath
|
|
12
|
+
*
|
|
13
|
+
* The hook is lightweight: reads last compile timestamp from wiki/log.md,
|
|
14
|
+
* queries only new context, and writes updated .md articles.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
const HOME = process.env.HOME || '/tmp';
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
try {
|
|
24
|
+
const cwd = process.cwd();
|
|
25
|
+
|
|
26
|
+
// 1. Check if StackMemory is initialized
|
|
27
|
+
const dbPath = path.join(cwd, '.stackmemory', 'context.db');
|
|
28
|
+
if (!fs.existsSync(dbPath)) return;
|
|
29
|
+
|
|
30
|
+
// 2. Check if Obsidian vault is configured
|
|
31
|
+
const configPath = path.join(cwd, '.stackmemory', 'config.yaml');
|
|
32
|
+
if (!fs.existsSync(configPath)) return;
|
|
33
|
+
|
|
34
|
+
const configContent = fs.readFileSync(configPath, 'utf-8');
|
|
35
|
+
const vaultMatch = configContent.match(
|
|
36
|
+
/obsidian:\s*\n\s+vaultPath:\s*["']?([^\n"']+)/
|
|
37
|
+
);
|
|
38
|
+
if (!vaultMatch) return;
|
|
39
|
+
|
|
40
|
+
const vaultPath = vaultMatch[1].trim();
|
|
41
|
+
if (!fs.existsSync(vaultPath)) return;
|
|
42
|
+
|
|
43
|
+
const subdirMatch = configContent.match(
|
|
44
|
+
/obsidian:\s*\n(?:\s+\w+:.*\n)*\s+subdir:\s*["']?([^\n"']+)/
|
|
45
|
+
);
|
|
46
|
+
const subdir = subdirMatch ? subdirMatch[1].trim() : 'stackmemory';
|
|
47
|
+
const wikiDir = path.join(vaultPath, subdir, 'wiki');
|
|
48
|
+
|
|
49
|
+
// 3. Ensure wiki directory exists
|
|
50
|
+
const dirs = [
|
|
51
|
+
wikiDir,
|
|
52
|
+
path.join(wikiDir, 'entities'),
|
|
53
|
+
path.join(wikiDir, 'concepts'),
|
|
54
|
+
path.join(wikiDir, 'sources'),
|
|
55
|
+
path.join(wikiDir, 'synthesis'),
|
|
56
|
+
];
|
|
57
|
+
for (const dir of dirs) {
|
|
58
|
+
if (!fs.existsSync(dir)) {
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 4. Get last compile time from log.md
|
|
64
|
+
const logPath = path.join(wikiDir, 'log.md');
|
|
65
|
+
let sinceEpoch = 0;
|
|
66
|
+
if (fs.existsSync(logPath)) {
|
|
67
|
+
const logContent = fs.readFileSync(logPath, 'utf-8');
|
|
68
|
+
const entries = logContent.match(/^## \[(\d{4}-\d{2}-\d{2})\]/gm);
|
|
69
|
+
if (entries && entries.length > 0) {
|
|
70
|
+
const last = entries[entries.length - 1];
|
|
71
|
+
const dateMatch = last.match(/\[(\d{4}-\d{2}-\d{2})\]/);
|
|
72
|
+
if (dateMatch) {
|
|
73
|
+
sinceEpoch = Math.floor(
|
|
74
|
+
new Date(dateMatch[1] + 'T00:00:00Z').getTime() / 1000
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 5. Open database and query new context
|
|
81
|
+
let Database;
|
|
82
|
+
try {
|
|
83
|
+
Database = require('better-sqlite3');
|
|
84
|
+
} catch {
|
|
85
|
+
// better-sqlite3 not available in this context
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const db = new Database(dbPath, { readonly: true });
|
|
90
|
+
|
|
91
|
+
const digests = db
|
|
92
|
+
.prepare(
|
|
93
|
+
`SELECT frame_id, name as frame_name, type as frame_type,
|
|
94
|
+
digest_text, created_at, closed_at
|
|
95
|
+
FROM frames
|
|
96
|
+
WHERE state = 'closed' AND digest_text IS NOT NULL
|
|
97
|
+
AND created_at >= ?
|
|
98
|
+
ORDER BY created_at DESC
|
|
99
|
+
LIMIT 100`
|
|
100
|
+
)
|
|
101
|
+
.all(sinceEpoch);
|
|
102
|
+
|
|
103
|
+
const entities = db
|
|
104
|
+
.prepare(
|
|
105
|
+
`SELECT entity_name, relation, value, context,
|
|
106
|
+
source_frame_id, valid_from, superseded_at
|
|
107
|
+
FROM entity_states
|
|
108
|
+
WHERE valid_from >= ?
|
|
109
|
+
ORDER BY valid_from DESC
|
|
110
|
+
LIMIT 500`
|
|
111
|
+
)
|
|
112
|
+
.all(sinceEpoch);
|
|
113
|
+
|
|
114
|
+
const anchors = db
|
|
115
|
+
.prepare(
|
|
116
|
+
`SELECT a.anchor_id, a.frame_id, f.name as frame_name,
|
|
117
|
+
a.type, a.text, a.priority, a.created_at
|
|
118
|
+
FROM anchors a
|
|
119
|
+
JOIN frames f ON f.frame_id = a.frame_id
|
|
120
|
+
WHERE a.created_at >= ?
|
|
121
|
+
ORDER BY a.created_at DESC
|
|
122
|
+
LIMIT 500`
|
|
123
|
+
)
|
|
124
|
+
.all(sinceEpoch);
|
|
125
|
+
|
|
126
|
+
db.close();
|
|
127
|
+
|
|
128
|
+
// 6. Skip if nothing new
|
|
129
|
+
if (digests.length === 0 && entities.length === 0 && anchors.length === 0) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 7. Write source articles for new digests
|
|
134
|
+
let created = 0;
|
|
135
|
+
let updated = 0;
|
|
136
|
+
|
|
137
|
+
for (const d of digests) {
|
|
138
|
+
const slug = slugify(d.frame_name);
|
|
139
|
+
const filePath = path.join(wikiDir, 'sources', slug + '.md');
|
|
140
|
+
if (fs.existsSync(filePath)) continue;
|
|
141
|
+
|
|
142
|
+
const content = [
|
|
143
|
+
'---',
|
|
144
|
+
`title: "${escapeYaml(d.frame_name)}"`,
|
|
145
|
+
`category: source`,
|
|
146
|
+
`frame_id: "${d.frame_id}"`,
|
|
147
|
+
`frame_type: "${d.frame_type}"`,
|
|
148
|
+
`created: ${new Date(d.created_at * 1000).toISOString()}`,
|
|
149
|
+
`updated: ${new Date().toISOString()}`,
|
|
150
|
+
`tags: [source, ${d.frame_type}]`,
|
|
151
|
+
'---',
|
|
152
|
+
'',
|
|
153
|
+
`# ${d.frame_name}`,
|
|
154
|
+
'',
|
|
155
|
+
`> Frame \`${d.frame_id.slice(0, 8)}\` | Type: ${d.frame_type}`,
|
|
156
|
+
'',
|
|
157
|
+
'## Summary',
|
|
158
|
+
'',
|
|
159
|
+
d.digest_text || 'No digest.',
|
|
160
|
+
'',
|
|
161
|
+
].join('\n');
|
|
162
|
+
|
|
163
|
+
fs.writeFileSync(filePath, content);
|
|
164
|
+
created++;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 8. Write/update entity pages
|
|
168
|
+
const entitiesByName = {};
|
|
169
|
+
for (const e of entities) {
|
|
170
|
+
if (!entitiesByName[e.entity_name]) entitiesByName[e.entity_name] = [];
|
|
171
|
+
entitiesByName[e.entity_name].push(e);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const [name, states] of Object.entries(entitiesByName)) {
|
|
175
|
+
const slug = slugify(name);
|
|
176
|
+
const filePath = path.join(wikiDir, 'entities', slug + '.md');
|
|
177
|
+
|
|
178
|
+
if (!fs.existsSync(filePath)) {
|
|
179
|
+
const current = states.filter((s) => s.superseded_at === null);
|
|
180
|
+
const lines = [
|
|
181
|
+
'---',
|
|
182
|
+
`title: "${escapeYaml(name)}"`,
|
|
183
|
+
`category: entity`,
|
|
184
|
+
`created: ${new Date().toISOString()}`,
|
|
185
|
+
`updated: ${new Date().toISOString()}`,
|
|
186
|
+
`tags: [entity]`,
|
|
187
|
+
'---',
|
|
188
|
+
'',
|
|
189
|
+
`# ${name}`,
|
|
190
|
+
'',
|
|
191
|
+
'## Current State',
|
|
192
|
+
'',
|
|
193
|
+
];
|
|
194
|
+
for (const s of current) {
|
|
195
|
+
lines.push(`- **${s.relation}**: ${s.value}`);
|
|
196
|
+
if (s.context) lines.push(` - _Context: ${s.context}_`);
|
|
197
|
+
}
|
|
198
|
+
lines.push('');
|
|
199
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
200
|
+
created++;
|
|
201
|
+
} else {
|
|
202
|
+
// Append new states
|
|
203
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
204
|
+
let changed = false;
|
|
205
|
+
for (const s of states) {
|
|
206
|
+
if (content.includes(s.value)) continue;
|
|
207
|
+
const entry = `- **${s.relation}**: ${s.value}`;
|
|
208
|
+
content = content.trimEnd() + '\n' + entry + '\n';
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if (changed) {
|
|
212
|
+
content = content.replace(
|
|
213
|
+
/updated:\s*.+/,
|
|
214
|
+
`updated: ${new Date().toISOString()}`
|
|
215
|
+
);
|
|
216
|
+
fs.writeFileSync(filePath, content);
|
|
217
|
+
updated++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 9. Write/update concept pages from anchors
|
|
223
|
+
const conceptMap = {
|
|
224
|
+
DECISION: 'Decisions',
|
|
225
|
+
FACT: 'Key Facts',
|
|
226
|
+
CONSTRAINT: 'Constraints',
|
|
227
|
+
RISK: 'Risks',
|
|
228
|
+
TODO: 'Action Items',
|
|
229
|
+
INTERFACE_CONTRACT: 'Interface Contracts',
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const conceptGroups = {};
|
|
233
|
+
for (const a of anchors) {
|
|
234
|
+
const concept = conceptMap[a.type] || 'Notes';
|
|
235
|
+
if (!conceptGroups[concept]) conceptGroups[concept] = [];
|
|
236
|
+
conceptGroups[concept].push(a);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const [concept, items] of Object.entries(conceptGroups)) {
|
|
240
|
+
const slug = slugify(concept);
|
|
241
|
+
const filePath = path.join(wikiDir, 'concepts', slug + '.md');
|
|
242
|
+
|
|
243
|
+
if (!fs.existsSync(filePath)) {
|
|
244
|
+
const lines = [
|
|
245
|
+
'---',
|
|
246
|
+
`title: "${escapeYaml(concept)}"`,
|
|
247
|
+
`category: concept`,
|
|
248
|
+
`created: ${new Date().toISOString()}`,
|
|
249
|
+
`updated: ${new Date().toISOString()}`,
|
|
250
|
+
`tags: [concept]`,
|
|
251
|
+
'---',
|
|
252
|
+
'',
|
|
253
|
+
`# ${concept}`,
|
|
254
|
+
'',
|
|
255
|
+
];
|
|
256
|
+
for (const a of items) {
|
|
257
|
+
const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
|
|
258
|
+
lines.push(
|
|
259
|
+
`- ${a.text}`,
|
|
260
|
+
` - _${date} — from [[sources/${slugify(a.frame_name)}]]_`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
lines.push('');
|
|
264
|
+
fs.writeFileSync(filePath, lines.join('\n'));
|
|
265
|
+
created++;
|
|
266
|
+
} else {
|
|
267
|
+
let content = fs.readFileSync(filePath, 'utf-8');
|
|
268
|
+
let changed = false;
|
|
269
|
+
for (const a of items) {
|
|
270
|
+
if (content.includes(a.text)) continue;
|
|
271
|
+
const date = new Date(a.created_at * 1000).toISOString().slice(0, 10);
|
|
272
|
+
const entry = `- ${a.text}\n - _${date} — from [[sources/${slugify(a.frame_name)}]]_`;
|
|
273
|
+
content = content.trimEnd() + '\n' + entry + '\n';
|
|
274
|
+
changed = true;
|
|
275
|
+
}
|
|
276
|
+
if (changed) {
|
|
277
|
+
content = content.replace(
|
|
278
|
+
/updated:\s*.+/,
|
|
279
|
+
`updated: ${new Date().toISOString()}`
|
|
280
|
+
);
|
|
281
|
+
fs.writeFileSync(filePath, content);
|
|
282
|
+
updated++;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 10. Update index.md
|
|
288
|
+
updateIndex(wikiDir);
|
|
289
|
+
|
|
290
|
+
// 11. Append to log.md
|
|
291
|
+
if (created > 0 || updated > 0) {
|
|
292
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
293
|
+
const logEntry = `\n## [${now}] auto-update | session stop\n${created} created, ${updated} updated from ${digests.length} digests, ${entities.length} entities, ${anchors.length} anchors\n`;
|
|
294
|
+
|
|
295
|
+
if (fs.existsSync(logPath)) {
|
|
296
|
+
fs.appendFileSync(logPath, logEntry);
|
|
297
|
+
} else {
|
|
298
|
+
fs.writeFileSync(
|
|
299
|
+
logPath,
|
|
300
|
+
'# Wiki Log\n\n> Chronological record of wiki operations.\n' +
|
|
301
|
+
logEntry
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
// Silent fail — never block the agent
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateIndex(wikiDir) {
|
|
311
|
+
const categories = ['entities', 'concepts', 'sources', 'synthesis'];
|
|
312
|
+
const articles = [];
|
|
313
|
+
|
|
314
|
+
for (const cat of categories) {
|
|
315
|
+
const dir = path.join(wikiDir, cat);
|
|
316
|
+
if (!fs.existsSync(dir)) continue;
|
|
317
|
+
for (const f of fs.readdirSync(dir)) {
|
|
318
|
+
if (!f.endsWith('.md')) continue;
|
|
319
|
+
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
|
|
320
|
+
const titleMatch = content.match(/^title:\s*"?([^"\n]+)"?\s*$/m);
|
|
321
|
+
articles.push({
|
|
322
|
+
path: `${cat}/${f}`,
|
|
323
|
+
title: titleMatch ? titleMatch[1] : f.replace('.md', ''),
|
|
324
|
+
category: cat,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const grouped = {};
|
|
330
|
+
for (const a of articles) {
|
|
331
|
+
if (!grouped[a.category]) grouped[a.category] = [];
|
|
332
|
+
grouped[a.category].push(a);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const lines = [
|
|
336
|
+
'---',
|
|
337
|
+
`updated: ${new Date().toISOString()}`,
|
|
338
|
+
`total_articles: ${articles.length}`,
|
|
339
|
+
'---',
|
|
340
|
+
'',
|
|
341
|
+
'# Wiki Index',
|
|
342
|
+
'',
|
|
343
|
+
`> ${articles.length} articles. Auto-maintained by StackMemory.`,
|
|
344
|
+
'',
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
for (const [cat, items] of Object.entries(grouped)) {
|
|
348
|
+
lines.push(`## ${cat.charAt(0).toUpperCase() + cat.slice(1)}`, '');
|
|
349
|
+
for (const item of items.slice(0, 200)) {
|
|
350
|
+
const link = item.path.replace('.md', '');
|
|
351
|
+
lines.push(`- [[${link}|${item.title}]]`);
|
|
352
|
+
}
|
|
353
|
+
lines.push('');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
fs.writeFileSync(path.join(wikiDir, 'index.md'), lines.join('\n'));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function slugify(text) {
|
|
360
|
+
return text
|
|
361
|
+
.toLowerCase()
|
|
362
|
+
.replace(/[^a-z0-9-_\s]/g, '')
|
|
363
|
+
.replace(/\s+/g, '-')
|
|
364
|
+
.replace(/-+/g, '-')
|
|
365
|
+
.slice(0, 60)
|
|
366
|
+
.replace(/-$/, '');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function escapeYaml(s) {
|
|
370
|
+
return s.replace(/"/g, '\\"').replace(/\n/g, ' ');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
main();
|