gyrus 0.1.0
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/LICENSE +21 -0
- package/README.md +273 -0
- package/package.json +78 -0
- package/src/commands/adr.ts +482 -0
- package/src/commands/doctor.ts +263 -0
- package/src/commands/init.ts +293 -0
- package/src/commands/knowledge.ts +446 -0
- package/src/commands/list.ts +51 -0
- package/src/commands/mcp.ts +94 -0
- package/src/commands/use.ts +65 -0
- package/src/config/index.ts +262 -0
- package/src/config/schema.ts +139 -0
- package/src/formatters/adr.ts +295 -0
- package/src/formatters/index.ts +44 -0
- package/src/formatters/knowledge.ts +282 -0
- package/src/formatters/workspace.ts +149 -0
- package/src/index.ts +153 -0
- package/src/operations/adr.ts +312 -0
- package/src/operations/index.ts +58 -0
- package/src/operations/knowledge.ts +324 -0
- package/src/operations/types.ts +199 -0
- package/src/operations/workspace.ts +108 -0
- package/src/server/mcp-stdio.ts +526 -0
- package/src/services/adr.ts +511 -0
- package/src/services/knowledge.ts +516 -0
- package/src/services/markdown.ts +155 -0
- package/src/services/workspace.ts +377 -0
- package/src/templates/knowledge/README.md +199 -0
- package/src/tools/adr-create.ts +99 -0
- package/src/tools/adr-list.ts +72 -0
- package/src/tools/adr-read.ts +54 -0
- package/src/tools/adr-search.ts +79 -0
- package/src/tools/adr-update.ts +95 -0
- package/src/tools/gyrus-list.ts +41 -0
- package/src/tools/gyrus-switch.ts +45 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/knowledge-create.ts +75 -0
- package/src/tools/knowledge-list.ts +60 -0
- package/src/tools/knowledge-read.ts +62 -0
- package/src/tools/knowledge-search.ts +65 -0
- package/src/tools/knowledge-update.ts +76 -0
- package/src/types/index.ts +343 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge service for file operations
|
|
3
|
+
* Handles reading, writing, searching, and listing knowledge notes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import type {
|
|
10
|
+
KnowledgeNote,
|
|
11
|
+
KnowledgeCategory,
|
|
12
|
+
CreateNoteInput,
|
|
13
|
+
UpdateNoteInput,
|
|
14
|
+
SearchOptions,
|
|
15
|
+
ListOptions,
|
|
16
|
+
OperationResult,
|
|
17
|
+
} from "../types/index.ts";
|
|
18
|
+
import {
|
|
19
|
+
parseMarkdown,
|
|
20
|
+
serializeMarkdown,
|
|
21
|
+
createMarkdownContent,
|
|
22
|
+
getCategoryFolder,
|
|
23
|
+
getTodayDate,
|
|
24
|
+
} from "./markdown.ts";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Knowledge service class
|
|
28
|
+
* Manages all operations on the knowledge notes system
|
|
29
|
+
*/
|
|
30
|
+
export class KnowledgeService {
|
|
31
|
+
private knowledgePath: string;
|
|
32
|
+
private cache: Map<string, KnowledgeNote> = new Map();
|
|
33
|
+
private cacheInitialized = false;
|
|
34
|
+
|
|
35
|
+
constructor(knowledgePath: string) {
|
|
36
|
+
this.knowledgePath = knowledgePath;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the knowledge path
|
|
41
|
+
*/
|
|
42
|
+
getPath(): string {
|
|
43
|
+
return this.knowledgePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize the cache by reading all notes
|
|
48
|
+
*/
|
|
49
|
+
async initialize(): Promise<void> {
|
|
50
|
+
if (this.cacheInitialized) return;
|
|
51
|
+
await this.refreshCache();
|
|
52
|
+
this.cacheInitialized = true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refresh the cache by re-reading all notes from disk
|
|
57
|
+
*/
|
|
58
|
+
async refreshCache(): Promise<void> {
|
|
59
|
+
this.cache.clear();
|
|
60
|
+
const notes = await this.readAllNotes();
|
|
61
|
+
for (const note of notes) {
|
|
62
|
+
this.cache.set(note.id, note);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Read all markdown files from the knowledge directory
|
|
68
|
+
*/
|
|
69
|
+
private async readAllNotes(): Promise<KnowledgeNote[]> {
|
|
70
|
+
const notes: KnowledgeNote[] = [];
|
|
71
|
+
const categories: KnowledgeCategory[] = [
|
|
72
|
+
"projects",
|
|
73
|
+
"patterns",
|
|
74
|
+
"tools",
|
|
75
|
+
"gotchas",
|
|
76
|
+
"decisions",
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const category of categories) {
|
|
80
|
+
const categoryPath = join(this.knowledgePath, category);
|
|
81
|
+
if (!existsSync(categoryPath)) continue;
|
|
82
|
+
|
|
83
|
+
const files = await readdir(categoryPath);
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file.endsWith(".md")) continue;
|
|
86
|
+
|
|
87
|
+
const filePath = join(categoryPath, file);
|
|
88
|
+
const content = await readFile(filePath, "utf-8");
|
|
89
|
+
const relativePath = relative(this.knowledgePath, filePath);
|
|
90
|
+
const note = parseMarkdown(content, relativePath);
|
|
91
|
+
|
|
92
|
+
if (note) {
|
|
93
|
+
notes.push(note);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return notes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get a note by ID
|
|
103
|
+
*/
|
|
104
|
+
async getNote(id: string): Promise<KnowledgeNote | null> {
|
|
105
|
+
await this.initialize();
|
|
106
|
+
return this.cache.get(id) ?? null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a note by path (relative to knowledge folder)
|
|
111
|
+
*/
|
|
112
|
+
async getNoteByPath(path: string): Promise<KnowledgeNote | null> {
|
|
113
|
+
await this.initialize();
|
|
114
|
+
|
|
115
|
+
for (const note of this.cache.values()) {
|
|
116
|
+
if (note.path === path) {
|
|
117
|
+
return note;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* List all notes with optional filtering
|
|
126
|
+
*/
|
|
127
|
+
async listNotes(options?: ListOptions): Promise<KnowledgeNote[]> {
|
|
128
|
+
await this.initialize();
|
|
129
|
+
|
|
130
|
+
let notes = Array.from(this.cache.values());
|
|
131
|
+
|
|
132
|
+
if (options?.category) {
|
|
133
|
+
notes = notes.filter((note) => note.category === options.category);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (options?.tags && options.tags.length > 0) {
|
|
137
|
+
notes = notes.filter((note) =>
|
|
138
|
+
options.tags!.some((tag) => note.tags.includes(tag))
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Sort by updated date (most recent first)
|
|
143
|
+
notes.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
144
|
+
|
|
145
|
+
return notes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Search notes by query, tags, or category
|
|
150
|
+
*/
|
|
151
|
+
async searchNotes(options: SearchOptions): Promise<KnowledgeNote[]> {
|
|
152
|
+
await this.initialize();
|
|
153
|
+
|
|
154
|
+
let notes = Array.from(this.cache.values());
|
|
155
|
+
|
|
156
|
+
// Filter by category
|
|
157
|
+
if (options.category) {
|
|
158
|
+
notes = notes.filter((note) => note.category === options.category);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Filter by tags (match any)
|
|
162
|
+
if (options.tags && options.tags.length > 0) {
|
|
163
|
+
notes = notes.filter((note) =>
|
|
164
|
+
options.tags!.some((tag) =>
|
|
165
|
+
note.tags.some((noteTag) =>
|
|
166
|
+
noteTag.toLowerCase().includes(tag.toLowerCase())
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Filter by text query (searches title and content)
|
|
173
|
+
if (options.query) {
|
|
174
|
+
const queryLower = options.query.toLowerCase();
|
|
175
|
+
notes = notes.filter(
|
|
176
|
+
(note) =>
|
|
177
|
+
note.title.toLowerCase().includes(queryLower) ||
|
|
178
|
+
note.content.toLowerCase().includes(queryLower) ||
|
|
179
|
+
note.id.toLowerCase().includes(queryLower) ||
|
|
180
|
+
(note.description?.toLowerCase().includes(queryLower) ?? false)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Sort by relevance (title matches first, then by updated date)
|
|
185
|
+
if (options.query) {
|
|
186
|
+
const queryLower = options.query.toLowerCase();
|
|
187
|
+
notes.sort((a, b) => {
|
|
188
|
+
const aInTitle = a.title.toLowerCase().includes(queryLower) ? 1 : 0;
|
|
189
|
+
const bInTitle = b.title.toLowerCase().includes(queryLower) ? 1 : 0;
|
|
190
|
+
if (aInTitle !== bInTitle) return bInTitle - aInTitle;
|
|
191
|
+
return b.updated.localeCompare(a.updated);
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
notes.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return notes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create a new knowledge note
|
|
202
|
+
*/
|
|
203
|
+
async createNote(input: CreateNoteInput): Promise<OperationResult> {
|
|
204
|
+
await this.initialize();
|
|
205
|
+
|
|
206
|
+
// Check if ID already exists
|
|
207
|
+
if (this.cache.has(input.id)) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
message: `Note with ID "${input.id}" already exists`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate ID format (kebab-case)
|
|
215
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(input.id)) {
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
message: "ID must be in kebab-case format (e.g., my-note-id)",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const today = getTodayDate();
|
|
223
|
+
const categoryFolder = getCategoryFolder(input.category);
|
|
224
|
+
const fileName = `${input.id}.md`;
|
|
225
|
+
const relativePath = join(categoryFolder, fileName);
|
|
226
|
+
const absolutePath = join(this.knowledgePath, relativePath);
|
|
227
|
+
|
|
228
|
+
// Ensure category directory exists
|
|
229
|
+
const categoryPath = join(this.knowledgePath, categoryFolder);
|
|
230
|
+
if (!existsSync(categoryPath)) {
|
|
231
|
+
await mkdir(categoryPath, { recursive: true });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Create markdown content
|
|
235
|
+
const markdownContent = createMarkdownContent({
|
|
236
|
+
id: input.id,
|
|
237
|
+
title: input.title,
|
|
238
|
+
content: input.content,
|
|
239
|
+
created: today,
|
|
240
|
+
updated: today,
|
|
241
|
+
tags: input.tags,
|
|
242
|
+
related: input.related,
|
|
243
|
+
description: input.description,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// Write file
|
|
247
|
+
await writeFile(absolutePath, markdownContent, "utf-8");
|
|
248
|
+
|
|
249
|
+
// Create note object and add to cache
|
|
250
|
+
const note: KnowledgeNote = {
|
|
251
|
+
id: input.id,
|
|
252
|
+
title: input.title,
|
|
253
|
+
created: today,
|
|
254
|
+
updated: today,
|
|
255
|
+
tags: input.tags ?? [],
|
|
256
|
+
related: input.related ?? [],
|
|
257
|
+
description: input.description,
|
|
258
|
+
content: input.content,
|
|
259
|
+
path: relativePath,
|
|
260
|
+
category: input.category,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
this.cache.set(input.id, note);
|
|
264
|
+
|
|
265
|
+
// Update index.md
|
|
266
|
+
await this.updateIndex();
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
success: true,
|
|
270
|
+
message: `Created note "${input.title}" at ${relativePath}`,
|
|
271
|
+
note,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Update an existing knowledge note
|
|
277
|
+
*/
|
|
278
|
+
async updateNote(input: UpdateNoteInput): Promise<OperationResult> {
|
|
279
|
+
await this.initialize();
|
|
280
|
+
|
|
281
|
+
const existingNote = this.cache.get(input.id);
|
|
282
|
+
if (!existingNote) {
|
|
283
|
+
return {
|
|
284
|
+
success: false,
|
|
285
|
+
message: `Note with ID "${input.id}" not found`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const today = getTodayDate();
|
|
290
|
+
|
|
291
|
+
// Merge updates with existing note
|
|
292
|
+
const updatedNote: KnowledgeNote = {
|
|
293
|
+
...existingNote,
|
|
294
|
+
title: input.title ?? existingNote.title,
|
|
295
|
+
content: input.content ?? existingNote.content,
|
|
296
|
+
tags: input.tags ?? existingNote.tags,
|
|
297
|
+
related: input.related ?? existingNote.related,
|
|
298
|
+
description: input.description ?? existingNote.description,
|
|
299
|
+
updated: today,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Serialize and write
|
|
303
|
+
const markdownContent = serializeMarkdown(updatedNote);
|
|
304
|
+
const absolutePath = join(this.knowledgePath, existingNote.path);
|
|
305
|
+
await writeFile(absolutePath, markdownContent, "utf-8");
|
|
306
|
+
|
|
307
|
+
// Update cache
|
|
308
|
+
this.cache.set(input.id, updatedNote);
|
|
309
|
+
|
|
310
|
+
// Update index.md if title or tags changed
|
|
311
|
+
if (input.title || input.tags) {
|
|
312
|
+
await this.updateIndex();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
success: true,
|
|
317
|
+
message: `Updated note "${updatedNote.title}"`,
|
|
318
|
+
note: updatedNote,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Update the index.md file to reflect current notes
|
|
324
|
+
*/
|
|
325
|
+
private async updateIndex(): Promise<void> {
|
|
326
|
+
const notes = Array.from(this.cache.values());
|
|
327
|
+
|
|
328
|
+
// Group notes by category
|
|
329
|
+
const byCategory: Record<string, KnowledgeNote[]> = {
|
|
330
|
+
projects: [],
|
|
331
|
+
patterns: [],
|
|
332
|
+
tools: [],
|
|
333
|
+
gotchas: [],
|
|
334
|
+
decisions: [],
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
for (const note of notes) {
|
|
338
|
+
if (note.category !== "root" && byCategory[note.category]) {
|
|
339
|
+
byCategory[note.category]!.push(note);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Sort each category alphabetically by ID
|
|
344
|
+
for (const category of Object.keys(byCategory)) {
|
|
345
|
+
byCategory[category]!.sort((a, b) => a.id.localeCompare(b.id));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Collect all tags
|
|
349
|
+
const tagCounts: Record<string, number> = {};
|
|
350
|
+
for (const note of notes) {
|
|
351
|
+
for (const tag of note.tags) {
|
|
352
|
+
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const today = getTodayDate();
|
|
357
|
+
const totalNotes = notes.length;
|
|
358
|
+
|
|
359
|
+
// Generate index content
|
|
360
|
+
const indexContent = `---
|
|
361
|
+
id: knowledge-index
|
|
362
|
+
title: Knowledge Index
|
|
363
|
+
created: ${today}
|
|
364
|
+
updated: ${today}
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
# Knowledge Index
|
|
368
|
+
|
|
369
|
+
Master index of all knowledge notes. **Keep this file updated when adding or removing notes.**
|
|
370
|
+
|
|
371
|
+
## Quick Stats
|
|
372
|
+
|
|
373
|
+
- Total Notes: ${totalNotes}
|
|
374
|
+
- Last Updated: ${today}
|
|
375
|
+
|
|
376
|
+
## System Files
|
|
377
|
+
|
|
378
|
+
| File | Description |
|
|
379
|
+
|------|-------------|
|
|
380
|
+
| [README.md](README.md) | Purpose, guidelines, and templates |
|
|
381
|
+
| [index.md](index.md) | This file - master index |
|
|
382
|
+
| [instructions.md](instructions.md) | AI agent workflow instructions |
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Projects
|
|
387
|
+
|
|
388
|
+
Project-specific knowledge and insights.
|
|
389
|
+
|
|
390
|
+
| ID | Title | Tags | Updated |
|
|
391
|
+
|----|-------|------|---------|
|
|
392
|
+
${this.formatCategoryTable(byCategory["projects"]!)}
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Patterns
|
|
397
|
+
|
|
398
|
+
Reusable patterns and solutions.
|
|
399
|
+
|
|
400
|
+
| ID | Title | Tags | Updated |
|
|
401
|
+
|----|-------|------|---------|
|
|
402
|
+
${this.formatCategoryTable(byCategory["patterns"]!)}
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Tools
|
|
407
|
+
|
|
408
|
+
Tool and technology notes.
|
|
409
|
+
|
|
410
|
+
| ID | Title | Tags | Updated |
|
|
411
|
+
|----|-------|------|---------|
|
|
412
|
+
${this.formatCategoryTable(byCategory["tools"]!)}
|
|
413
|
+
|
|
414
|
+
---
|
|
415
|
+
|
|
416
|
+
## Gotchas
|
|
417
|
+
|
|
418
|
+
Common pitfalls and their solutions.
|
|
419
|
+
|
|
420
|
+
| ID | Title | Tags | Updated |
|
|
421
|
+
|----|-------|------|---------|
|
|
422
|
+
${this.formatCategoryTable(byCategory["gotchas"]!)}
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Decisions
|
|
427
|
+
|
|
428
|
+
Key decisions and their rationale.
|
|
429
|
+
|
|
430
|
+
| ID | Title | Tags | Updated |
|
|
431
|
+
|----|-------|------|---------|
|
|
432
|
+
${this.formatCategoryTable(byCategory["decisions"]!)}
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Tags Index
|
|
437
|
+
|
|
438
|
+
Quick reference of all tags in use:
|
|
439
|
+
|
|
440
|
+
${this.formatTagsIndex(tagCounts)}`;
|
|
441
|
+
|
|
442
|
+
const indexPath = join(this.knowledgePath, "index.md");
|
|
443
|
+
await writeFile(indexPath, indexContent, "utf-8");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Format a category's notes as a markdown table
|
|
448
|
+
*/
|
|
449
|
+
private formatCategoryTable(notes: KnowledgeNote[]): string {
|
|
450
|
+
if (notes.length === 0) {
|
|
451
|
+
return "| - | No notes yet | - | - |";
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return notes
|
|
455
|
+
.map((note) => {
|
|
456
|
+
const tags = note.tags.join(", ") || "-";
|
|
457
|
+
return `| [${note.id}](${note.path}) | ${note.title} | ${tags} | ${note.updated} |`;
|
|
458
|
+
})
|
|
459
|
+
.join("\n");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Format the tags index section
|
|
464
|
+
*/
|
|
465
|
+
private formatTagsIndex(tagCounts: Record<string, number>): string {
|
|
466
|
+
const sortedTags = Object.entries(tagCounts).sort(([a], [b]) =>
|
|
467
|
+
a.localeCompare(b)
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
if (sortedTags.length === 0) {
|
|
471
|
+
return "No tags yet.";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return sortedTags
|
|
475
|
+
.map(([tag, count]) => `- \`${tag}\` (${count} note${count > 1 ? "s" : ""})`)
|
|
476
|
+
.join("\n");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Get all unique tags across all notes
|
|
481
|
+
*/
|
|
482
|
+
async getAllTags(): Promise<string[]> {
|
|
483
|
+
await this.initialize();
|
|
484
|
+
|
|
485
|
+
const tags = new Set<string>();
|
|
486
|
+
for (const note of this.cache.values()) {
|
|
487
|
+
for (const tag of note.tags) {
|
|
488
|
+
tags.add(tag);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return Array.from(tags).sort();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Get all categories with note counts
|
|
497
|
+
*/
|
|
498
|
+
async getCategoryCounts(): Promise<Record<KnowledgeCategory, number>> {
|
|
499
|
+
await this.initialize();
|
|
500
|
+
|
|
501
|
+
const counts: Record<KnowledgeCategory, number> = {
|
|
502
|
+
projects: 0,
|
|
503
|
+
patterns: 0,
|
|
504
|
+
tools: 0,
|
|
505
|
+
gotchas: 0,
|
|
506
|
+
decisions: 0,
|
|
507
|
+
root: 0,
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
for (const note of this.cache.values()) {
|
|
511
|
+
counts[note.category]++;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return counts;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown parsing utilities for Gyrus
|
|
3
|
+
* Handles frontmatter extraction and serialization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import matter from "gray-matter";
|
|
7
|
+
import type {
|
|
8
|
+
KnowledgeNote,
|
|
9
|
+
KnowledgeFrontmatter,
|
|
10
|
+
KnowledgeCategory,
|
|
11
|
+
} from "../types/index.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Get today's date in YYYY-MM-DD format
|
|
15
|
+
*/
|
|
16
|
+
export function getTodayDate(): string {
|
|
17
|
+
const now = new Date();
|
|
18
|
+
const year = now.getFullYear();
|
|
19
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
20
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
21
|
+
return `${year}-${month}-${day}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get category folder name
|
|
26
|
+
*/
|
|
27
|
+
export function getCategoryFolder(
|
|
28
|
+
category: Exclude<KnowledgeCategory, "root">
|
|
29
|
+
): string {
|
|
30
|
+
return category;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Determine category from file path
|
|
35
|
+
*/
|
|
36
|
+
export function getCategoryFromPath(path: string): KnowledgeCategory {
|
|
37
|
+
const parts = path.split("/");
|
|
38
|
+
if (parts.length < 2) {
|
|
39
|
+
return "root";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const folder = parts[0];
|
|
43
|
+
const validCategories: KnowledgeCategory[] = [
|
|
44
|
+
"projects",
|
|
45
|
+
"patterns",
|
|
46
|
+
"tools",
|
|
47
|
+
"gotchas",
|
|
48
|
+
"decisions",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
if (validCategories.includes(folder as KnowledgeCategory)) {
|
|
52
|
+
return folder as KnowledgeCategory;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return "root";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a markdown file with YAML frontmatter into a KnowledgeNote
|
|
60
|
+
* Returns null if parsing fails or required fields are missing
|
|
61
|
+
*/
|
|
62
|
+
export function parseMarkdown(
|
|
63
|
+
content: string,
|
|
64
|
+
relativePath: string
|
|
65
|
+
): KnowledgeNote | null {
|
|
66
|
+
try {
|
|
67
|
+
const { data, content: markdownContent } = matter(content);
|
|
68
|
+
const frontmatter = data as Partial<KnowledgeFrontmatter>;
|
|
69
|
+
|
|
70
|
+
// Validate required fields
|
|
71
|
+
if (
|
|
72
|
+
!frontmatter.id ||
|
|
73
|
+
!frontmatter.title ||
|
|
74
|
+
!frontmatter.created ||
|
|
75
|
+
!frontmatter.updated
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const category = getCategoryFromPath(relativePath);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
id: frontmatter.id,
|
|
84
|
+
title: frontmatter.title,
|
|
85
|
+
created: String(frontmatter.created),
|
|
86
|
+
updated: String(frontmatter.updated),
|
|
87
|
+
tags: frontmatter.tags ?? [],
|
|
88
|
+
related: frontmatter.related ?? [],
|
|
89
|
+
description: frontmatter.description,
|
|
90
|
+
content: markdownContent.trim(),
|
|
91
|
+
path: relativePath,
|
|
92
|
+
category,
|
|
93
|
+
};
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Serialize a KnowledgeNote back to markdown with frontmatter
|
|
101
|
+
*/
|
|
102
|
+
export function serializeMarkdown(note: KnowledgeNote): string {
|
|
103
|
+
const frontmatter: KnowledgeFrontmatter = {
|
|
104
|
+
id: note.id,
|
|
105
|
+
title: note.title,
|
|
106
|
+
created: note.created,
|
|
107
|
+
updated: note.updated,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Only include optional fields if they have values
|
|
111
|
+
if (note.tags.length > 0) {
|
|
112
|
+
frontmatter.tags = note.tags;
|
|
113
|
+
}
|
|
114
|
+
if (note.related.length > 0) {
|
|
115
|
+
frontmatter.related = note.related;
|
|
116
|
+
}
|
|
117
|
+
if (note.description) {
|
|
118
|
+
frontmatter.description = note.description;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return matter.stringify(note.content, frontmatter);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Create markdown content for a new note
|
|
126
|
+
*/
|
|
127
|
+
export function createMarkdownContent(options: {
|
|
128
|
+
id: string;
|
|
129
|
+
title: string;
|
|
130
|
+
content: string;
|
|
131
|
+
created: string;
|
|
132
|
+
updated: string;
|
|
133
|
+
tags?: string[];
|
|
134
|
+
related?: string[];
|
|
135
|
+
description?: string;
|
|
136
|
+
}): string {
|
|
137
|
+
const frontmatter: Record<string, unknown> = {
|
|
138
|
+
id: options.id,
|
|
139
|
+
title: options.title,
|
|
140
|
+
created: options.created,
|
|
141
|
+
updated: options.updated,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (options.tags && options.tags.length > 0) {
|
|
145
|
+
frontmatter.tags = options.tags;
|
|
146
|
+
}
|
|
147
|
+
if (options.related && options.related.length > 0) {
|
|
148
|
+
frontmatter.related = options.related;
|
|
149
|
+
}
|
|
150
|
+
if (options.description) {
|
|
151
|
+
frontmatter.description = options.description;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return matter.stringify(options.content, frontmatter);
|
|
155
|
+
}
|