jowork 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/dist/chunk-3NMLDZBL.js +4031 -0
- package/dist/chunk-S24PDC46.js +569 -0
- package/dist/cli.js +1407 -0
- package/dist/src-WYAQWZZZ.js +66 -0
- package/dist/transport.js +19 -0
- package/package.json +56 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildFtsQuery,
|
|
3
|
+
connectorConfigs,
|
|
4
|
+
createId,
|
|
5
|
+
detectSourceFromQuery,
|
|
6
|
+
memories,
|
|
7
|
+
objectBodies,
|
|
8
|
+
objects
|
|
9
|
+
} from "./chunk-3NMLDZBL.js";
|
|
10
|
+
|
|
11
|
+
// src/utils/paths.ts
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { mkdirSync } from "fs";
|
|
14
|
+
var HOME = process.env["HOME"] ?? "/tmp";
|
|
15
|
+
function joworkDir() {
|
|
16
|
+
return join(HOME, ".jowork");
|
|
17
|
+
}
|
|
18
|
+
function dataDir() {
|
|
19
|
+
const dir = join(joworkDir(), "data");
|
|
20
|
+
mkdirSync(dir, { recursive: true });
|
|
21
|
+
return dir;
|
|
22
|
+
}
|
|
23
|
+
function dbPath() {
|
|
24
|
+
return join(dataDir(), "jowork.db");
|
|
25
|
+
}
|
|
26
|
+
function credentialsDir() {
|
|
27
|
+
const dir = join(joworkDir(), "credentials");
|
|
28
|
+
mkdirSync(dir, { recursive: true });
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
function configPath() {
|
|
32
|
+
return join(joworkDir(), "config.json");
|
|
33
|
+
}
|
|
34
|
+
function logsDir() {
|
|
35
|
+
const dir = join(joworkDir(), "logs");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
return dir;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/mcp/server.ts
|
|
41
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import Database from "better-sqlite3";
|
|
44
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
45
|
+
import { like, eq, or, desc } from "drizzle-orm";
|
|
46
|
+
import { existsSync, readFileSync } from "fs";
|
|
47
|
+
import { join as join3 } from "path";
|
|
48
|
+
|
|
49
|
+
// src/utils/logger.ts
|
|
50
|
+
import { pino } from "pino";
|
|
51
|
+
import { join as join2 } from "path";
|
|
52
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
53
|
+
function getLogDir() {
|
|
54
|
+
const dir = join2(process.env["HOME"] ?? "/tmp", ".jowork", "logs");
|
|
55
|
+
mkdirSync2(dir, { recursive: true });
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
var logger = pino({
|
|
59
|
+
level: process.env["LOG_LEVEL"] ?? "info",
|
|
60
|
+
transport: process.env["JOWORK_LOG_FILE"] ? { target: "pino/file", options: { destination: join2(getLogDir(), "jowork.log") } } : { target: "pino-pretty", options: { destination: 2 } }
|
|
61
|
+
// stderr
|
|
62
|
+
});
|
|
63
|
+
function logInfo(category, msg, ctx) {
|
|
64
|
+
logger.info({ category, ...ctx }, msg);
|
|
65
|
+
}
|
|
66
|
+
function logError(category, msg, ctx) {
|
|
67
|
+
logger.error({ category, ...ctx }, msg);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/mcp/server.ts
|
|
71
|
+
function escapeLike(input) {
|
|
72
|
+
return input.replace(/[%_\\]/g, "\\$&");
|
|
73
|
+
}
|
|
74
|
+
function escapeFtsQuery(query) {
|
|
75
|
+
return query.split(/\s+/).filter(Boolean).map((token) => {
|
|
76
|
+
if (/^(AND|OR|NOT|NEAR)$/i.test(token) || /[*"(){}:^~\-+[\]]/g.test(token)) {
|
|
77
|
+
return `"${token.replace(/"/g, '""')}"`;
|
|
78
|
+
}
|
|
79
|
+
return token;
|
|
80
|
+
}).join(" ");
|
|
81
|
+
}
|
|
82
|
+
var MAX_MEMORIES = 100;
|
|
83
|
+
function createJoWorkMcpServer(opts) {
|
|
84
|
+
const sqlite = new Database(opts.dbPath);
|
|
85
|
+
sqlite.pragma("journal_mode = WAL");
|
|
86
|
+
const db = drizzle(sqlite);
|
|
87
|
+
const server = new McpServer({ name: "jowork", version: "0.1.0" });
|
|
88
|
+
server.server.onclose = () => {
|
|
89
|
+
try {
|
|
90
|
+
sqlite.close();
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
server.tool(
|
|
95
|
+
"search_data",
|
|
96
|
+
{
|
|
97
|
+
query: z.string().describe("Keywords to search -- works like grep across all synced data"),
|
|
98
|
+
source: z.string().optional().describe("Filter by data source: github, feishu, gitlab, notion, slack, local"),
|
|
99
|
+
limit: z.number().optional().default(20).describe("Max results (default 20)")
|
|
100
|
+
},
|
|
101
|
+
async ({ query, source, limit }) => {
|
|
102
|
+
const t0 = Date.now();
|
|
103
|
+
if (!source) {
|
|
104
|
+
source = detectSourceFromQuery(query) ?? void 0;
|
|
105
|
+
}
|
|
106
|
+
const ftsMatchQuery = buildFtsQuery(query);
|
|
107
|
+
if (ftsMatchQuery) {
|
|
108
|
+
try {
|
|
109
|
+
const ftsQuery = source ? `SELECT o.id, o.title, o.summary, o.source, o.source_type, o.uri, o.tags
|
|
110
|
+
FROM objects_fts JOIN objects o ON o.rowid = objects_fts.rowid
|
|
111
|
+
WHERE objects_fts MATCH ? AND o.source = ? LIMIT ?` : `SELECT o.id, o.title, o.summary, o.source, o.source_type, o.uri, o.tags
|
|
112
|
+
FROM objects_fts JOIN objects o ON o.rowid = objects_fts.rowid
|
|
113
|
+
WHERE objects_fts MATCH ? LIMIT ?`;
|
|
114
|
+
const ftsArgs = source ? [ftsMatchQuery, source, limit] : [ftsMatchQuery, limit];
|
|
115
|
+
const ftsResults = sqlite.prepare(ftsQuery).all(...ftsArgs);
|
|
116
|
+
if (ftsResults.length > 0) {
|
|
117
|
+
logInfo("mcp", `search_data: "${query}" (FTS)`, { source, resultCount: ftsResults.length, ms: Date.now() - t0 });
|
|
118
|
+
return { content: [{ type: "text", text: JSON.stringify(ftsResults, null, 2) }] };
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const cleanedQuery = query.replace(/飞书|feishu|lark|github|gitlab|notion|slack/gi, "").replace(/群里|最近|在|讨论|什么|话题|有哪些|是什么|怎么样|帮我|告诉我|查一下/g, "").trim();
|
|
124
|
+
let rows = [];
|
|
125
|
+
if (source && cleanedQuery.length >= 2) {
|
|
126
|
+
const segments = cleanedQuery.split(/\s+/).filter((s) => s.length >= 2);
|
|
127
|
+
if (segments.length > 0) {
|
|
128
|
+
const conditions = segments.map(() => "(title LIKE ? OR summary LIKE ? OR tags LIKE ?)").join(" OR ");
|
|
129
|
+
const params = [];
|
|
130
|
+
for (const seg of segments) {
|
|
131
|
+
const p = `%${escapeLike(seg)}%`;
|
|
132
|
+
params.push(p, p, p);
|
|
133
|
+
}
|
|
134
|
+
params.push(source, limit);
|
|
135
|
+
rows = sqlite.prepare(`
|
|
136
|
+
SELECT id, title, summary, source, source_type, uri, tags FROM objects
|
|
137
|
+
WHERE (${conditions}) AND source = ? ORDER BY last_synced_at DESC LIMIT ?
|
|
138
|
+
`).all(...params);
|
|
139
|
+
} else {
|
|
140
|
+
rows = [];
|
|
141
|
+
}
|
|
142
|
+
} else if (source) {
|
|
143
|
+
rows = sqlite.prepare(`
|
|
144
|
+
SELECT id, title, summary, source, source_type, uri, tags FROM objects
|
|
145
|
+
WHERE source = ? ORDER BY last_synced_at DESC LIMIT ?
|
|
146
|
+
`).all(source, limit);
|
|
147
|
+
} else if (cleanedQuery.length >= 2) {
|
|
148
|
+
const pattern = `%${escapeLike(cleanedQuery)}%`;
|
|
149
|
+
rows = sqlite.prepare(`
|
|
150
|
+
SELECT id, title, summary, source, source_type, uri, tags FROM objects
|
|
151
|
+
WHERE title LIKE ? OR summary LIKE ? OR tags LIKE ? OR source LIKE ? OR source_type LIKE ?
|
|
152
|
+
ORDER BY last_synced_at DESC LIMIT ?
|
|
153
|
+
`).all(pattern, pattern, pattern, pattern, pattern, limit);
|
|
154
|
+
} else {
|
|
155
|
+
rows = [];
|
|
156
|
+
}
|
|
157
|
+
logInfo("mcp", `search_data: "${query}"`, { source, resultCount: rows.length, ms: Date.now() - t0 });
|
|
158
|
+
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
server.tool(
|
|
162
|
+
"list_sources",
|
|
163
|
+
{},
|
|
164
|
+
async () => {
|
|
165
|
+
const sources = db.select().from(connectorConfigs).all();
|
|
166
|
+
const counts = sqlite.prepare(
|
|
167
|
+
`SELECT source, COUNT(*) as count FROM objects GROUP BY source`
|
|
168
|
+
).all();
|
|
169
|
+
return {
|
|
170
|
+
content: [{ type: "text", text: JSON.stringify({ connectors: sources, objectCounts: counts }, null, 2) }]
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
);
|
|
174
|
+
server.tool(
|
|
175
|
+
"fetch_content",
|
|
176
|
+
{
|
|
177
|
+
uri: z.string().describe("Object URI from search_data results")
|
|
178
|
+
},
|
|
179
|
+
async ({ uri }) => {
|
|
180
|
+
const obj = db.select().from(objects).where(eq(objects.uri, uri)).get();
|
|
181
|
+
if (!obj) {
|
|
182
|
+
return { content: [{ type: "text", text: `Object not found: ${uri}` }] };
|
|
183
|
+
}
|
|
184
|
+
const body = db.select().from(objectBodies).where(eq(objectBodies.objectId, obj.id)).get();
|
|
185
|
+
return {
|
|
186
|
+
content: [{
|
|
187
|
+
type: "text",
|
|
188
|
+
text: JSON.stringify({ ...obj, body: body?.content ?? null, contentType: body?.contentType ?? null }, null, 2)
|
|
189
|
+
}]
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
);
|
|
193
|
+
server.tool(
|
|
194
|
+
"fetch_doc_map",
|
|
195
|
+
{
|
|
196
|
+
id: z.string().describe("Object ID from search_data results")
|
|
197
|
+
},
|
|
198
|
+
async ({ id }) => {
|
|
199
|
+
const row = sqlite.prepare(`SELECT doc_map, title FROM objects WHERE id = ?`).get(id);
|
|
200
|
+
if (!row) return { content: [{ type: "text", text: `Object not found: ${id}` }] };
|
|
201
|
+
if (!row.doc_map) {
|
|
202
|
+
return { content: [{ type: "text", text: `"${row.title}" has no document map (too small). Use fetch_content instead.` }] };
|
|
203
|
+
}
|
|
204
|
+
return { content: [{ type: "text", text: row.doc_map }] };
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
server.tool(
|
|
208
|
+
"fetch_chunk",
|
|
209
|
+
{
|
|
210
|
+
id: z.string().describe("Object ID"),
|
|
211
|
+
idx: z.number().describe("Chunk index (0-based, see fetch_doc_map output for available indices)")
|
|
212
|
+
},
|
|
213
|
+
async ({ id, idx }) => {
|
|
214
|
+
const chunk = sqlite.prepare(
|
|
215
|
+
`SELECT heading, content, tokens FROM object_chunks WHERE object_id = ? AND idx = ?`
|
|
216
|
+
).get(id, idx);
|
|
217
|
+
if (!chunk) {
|
|
218
|
+
return { content: [{ type: "text", text: "Chunk not found. Call fetch_doc_map first to see available chunks." }] };
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
content: [{ type: "text", text: JSON.stringify({ heading: chunk.heading, tokens: chunk.tokens, content: chunk.content }, null, 2) }]
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
server.tool(
|
|
226
|
+
"read_memory",
|
|
227
|
+
{
|
|
228
|
+
query: z.string().describe("Search keywords -- matches against title, content, and tags"),
|
|
229
|
+
limit: z.number().optional().default(10).describe("Max results")
|
|
230
|
+
},
|
|
231
|
+
async ({ query, limit }) => {
|
|
232
|
+
const pattern = `%${escapeLike(query)}%`;
|
|
233
|
+
const rows = db.select().from(memories).where(or(like(memories.title, pattern), like(memories.content, pattern), like(memories.tags, pattern))).orderBy(desc(memories.updatedAt)).limit(limit).all().filter((r) => r.scope === "personal" || process.env["JOWORK_MODE"] === "team");
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
sqlite.prepare(`UPDATE memories SET last_used_at = ?, access_count = access_count + 1 WHERE id = ?`).run(now, row.id);
|
|
237
|
+
}
|
|
238
|
+
const results = rows.map((r) => ({
|
|
239
|
+
id: r.id,
|
|
240
|
+
title: r.title,
|
|
241
|
+
content: r.content,
|
|
242
|
+
tags: r.tags ? JSON.parse(r.tags) : [],
|
|
243
|
+
scope: r.scope,
|
|
244
|
+
pinned: r.pinned === 1
|
|
245
|
+
}));
|
|
246
|
+
if (results.length === 0) return { content: [{ type: "text", text: `No memories found for: ${query}` }] };
|
|
247
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
server.tool(
|
|
251
|
+
"write_memory",
|
|
252
|
+
{
|
|
253
|
+
title: z.string().describe("Short descriptive title for the memory"),
|
|
254
|
+
content: z.string().describe("Memory content -- what to remember"),
|
|
255
|
+
tags: z.array(z.string()).optional().describe('Tags for categorization (e.g. ["decision", "preference"])'),
|
|
256
|
+
scope: z.enum(["personal", "team"]).optional().default("personal").describe("Scope")
|
|
257
|
+
},
|
|
258
|
+
async ({ title, content, tags, scope }) => {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const id = createId("mem");
|
|
261
|
+
db.insert(memories).values({
|
|
262
|
+
id,
|
|
263
|
+
title,
|
|
264
|
+
content,
|
|
265
|
+
tags: JSON.stringify(tags ?? []),
|
|
266
|
+
scope,
|
|
267
|
+
pinned: 0,
|
|
268
|
+
source: "auto",
|
|
269
|
+
lastUsedAt: null,
|
|
270
|
+
createdAt: now,
|
|
271
|
+
updatedAt: now
|
|
272
|
+
}).run();
|
|
273
|
+
try {
|
|
274
|
+
const rowid = sqlite.prepare("SELECT rowid FROM memories WHERE id = ?").get(id);
|
|
275
|
+
if (rowid) {
|
|
276
|
+
sqlite.prepare(
|
|
277
|
+
"INSERT INTO memories_fts(rowid, title, content, tags) VALUES (?, ?, ?, ?)"
|
|
278
|
+
).run(rowid.rowid, title, content, JSON.stringify(tags ?? []));
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
const count = sqlite.prepare("SELECT COUNT(*) AS c FROM memories").get();
|
|
284
|
+
if (count.c > MAX_MEMORIES) {
|
|
285
|
+
const excess = count.c - MAX_MEMORIES;
|
|
286
|
+
const toDelete = sqlite.prepare(
|
|
287
|
+
`SELECT id, rowid, title, content, tags FROM memories WHERE pinned = 0 ORDER BY updated_at ASC LIMIT ?`
|
|
288
|
+
).all(excess);
|
|
289
|
+
for (const row of toDelete) {
|
|
290
|
+
try {
|
|
291
|
+
sqlite.prepare(
|
|
292
|
+
`INSERT INTO memories_fts(memories_fts, rowid, title, content, tags) VALUES ('delete', ?, ?, ?, ?)`
|
|
293
|
+
).run(row.rowid, row.title, row.content, row.tags ?? "");
|
|
294
|
+
} catch {
|
|
295
|
+
}
|
|
296
|
+
sqlite.prepare("DELETE FROM memories WHERE id = ?").run(row.id);
|
|
297
|
+
}
|
|
298
|
+
logInfo("mcp", `write_memory: auto-truncated ${toDelete.length} old memories (cap: ${MAX_MEMORIES})`);
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
}
|
|
302
|
+
logInfo("mcp", `write_memory: "${title}"`, { id, tags, scope });
|
|
303
|
+
return { content: [{ type: "text", text: `Memory saved: "${title}" (id: ${id})` }] };
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
server.tool(
|
|
307
|
+
"search_memory",
|
|
308
|
+
{
|
|
309
|
+
query: z.string().describe("Search query -- uses full-text search with time-weighted ranking"),
|
|
310
|
+
limit: z.number().optional().default(10).describe("Max results")
|
|
311
|
+
},
|
|
312
|
+
async ({ query, limit }) => {
|
|
313
|
+
const t0 = Date.now();
|
|
314
|
+
const escapedQuery = escapeFtsQuery(query);
|
|
315
|
+
let results = [];
|
|
316
|
+
try {
|
|
317
|
+
const ftsRows = sqlite.prepare(`
|
|
318
|
+
SELECT m.id, m.title, m.content, m.tags, m.scope, m.pinned,
|
|
319
|
+
m.access_count, m.last_used_at, m.created_at, m.updated_at,
|
|
320
|
+
rank
|
|
321
|
+
FROM memories_fts f
|
|
322
|
+
JOIN memories m ON m.rowid = f.rowid
|
|
323
|
+
WHERE memories_fts MATCH ?
|
|
324
|
+
ORDER BY rank
|
|
325
|
+
LIMIT ?
|
|
326
|
+
`).all(escapedQuery, limit);
|
|
327
|
+
if (ftsRows.length > 0) {
|
|
328
|
+
const now2 = Date.now();
|
|
329
|
+
const scored = ftsRows.map((r) => {
|
|
330
|
+
const recency = r.last_used_at ? Math.max(0, 1 - (now2 - r.last_used_at) / (30 * 24 * 60 * 60 * 1e3)) : 0;
|
|
331
|
+
const accessBoost = Math.min(r.access_count * 0.05, 0.5);
|
|
332
|
+
const pinnedBoost = r.pinned ? 0.3 : 0;
|
|
333
|
+
const score = -r.rank + recency * 2 + accessBoost + pinnedBoost;
|
|
334
|
+
return { ...r, score };
|
|
335
|
+
});
|
|
336
|
+
scored.sort((a, b) => b.score - a.score);
|
|
337
|
+
results = scored.map((r) => ({
|
|
338
|
+
id: r.id,
|
|
339
|
+
title: r.title,
|
|
340
|
+
content: r.content,
|
|
341
|
+
tags: r.tags ? JSON.parse(r.tags) : [],
|
|
342
|
+
scope: r.scope,
|
|
343
|
+
pinned: r.pinned === 1,
|
|
344
|
+
accessCount: r.access_count
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
if (results.length === 0) {
|
|
350
|
+
const pattern = `%${escapeLike(query)}%`;
|
|
351
|
+
const likeRows = db.select().from(memories).where(or(like(memories.title, pattern), like(memories.content, pattern), like(memories.tags, pattern))).orderBy(desc(memories.updatedAt)).limit(limit).all();
|
|
352
|
+
results = likeRows.map((r) => ({
|
|
353
|
+
id: r.id,
|
|
354
|
+
title: r.title,
|
|
355
|
+
content: r.content,
|
|
356
|
+
tags: r.tags ? JSON.parse(r.tags) : [],
|
|
357
|
+
scope: r.scope,
|
|
358
|
+
pinned: r.pinned === 1,
|
|
359
|
+
accessCount: r.accessCount
|
|
360
|
+
}));
|
|
361
|
+
}
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
for (const r of results) {
|
|
364
|
+
sqlite.prepare("UPDATE memories SET last_used_at = ?, access_count = access_count + 1 WHERE id = ?").run(now, r.id);
|
|
365
|
+
}
|
|
366
|
+
logInfo("mcp", `search_memory: "${query}"`, { resultCount: results.length, ms: Date.now() - t0 });
|
|
367
|
+
if (results.length === 0) return { content: [{ type: "text", text: `No memories found for: ${query}` }] };
|
|
368
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
369
|
+
}
|
|
370
|
+
);
|
|
371
|
+
server.tool(
|
|
372
|
+
"get_environment",
|
|
373
|
+
{},
|
|
374
|
+
async () => {
|
|
375
|
+
const now = /* @__PURE__ */ new Date();
|
|
376
|
+
const info = {
|
|
377
|
+
datetime: now.toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" }),
|
|
378
|
+
timezone: "Asia/Shanghai",
|
|
379
|
+
platform: process.platform,
|
|
380
|
+
arch: process.arch,
|
|
381
|
+
nodeVersion: process.version,
|
|
382
|
+
uptime: `${Math.round(process.uptime() / 60)} min`,
|
|
383
|
+
memoryUsage: `${Math.round(process.memoryUsage().heapUsed / 1024 / 1024)}MB heap`
|
|
384
|
+
};
|
|
385
|
+
return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] };
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
server.resource(
|
|
389
|
+
"connectors",
|
|
390
|
+
"jowork://connectors",
|
|
391
|
+
async (uri) => {
|
|
392
|
+
const sources = db.select().from(connectorConfigs).all();
|
|
393
|
+
const counts = sqlite.prepare(
|
|
394
|
+
`SELECT source, COUNT(*) as count FROM objects GROUP BY source`
|
|
395
|
+
).all();
|
|
396
|
+
return {
|
|
397
|
+
contents: [{
|
|
398
|
+
uri: uri.href,
|
|
399
|
+
mimeType: "application/json",
|
|
400
|
+
text: JSON.stringify({ connectors: sources, objectCounts: counts }, null, 2)
|
|
401
|
+
}]
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
);
|
|
405
|
+
server.resource(
|
|
406
|
+
"memories",
|
|
407
|
+
"jowork://memories",
|
|
408
|
+
async (uri) => {
|
|
409
|
+
const mems = sqlite.prepare(
|
|
410
|
+
`SELECT id, title, tags, scope, pinned, access_count, updated_at FROM memories ORDER BY updated_at DESC LIMIT 50`
|
|
411
|
+
).all();
|
|
412
|
+
return {
|
|
413
|
+
contents: [{
|
|
414
|
+
uri: uri.href,
|
|
415
|
+
mimeType: "application/json",
|
|
416
|
+
text: JSON.stringify(mems, null, 2)
|
|
417
|
+
}]
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
);
|
|
421
|
+
server.resource(
|
|
422
|
+
"status",
|
|
423
|
+
"jowork://status",
|
|
424
|
+
async (uri) => {
|
|
425
|
+
const tables = ["objects", "memories", "connector_configs", "object_links"];
|
|
426
|
+
const counts = {};
|
|
427
|
+
for (const table of tables) {
|
|
428
|
+
try {
|
|
429
|
+
const row = sqlite.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
|
|
430
|
+
counts[table] = row.count;
|
|
431
|
+
} catch {
|
|
432
|
+
counts[table] = 0;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
let lastSync = null;
|
|
436
|
+
try {
|
|
437
|
+
const cursor = sqlite.prepare(
|
|
438
|
+
`SELECT connector_id, last_synced_at FROM sync_cursors ORDER BY last_synced_at DESC LIMIT 1`
|
|
439
|
+
).get();
|
|
440
|
+
if (cursor) lastSync = { connector: cursor.connector_id, at: new Date(cursor.last_synced_at).toISOString() };
|
|
441
|
+
} catch {
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
contents: [{
|
|
445
|
+
uri: uri.href,
|
|
446
|
+
mimeType: "application/json",
|
|
447
|
+
text: JSON.stringify({ tableCounts: counts, lastSync }, null, 2)
|
|
448
|
+
}]
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
);
|
|
452
|
+
server.tool(
|
|
453
|
+
"get_goals",
|
|
454
|
+
{
|
|
455
|
+
status: z.string().optional().describe("Filter: active, paused, completed")
|
|
456
|
+
},
|
|
457
|
+
async ({ status }) => {
|
|
458
|
+
const goals = sqlite.prepare(
|
|
459
|
+
status ? `SELECT g.*, (SELECT COUNT(*) FROM signals WHERE goal_id = g.id) as signal_count FROM goals g WHERE g.status = ? ORDER BY g.created_at DESC` : `SELECT g.*, (SELECT COUNT(*) FROM signals WHERE goal_id = g.id) as signal_count FROM goals g ORDER BY g.created_at DESC`
|
|
460
|
+
).all(...status ? [status] : []);
|
|
461
|
+
return { content: [{ type: "text", text: JSON.stringify(goals, null, 2) }] };
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
server.tool(
|
|
465
|
+
"get_metrics",
|
|
466
|
+
{
|
|
467
|
+
goal_id: z.string().optional().describe("Goal ID (shows all active if omitted)")
|
|
468
|
+
},
|
|
469
|
+
async ({ goal_id }) => {
|
|
470
|
+
const query = goal_id ? `SELECT s.*, m.threshold, m.comparison, m.current, m.met, g.title as goal_title
|
|
471
|
+
FROM signals s
|
|
472
|
+
JOIN goals g ON g.id = s.goal_id
|
|
473
|
+
LEFT JOIN measures m ON m.signal_id = s.id
|
|
474
|
+
WHERE s.goal_id = ? ORDER BY s.created_at` : `SELECT s.*, m.threshold, m.comparison, m.current, m.met, g.title as goal_title
|
|
475
|
+
FROM signals s
|
|
476
|
+
JOIN goals g ON g.id = s.goal_id AND g.status = 'active'
|
|
477
|
+
LEFT JOIN measures m ON m.signal_id = s.id
|
|
478
|
+
ORDER BY g.created_at DESC, s.created_at`;
|
|
479
|
+
const rows = sqlite.prepare(query).all(...goal_id ? [goal_id] : []);
|
|
480
|
+
return { content: [{ type: "text", text: JSON.stringify(rows, null, 2) }] };
|
|
481
|
+
}
|
|
482
|
+
);
|
|
483
|
+
server.tool(
|
|
484
|
+
"update_goal",
|
|
485
|
+
{
|
|
486
|
+
goal_id: z.string().describe("Goal ID"),
|
|
487
|
+
title: z.string().optional().describe("New title"),
|
|
488
|
+
description: z.string().optional().describe("New description"),
|
|
489
|
+
status: z.enum(["active", "paused", "completed"]).optional().describe("New status")
|
|
490
|
+
},
|
|
491
|
+
async ({ goal_id, title, description, status }) => {
|
|
492
|
+
const existing = sqlite.prepare("SELECT * FROM goals WHERE id = ?").get(goal_id);
|
|
493
|
+
if (!existing) return { content: [{ type: "text", text: `Goal not found: ${goal_id}` }] };
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
const sets = ["updated_at = ?"];
|
|
496
|
+
const args = [now];
|
|
497
|
+
if (title) {
|
|
498
|
+
sets.push("title = ?");
|
|
499
|
+
args.push(title);
|
|
500
|
+
}
|
|
501
|
+
if (description) {
|
|
502
|
+
sets.push("description = ?");
|
|
503
|
+
args.push(description);
|
|
504
|
+
}
|
|
505
|
+
if (status) {
|
|
506
|
+
sets.push("status = ?");
|
|
507
|
+
args.push(status);
|
|
508
|
+
}
|
|
509
|
+
args.push(goal_id);
|
|
510
|
+
sqlite.prepare(`UPDATE goals SET ${sets.join(", ")} WHERE id = ?`).run(...args);
|
|
511
|
+
const updated = sqlite.prepare("SELECT * FROM goals WHERE id = ?").get(goal_id);
|
|
512
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
server.tool(
|
|
516
|
+
"push_to_channel",
|
|
517
|
+
{
|
|
518
|
+
channel: z.string().describe("Channel: feishu, slack, telegram"),
|
|
519
|
+
target: z.string().describe("Target ID (chat_id for feishu, channel for slack)"),
|
|
520
|
+
message: z.string().describe("Message content")
|
|
521
|
+
},
|
|
522
|
+
async ({ channel, target, message }) => {
|
|
523
|
+
if (channel === "feishu") {
|
|
524
|
+
try {
|
|
525
|
+
const credFile = join3(process.env["HOME"] ?? "", ".jowork", "credentials", "feishu.json");
|
|
526
|
+
if (!existsSync(credFile)) {
|
|
527
|
+
return { content: [{ type: "text", text: "Feishu not connected. Run `jowork connect feishu` first." }] };
|
|
528
|
+
}
|
|
529
|
+
const cred = JSON.parse(readFileSync(credFile, "utf-8"));
|
|
530
|
+
const tokenRes = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: { "Content-Type": "application/json" },
|
|
533
|
+
body: JSON.stringify({ app_id: cred.data.appId, app_secret: cred.data.appSecret })
|
|
534
|
+
});
|
|
535
|
+
const tokenData = await tokenRes.json();
|
|
536
|
+
if (tokenData.code !== 0) return { content: [{ type: "text", text: `Feishu auth failed: ${tokenData.code}` }] };
|
|
537
|
+
const msgRes = await fetch("https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id", {
|
|
538
|
+
method: "POST",
|
|
539
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokenData.tenant_access_token}` },
|
|
540
|
+
body: JSON.stringify({
|
|
541
|
+
receive_id: target,
|
|
542
|
+
msg_type: "text",
|
|
543
|
+
content: JSON.stringify({ text: message })
|
|
544
|
+
})
|
|
545
|
+
});
|
|
546
|
+
const msgData = await msgRes.json();
|
|
547
|
+
if (msgData.code !== 0) return { content: [{ type: "text", text: `Feishu send failed: ${msgData.code}` }] };
|
|
548
|
+
logInfo("mcp", `push_to_channel: feishu/${target}`, { length: message.length });
|
|
549
|
+
return { content: [{ type: "text", text: `\u2713 Message sent to Feishu chat ${target}` }] };
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return { content: [{ type: "text", text: `Error: ${err}` }] };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
return { content: [{ type: "text", text: `Channel "${channel}" not yet supported. Available: feishu` }] };
|
|
555
|
+
}
|
|
556
|
+
);
|
|
557
|
+
return server;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export {
|
|
561
|
+
logInfo,
|
|
562
|
+
logError,
|
|
563
|
+
joworkDir,
|
|
564
|
+
dbPath,
|
|
565
|
+
credentialsDir,
|
|
566
|
+
configPath,
|
|
567
|
+
logsDir,
|
|
568
|
+
createJoWorkMcpServer
|
|
569
|
+
};
|