@khoinguyen2002/doc-mcp 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.d.ts +6 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +22 -7
- package/dist/db/rateLimiter.d.ts +6 -0
- package/dist/db/rateLimiter.d.ts.map +1 -0
- package/dist/db/rateLimiter.js +20 -0
- package/dist/db/syncState.d.ts +12 -0
- package/dist/db/syncState.d.ts.map +1 -0
- package/dist/db/syncState.js +69 -0
- package/dist/db/vector.d.ts +61 -6
- package/dist/db/vector.d.ts.map +1 -1
- package/dist/db/vector.js +249 -109
- package/dist/mcp-server.js +47 -37
- package/dist/tools/driveTools.d.ts +20 -16
- package/dist/tools/driveTools.d.ts.map +1 -1
- package/dist/tools/driveTools.js +101 -144
- package/dist/tools/ingestFlow.d.ts +8 -0
- package/dist/tools/ingestFlow.d.ts.map +1 -0
- package/dist/tools/ingestFlow.js +407 -0
- package/dist/tools/knowledgeTools.d.ts +32 -4
- package/dist/tools/knowledgeTools.d.ts.map +1 -1
- package/dist/tools/knowledgeTools.js +29 -34
- package/package.json +8 -1
- package/src/config.ts +28 -9
- package/src/db/rateLimiter.ts +25 -0
- package/src/db/syncState.ts +87 -0
- package/src/db/vector.ts +305 -115
- package/src/mcp-server.ts +56 -48
- package/src/tools/driveTools.ts +111 -168
- package/src/tools/ingestFlow.ts +508 -0
- package/src/tools/knowledgeTools.ts +34 -33
- package/src/types/turndown-plugin-gfm.d.ts +8 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import { google } from "googleapis";
|
|
2
|
+
import { toHast } from "@googleworkspace/google-docs-hast";
|
|
3
|
+
import { toHtml } from "hast-util-to-html";
|
|
4
|
+
import * as crypto from "crypto";
|
|
5
|
+
import TurndownService from "turndown";
|
|
6
|
+
import { gfm } from "turndown-plugin-gfm";
|
|
7
|
+
import { config } from "../config.js";
|
|
8
|
+
import { get_encoding, type Tiktoken } from "tiktoken";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
embedBatch,
|
|
12
|
+
getBlockPointId,
|
|
13
|
+
getBlockMetaByIds,
|
|
14
|
+
deletePointsByIds,
|
|
15
|
+
upsertChunkBatch,
|
|
16
|
+
updateBlockOffsets,
|
|
17
|
+
ChunkUpsert,
|
|
18
|
+
} from "../db/vector.js";
|
|
19
|
+
import { getSyncEntry, setSyncEntry, getImageDesc, setImageDesc } from "../db/syncState.js";
|
|
20
|
+
import { waitForRateLimit } from "../db/rateLimiter.js";
|
|
21
|
+
|
|
22
|
+
// ─── Turndown setup ───────────────────────────────────────────────────────────
|
|
23
|
+
const turndownService = new TurndownService({
|
|
24
|
+
headingStyle: "atx",
|
|
25
|
+
codeBlockStyle: "fenced",
|
|
26
|
+
bulletListMarker: "-",
|
|
27
|
+
});
|
|
28
|
+
turndownService.use(gfm);
|
|
29
|
+
// Replace img tags with readable placeholder (Drive blob URLs are useless)
|
|
30
|
+
turndownService.addRule("images", {
|
|
31
|
+
filter: "img",
|
|
32
|
+
replacement: (_content, node: any) => {
|
|
33
|
+
const alt = node.getAttribute?.("alt") || "";
|
|
34
|
+
return alt ? `[Image: ${alt}]` : "[Image]";
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// ─── Google Auth ──────────────────────────────────────────────────────────────
|
|
39
|
+
function getGoogleClients() {
|
|
40
|
+
const clientEmail = config.DOC_MCP_GOOGLE_CLIENT_EMAIL;
|
|
41
|
+
let privateKey = config.DOC_MCP_GOOGLE_PRIVATE_KEY;
|
|
42
|
+
|
|
43
|
+
if (!clientEmail || !privateKey) {
|
|
44
|
+
throw new Error("Google credentials not configured.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (privateKey.startsWith('"') && privateKey.endsWith('"')) {
|
|
48
|
+
privateKey = privateKey.slice(1, -1);
|
|
49
|
+
}
|
|
50
|
+
privateKey = privateKey.replace(/\\n/g, "\n");
|
|
51
|
+
|
|
52
|
+
const auth = new google.auth.JWT({
|
|
53
|
+
email: clientEmail,
|
|
54
|
+
key: privateKey,
|
|
55
|
+
scopes: [
|
|
56
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
|
57
|
+
"https://www.googleapis.com/auth/documents.readonly",
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
drive: google.drive({ version: "v3", auth }),
|
|
63
|
+
docs: google.docs({ version: "v1", auth }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── HAST Image Collection ───────────────────────────────────────────────────
|
|
68
|
+
/** Collect all img src URLs from a HAST tree. */
|
|
69
|
+
function collectImageSrcs(node: any, srcs: Set<string>) {
|
|
70
|
+
if (!node) return;
|
|
71
|
+
if (node.type === "element" && node.tagName === "img" && node.properties?.src) {
|
|
72
|
+
srcs.add(String(node.properties.src));
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(node.children)) {
|
|
75
|
+
for (const child of node.children) collectImageSrcs(child, srcs);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Replace img nodes with description text from descMap. */
|
|
80
|
+
function sanitizeHast(node: any, descMap: Map<string, string>) {
|
|
81
|
+
if (!node) return;
|
|
82
|
+
if (node.type === "element" && node.tagName === "img") {
|
|
83
|
+
const src = String(node.properties?.src ?? "");
|
|
84
|
+
const description =
|
|
85
|
+
descMap.get(src) ||
|
|
86
|
+
(node.properties?.alt ? String(node.properties.alt) : "");
|
|
87
|
+
const label = description ? `: ${description}` : "";
|
|
88
|
+
node.tagName = "span";
|
|
89
|
+
node.properties = { className: ["img-placeholder"] };
|
|
90
|
+
node.children = [{ type: "text", value: `[Image${label}]` }];
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (Array.isArray(node.children)) {
|
|
94
|
+
for (const child of node.children) sanitizeHast(child, descMap);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Vision LLM ──────────────────────────────────────────────────────────────
|
|
99
|
+
async function downloadImage(
|
|
100
|
+
url: string
|
|
101
|
+
): Promise<{ buffer: Buffer; mimeType: string } | null> {
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
104
|
+
if (!res.ok) return null;
|
|
105
|
+
const contentType = res.headers.get("content-type") || "image/png";
|
|
106
|
+
const mimeType = contentType.split(";")[0].trim();
|
|
107
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
108
|
+
return { buffer, mimeType };
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function describeImageWithVision(
|
|
115
|
+
buffer: Buffer,
|
|
116
|
+
mimeType: string
|
|
117
|
+
): Promise<string> {
|
|
118
|
+
const base64 = buffer.toString("base64");
|
|
119
|
+
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${config.OPENROUTER_API_KEY}`,
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
model: config.VISION_MODEL_ID,
|
|
127
|
+
messages: [
|
|
128
|
+
{
|
|
129
|
+
role: "user",
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "image_url",
|
|
133
|
+
image_url: { url: `data:${mimeType};base64,${base64}` },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: "text",
|
|
137
|
+
text: "Describe this image concisely in 1-3 sentences for a developer reading technical documentation. Focus on UI layout, data shown, flow diagrams, or key visible text.",
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
max_tokens: 300,
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!res.ok) {
|
|
147
|
+
console.error(`[Vision] API error: ${res.status}`);
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
const json: any = await res.json();
|
|
151
|
+
return json.choices?.[0]?.message?.content?.trim() || "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Process all images in a HAST tree:
|
|
156
|
+
* 1. Download image binary
|
|
157
|
+
* 2. Check Redis cache by binary hash
|
|
158
|
+
* 3. Call vision LLM if cache miss
|
|
159
|
+
* 4. Return src→description map
|
|
160
|
+
*/
|
|
161
|
+
async function processImages(hast: any): Promise<Map<string, string>> {
|
|
162
|
+
const descMap = new Map<string, string>();
|
|
163
|
+
|
|
164
|
+
if (!config.VISION_MODEL_ID) {
|
|
165
|
+
// Vision not configured — fall back to alt text only
|
|
166
|
+
return descMap;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const srcs = new Set<string>();
|
|
170
|
+
collectImageSrcs(hast, srcs);
|
|
171
|
+
if (srcs.size === 0) return descMap;
|
|
172
|
+
|
|
173
|
+
console.error(`[Vision] Processing ${srcs.size} image(s)...`);
|
|
174
|
+
|
|
175
|
+
for (const src of srcs) {
|
|
176
|
+
const image = await downloadImage(src);
|
|
177
|
+
if (!image) {
|
|
178
|
+
console.error(`[Vision] Failed to download: ${src.substring(0, 60)}...`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const imageHash = crypto.createHash("md5").update(image.buffer).digest("hex");
|
|
183
|
+
|
|
184
|
+
// Check Redis cache
|
|
185
|
+
const cached = await getImageDesc(imageHash);
|
|
186
|
+
if (cached) {
|
|
187
|
+
console.error(`[Vision] Cache hit for image hash ${imageHash.substring(0, 8)}`);
|
|
188
|
+
descMap.set(src, cached);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Call vision LLM
|
|
193
|
+
console.error(`[Vision] Describing image hash ${imageHash.substring(0, 8)}...`);
|
|
194
|
+
const description = await describeImageWithVision(image.buffer, image.mimeType);
|
|
195
|
+
|
|
196
|
+
if (description) {
|
|
197
|
+
await setImageDesc(imageHash, description);
|
|
198
|
+
descMap.set(src, description);
|
|
199
|
+
console.error(`[Vision] Stored: "${description.substring(0, 80)}..."`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return descMap;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ─── HTML → Markdown ─────────────────────────────────────────────────────────
|
|
207
|
+
export async function googleDocToMarkdown(
|
|
208
|
+
docJson: any
|
|
209
|
+
): Promise<string> {
|
|
210
|
+
const hast = toHast(docJson);
|
|
211
|
+
const descMap = await processImages(hast);
|
|
212
|
+
sanitizeHast(hast, descMap);
|
|
213
|
+
const html = toHtml(hast as any);
|
|
214
|
+
// 1. Strip inline styles/attrs from table/row/cell tags
|
|
215
|
+
let cleanHtml = html.replace(
|
|
216
|
+
/<(table|thead|tbody|tr|td|th)(\s[^>]*)>/gi,
|
|
217
|
+
(_, tag) => `<${tag}>`
|
|
218
|
+
);
|
|
219
|
+
// 2. Fix tables for turndown-plugin-gfm:
|
|
220
|
+
// - Strip <p> wrappers inside cells (GFM requires inline content only)
|
|
221
|
+
// - Strip <span> attributes (inline styles break cell content parsing)
|
|
222
|
+
// - Convert first <tr>'s <td> → <th> so isHeadingRow() returns true
|
|
223
|
+
// (Google Docs never uses <th>; without this the table rule doesn't fire)
|
|
224
|
+
cleanHtml = cleanHtml.replace(
|
|
225
|
+
/<table[\s\S]*?<\/table>/gi,
|
|
226
|
+
(tableBlock) => {
|
|
227
|
+
let cleaned = tableBlock
|
|
228
|
+
.replace(/<\/?p[^>]*>/gi, "") // strip <p> wrappers
|
|
229
|
+
.replace(/<span[^>]*>/gi, "") // strip <span> open tags w/ attrs
|
|
230
|
+
.replace(/<\/span>/gi, ""); // strip </span>
|
|
231
|
+
// Promote first <tr>'s cells to <th> so GFM table rule fires
|
|
232
|
+
let firstRow = true;
|
|
233
|
+
return cleaned.replace(/<tr>([\s\S]*?)<\/tr>/gi, (rowMatch, rowContent) => {
|
|
234
|
+
if (firstRow) {
|
|
235
|
+
firstRow = false;
|
|
236
|
+
return "<tr>" +
|
|
237
|
+
rowContent
|
|
238
|
+
.replace(/<td>/gi, "<th>")
|
|
239
|
+
.replace(/<\/td>/gi, "</th>") +
|
|
240
|
+
"</tr>";
|
|
241
|
+
}
|
|
242
|
+
return rowMatch;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
);
|
|
246
|
+
return turndownService.turndown(cleanHtml);
|
|
247
|
+
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Convert a multi-tab Google Doc to a single Markdown string.
|
|
252
|
+
* Each tab becomes a top-level section separated by ---.
|
|
253
|
+
*/
|
|
254
|
+
async function docToMarkdown(docData: any): Promise<string> {
|
|
255
|
+
if (docData.tabs && docData.tabs.length > 0) {
|
|
256
|
+
const tabMarkdowns: string[] = [];
|
|
257
|
+
for (const tab of docData.tabs as any[]) {
|
|
258
|
+
if (!tab.documentTab?.body) continue;
|
|
259
|
+
const tabTitle = tab.tabProperties?.title || "Tab";
|
|
260
|
+
// Spread full documentTab so toHast resolves inline objects per-tab
|
|
261
|
+
const tabDoc = { ...docData, ...tab.documentTab };
|
|
262
|
+
const md = await googleDocToMarkdown(tabDoc);
|
|
263
|
+
tabMarkdowns.push(`# ${tabTitle}\n\n${md}`);
|
|
264
|
+
}
|
|
265
|
+
return tabMarkdowns.join("\n\n---\n\n");
|
|
266
|
+
}
|
|
267
|
+
// Single-tab (legacy) document
|
|
268
|
+
return googleDocToMarkdown(docData);
|
|
269
|
+
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Chunking ─────────────────────────────────────────────────────────────────
|
|
273
|
+
/**
|
|
274
|
+
* Split Markdown at headings (#, ##), merge small sections up to MAX_CHUNK_SIZE.
|
|
275
|
+
* Sections exceeding MAX_CHUNK_SIZE are split at the nearest newline boundary.
|
|
276
|
+
*
|
|
277
|
+
* Effective MAX_CHUNK_SIZE is capped so that even the worst-case content
|
|
278
|
+
* (all-Thai, ~3 cl100k tokens/char × TOKEN_SAFETY_MULTIPLIER) stays within
|
|
279
|
+
* 40% of EMBEDDING_MAX_TOKENS — guaranteeing at least 2 chunks can fit per batch
|
|
280
|
+
* regardless of which embedding model is configured.
|
|
281
|
+
*/
|
|
282
|
+
function chunkMarkdown(markdown: string): string[] {
|
|
283
|
+
// Worst-case Thai tokenization: 3 cl100k tokens/char × 1.4 safety = ~4.2 tokens/char
|
|
284
|
+
const worstCaseTokensPerChar = 3 * TOKEN_SAFETY_MULTIPLIER;
|
|
285
|
+
// Allow each chunk to use at most 40% of the token budget
|
|
286
|
+
const maxCharsFromBudget = Math.max(
|
|
287
|
+
500,
|
|
288
|
+
Math.floor((config.EMBEDDING_MAX_TOKENS * 0.4) / worstCaseTokensPerChar)
|
|
289
|
+
);
|
|
290
|
+
const MAX_CHUNK_SIZE = Math.min(config.MAX_CHUNK_SIZE, maxCharsFromBudget);
|
|
291
|
+
|
|
292
|
+
console.error(
|
|
293
|
+
`[Chunk] effectiveChunkSize=${MAX_CHUNK_SIZE} ` +
|
|
294
|
+
`(config=${config.MAX_CHUNK_SIZE}, budgetCap=${maxCharsFromBudget}, ` +
|
|
295
|
+
`maxTokens=${config.EMBEDDING_MAX_TOKENS})`
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
// Split at markdown headings (keep the heading in the next chunk)
|
|
300
|
+
const sections = markdown
|
|
301
|
+
.split(/(?=\n#{1,2} )/g)
|
|
302
|
+
.filter((s) => s.trim().length > 0);
|
|
303
|
+
|
|
304
|
+
const chunks: string[] = [];
|
|
305
|
+
let current = "";
|
|
306
|
+
|
|
307
|
+
for (let section of sections) {
|
|
308
|
+
// Section exceeds MAX_CHUNK_SIZE → split at newline boundaries
|
|
309
|
+
while (section.length > MAX_CHUNK_SIZE) {
|
|
310
|
+
if (current.length > 0) {
|
|
311
|
+
chunks.push(current);
|
|
312
|
+
current = "";
|
|
313
|
+
}
|
|
314
|
+
// Find the nearest newline in the second half of the window
|
|
315
|
+
// to avoid cutting mid-line (table row, sentence, etc.)
|
|
316
|
+
let cutAt = MAX_CHUNK_SIZE;
|
|
317
|
+
const newlineIdx = section.lastIndexOf("\n", MAX_CHUNK_SIZE);
|
|
318
|
+
if (newlineIdx > MAX_CHUNK_SIZE * 0.5) {
|
|
319
|
+
cutAt = newlineIdx + 1; // include the \n in the current chunk
|
|
320
|
+
}
|
|
321
|
+
chunks.push(section.substring(0, cutAt));
|
|
322
|
+
section = section.substring(cutAt);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (
|
|
326
|
+
current.length > 0 &&
|
|
327
|
+
current.length + section.length > MAX_CHUNK_SIZE
|
|
328
|
+
) {
|
|
329
|
+
chunks.push(current);
|
|
330
|
+
current = section;
|
|
331
|
+
} else {
|
|
332
|
+
current += section;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (current.trim()) chunks.push(current);
|
|
336
|
+
return chunks;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function calculateHash(content: string): string {
|
|
340
|
+
return crypto.createHash("md5").update(content).digest("hex");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Batch Packing ────────────────────────────────────────────────────────────
|
|
344
|
+
/**
|
|
345
|
+
* Token counter using tiktoken cl100k_base (GPT-4 tokenizer).
|
|
346
|
+
* cl100k_base is used as a close approximation for LLaMA-2 based models.
|
|
347
|
+
* A 1.4x safety multiplier is applied because LLaMA-2's SentencePiece tokenizer
|
|
348
|
+
* tokenizes Thai/multilingual text significantly worse than cl100k_base.
|
|
349
|
+
* Encoder is initialized once at module level to avoid repeated WASM loads.
|
|
350
|
+
*/
|
|
351
|
+
let _enc: Tiktoken | null = null;
|
|
352
|
+
function getEncoder(): Tiktoken {
|
|
353
|
+
if (!_enc) _enc = get_encoding("cl100k_base");
|
|
354
|
+
return _enc;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const TOKEN_SAFETY_MULTIPLIER = 1.4;
|
|
358
|
+
|
|
359
|
+
function countTokens(text: string): number {
|
|
360
|
+
const enc = getEncoder();
|
|
361
|
+
return Math.ceil(enc.encode(text).length * TOKEN_SAFETY_MULTIPLIER);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
interface BlockToEmbed {
|
|
365
|
+
index: number;
|
|
366
|
+
offset: number;
|
|
367
|
+
text: string;
|
|
368
|
+
hash: string;
|
|
369
|
+
pointId: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function packIntoBatches(
|
|
373
|
+
blocks: BlockToEmbed[],
|
|
374
|
+
maxTokens: number
|
|
375
|
+
): BlockToEmbed[][] {
|
|
376
|
+
const batches: BlockToEmbed[][] = [];
|
|
377
|
+
let current: BlockToEmbed[] = [];
|
|
378
|
+
let currentTokens = 0;
|
|
379
|
+
|
|
380
|
+
for (const block of blocks) {
|
|
381
|
+
const blockTokens = countTokens(block.text);
|
|
382
|
+
if (current.length > 0 && currentTokens + blockTokens > maxTokens) {
|
|
383
|
+
batches.push(current);
|
|
384
|
+
current = [block];
|
|
385
|
+
currentTokens = blockTokens;
|
|
386
|
+
} else {
|
|
387
|
+
current.push(block);
|
|
388
|
+
currentTokens += blockTokens;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (current.length > 0) batches.push(current);
|
|
392
|
+
return batches;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ─── Core Sync ────────────────────────────────────────────────────────────────
|
|
396
|
+
export async function syncSingleDocument(
|
|
397
|
+
fileId: string,
|
|
398
|
+
driveModifiedTime: string,
|
|
399
|
+
title: string
|
|
400
|
+
): Promise<{
|
|
401
|
+
synced: boolean;
|
|
402
|
+
content: string;
|
|
403
|
+
upsertedCount?: number;
|
|
404
|
+
skippedCount?: number;
|
|
405
|
+
}> {
|
|
406
|
+
const { docs } = getGoogleClients();
|
|
407
|
+
|
|
408
|
+
// 1. Fetch doc with ALL tabs content + convert to Markdown
|
|
409
|
+
const docRes = await docs.documents.get({
|
|
410
|
+
documentId: fileId,
|
|
411
|
+
includeTabsContent: true, // fetch all document tabs
|
|
412
|
+
} as any);
|
|
413
|
+
const markdown = await docToMarkdown(docRes.data);
|
|
414
|
+
|
|
415
|
+
// 2. Check sync state — skip embedding if unchanged
|
|
416
|
+
const syncEntry = await getSyncEntry(fileId);
|
|
417
|
+
if (syncEntry?.modifiedTime === driveModifiedTime) {
|
|
418
|
+
console.error(`[Sync] "${title}": unchanged, skipping embedding.`);
|
|
419
|
+
return { synced: false, content: markdown };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 3. Chunk Markdown
|
|
423
|
+
const newBlocks = chunkMarkdown(markdown);
|
|
424
|
+
|
|
425
|
+
// 4. Get existing block hashes via deterministic IDs
|
|
426
|
+
const oldBlockCount = syncEntry?.blockCount ?? 0;
|
|
427
|
+
const oldPointIds = Array.from({ length: oldBlockCount }, (_, i) =>
|
|
428
|
+
getBlockPointId(fileId, i)
|
|
429
|
+
);
|
|
430
|
+
const existingMeta = await getBlockMetaByIds(oldPointIds);
|
|
431
|
+
|
|
432
|
+
// 5. Diff blocks
|
|
433
|
+
const blocksToEmbed: BlockToEmbed[] = [];
|
|
434
|
+
const blocksToUpdateOffset: { pointId: string; offset: number }[] = [];
|
|
435
|
+
let skippedCount = 0;
|
|
436
|
+
let charOffset = 0;
|
|
437
|
+
|
|
438
|
+
for (let i = 0; i < newBlocks.length; i++) {
|
|
439
|
+
const text = newBlocks[i];
|
|
440
|
+
const hash = calculateHash(text);
|
|
441
|
+
const pointId = getBlockPointId(fileId, i);
|
|
442
|
+
const existing = existingMeta[pointId];
|
|
443
|
+
|
|
444
|
+
if (existing && existing.hash === hash) {
|
|
445
|
+
// Content unchanged — but check if offset shifted (due to edits in earlier blocks)
|
|
446
|
+
if (existing.offset !== charOffset) {
|
|
447
|
+
blocksToUpdateOffset.push({ pointId, offset: charOffset });
|
|
448
|
+
}
|
|
449
|
+
skippedCount++;
|
|
450
|
+
} else {
|
|
451
|
+
blocksToEmbed.push({ index: i, offset: charOffset, text, hash, pointId });
|
|
452
|
+
}
|
|
453
|
+
charOffset += text.length;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// 6. Delete obsolete blocks (doc shrunk)
|
|
457
|
+
const obsoletePointIds = Array.from(
|
|
458
|
+
{ length: Math.max(0, oldBlockCount - newBlocks.length) },
|
|
459
|
+
(_, i) => getBlockPointId(fileId, newBlocks.length + i)
|
|
460
|
+
);
|
|
461
|
+
if (obsoletePointIds.length > 0) {
|
|
462
|
+
await deletePointsByIds(obsoletePointIds);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 7. Fix stale offsets for unchanged blocks (no re-embed needed)
|
|
466
|
+
await updateBlockOffsets(blocksToUpdateOffset);
|
|
467
|
+
|
|
468
|
+
// 8. Batch embed + upsert
|
|
469
|
+
const batches = packIntoBatches(blocksToEmbed, config.EMBEDDING_MAX_TOKENS);
|
|
470
|
+
let upsertedCount = 0;
|
|
471
|
+
|
|
472
|
+
for (let b = 0; b < batches.length; b++) {
|
|
473
|
+
const batch = batches[b];
|
|
474
|
+
console.error(
|
|
475
|
+
`[Embed] Batch ${b + 1}/${batches.length}: ${batch.length} chunk(s)`
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
await waitForRateLimit();
|
|
479
|
+
const vectors = await embedBatch(batch.map((bl) => bl.text));
|
|
480
|
+
|
|
481
|
+
const chunkUpserts: ChunkUpsert[] = batch.map((bl, vi) => ({
|
|
482
|
+
pointId: bl.pointId,
|
|
483
|
+
vector: vectors[vi],
|
|
484
|
+
text: bl.text,
|
|
485
|
+
title,
|
|
486
|
+
blockIndex: bl.index,
|
|
487
|
+
blockHash: bl.hash,
|
|
488
|
+
source: "google_drive",
|
|
489
|
+
offset: bl.offset,
|
|
490
|
+
}));
|
|
491
|
+
|
|
492
|
+
await upsertChunkBatch(chunkUpserts);
|
|
493
|
+
upsertedCount += batch.length;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 8. Update sync state in Redis
|
|
497
|
+
await setSyncEntry(fileId, {
|
|
498
|
+
modifiedTime: driveModifiedTime,
|
|
499
|
+
blockCount: newBlocks.length,
|
|
500
|
+
title,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
console.error(
|
|
504
|
+
`[Sync] "${title}": ${upsertedCount} upserted, ${skippedCount} skipped, ${obsoletePointIds.length} deleted.`
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
return { synced: true, content: markdown, upsertedCount, skippedCount };
|
|
508
|
+
}
|
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { syncFolderState } from "./driveTools.js";
|
|
1
|
+
import { searchProjectMemory, upsertAgentNote, exactSearchChunks } from "../db/vector.js";
|
|
2
|
+
import { syncAllDocuments } from "./driveTools.js";
|
|
4
3
|
|
|
5
4
|
export async function saveAgentNote(content: string) {
|
|
6
|
-
const folderId = config.DOC_MCP_DRIVE_FOLDER_ID;
|
|
7
|
-
if (!folderId) {
|
|
8
|
-
return {
|
|
9
|
-
success: false,
|
|
10
|
-
error: "DOC_MCP_DRIVE_FOLDER_ID is not configured.",
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
5
|
try {
|
|
14
|
-
await
|
|
15
|
-
source: "agent",
|
|
16
|
-
});
|
|
6
|
+
await upsertAgentNote(content);
|
|
17
7
|
return {
|
|
18
8
|
success: true,
|
|
19
9
|
message: "Successfully stored note in vector memory.",
|
|
@@ -24,19 +14,36 @@ export async function saveAgentNote(content: string) {
|
|
|
24
14
|
}
|
|
25
15
|
|
|
26
16
|
export async function searchKnowledge(query: string, topK: number = 3) {
|
|
27
|
-
|
|
28
|
-
|
|
17
|
+
try {
|
|
18
|
+
// Auto-sync all documents before searching
|
|
19
|
+
await syncAllDocuments();
|
|
20
|
+
|
|
21
|
+
const results = await searchProjectMemory(query, topK);
|
|
22
|
+
|
|
23
|
+
if (!results || results.length === 0) {
|
|
24
|
+
return { success: true, results: "NOT_FOUND" };
|
|
25
|
+
}
|
|
26
|
+
|
|
29
27
|
return {
|
|
30
|
-
success:
|
|
31
|
-
|
|
28
|
+
success: true,
|
|
29
|
+
results: results.map((r: any) => ({
|
|
30
|
+
title: r.title || "Unknown",
|
|
31
|
+
offset: r.offset ?? 0,
|
|
32
|
+
text: r.text,
|
|
33
|
+
})),
|
|
32
34
|
};
|
|
35
|
+
} catch (err: any) {
|
|
36
|
+
return { success: false, error: `Failed to search: ${err.message}` };
|
|
33
37
|
}
|
|
34
|
-
|
|
38
|
+
}
|
|
39
|
+
export async function searchExact(
|
|
40
|
+
term: string,
|
|
41
|
+
limit: number = 50
|
|
42
|
+
) {
|
|
35
43
|
try {
|
|
36
|
-
|
|
37
|
-
await syncFolderState(folderId);
|
|
44
|
+
await syncAllDocuments();
|
|
38
45
|
|
|
39
|
-
const results = await
|
|
46
|
+
const results = await exactSearchChunks(term, limit);
|
|
40
47
|
|
|
41
48
|
if (!results || results.length === 0) {
|
|
42
49
|
return { success: true, results: "NOT_FOUND" };
|
|
@@ -44,18 +51,12 @@ export async function searchKnowledge(query: string, topK: number = 3) {
|
|
|
44
51
|
|
|
45
52
|
return {
|
|
46
53
|
success: true,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (metaObj.title) title = metaObj.title;
|
|
54
|
-
} catch (e) {}
|
|
55
|
-
}
|
|
56
|
-
return `[File: ${title} | File ID: ${r.file_id || "N/A"}]\n${r.text}`;
|
|
57
|
-
})
|
|
58
|
-
.join("\n\n---\n\n"),
|
|
54
|
+
totalFound: results.length,
|
|
55
|
+
results: results.map((r: any) => ({
|
|
56
|
+
title: r.title || "Unknown",
|
|
57
|
+
offset: r.offset ?? 0,
|
|
58
|
+
text: r.text,
|
|
59
|
+
})),
|
|
59
60
|
};
|
|
60
61
|
} catch (err: any) {
|
|
61
62
|
return { success: false, error: `Failed to search: ${err.message}` };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
declare module "turndown-plugin-gfm" {
|
|
2
|
+
import TurndownService from "turndown";
|
|
3
|
+
export function gfm(service: TurndownService): void;
|
|
4
|
+
export function tables(service: TurndownService): void;
|
|
5
|
+
export function strikethrough(service: TurndownService): void;
|
|
6
|
+
export function taskListItems(service: TurndownService): void;
|
|
7
|
+
export function highlightedCodeBlock(service: TurndownService): void;
|
|
8
|
+
}
|