@teknologika/chisel-knowledge-mcp 0.1.0 → 0.1.1
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 +12 -1
- package/dist/domains/workspace/anthropic-adapter.js +18 -0
- package/dist/domains/workspace/inbox-index.js +96 -0
- package/dist/domains/workspace/indexing.js +56 -0
- package/dist/domains/workspace/knowledge-index.js +1 -56
- package/dist/domains/workspace/llm-adapter.js +1 -0
- package/dist/domains/workspace/process-inbox.js +275 -0
- package/dist/domains/workspace/workspace.service.js +163 -7
- package/dist/index.js +1 -0
- package/dist/infrastructure/mcp/mcp-server.js +6 -1
- package/dist/infrastructure/mcp/tool-schemas.js +35 -0
- package/dist/server.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Standalone MCP server and library for building and managing knowledge workspaces.
|
|
4
4
|
|
|
5
|
-
Canonical behavior documentation lives in [docs/chisel-knowledge-mcp.md](./docs/chisel-knowledge-mcp.md).
|
|
5
|
+
Canonical behavior documentation lives in [docs/chisel-knowledge-mcp.md](./docs/chisel-knowledge-mcp.md) and is routed from [docs/CANONICAL_DOCS.md](./docs/CANONICAL_DOCS.md).
|
|
6
6
|
|
|
7
7
|
## Requirements
|
|
8
8
|
|
|
@@ -25,6 +25,17 @@ import { WorkspaceService, KnowledgeIndex } from '@teknologika/chisel-knowledge-
|
|
|
25
25
|
|
|
26
26
|
The MCP server remains available from the `server` subpath and through the published binary.
|
|
27
27
|
|
|
28
|
+
## Workspace Workflow
|
|
29
|
+
|
|
30
|
+
The workspace service and MCP server expose a deterministic inbox pipeline:
|
|
31
|
+
|
|
32
|
+
- `knowledge_get_next_inbox_file` returns the first unprocessed inbox file with its content.
|
|
33
|
+
- `knowledge_get_dedupe_context` returns search results from both `knowledge/` and `inbox/` for a file-specific query.
|
|
34
|
+
- `knowledge_compile_new` writes a new article into `knowledge/`, updates `knowledge/index.md`, appends `knowledge/log.md`, and archives the source inbox file.
|
|
35
|
+
- `knowledge_compile_extend` writes a revised article into `knowledge/`, updates the article's `Updated` entry in `knowledge/index.md`, appends `knowledge/log.md`, and archives the source inbox file.
|
|
36
|
+
|
|
37
|
+
These tools are deterministic. The LLM that consumes the MCP server decides the article content and the dedupe outcome; the server only performs file and index updates.
|
|
38
|
+
|
|
28
39
|
## Build
|
|
29
40
|
|
|
30
41
|
```bash
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
export function createAnthropicAdapter(model = 'claude-haiku-4-5-20251001') {
|
|
3
|
+
const client = new Anthropic();
|
|
4
|
+
return {
|
|
5
|
+
async complete(prompt) {
|
|
6
|
+
const message = await client.messages.create({
|
|
7
|
+
model,
|
|
8
|
+
max_tokens: 4096,
|
|
9
|
+
messages: [{ role: 'user', content: prompt }],
|
|
10
|
+
});
|
|
11
|
+
const block = message.content[0];
|
|
12
|
+
if (!block || block.type !== 'text') {
|
|
13
|
+
throw new Error('Unexpected response type from Anthropic API');
|
|
14
|
+
}
|
|
15
|
+
return block.text;
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { chunkMarkdown, normalizeFtsQuery } from './indexing.js';
|
|
5
|
+
const SCHEMA = `
|
|
6
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
7
|
+
id INTEGER PRIMARY KEY,
|
|
8
|
+
path TEXT NOT NULL,
|
|
9
|
+
heading TEXT,
|
|
10
|
+
body TEXT NOT NULL,
|
|
11
|
+
mtime INTEGER NOT NULL
|
|
12
|
+
);
|
|
13
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
|
|
14
|
+
path, heading, body,
|
|
15
|
+
content='chunks', content_rowid='id'
|
|
16
|
+
);
|
|
17
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
|
|
18
|
+
INSERT INTO chunks_fts(rowid, path, heading, body)
|
|
19
|
+
VALUES (new.id, new.path, new.heading, new.body);
|
|
20
|
+
END;
|
|
21
|
+
CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
|
|
22
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, path, heading, body)
|
|
23
|
+
VALUES ('delete', old.id, old.path, old.heading, old.body);
|
|
24
|
+
END;
|
|
25
|
+
CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
|
|
26
|
+
INSERT INTO chunks_fts(chunks_fts, rowid, path, heading, body)
|
|
27
|
+
VALUES ('delete', old.id, old.path, old.heading, old.body);
|
|
28
|
+
INSERT INTO chunks_fts(rowid, path, heading, body)
|
|
29
|
+
VALUES (new.id, new.path, new.heading, new.body);
|
|
30
|
+
END;
|
|
31
|
+
`;
|
|
32
|
+
export class InboxIndex {
|
|
33
|
+
workspacePath;
|
|
34
|
+
db;
|
|
35
|
+
constructor(workspacePath) {
|
|
36
|
+
this.workspacePath = workspacePath;
|
|
37
|
+
const dbPath = join(workspacePath, '.inbox-index.db');
|
|
38
|
+
this.db = new DatabaseSync(dbPath);
|
|
39
|
+
this.db.exec('PRAGMA journal_mode = WAL');
|
|
40
|
+
this.db.exec(SCHEMA);
|
|
41
|
+
}
|
|
42
|
+
indexFile(absPath) {
|
|
43
|
+
const path = relative(this.workspacePath, absPath);
|
|
44
|
+
const mtime = statSync(absPath).mtimeMs;
|
|
45
|
+
const existing = this.db
|
|
46
|
+
.prepare('SELECT mtime FROM chunks WHERE path = ? LIMIT 1')
|
|
47
|
+
.get(path);
|
|
48
|
+
if (existing && existing.mtime === mtime)
|
|
49
|
+
return;
|
|
50
|
+
this.db.prepare('DELETE FROM chunks WHERE path = ?').run(path);
|
|
51
|
+
const raw = readFileSync(absPath, 'utf8');
|
|
52
|
+
const chunks = chunkMarkdown(raw);
|
|
53
|
+
const insert = this.db.prepare('INSERT INTO chunks (path, heading, body, mtime) VALUES (?, ?, ?, ?)');
|
|
54
|
+
this.db.exec('BEGIN');
|
|
55
|
+
try {
|
|
56
|
+
for (const chunk of chunks) {
|
|
57
|
+
insert.run(path, chunk.heading ?? null, chunk.body, mtime);
|
|
58
|
+
}
|
|
59
|
+
this.db.exec('COMMIT');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
this.db.exec('ROLLBACK');
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
removeFile(absPath) {
|
|
67
|
+
const path = relative(this.workspacePath, absPath);
|
|
68
|
+
this.db.prepare('DELETE FROM chunks WHERE path = ?').run(path);
|
|
69
|
+
}
|
|
70
|
+
search(query, limit = 10) {
|
|
71
|
+
const normalized = normalizeFtsQuery(query);
|
|
72
|
+
if (!normalized)
|
|
73
|
+
return { results: [] };
|
|
74
|
+
const rows = this.db
|
|
75
|
+
.prepare(`
|
|
76
|
+
SELECT c.path, c.heading,
|
|
77
|
+
snippet(chunks_fts, 2, '[', ']', '...', 16) AS excerpt
|
|
78
|
+
FROM chunks_fts f
|
|
79
|
+
JOIN chunks c ON c.id = f.rowid
|
|
80
|
+
WHERE chunks_fts MATCH ?
|
|
81
|
+
ORDER BY bm25(chunks_fts)
|
|
82
|
+
LIMIT ?
|
|
83
|
+
`)
|
|
84
|
+
.all(normalized, limit);
|
|
85
|
+
return {
|
|
86
|
+
results: rows.map((row, index) => ({
|
|
87
|
+
file: row.path,
|
|
88
|
+
excerpt: row.heading ? `**${row.heading}**\n${row.excerpt}` : row.excerpt,
|
|
89
|
+
score: 1 - index / Math.max(rows.length, 1),
|
|
90
|
+
})),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
close() {
|
|
94
|
+
this.db.close();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export function chunkMarkdown(content) {
|
|
2
|
+
const stripped = content.replace(/^---[\s\S]*?---\n?/, '');
|
|
3
|
+
const cleaned = stripped
|
|
4
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
5
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
6
|
+
.trim();
|
|
7
|
+
const lines = cleaned.split('\n');
|
|
8
|
+
const chunks = [];
|
|
9
|
+
let currentHeading = null;
|
|
10
|
+
let currentLines = [];
|
|
11
|
+
const flush = () => {
|
|
12
|
+
const body = currentLines.join('\n').trim();
|
|
13
|
+
if (!body)
|
|
14
|
+
return;
|
|
15
|
+
if (body.length > 1500) {
|
|
16
|
+
const paragraphs = body.split(/\n{2,}/);
|
|
17
|
+
let acc = '';
|
|
18
|
+
for (const para of paragraphs) {
|
|
19
|
+
if (acc.length + para.length > 1500 && acc.length > 0) {
|
|
20
|
+
chunks.push({ heading: currentHeading, body: acc.trim() });
|
|
21
|
+
acc = para;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
acc = acc ? `${acc}\n\n${para}` : para;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (acc.trim())
|
|
28
|
+
chunks.push({ heading: currentHeading, body: acc.trim() });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
chunks.push({ heading: currentHeading, body });
|
|
32
|
+
}
|
|
33
|
+
currentLines = [];
|
|
34
|
+
};
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
const headingMatch = /^(#{1,3})\s+(.+)/.exec(line);
|
|
37
|
+
if (headingMatch) {
|
|
38
|
+
flush();
|
|
39
|
+
currentHeading = headingMatch[2].trim();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
currentLines.push(line);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
return chunks;
|
|
47
|
+
}
|
|
48
|
+
export function normalizeFtsQuery(query) {
|
|
49
|
+
return query
|
|
50
|
+
.trim()
|
|
51
|
+
.split(/\s+/)
|
|
52
|
+
.map((token) => token.replace(/[^a-zA-Z0-9_]/g, ''))
|
|
53
|
+
.filter((token) => token.length > 0)
|
|
54
|
+
.map((token) => `${token}*`)
|
|
55
|
+
.join(' ');
|
|
56
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, relative } from 'node:path';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { chunkMarkdown, normalizeFtsQuery } from './indexing.js';
|
|
4
5
|
const SCHEMA = `
|
|
5
6
|
CREATE TABLE IF NOT EXISTS chunks (
|
|
6
7
|
id INTEGER PRIMARY KEY,
|
|
@@ -93,59 +94,3 @@ export class KnowledgeIndex {
|
|
|
93
94
|
this.db.close();
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
|
-
function chunkMarkdown(content) {
|
|
97
|
-
const stripped = content.replace(/^---[\s\S]*?---\n?/, '');
|
|
98
|
-
const cleaned = stripped
|
|
99
|
-
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
100
|
-
.replace(/<!--[\s\S]*?-->/g, '')
|
|
101
|
-
.trim();
|
|
102
|
-
const lines = cleaned.split('\n');
|
|
103
|
-
const chunks = [];
|
|
104
|
-
let currentHeading = null;
|
|
105
|
-
let currentLines = [];
|
|
106
|
-
const flush = () => {
|
|
107
|
-
const body = currentLines.join('\n').trim();
|
|
108
|
-
if (!body)
|
|
109
|
-
return;
|
|
110
|
-
if (body.length > 1500) {
|
|
111
|
-
const paragraphs = body.split(/\n{2,}/);
|
|
112
|
-
let acc = '';
|
|
113
|
-
for (const para of paragraphs) {
|
|
114
|
-
if (acc.length + para.length > 1500 && acc.length > 0) {
|
|
115
|
-
chunks.push({ heading: currentHeading, body: acc.trim() });
|
|
116
|
-
acc = para;
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
acc = acc ? `${acc}\n\n${para}` : para;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
if (acc.trim())
|
|
123
|
-
chunks.push({ heading: currentHeading, body: acc.trim() });
|
|
124
|
-
}
|
|
125
|
-
else {
|
|
126
|
-
chunks.push({ heading: currentHeading, body });
|
|
127
|
-
}
|
|
128
|
-
currentLines = [];
|
|
129
|
-
};
|
|
130
|
-
for (const line of lines) {
|
|
131
|
-
const headingMatch = /^(#{1,3})\s+(.+)/.exec(line);
|
|
132
|
-
if (headingMatch) {
|
|
133
|
-
flush();
|
|
134
|
-
currentHeading = headingMatch[2].trim();
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
currentLines.push(line);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
flush();
|
|
141
|
-
return chunks;
|
|
142
|
-
}
|
|
143
|
-
function normalizeFtsQuery(query) {
|
|
144
|
-
return query
|
|
145
|
-
.trim()
|
|
146
|
-
.split(/\s+/)
|
|
147
|
-
.map((token) => token.replace(/[^a-zA-Z0-9_]/g, ''))
|
|
148
|
-
.filter((token) => token.length > 0)
|
|
149
|
-
.map((token) => `${token}*`)
|
|
150
|
-
.join(' ');
|
|
151
|
-
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
function buildDedupePrompt(fileContent, filePath, knowledgeMatches, inboxMatches) {
|
|
4
|
+
return `You are classifying an inbox file to decide how to compile it into a knowledge base.
|
|
5
|
+
|
|
6
|
+
FILE: ${filePath}
|
|
7
|
+
|
|
8
|
+
CONTENT:
|
|
9
|
+
${fileContent}
|
|
10
|
+
|
|
11
|
+
EXISTING KNOWLEDGE MATCHES (from knowledge/ FTS search):
|
|
12
|
+
${knowledgeMatches || 'none'}
|
|
13
|
+
|
|
14
|
+
EXISTING INBOX MATCHES (from inbox/ FTS search, excluding this file):
|
|
15
|
+
${inboxMatches || 'none'}
|
|
16
|
+
|
|
17
|
+
Based on the content and search results, respond with ONLY this block and nothing else:
|
|
18
|
+
|
|
19
|
+
FILE: ${filePath}
|
|
20
|
+
DECISION: new | extend | skip
|
|
21
|
+
TYPE: concept | connection
|
|
22
|
+
TARGET: <existing knowledge article path under knowledge/, or "none">
|
|
23
|
+
REASON: <one sentence>
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- new: no meaningful overlap in search results
|
|
27
|
+
- extend: an existing knowledge article covers the same ground
|
|
28
|
+
- skip: content already fully captured, nothing to add
|
|
29
|
+
- concept: atomic knowledge, pattern, lesson, or reference
|
|
30
|
+
- connection: reveals a non-obvious relationship between 2+ existing concepts`;
|
|
31
|
+
}
|
|
32
|
+
function buildCompileNewPrompt(fileContent, filePath, articleType) {
|
|
33
|
+
const isConnection = articleType === 'connection';
|
|
34
|
+
const frontmatterExample = isConnection
|
|
35
|
+
? `---\ntitle: "Connection: X and Y"\nconnects:\n - "concepts/concept-x"\n - "concepts/concept-y"\nsources:\n - "${filePath}"\ncreated: ${todayISO()}\nupdated: ${todayISO()}\n---`
|
|
36
|
+
: `---\ntitle: "Concept Name"\ntags: []\nsources:\n - "${filePath}"\ncreated: ${todayISO()}\nupdated: ${todayISO()}\n---`;
|
|
37
|
+
return `You are compiling an inbox file into a knowledge base article.
|
|
38
|
+
|
|
39
|
+
SOURCE FILE: ${filePath}
|
|
40
|
+
ARTICLE TYPE: ${articleType}
|
|
41
|
+
|
|
42
|
+
SOURCE CONTENT:
|
|
43
|
+
${fileContent}
|
|
44
|
+
|
|
45
|
+
Write a complete markdown article. Return ONLY the article content, no explanation.
|
|
46
|
+
|
|
47
|
+
Requirements:
|
|
48
|
+
- Start with YAML frontmatter using this structure:
|
|
49
|
+
${frontmatterExample}
|
|
50
|
+
|
|
51
|
+
- Then the article body containing:
|
|
52
|
+
- # Title
|
|
53
|
+
- ## Detail (preserve source prose verbatim if already well-structured - do not paraphrase)
|
|
54
|
+
- ## Key Takeaways (3-5 bullets - ONLY if source is raw unstructured notes)
|
|
55
|
+
- ## Related ([[wikilinks]] to related concepts if known)
|
|
56
|
+
- ## Source
|
|
57
|
+
- ${filePath}
|
|
58
|
+
|
|
59
|
+
Content fidelity rule: if the source is already well-structured prose, preserve it
|
|
60
|
+
verbatim or near-verbatim. Add structure around it, do not replace it.
|
|
61
|
+
Only summarise into bullets if the source is raw unstructured notes.`;
|
|
62
|
+
}
|
|
63
|
+
function buildCompileExtendPrompt(fileContent, filePath, existingContent, targetPath) {
|
|
64
|
+
return `You are extending an existing knowledge base article with new information from an inbox file.
|
|
65
|
+
|
|
66
|
+
INBOX FILE: ${filePath}
|
|
67
|
+
EXISTING ARTICLE: ${targetPath}
|
|
68
|
+
|
|
69
|
+
INBOX CONTENT:
|
|
70
|
+
${fileContent}
|
|
71
|
+
|
|
72
|
+
EXISTING ARTICLE CONTENT:
|
|
73
|
+
${existingContent}
|
|
74
|
+
|
|
75
|
+
Identify what is genuinely new in the inbox file not already covered in the existing article.
|
|
76
|
+
If nothing is new, respond with exactly: NOTHING_NEW
|
|
77
|
+
|
|
78
|
+
Otherwise return the COMPLETE updated article with:
|
|
79
|
+
- New content appended or inserted (never rewrite content that is still accurate)
|
|
80
|
+
- ${filePath} added to sources list in YAML frontmatter
|
|
81
|
+
- Updated date in YAML frontmatter set to ${todayISO()}
|
|
82
|
+
- ${filePath} added to ## Source section
|
|
83
|
+
|
|
84
|
+
Return ONLY the complete updated article content, no explanation.`;
|
|
85
|
+
}
|
|
86
|
+
function parseDedupeResult(response, filePath) {
|
|
87
|
+
const lines = response.trim().split('\n');
|
|
88
|
+
const get = (key) => {
|
|
89
|
+
const line = lines.find((value) => value.startsWith(`${key}:`));
|
|
90
|
+
return line ? line.slice(key.length + 1).trim().replace(/^["']|["']$/g, '') : '';
|
|
91
|
+
};
|
|
92
|
+
const rawDecision = get('DECISION').toLowerCase();
|
|
93
|
+
const decision = rawDecision === 'extend' ? 'extend' :
|
|
94
|
+
rawDecision === 'skip' ? 'skip' : 'new';
|
|
95
|
+
const rawType = get('TYPE').toLowerCase();
|
|
96
|
+
const type = rawType === 'connection' ? 'connection' : 'concept';
|
|
97
|
+
const rawTarget = get('TARGET');
|
|
98
|
+
const target = rawTarget === 'none' || !rawTarget
|
|
99
|
+
? null
|
|
100
|
+
: rawTarget.startsWith('knowledge/')
|
|
101
|
+
? rawTarget
|
|
102
|
+
: `knowledge/${rawTarget.replace(/^\/+/, '')}`;
|
|
103
|
+
const reason = get('REASON') || 'No reason provided';
|
|
104
|
+
return { file: filePath, decision, type, target, reason };
|
|
105
|
+
}
|
|
106
|
+
function deriveArticlePath(filePath, type, articleContent) {
|
|
107
|
+
const titleMatch = /^title:\s*["']?(.+?)["']?\s*$/m.exec(articleContent);
|
|
108
|
+
const title = titleMatch ? titleMatch[1].trim() : '';
|
|
109
|
+
const slug = slugify(title || filePath.split('/').pop()?.replace(/\.md$/, '') || 'untitled');
|
|
110
|
+
const dir = type === 'connection' ? 'connections' : 'concepts';
|
|
111
|
+
return `knowledge/${dir}/${slug}.md`;
|
|
112
|
+
}
|
|
113
|
+
function appendLog(logPath, entry) {
|
|
114
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
115
|
+
if (!existsSync(logPath)) {
|
|
116
|
+
writeFileSync(logPath, '# Build Log\n\n', 'utf8');
|
|
117
|
+
}
|
|
118
|
+
const current = readFileSync(logPath, 'utf8');
|
|
119
|
+
writeFileSync(logPath, `${current}\n${entry}\n`, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
function updateIndex(indexPath, articlePath, type, summary, sourceFile) {
|
|
122
|
+
const header = '# Knowledge Base Index\n\n| Article | Type | Summary | Sources | Updated |\n|---------|------|---------|---------|---------|';
|
|
123
|
+
const safeSummary = summary.replace(/\|/g, '\\|');
|
|
124
|
+
const safeSource = sourceFile.replace(/\|/g, '\\|');
|
|
125
|
+
const row = `| [[${articlePath.replace(/\.md$/, '')}]] | ${type} | ${safeSummary} | ${safeSource} | ${todayISO()} |`;
|
|
126
|
+
mkdirSync(dirname(indexPath), { recursive: true });
|
|
127
|
+
if (!existsSync(indexPath)) {
|
|
128
|
+
writeFileSync(indexPath, `${header}\n${row}\n`, 'utf8');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
132
|
+
writeFileSync(indexPath, `${current.trimEnd()}\n${row}\n`, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
function updateIndexRow(indexPath, articlePath) {
|
|
135
|
+
if (!existsSync(indexPath))
|
|
136
|
+
return;
|
|
137
|
+
const ref = articlePath.replace(/\.md$/, '');
|
|
138
|
+
const today = todayISO();
|
|
139
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
140
|
+
const updated = current.replace(new RegExp(`(\\|\\s*\\[\\[${escapeRegex(ref)}\\]\\][^|]*\\|[^|]*\\|[^|]*\\|[^|]*\\|)([^|]*)\\|`), `$1 ${today} |`);
|
|
141
|
+
writeFileSync(indexPath, updated, 'utf8');
|
|
142
|
+
}
|
|
143
|
+
function extractSummary(articleContent) {
|
|
144
|
+
const takeawaysMatch = /## Key Takeaways\s*\n([\s\S]*?)(?=\n##|$)/.exec(articleContent);
|
|
145
|
+
if (takeawaysMatch) {
|
|
146
|
+
const firstBullet = /^[-*]\s+(.+)$/m.exec(takeawaysMatch[1]);
|
|
147
|
+
if (firstBullet)
|
|
148
|
+
return firstBullet[1].trim().slice(0, 100);
|
|
149
|
+
}
|
|
150
|
+
const detailMatch = /## Detail\s*\n([\s\S]*?)(?=\n##|$)/.exec(articleContent);
|
|
151
|
+
if (detailMatch) {
|
|
152
|
+
return detailMatch[1].trim().split('\n')[0]?.slice(0, 100) ?? '';
|
|
153
|
+
}
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
function todayISO() {
|
|
157
|
+
return new Date().toISOString().slice(0, 10);
|
|
158
|
+
}
|
|
159
|
+
function nowISO() {
|
|
160
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
161
|
+
}
|
|
162
|
+
function slugify(text) {
|
|
163
|
+
return text
|
|
164
|
+
.toLowerCase()
|
|
165
|
+
.replace(/[^a-z0-9\s-]/g, '')
|
|
166
|
+
.trim()
|
|
167
|
+
.replace(/\s+/g, '-')
|
|
168
|
+
.replace(/-+/g, '-')
|
|
169
|
+
.slice(0, 60) || 'untitled';
|
|
170
|
+
}
|
|
171
|
+
function escapeRegex(text) {
|
|
172
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
173
|
+
}
|
|
174
|
+
function extractKeyTerms(content) {
|
|
175
|
+
const stripped = content
|
|
176
|
+
.replace(/^---[\s\S]*?---\n?/, '')
|
|
177
|
+
.replace(/[#*`[\]()]/g, ' ')
|
|
178
|
+
.toLowerCase();
|
|
179
|
+
const stopWords = new Set([
|
|
180
|
+
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can',
|
|
181
|
+
'has', 'was', 'this', 'that', 'with', 'from', 'they', 'will',
|
|
182
|
+
'have', 'been', 'when', 'what', 'how', 'its', 'one', 'out',
|
|
183
|
+
]);
|
|
184
|
+
const freq = new Map();
|
|
185
|
+
for (const word of stripped.split(/\s+/)) {
|
|
186
|
+
const clean = word.replace(/[^a-z0-9-]/g, '');
|
|
187
|
+
if (clean.length >= 4 && !stopWords.has(clean)) {
|
|
188
|
+
freq.set(clean, (freq.get(clean) ?? 0) + 1);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...freq.entries()]
|
|
192
|
+
.sort((a, b) => b[1] - a[1])
|
|
193
|
+
.slice(0, 3)
|
|
194
|
+
.map(([word]) => word);
|
|
195
|
+
}
|
|
196
|
+
export async function processInbox(service, llm, workspaceName, limit) {
|
|
197
|
+
const workspace = service.resolve(workspaceName);
|
|
198
|
+
const logPath = join(workspace.path, 'knowledge', 'log.md');
|
|
199
|
+
const indexPath = join(workspace.path, 'knowledge', 'index.md');
|
|
200
|
+
mkdirSync(join(workspace.path, 'knowledge'), { recursive: true });
|
|
201
|
+
const results = [];
|
|
202
|
+
let fileCount = 0;
|
|
203
|
+
while (true) {
|
|
204
|
+
if (limit !== undefined && fileCount >= limit)
|
|
205
|
+
break;
|
|
206
|
+
const inbox = service.listInbox(workspaceName);
|
|
207
|
+
if (inbox.files.length === 0)
|
|
208
|
+
break;
|
|
209
|
+
const nextFile = inbox.files[0];
|
|
210
|
+
const filePath = nextFile.path;
|
|
211
|
+
fileCount++;
|
|
212
|
+
try {
|
|
213
|
+
const { content: fileContent } = service.read(workspaceName, filePath);
|
|
214
|
+
const keyTerms = extractKeyTerms(fileContent);
|
|
215
|
+
const query = keyTerms.join(' ');
|
|
216
|
+
const knowledgeMatches = service.search(workspaceName, query, 5);
|
|
217
|
+
const inboxMatches = service.searchInbox(workspaceName, query, 5);
|
|
218
|
+
const knowledgeMatchText = knowledgeMatches.results
|
|
219
|
+
.map((result) => `- ${result.file}: ${result.excerpt.slice(0, 100)}`)
|
|
220
|
+
.join('\n');
|
|
221
|
+
const inboxMatchText = inboxMatches.results
|
|
222
|
+
.filter((result) => result.file !== filePath)
|
|
223
|
+
.map((result) => `- ${result.file}: ${result.excerpt.slice(0, 100)}`)
|
|
224
|
+
.join('\n');
|
|
225
|
+
const dedupePrompt = buildDedupePrompt(fileContent, filePath, knowledgeMatchText, inboxMatchText);
|
|
226
|
+
const dedupeResponse = await llm.complete(dedupePrompt);
|
|
227
|
+
const dedupe = parseDedupeResult(dedupeResponse, filePath);
|
|
228
|
+
if (dedupe.decision === 'skip') {
|
|
229
|
+
appendLog(logPath, `## [${nowISO()}] skip\n- Source: ${filePath}\n- Reason: ${dedupe.reason}`);
|
|
230
|
+
service.archive(workspaceName, filePath);
|
|
231
|
+
results.push({ file: filePath, decision: 'skip', articlePath: null, reason: dedupe.reason });
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (dedupe.decision === 'extend' && dedupe.target) {
|
|
235
|
+
const { content: existingContent } = service.read(workspaceName, dedupe.target);
|
|
236
|
+
const extendPrompt = buildCompileExtendPrompt(fileContent, filePath, existingContent, dedupe.target);
|
|
237
|
+
const extendResponse = await llm.complete(extendPrompt);
|
|
238
|
+
if (extendResponse.trim() === 'NOTHING_NEW') {
|
|
239
|
+
appendLog(logPath, `## [${nowISO()}] skip\n- Source: ${filePath}\n- Reason: Nothing new vs ${dedupe.target}`);
|
|
240
|
+
service.archive(workspaceName, filePath);
|
|
241
|
+
results.push({ file: filePath, decision: 'skip', articlePath: dedupe.target, reason: 'Nothing new' });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
service.write(workspaceName, dedupe.target.replace(/^knowledge\//, ''), extendResponse);
|
|
245
|
+
updateIndexRow(indexPath, dedupe.target);
|
|
246
|
+
appendLog(logPath, `## [${nowISO()}] extend\n- Source: ${filePath}\n- Updated: [[${dedupe.target.replace(/\.md$/, '')}]]`);
|
|
247
|
+
service.archive(workspaceName, filePath);
|
|
248
|
+
results.push({ file: filePath, decision: 'extend', articlePath: dedupe.target, reason: dedupe.reason });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const compilePrompt = buildCompileNewPrompt(fileContent, filePath, dedupe.type);
|
|
252
|
+
const articleContent = await llm.complete(compilePrompt);
|
|
253
|
+
const resolvedArticlePath = deriveArticlePath(filePath, dedupe.type, articleContent);
|
|
254
|
+
service.write(workspaceName, resolvedArticlePath.replace(/^knowledge\//, ''), articleContent);
|
|
255
|
+
const summary = extractSummary(articleContent);
|
|
256
|
+
updateIndex(indexPath, resolvedArticlePath, dedupe.type, summary, filePath);
|
|
257
|
+
appendLog(logPath, `## [${nowISO()}] compile\n- Source: ${filePath}\n- Created: [[${resolvedArticlePath.replace(/\.md$/, '')}]]\n- Updated: index.md, log.md`);
|
|
258
|
+
service.archive(workspaceName, filePath);
|
|
259
|
+
results.push({ file: filePath, decision: 'new', articlePath: resolvedArticlePath, reason: dedupe.reason });
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
263
|
+
results.push({ file: filePath, decision: 'skip', articlePath: null, reason: 'error', error: message });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const completed = results.filter((result) => result.error === undefined);
|
|
267
|
+
return {
|
|
268
|
+
processed: results,
|
|
269
|
+
total: results.length,
|
|
270
|
+
compiled: completed.filter((result) => result.decision === 'new').length,
|
|
271
|
+
extended: completed.filter((result) => result.decision === 'extend').length,
|
|
272
|
+
skipped: completed.filter((result) => result.decision === 'skip').length,
|
|
273
|
+
errors: results.filter((result) => result.error !== undefined).length,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { basename, dirname, resolve, join, relative } from 'node:path';
|
|
2
|
+
import { basename, dirname, resolve, join, relative, sep } from 'node:path';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
|
|
5
5
|
import { KnowledgeIndex } from './knowledge-index.js';
|
|
6
|
+
import { InboxIndex } from './inbox-index.js';
|
|
6
7
|
import { loadConfig } from '../../shared/config/index.js';
|
|
7
8
|
export class WorkspaceService {
|
|
8
9
|
config;
|
|
@@ -28,11 +29,19 @@ export class WorkspaceService {
|
|
|
28
29
|
status(name) {
|
|
29
30
|
const workspace = this.resolve(name);
|
|
30
31
|
const exists = existsSync(workspace.path);
|
|
32
|
+
const inboxRoot = join(workspace.path, 'inbox');
|
|
33
|
+
const knowledgeRoot = join(workspace.path, 'knowledge');
|
|
34
|
+
const inboxCount = exists && existsSync(inboxRoot)
|
|
35
|
+
? this.collectMarkdownFiles(workspace.path, inboxRoot, ['archived']).length
|
|
36
|
+
: 0;
|
|
37
|
+
const knowledgeCount = exists && existsSync(knowledgeRoot)
|
|
38
|
+
? this.collectMarkdownFiles(workspace.path, knowledgeRoot).length
|
|
39
|
+
: 0;
|
|
31
40
|
return {
|
|
32
41
|
...workspace,
|
|
33
42
|
exists,
|
|
34
|
-
inboxCount
|
|
35
|
-
knowledgeCount
|
|
43
|
+
inboxCount,
|
|
44
|
+
knowledgeCount,
|
|
36
45
|
lastCompiled: null,
|
|
37
46
|
};
|
|
38
47
|
}
|
|
@@ -42,13 +51,26 @@ export class WorkspaceService {
|
|
|
42
51
|
mkdirSync(inboxPath, { recursive: true });
|
|
43
52
|
const filePath = join(inboxPath, `${currentDateStamp()}-${slugify(title)}.md`);
|
|
44
53
|
writeFileSync(filePath, content, 'utf8');
|
|
54
|
+
const index = new InboxIndex(workspace.path);
|
|
55
|
+
try {
|
|
56
|
+
index.indexFile(filePath);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
index.close();
|
|
60
|
+
}
|
|
45
61
|
return {
|
|
46
62
|
file: relative(workspace.path, filePath),
|
|
47
63
|
workspace: workspace.name,
|
|
48
64
|
};
|
|
49
65
|
}
|
|
50
66
|
ingestClipboard(name, title) {
|
|
51
|
-
|
|
67
|
+
let clipboard;
|
|
68
|
+
try {
|
|
69
|
+
clipboard = execSync('pbpaste', { encoding: 'utf8' });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new McpError(ErrorCode.InternalError, 'knowledge_ingest_clipboard requires macOS (pbpaste unavailable)');
|
|
73
|
+
}
|
|
52
74
|
return this.ingestText(name, clipboard, title);
|
|
53
75
|
}
|
|
54
76
|
async ingestUrl(name, url, title) {
|
|
@@ -87,9 +109,97 @@ export class WorkspaceService {
|
|
|
87
109
|
index.close();
|
|
88
110
|
}
|
|
89
111
|
}
|
|
112
|
+
searchInbox(name, query, limit = 10) {
|
|
113
|
+
const workspace = this.resolve(name);
|
|
114
|
+
const inboxRoot = join(workspace.path, 'inbox');
|
|
115
|
+
if (!existsSync(inboxRoot)) {
|
|
116
|
+
return { results: [] };
|
|
117
|
+
}
|
|
118
|
+
const index = new InboxIndex(workspace.path);
|
|
119
|
+
try {
|
|
120
|
+
const files = this.collectMarkdownFiles(workspace.path, inboxRoot, ['archived']);
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
index.indexFile(join(workspace.path, file.path));
|
|
123
|
+
}
|
|
124
|
+
return index.search(query, limit);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
index.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
getNextInboxFile(name) {
|
|
131
|
+
const workspace = this.resolve(name);
|
|
132
|
+
const inboxRoot = join(workspace.path, 'inbox');
|
|
133
|
+
if (!existsSync(inboxRoot)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const files = this.collectMarkdownFiles(workspace.path, inboxRoot, ['archived']);
|
|
137
|
+
if (files.length === 0) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const first = files[0];
|
|
141
|
+
const absPath = join(workspace.path, first.path);
|
|
142
|
+
const content = readFileSync(absPath, 'utf8');
|
|
143
|
+
return {
|
|
144
|
+
file: first.path,
|
|
145
|
+
content,
|
|
146
|
+
size: first.size,
|
|
147
|
+
modified: first.modified,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
getDedupeContext(name, file, query) {
|
|
151
|
+
const knowledgeMatches = this.search(name, query, 5);
|
|
152
|
+
const inboxMatches = this.searchInbox(name, query, 5);
|
|
153
|
+
return {
|
|
154
|
+
file,
|
|
155
|
+
knowledgeMatches: knowledgeMatches.results.filter((result) => result.file !== file),
|
|
156
|
+
inboxMatches: inboxMatches.results.filter((result) => result.file !== file),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
compileNew(name, inboxFile, articlePath, content) {
|
|
160
|
+
const workspace = this.resolve(name);
|
|
161
|
+
const knowledgeRoot = join(workspace.path, 'knowledge');
|
|
162
|
+
mkdirSync(knowledgeRoot, { recursive: true });
|
|
163
|
+
this.write(name, articlePath, content);
|
|
164
|
+
const indexPath = join(knowledgeRoot, 'index.md');
|
|
165
|
+
const summary = extractSummary(content);
|
|
166
|
+
const today = todayISO();
|
|
167
|
+
const ref = `knowledge/${articlePath}`.replace(/\.md$/, '');
|
|
168
|
+
const row = `| [[${ref}]] | ${summary} | ${inboxFile} | ${today} |`;
|
|
169
|
+
if (!existsSync(indexPath)) {
|
|
170
|
+
writeFileSync(indexPath, '# Knowledge Base Index\n\n| Article | Summary | Sources | Updated |\n|---------|---------|---------|---------|\n' + row + '\n', 'utf8');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
174
|
+
writeFileSync(indexPath, current.trimEnd() + '\n' + row + '\n', 'utf8');
|
|
175
|
+
}
|
|
176
|
+
appendLog(join(knowledgeRoot, 'log.md'), `## [${nowISO()}] compile\n- Source: ${inboxFile}\n- Created: [[${ref}]]\n- Updated: index.md, log.md`);
|
|
177
|
+
this.archive(name, inboxFile);
|
|
178
|
+
return { articlePath: `knowledge/${articlePath}`, inboxFile, workspace: name };
|
|
179
|
+
}
|
|
180
|
+
compileExtend(name, inboxFile, targetPath, updatedContent) {
|
|
181
|
+
const workspace = this.resolve(name);
|
|
182
|
+
const knowledgeRoot = join(workspace.path, 'knowledge');
|
|
183
|
+
this.write(name, targetPath.replace(/^knowledge\//, ''), updatedContent);
|
|
184
|
+
const indexPath = join(knowledgeRoot, 'index.md');
|
|
185
|
+
if (existsSync(indexPath)) {
|
|
186
|
+
const ref = targetPath.replace(/\.md$/, '');
|
|
187
|
+
const today = todayISO();
|
|
188
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
189
|
+
const updated = current.replace(new RegExp(`(\\|\\s*\\[\\[${escapeRegex(ref)}\\]\\][^|]*\\|[^|]*\\|[^|]*\\|)([^|]*)\\|`), `$1 ${today} |`);
|
|
190
|
+
writeFileSync(indexPath, updated, 'utf8');
|
|
191
|
+
}
|
|
192
|
+
const ref = targetPath.replace(/\.md$/, '');
|
|
193
|
+
appendLog(join(knowledgeRoot, 'log.md'), `## [${nowISO()}] extend\n- Source: ${inboxFile}\n- Updated: [[${ref}]]`);
|
|
194
|
+
this.archive(name, inboxFile);
|
|
195
|
+
return { targetPath, inboxFile, workspace: name };
|
|
196
|
+
}
|
|
90
197
|
read(name, pathName) {
|
|
91
198
|
const workspace = this.resolve(name);
|
|
92
199
|
const filePath = resolve(workspace.path, pathName);
|
|
200
|
+
if (!filePath.startsWith(workspace.path + sep)) {
|
|
201
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path escapes workspace boundary');
|
|
202
|
+
}
|
|
93
203
|
const content = readFileSync(filePath, 'utf8');
|
|
94
204
|
return {
|
|
95
205
|
content,
|
|
@@ -120,7 +230,11 @@ export class WorkspaceService {
|
|
|
120
230
|
}
|
|
121
231
|
write(name, pathName, content) {
|
|
122
232
|
const workspace = this.resolve(name);
|
|
123
|
-
const
|
|
233
|
+
const knowledgeRoot = join(workspace.path, 'knowledge');
|
|
234
|
+
const target = join(knowledgeRoot, pathName);
|
|
235
|
+
if (!target.startsWith(knowledgeRoot + sep)) {
|
|
236
|
+
throw new McpError(ErrorCode.InvalidParams, 'Path escapes knowledge boundary');
|
|
237
|
+
}
|
|
124
238
|
mkdirSync(dirname(target), { recursive: true });
|
|
125
239
|
writeFileSync(target, content, 'utf8');
|
|
126
240
|
return {
|
|
@@ -130,17 +244,31 @@ export class WorkspaceService {
|
|
|
130
244
|
}
|
|
131
245
|
archive(name, file) {
|
|
132
246
|
const workspace = this.resolve(name);
|
|
133
|
-
const
|
|
247
|
+
const inboxRoot = join(workspace.path, 'inbox');
|
|
248
|
+
const source = resolve(workspace.path, file);
|
|
249
|
+
if (!source.startsWith(inboxRoot + sep)) {
|
|
250
|
+
throw new McpError(ErrorCode.InvalidParams, 'File must be within inbox/');
|
|
251
|
+
}
|
|
252
|
+
if (source.startsWith(join(inboxRoot, 'archived') + sep)) {
|
|
253
|
+
throw new McpError(ErrorCode.InvalidParams, 'File is already archived');
|
|
254
|
+
}
|
|
134
255
|
if (!existsSync(source)) {
|
|
135
256
|
throw new McpError(ErrorCode.InvalidParams, `File not found: ${file}`);
|
|
136
257
|
}
|
|
137
|
-
const archivedDir = join(
|
|
258
|
+
const archivedDir = join(inboxRoot, 'archived');
|
|
138
259
|
mkdirSync(archivedDir, { recursive: true });
|
|
139
260
|
let destination = join(archivedDir, basename(source));
|
|
140
261
|
if (existsSync(destination)) {
|
|
141
262
|
destination = join(archivedDir, `${Date.now()}-${basename(source)}`);
|
|
142
263
|
}
|
|
143
264
|
renameSync(source, destination);
|
|
265
|
+
const index = new InboxIndex(workspace.path);
|
|
266
|
+
try {
|
|
267
|
+
index.removeFile(source);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
index.close();
|
|
271
|
+
}
|
|
144
272
|
return {
|
|
145
273
|
original: relative(workspace.path, source),
|
|
146
274
|
archived: relative(workspace.path, destination),
|
|
@@ -196,3 +324,31 @@ function extractJinaTitle(content) {
|
|
|
196
324
|
const match = /^Title:\s*(.+)$/m.exec(content);
|
|
197
325
|
return match ? match[1].trim() : null;
|
|
198
326
|
}
|
|
327
|
+
function todayISO() {
|
|
328
|
+
return new Date().toISOString().slice(0, 10);
|
|
329
|
+
}
|
|
330
|
+
function nowISO() {
|
|
331
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
332
|
+
}
|
|
333
|
+
function escapeRegex(text) {
|
|
334
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
335
|
+
}
|
|
336
|
+
function extractSummary(content) {
|
|
337
|
+
const takeaways = /## Key Takeaways\s*\n([\s\S]*?)(?=\n##|$)/.exec(content);
|
|
338
|
+
if (takeaways) {
|
|
339
|
+
const bullet = /^[-*]\s+(.+)$/m.exec(takeaways[1]);
|
|
340
|
+
if (bullet)
|
|
341
|
+
return bullet[1].trim().slice(0, 100).replace(/\|/g, '\\|');
|
|
342
|
+
}
|
|
343
|
+
const detail = /## Detail\s*\n([\s\S]*?)(?=\n##|$)/.exec(content);
|
|
344
|
+
if (detail)
|
|
345
|
+
return detail[1].trim().split('\n')[0]?.slice(0, 100).replace(/\|/g, '\\|') ?? '';
|
|
346
|
+
return '';
|
|
347
|
+
}
|
|
348
|
+
function appendLog(logPath, entry) {
|
|
349
|
+
if (!existsSync(logPath)) {
|
|
350
|
+
writeFileSync(logPath, '# Build Log\n\n', 'utf8');
|
|
351
|
+
}
|
|
352
|
+
const current = readFileSync(logPath, 'utf8');
|
|
353
|
+
writeFileSync(logPath, current.trimEnd() + '\n\n' + entry + '\n', 'utf8');
|
|
354
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
// and other consumers without requiring the MCP protocol.
|
|
3
3
|
export { WorkspaceService } from './domains/workspace/workspace.service.js';
|
|
4
4
|
export { KnowledgeIndex } from './domains/workspace/knowledge-index.js';
|
|
5
|
+
export { InboxIndex } from './domains/workspace/inbox-index.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
3
|
import { WorkspaceService } from '../../domains/workspace/index.js';
|
|
4
|
-
import { KnowledgeArchiveSchema, KnowledgeIngestClipboardSchema, KnowledgeIngestTextSchema, KnowledgeIngestUrlSchema, KnowledgeListInboxSchema, KnowledgeListSchema, KnowledgeListWorkspacesSchema, KnowledgeReadSchema, KnowledgeSearchSchema, KnowledgeWorkspaceStatusSchema, KnowledgeWriteSchema, } from './tool-schemas.js';
|
|
4
|
+
import { KnowledgeArchiveSchema, KnowledgeCompileExtendSchema, KnowledgeCompileNewSchema, KnowledgeGetDedupeContextSchema, KnowledgeGetNextInboxFileSchema, KnowledgeIngestClipboardSchema, KnowledgeIngestTextSchema, KnowledgeIngestUrlSchema, KnowledgeListInboxSchema, KnowledgeListSchema, KnowledgeListWorkspacesSchema, KnowledgeReadSchema, KnowledgeSearchInboxSchema, KnowledgeSearchSchema, KnowledgeWorkspaceStatusSchema, KnowledgeWriteSchema, } from './tool-schemas.js';
|
|
5
5
|
function textContent(value) {
|
|
6
6
|
return {
|
|
7
7
|
content: [
|
|
@@ -24,6 +24,11 @@ export function createMcpServer() {
|
|
|
24
24
|
server.tool('knowledge_ingest_clipboard', 'Read clipboard text and write it into a workspace inbox.', KnowledgeIngestClipboardSchema, async ({ workspace, title }) => textContent(service.ingestClipboard(workspace, title)));
|
|
25
25
|
server.tool('knowledge_ingest_url', 'Ingest a URL into a workspace inbox.', KnowledgeIngestUrlSchema, async ({ workspace, url, title }) => textContent(await service.ingestUrl(workspace, url, title)));
|
|
26
26
|
server.tool('knowledge_search', 'Search workspace knowledge.', KnowledgeSearchSchema, async ({ workspace, query, limit }) => textContent(service.search(workspace, query, limit)));
|
|
27
|
+
server.tool('knowledge_search_inbox', 'Full-text search over uncompiled inbox files in a workspace. Use this before compiling to check if an inbox file overlaps with existing inbox content.', KnowledgeSearchInboxSchema, async ({ workspace, query, limit }) => textContent(service.searchInbox(workspace, query, limit)));
|
|
28
|
+
server.tool('knowledge_get_next_inbox_file', 'Get the next unprocessed inbox file and its content. Returns null when inbox is empty. Call this to start processing one file.', KnowledgeGetNextInboxFileSchema, async ({ workspace }) => textContent(service.getNextInboxFile(workspace)));
|
|
29
|
+
server.tool('knowledge_get_dedupe_context', 'Run FTS searches against knowledge/ and inbox/ for a given query and return results. Call this after reading an inbox file to check for overlap before deciding whether to compile new, extend, or skip.', KnowledgeGetDedupeContextSchema, async ({ workspace, file, query }) => textContent(service.getDedupeContext(workspace, file, query)));
|
|
30
|
+
server.tool('knowledge_compile_new', 'Write a compiled article to knowledge/, update index.md, append to log.md, and archive the inbox file. Call this when decision is "new". Provide the article content you have written and the target path within knowledge/.', KnowledgeCompileNewSchema, async ({ workspace, inbox_file, article_path, content }) => textContent(service.compileNew(workspace, inbox_file, article_path, content)));
|
|
31
|
+
server.tool('knowledge_compile_extend', 'Write an updated article to knowledge/, update the Updated column in index.md, append to log.md, and archive the inbox file. Call this when decision is "extend". Provide the full updated article content.', KnowledgeCompileExtendSchema, async ({ workspace, inbox_file, target_path, content }) => textContent(service.compileExtend(workspace, inbox_file, target_path, content)));
|
|
27
32
|
server.tool('knowledge_list_inbox', 'List uncompiled files in a workspace inbox.', KnowledgeListInboxSchema, async ({ workspace }) => textContent(service.listInbox(workspace)));
|
|
28
33
|
server.tool('knowledge_write', 'Write a compiled article into the workspace knowledge directory.', KnowledgeWriteSchema, async ({ workspace, path, content }) => textContent(service.write(workspace, path, content)));
|
|
29
34
|
server.tool('knowledge_archive', 'Move a processed inbox file to inbox/archived/.', KnowledgeArchiveSchema, async ({ workspace, file }) => textContent(service.archive(workspace, file)));
|
|
@@ -22,6 +22,31 @@ const KnowledgeSearchInputSchema = z.object({
|
|
|
22
22
|
query: z.string(),
|
|
23
23
|
limit: z.number().int().positive().optional(),
|
|
24
24
|
});
|
|
25
|
+
const KnowledgeSearchInboxInputSchema = z.object({
|
|
26
|
+
workspace: z.string(),
|
|
27
|
+
query: z.string(),
|
|
28
|
+
limit: z.number().int().positive().optional(),
|
|
29
|
+
});
|
|
30
|
+
const KnowledgeGetNextInboxFileInputSchema = z.object({
|
|
31
|
+
workspace: z.string(),
|
|
32
|
+
});
|
|
33
|
+
const KnowledgeGetDedupeContextInputSchema = z.object({
|
|
34
|
+
workspace: z.string(),
|
|
35
|
+
file: z.string().describe('Inbox file path relative to workspace root'),
|
|
36
|
+
query: z.string().describe('2-3 key terms extracted from the file content'),
|
|
37
|
+
});
|
|
38
|
+
const KnowledgeCompileNewInputSchema = z.object({
|
|
39
|
+
workspace: z.string(),
|
|
40
|
+
inbox_file: z.string().describe('Inbox file path to archive after compile'),
|
|
41
|
+
article_path: z.string().describe('Target path relative to knowledge/ e.g. "concepts/my-article.md"'),
|
|
42
|
+
content: z.string().describe('Full compiled article markdown including YAML frontmatter'),
|
|
43
|
+
});
|
|
44
|
+
const KnowledgeCompileExtendInputSchema = z.object({
|
|
45
|
+
workspace: z.string(),
|
|
46
|
+
inbox_file: z.string().describe('Inbox file path to archive after extend'),
|
|
47
|
+
target_path: z.string().describe('Existing article path relative to workspace root e.g. "knowledge/concepts/existing.md"'),
|
|
48
|
+
content: z.string().describe('Full updated article markdown including YAML frontmatter'),
|
|
49
|
+
});
|
|
25
50
|
const KnowledgeListInboxInputSchema = z.object({
|
|
26
51
|
workspace: z.string(),
|
|
27
52
|
});
|
|
@@ -48,6 +73,11 @@ export const KnowledgeIngestTextSchema = KnowledgeIngestTextInputSchema.shape;
|
|
|
48
73
|
export const KnowledgeIngestClipboardSchema = KnowledgeIngestClipboardInputSchema.shape;
|
|
49
74
|
export const KnowledgeIngestUrlSchema = KnowledgeIngestUrlInputSchema.shape;
|
|
50
75
|
export const KnowledgeSearchSchema = KnowledgeSearchInputSchema.shape;
|
|
76
|
+
export const KnowledgeSearchInboxSchema = KnowledgeSearchInboxInputSchema.shape;
|
|
77
|
+
export const KnowledgeGetNextInboxFileSchema = KnowledgeGetNextInboxFileInputSchema.shape;
|
|
78
|
+
export const KnowledgeGetDedupeContextSchema = KnowledgeGetDedupeContextInputSchema.shape;
|
|
79
|
+
export const KnowledgeCompileNewSchema = KnowledgeCompileNewInputSchema.shape;
|
|
80
|
+
export const KnowledgeCompileExtendSchema = KnowledgeCompileExtendInputSchema.shape;
|
|
51
81
|
export const KnowledgeListInboxSchema = KnowledgeListInboxInputSchema.shape;
|
|
52
82
|
export const KnowledgeWriteSchema = KnowledgeWriteInputSchema.shape;
|
|
53
83
|
export const KnowledgeArchiveSchema = KnowledgeArchiveInputSchema.shape;
|
|
@@ -60,6 +90,11 @@ export const ToolSchemas = {
|
|
|
60
90
|
knowledge_ingest_clipboard: KnowledgeIngestClipboardSchema,
|
|
61
91
|
knowledge_ingest_url: KnowledgeIngestUrlSchema,
|
|
62
92
|
knowledge_search: KnowledgeSearchSchema,
|
|
93
|
+
knowledge_search_inbox: KnowledgeSearchInboxSchema,
|
|
94
|
+
knowledge_get_next_inbox_file: KnowledgeGetNextInboxFileSchema,
|
|
95
|
+
knowledge_get_dedupe_context: KnowledgeGetDedupeContextSchema,
|
|
96
|
+
knowledge_compile_new: KnowledgeCompileNewSchema,
|
|
97
|
+
knowledge_compile_extend: KnowledgeCompileExtendSchema,
|
|
63
98
|
knowledge_list_inbox: KnowledgeListInboxSchema,
|
|
64
99
|
knowledge_write: KnowledgeWriteSchema,
|
|
65
100
|
knowledge_archive: KnowledgeArchiveSchema,
|
package/dist/server.js
CHANGED