@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 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: 0,
35
- knowledgeCount: 0,
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
- const clipboard = execSync('pbpaste', { encoding: 'utf8' });
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 target = join(workspace.path, 'knowledge', pathName);
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 source = join(workspace.path, file);
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(workspace.path, 'inbox', 'archived');
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
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { startMcpServer } from './infrastructure/mcp/index.js';
2
3
  startMcpServer().catch((err) => {
3
4
  const message = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teknologika/chisel-knowledge-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Standalone MCP server and library for building and managing knowledge workspaces.",
5
5
  "type": "module",
6
6
  "exports": {