mooteam-mcp 0.2.1 → 0.3.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/README.md +27 -7
- package/dist/api-client.js +4 -4
- package/dist/attachments.js +29 -13
- package/dist/config.js +3 -1
- package/dist/document-worker.js +201 -0
- package/dist/documents.js +26 -0
- package/dist/history.js +85 -0
- package/dist/local-state.js +73 -0
- package/dist/pdf-worker.js +46 -17
- package/dist/related.js +91 -0
- package/dist/roles.js +50 -0
- package/dist/search.js +35 -0
- package/dist/server.js +62 -16
- package/dist/task-context.js +5 -1
- package/docs/api.md +97 -6
- package/docs/configuration.md +51 -7
- package/docs/development.md +26 -3
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -15,7 +15,11 @@ This is an independent community integration, not an official Moo.team product.
|
|
|
15
15
|
- Adds user-confirmed participant roles from an optional local directory.
|
|
16
16
|
- Separates human discussion from optional activity history.
|
|
17
17
|
- Associates files with their task description or specific comment.
|
|
18
|
-
-
|
|
18
|
+
- Searches task titles/descriptions by project, assignee and lifecycle status.
|
|
19
|
+
- Reads parents, subtasks and task links with their discussion source.
|
|
20
|
+
- Compares reads using a bounded gzip history of fingerprints, without storing task bodies.
|
|
21
|
+
- Updates user-confirmed roles through MCP in a local directory.
|
|
22
|
+
- Reads images, PDF text/page images, DOCX/XLSX/PPTX text, ZIP listings and text members.
|
|
19
23
|
- Reports incomplete pagination, unsupported formats and truncated extraction.
|
|
20
24
|
- Accepts old `app.moo.team` links, new `new-app.moo.team` task links and numeric IDs.
|
|
21
25
|
|
|
@@ -70,18 +74,34 @@ Listing an attachment does not mean its contents have been read.
|
|
|
70
74
|
|
|
71
75
|
| Tool | Purpose |
|
|
72
76
|
|---|---|
|
|
73
|
-
| `get_task_context` | Task details, attributed comments, file manifest and
|
|
77
|
+
| `get_task_context` | Task details, attributed comments, file manifest and changes since last read |
|
|
74
78
|
| `read_attachment` | Read a file belonging to that task or its visible comments |
|
|
79
|
+
| `get_task_changes` | Compare current data with the last local baseline |
|
|
80
|
+
| `get_related_tasks` | Read parents, subtasks and tasks linked in discussion |
|
|
81
|
+
| `search_tasks` | Search titles/descriptions with bounded collection scanning |
|
|
82
|
+
| `list_projects` | Discover accessible project names and IDs |
|
|
83
|
+
| `list_participants` | Resolve people and their local roles |
|
|
84
|
+
| `set_participant_role` | Save an explicitly supplied role locally |
|
|
75
85
|
|
|
76
86
|
## Boundaries
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
or upload files.
|
|
88
|
+
All Moo.team requests are GET-only. The server cannot post comments, change tasks,
|
|
89
|
+
track time or upload files. It can update local history and participant roles.
|
|
90
|
+
Access is limited by the configured Moo.team account's permissions.
|
|
80
91
|
It uses observed application API endpoints, which may change without notice.
|
|
81
92
|
|
|
82
|
-
|
|
83
|
-
or
|
|
84
|
-
|
|
93
|
+
Attachment bytes and rendered pages stay in memory; there is no attachment cache
|
|
94
|
+
or document temporary directory. Images have a 5 MiB output limit. Selected PDF
|
|
95
|
+
pages can be returned as images for the assistant to inspect scans and diagrams;
|
|
96
|
+
there is no automatic OCR transcript. Office extraction reads text and reports
|
|
97
|
+
visual/layout limitations. Legacy DOC/XLS/PPT, audio, video and non-ZIP archives
|
|
98
|
+
are unsupported. External links are retained without fetching them.
|
|
99
|
+
|
|
100
|
+
History defaults to at most **1 MiB compressed per workspace**, 200 tasks and
|
|
101
|
+
90 days since last observation, with at most 20 change events per task. It stores
|
|
102
|
+
hashes, IDs, statuses and timestamps, never task/comment bodies or file contents.
|
|
103
|
+
Expiry and eviction run on the next tracked read. See [configuration](docs/configuration.md)
|
|
104
|
+
for storage details, disabling history and limits.
|
|
85
105
|
|
|
86
106
|
## Documentation
|
|
87
107
|
|
package/dist/api-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MooTeamError } from './errors.js';
|
|
2
2
|
import { record } from './types.js';
|
|
3
3
|
const API_BASE = 'https://api.moo.team/api';
|
|
4
|
-
const READ_ROUTES = /^\/(?:tasks
|
|
4
|
+
const READ_ROUTES = /^\/(?:tasks(?:\/\d+)?|comments|user-profiles|task-statuses|projects(?:\/\d+)?|activity-logs\/task\/\d+|files\/\d+)$/;
|
|
5
5
|
export class ApiClient {
|
|
6
6
|
config;
|
|
7
7
|
log;
|
|
@@ -21,10 +21,10 @@ export class ApiClient {
|
|
|
21
21
|
throw new MooTeamError('INVALID_RESPONSE', 'Moo.team returned an invalid JSON response.');
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
async collection(path, params = {}) {
|
|
24
|
+
async collection(path, params = {}, maxPages = this.config.maxPages) {
|
|
25
25
|
const result = { items: [], complete: false, total: null, pagesRead: 0, warnings: [] };
|
|
26
26
|
const seen = new Set();
|
|
27
|
-
for (let page = 1; page <= this.config.maxPages; page++) {
|
|
27
|
+
for (let page = 1; page <= Math.min(maxPages, this.config.maxPages); page++) {
|
|
28
28
|
let raw;
|
|
29
29
|
try {
|
|
30
30
|
raw = await this.json(path, { ...params, page });
|
|
@@ -79,7 +79,7 @@ export class ApiClient {
|
|
|
79
79
|
result.warnings.push('Missing pagination metadata; completeness cannot be established.');
|
|
80
80
|
return result;
|
|
81
81
|
}
|
|
82
|
-
result.warnings.push(`Stopped at the configured limit of ${this.config.maxPages} pages.`);
|
|
82
|
+
result.warnings.push(`Stopped at the configured limit of ${Math.min(maxPages, this.config.maxPages)} pages.`);
|
|
83
83
|
return result;
|
|
84
84
|
}
|
|
85
85
|
async file(fileId) {
|
package/dist/attachments.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { Worker } from 'node:worker_threads';
|
|
2
2
|
import { MooTeamError } from './errors.js';
|
|
3
|
+
import { extractDocument } from './documents.js';
|
|
3
4
|
export class AttachmentService {
|
|
4
5
|
context;
|
|
5
6
|
constructor(context) {
|
|
6
7
|
this.context = context;
|
|
7
8
|
}
|
|
8
|
-
async read(task, fileId, maxCharacters = 50000, maxPages = 30) {
|
|
9
|
+
async read(task, fileId, maxCharacters = 50000, maxPages = 30, options = {}) {
|
|
9
10
|
const context = await this.context.getContext(task, { commentsLimit: 1 });
|
|
10
11
|
const attachment = context.attachments.find(f => f.fileId === fileId);
|
|
11
12
|
if (!attachment)
|
|
@@ -15,29 +16,40 @@ export class AttachmentService {
|
|
|
15
16
|
const { bytes, mimeType } = await this.context.api.file(fileId);
|
|
16
17
|
this.context.api.log('debug', 'attachment.loaded', { fileId, bytes: bytes.length, mimeType });
|
|
17
18
|
const base = { attachment, downloadedBytes: bytes.length, mimeType };
|
|
19
|
+
const isPdf = Buffer.from(bytes.subarray(0, 5)).toString('ascii') === '%PDF-';
|
|
20
|
+
const isZip = bytes[0] === 0x50 && bytes[1] === 0x4b && [[3, 4], [5, 6], [7, 8]].some(([a, b]) => bytes[2] === a && bytes[3] === b);
|
|
21
|
+
if (options.pdfPages && !isPdf)
|
|
22
|
+
throw new MooTeamError('UNSUPPORTED_FORMAT', 'Page rendering is supported only for PDF attachments.');
|
|
23
|
+
if (options.archiveMember && !isZip)
|
|
24
|
+
throw new MooTeamError('UNSUPPORTED_FORMAT', 'Member selection requires a ZIP attachment.');
|
|
18
25
|
const imageMime = imageType(bytes);
|
|
19
26
|
if (imageMime) {
|
|
20
27
|
if (bytes.length > 5 * 1024 * 1024)
|
|
21
28
|
throw new MooTeamError('IMAGE_OUTPUT_LIMIT', 'This image exceeds the 5 MiB image output limit. Its contents have not been returned.');
|
|
22
29
|
return { ...base, kind: 'image', imageMime, data: Buffer.from(bytes).toString('base64'), complete: true };
|
|
23
30
|
}
|
|
24
|
-
if (
|
|
25
|
-
const pdf = await extractPdf(bytes, maxPages, maxCharacters, this.context.api.config.timeoutMs);
|
|
31
|
+
if (isPdf) {
|
|
32
|
+
const pdf = await extractPdf(bytes, maxPages, maxCharacters, this.context.api.config.timeoutMs, options.pdfPages);
|
|
33
|
+
if (options.pdfPages)
|
|
34
|
+
return { ...base, kind: 'pdf-pages', ...pdf, complete: !pdf.truncated, warnings: ['Selected PDF pages rendered in memory for visual inspection; this is not an OCR transcript or the whole document.', 'PDF.js may omit unsupported features or source images above 16 million pixels.'] };
|
|
26
35
|
return { ...base, kind: 'pdf', ...pdf, complete: !pdf.truncated, warnings: pdf.textFound ? ['PDF text extraction does not include diagrams, image content or OCR.'] : ['No embedded text found. This PDF may require OCR or visual inspection.'] };
|
|
27
36
|
}
|
|
37
|
+
if (isZip)
|
|
38
|
+
return { ...base, ...await extractDocument(bytes, { maxCharacters, maxPages, archiveMember: options.archiveMember }, this.context.api.config.timeoutMs) };
|
|
28
39
|
if (mimeType.startsWith('text/') || /(?:json|xml|yaml|javascript)/.test(mimeType) || /\.(txt|md|csv|tsv|json|xml|yaml|yml|log|js|ts|css|html|sql|py|php)$/i.test(attachment.name)) {
|
|
29
40
|
let text;
|
|
41
|
+
const encoding = options.encoding && options.encoding !== 'auto' ? options.encoding : bytes[0] === 255 && bytes[1] === 254 ? 'utf-16le' : bytes[0] === 254 && bytes[1] === 255 ? 'utf-16be' : 'utf-8';
|
|
30
42
|
try {
|
|
31
|
-
text = new TextDecoder(
|
|
43
|
+
text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
|
|
32
44
|
}
|
|
33
45
|
catch {
|
|
34
|
-
throw new MooTeamError('UNSUPPORTED_ENCODING', '
|
|
46
|
+
throw new MooTeamError('UNSUPPORTED_ENCODING', 'Text decoding failed. Use an explicit supported encoding if this is not UTF-8 or BOM-marked UTF-16.');
|
|
35
47
|
}
|
|
36
48
|
if (text.includes('\0'))
|
|
37
49
|
throw new MooTeamError('UNSUPPORTED_FORMAT', 'The file contains binary data and cannot be treated as text.');
|
|
38
|
-
return { ...base, kind: 'text', text: text.slice(0, maxCharacters), totalCharacters: text.length, complete: text.length <= maxCharacters };
|
|
50
|
+
return { ...base, kind: 'text', text: text.slice(0, maxCharacters), encoding, totalCharacters: text.length, complete: text.length <= maxCharacters };
|
|
39
51
|
}
|
|
40
|
-
return { ...base, kind: 'unsupported', complete: false, message: '
|
|
52
|
+
return { ...base, kind: 'unsupported', complete: false, message: 'Supported: images, PDF text/page images, DOCX/XLSX/PPTX text, ZIP inventory/member text and supported text encodings. Legacy DOC/XLS/PPT, other archives, audio and video are not extracted.' };
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
function imageType(bytes) {
|
|
@@ -52,23 +64,27 @@ function imageType(bytes) {
|
|
|
52
64
|
return 'image/webp';
|
|
53
65
|
return null;
|
|
54
66
|
}
|
|
55
|
-
export function extractPdf(bytes, maxPages, maxCharacters, timeoutMs) {
|
|
67
|
+
export function extractPdf(bytes, maxPages, maxCharacters, timeoutMs, pdfPages) {
|
|
68
|
+
if (pdfPages && (pdfPages.length < 1 || pdfPages.length > 5 || pdfPages.some(p => !Number.isSafeInteger(p) || p < 1)))
|
|
69
|
+
return Promise.reject(new MooTeamError('INVALID_PDF_PAGES', 'Select 1 to 5 positive PDF page numbers.'));
|
|
56
70
|
return new Promise((resolve, reject) => {
|
|
57
|
-
|
|
71
|
+
let finished = false;
|
|
72
|
+
const worker = new Worker(new URL('./pdf-worker.js', import.meta.url), { workerData: { bytes, maxPages, maxCharacters, pdfPages }, resourceLimits: { maxOldGenerationSizeMb: 256 }, stdout: true, stderr: true });
|
|
58
73
|
// Parser output is isolated from MCP stdout and may contain document data, so do not forward it.
|
|
59
74
|
worker.stdout.resume();
|
|
60
75
|
worker.stderr.resume();
|
|
61
|
-
const timeout = setTimeout(() => { void worker.terminate(); reject(new MooTeamError('PDF_TIMEOUT', 'PDF extraction exceeded the configured timeout.')); }, timeoutMs);
|
|
76
|
+
const timeout = setTimeout(() => { finished = true; void worker.terminate(); reject(new MooTeamError('PDF_TIMEOUT', 'PDF extraction exceeded the configured timeout.')); }, timeoutMs);
|
|
62
77
|
worker.once('message', (result) => {
|
|
78
|
+
finished = true;
|
|
63
79
|
clearTimeout(timeout);
|
|
64
80
|
void worker.terminate();
|
|
65
81
|
if (result.error)
|
|
66
|
-
reject(new MooTeamError('PDF_EXTRACTION_FAILED', 'Could not
|
|
82
|
+
reject(new MooTeamError('PDF_EXTRACTION_FAILED', 'Could not read this PDF or selected pages; check page numbers, encryption and rendering limits.'));
|
|
67
83
|
else
|
|
68
84
|
resolve(result);
|
|
69
85
|
});
|
|
70
|
-
worker.once('error', () => { clearTimeout(timeout); reject(new MooTeamError('PDF_EXTRACTION_FAILED', 'The PDF parser failed.')); });
|
|
71
|
-
worker.once('exit',
|
|
86
|
+
worker.once('error', () => { finished = true; clearTimeout(timeout); reject(new MooTeamError('PDF_EXTRACTION_FAILED', 'The PDF parser failed.')); });
|
|
87
|
+
worker.once('exit', () => { clearTimeout(timeout); if (!finished)
|
|
72
88
|
reject(new MooTeamError('PDF_EXTRACTION_FAILED', 'The PDF parser exited before completing.')); });
|
|
73
89
|
});
|
|
74
90
|
}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
4
5
|
import { MooTeamError } from './errors.js';
|
|
5
6
|
export const defaultConfigPath = () => join(homedir(), '.config', 'mooteam-mcp', 'config.json');
|
|
6
7
|
export function loadConfig(env = process.env) {
|
|
@@ -32,7 +33,8 @@ export function loadConfig(env = process.env) {
|
|
|
32
33
|
if (!['debug', 'info', 'warn', 'error', 'silent'].includes(logLevel))
|
|
33
34
|
throw new MooTeamError('CONFIG_INVALID', 'LOG_LEVEL must be debug, info, warn, error or silent.');
|
|
34
35
|
const rolesFile = env.MOOTEAM_ROLES_FILE || join(dirname(path), 'roles.json');
|
|
35
|
-
|
|
36
|
+
const historyFile = env.MOOTEAM_HISTORY === 'off' ? undefined : join(dirname(path), `history-${createHash('sha256').update(company).digest('hex').slice(0, 16)}.json.gz`);
|
|
37
|
+
return { token, company, fileToken, rolesFile, historyFile, historyMaxBytes: integer(env.MOOTEAM_HISTORY_MAX_BYTES, 1048576, 16384, 10485760), historyMaxTasks: integer(env.MOOTEAM_HISTORY_MAX_TASKS, 200, 1, 2000), historyRetentionDays: integer(env.MOOTEAM_HISTORY_DAYS, 90, 1, 365), timeoutMs: integer(env.MOOTEAM_TIMEOUT_MS, 30000, 1000, 120000), maxPages: integer(env.MOOTEAM_MAX_PAGES, 100, 1, 1000), maxAttachmentBytes: integer(env.MOOTEAM_MAX_ATTACHMENT_BYTES, 10 * 1024 * 1024, 1024, 50 * 1024 * 1024), logLevel: logLevel };
|
|
36
38
|
}
|
|
37
39
|
function integer(value, fallback, min, max) {
|
|
38
40
|
if (value === undefined)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { posix } from 'node:path';
|
|
3
|
+
import { fromBuffer } from 'yauzl';
|
|
4
|
+
import { SaxesParser } from 'saxes';
|
|
5
|
+
const children = (node) => node.children.filter((n) => typeof n !== 'string');
|
|
6
|
+
const all = (node, name) => [...(node.name === name ? [node] : []), ...children(node).flatMap(n => all(n, name))];
|
|
7
|
+
const text = (node) => node?.children.map(n => typeof n === 'string' ? n : text(n)).join('') ?? '';
|
|
8
|
+
function xml(bytes) {
|
|
9
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
10
|
+
if (/<!DOCTYPE|<!ENTITY/i.test(source))
|
|
11
|
+
throw new Error('DTD unsupported');
|
|
12
|
+
const root = { name: '#document', attrs: {}, children: [] }, stack = [root];
|
|
13
|
+
let nodes = 0;
|
|
14
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
15
|
+
parser.on('opentag', tag => {
|
|
16
|
+
if (++nodes > 100000 || stack.length > 128)
|
|
17
|
+
throw new Error('XML limit');
|
|
18
|
+
const node = { name: tag.local, attrs: {}, children: [] };
|
|
19
|
+
for (const attr of Object.values(tag.attributes))
|
|
20
|
+
if (typeof attr !== 'string')
|
|
21
|
+
node.attrs[attr.uri.endsWith('/relationships') ? `r:${attr.local}` : attr.local] = attr.value;
|
|
22
|
+
stack.at(-1).children.push(node);
|
|
23
|
+
stack.push(node);
|
|
24
|
+
});
|
|
25
|
+
parser.on('text', value => stack.at(-1).children.push(value));
|
|
26
|
+
parser.on('cdata', value => stack.at(-1).children.push(value));
|
|
27
|
+
parser.on('closetag', () => { stack.pop(); });
|
|
28
|
+
parser.write(source).close();
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
async function main() {
|
|
32
|
+
const zip = await new Promise((resolve, reject) => fromBuffer(Buffer.from(workerData.bytes), { lazyEntries: true, autoClose: false, validateEntrySizes: true }, (error, z) => error ? reject(error) : resolve(z)));
|
|
33
|
+
try {
|
|
34
|
+
const entries = new Map();
|
|
35
|
+
let inventoryComplete = true;
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
zip.once('error', reject);
|
|
38
|
+
zip.once('end', resolve);
|
|
39
|
+
zip.on('entry', entry => {
|
|
40
|
+
if (entries.size >= 1000) {
|
|
41
|
+
inventoryComplete = false;
|
|
42
|
+
resolve();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (entries.has(entry.fileName)) {
|
|
46
|
+
reject(new Error('Duplicate ZIP path'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
entries.set(entry.fileName, entry);
|
|
50
|
+
zip.readEntry();
|
|
51
|
+
});
|
|
52
|
+
zip.readEntry();
|
|
53
|
+
});
|
|
54
|
+
let inflated = 0;
|
|
55
|
+
const read = async (name) => {
|
|
56
|
+
const entry = entries.get(name);
|
|
57
|
+
if (!entry || entry.isEncrypted() || entry.uncompressedSize > 8 * 1024 * 1024)
|
|
58
|
+
throw new Error('Invalid ZIP member');
|
|
59
|
+
return new Promise((resolve, reject) => zip.openReadStream(entry, (error, stream) => {
|
|
60
|
+
if (error || !stream) {
|
|
61
|
+
reject(error);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const chunks = [];
|
|
65
|
+
let size = 0;
|
|
66
|
+
stream.on('data', chunk => {
|
|
67
|
+
size += chunk.length;
|
|
68
|
+
inflated += chunk.length;
|
|
69
|
+
if (size > 8 * 1024 * 1024 || inflated > 24 * 1024 * 1024) {
|
|
70
|
+
stream.destroy(new Error('Inflation limit'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
});
|
|
75
|
+
stream.once('error', reject);
|
|
76
|
+
stream.once('end', () => resolve(Buffer.concat(chunks)));
|
|
77
|
+
}));
|
|
78
|
+
};
|
|
79
|
+
const warnings = [];
|
|
80
|
+
let output = '', truncated = false;
|
|
81
|
+
const emit = (value) => {
|
|
82
|
+
const room = workerData.maxCharacters - output.length;
|
|
83
|
+
output += value.slice(0, Math.max(0, room));
|
|
84
|
+
if (value.length > room)
|
|
85
|
+
truncated = true;
|
|
86
|
+
};
|
|
87
|
+
const relationships = async (part) => {
|
|
88
|
+
const path = posix.join(posix.dirname(part), '_rels', posix.basename(part) + '.rels');
|
|
89
|
+
const map = new Map();
|
|
90
|
+
if (!entries.has(path))
|
|
91
|
+
return map;
|
|
92
|
+
for (const r of all(xml(await read(path)), 'Relationship')) {
|
|
93
|
+
if (r.attrs.TargetMode === 'External')
|
|
94
|
+
continue;
|
|
95
|
+
const target = r.attrs.Target ?? '';
|
|
96
|
+
if (!target || target.includes('\\') || /^[a-z]+:/i.test(target))
|
|
97
|
+
throw new Error('Invalid relationship');
|
|
98
|
+
const resolved = target.startsWith('/') ? target.slice(1) : posix.normalize(posix.join(posix.dirname(part), target));
|
|
99
|
+
if (resolved.startsWith('../'))
|
|
100
|
+
throw new Error('Relationship escapes archive');
|
|
101
|
+
map.set(r.attrs.Id, resolved);
|
|
102
|
+
}
|
|
103
|
+
return map;
|
|
104
|
+
};
|
|
105
|
+
if (workerData.archiveMember) {
|
|
106
|
+
const name = String(workerData.archiveMember);
|
|
107
|
+
if (!/\.(txt|md|csv|tsv|json|xml|yaml|yml|log|js|ts|css|html|sql|py|php)$/i.test(name))
|
|
108
|
+
throw new Error('Only text archive members can be read');
|
|
109
|
+
const source = new TextDecoder('utf-8', { fatal: true }).decode(await read(name));
|
|
110
|
+
if (source.includes('\0'))
|
|
111
|
+
throw new Error('Binary archive member');
|
|
112
|
+
emit(source);
|
|
113
|
+
return { kind: 'zip', text: output, complete: !truncated, warnings: ['Only the selected UTF-8 text member was read. Nested archives and external links were not opened.'] };
|
|
114
|
+
}
|
|
115
|
+
const kind = entries.has('word/document.xml') ? 'docx' : entries.has('xl/workbook.xml') ? 'xlsx' : entries.has('ppt/presentation.xml') ? 'pptx' : 'zip';
|
|
116
|
+
if (kind === 'zip')
|
|
117
|
+
return { kind, entries: [...entries.values()].map(e => ({ name: e.fileName, sizeBytes: e.uncompressedSize, compressedBytes: e.compressedSize, encrypted: e.isEncrypted() })), complete: inventoryComplete, warnings: [inventoryComplete ? 'Archive inventory only; member contents were not read.' : 'Archive inventory stopped after 1000 entries.'] };
|
|
118
|
+
if (!inventoryComplete)
|
|
119
|
+
throw new Error('Office archive entry limit');
|
|
120
|
+
if (kind === 'docx') {
|
|
121
|
+
const render = (n) => {
|
|
122
|
+
if (n.name === 'del')
|
|
123
|
+
return '~~' + children(n).map(render).join('') + '~~';
|
|
124
|
+
if (n.name === 't' || n.name === 'delText')
|
|
125
|
+
return text(n);
|
|
126
|
+
if (n.name === 'tab')
|
|
127
|
+
return '\t';
|
|
128
|
+
if (n.name === 'br' || n.name === 'cr')
|
|
129
|
+
return '\n';
|
|
130
|
+
const inner = children(n).map(render).join('');
|
|
131
|
+
return inner + (['p', 'tr'].includes(n.name) ? '\n' : n.name === 'tc' ? '\t' : '');
|
|
132
|
+
};
|
|
133
|
+
emit(render(xml(await read('word/document.xml'))));
|
|
134
|
+
for (const name of [...entries.keys()].filter(n => /^word\/(header\d+|footer\d+|footnotes|endnotes)\.xml$/.test(n))) {
|
|
135
|
+
if (truncated)
|
|
136
|
+
break;
|
|
137
|
+
emit(`\n[${name}]\n` + render(xml(await read(name))));
|
|
138
|
+
}
|
|
139
|
+
warnings.push('Extracted document text, tables and notes; layout, drawings and embedded images were not interpreted. Deleted tracked text is struck through.');
|
|
140
|
+
}
|
|
141
|
+
else if (kind === 'xlsx') {
|
|
142
|
+
const workbook = xml(await read('xl/workbook.xml')), rels = await relationships('xl/workbook.xml');
|
|
143
|
+
const shared = entries.has('xl/sharedStrings.xml') ? all(xml(await read('xl/sharedStrings.xml')), 'si').map(si => all(si, 't').map(text).join('')) : [];
|
|
144
|
+
const sheets = all(workbook, 'sheet');
|
|
145
|
+
let cells = 0;
|
|
146
|
+
for (const sheet of sheets.slice(0, workerData.maxPages)) {
|
|
147
|
+
if (truncated)
|
|
148
|
+
break;
|
|
149
|
+
const path = rels.get(sheet.attrs['r:id']);
|
|
150
|
+
if (!path)
|
|
151
|
+
throw new Error('Missing sheet relation');
|
|
152
|
+
emit(`\n[Sheet: ${sheet.attrs.name ?? ''}]\n`);
|
|
153
|
+
for (const cell of all(xml(await read(path)), 'c')) {
|
|
154
|
+
if (++cells > 10000) {
|
|
155
|
+
truncated = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
const raw = text(all(cell, 'v')[0]);
|
|
159
|
+
let value = cell.attrs.t === 's' ? shared[Number(raw)] : cell.attrs.t === 'inlineStr' ? all(cell, 't').map(text).join('') : cell.attrs.t === 'b' ? (raw === '1' ? 'true' : 'false') : raw;
|
|
160
|
+
if (value === undefined)
|
|
161
|
+
throw new Error('Invalid shared string index');
|
|
162
|
+
const formula = all(cell, 'f')[0];
|
|
163
|
+
if (formula)
|
|
164
|
+
value = `=${text(formula)} [cached: ${value}]`;
|
|
165
|
+
emit(`${cell.attrs.r ?? '?'}\t${value}\n`);
|
|
166
|
+
if (truncated)
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (sheets.length > workerData.maxPages)
|
|
171
|
+
truncated = true;
|
|
172
|
+
warnings.push('Formulas are never evaluated; cached values may be stale. Numbers/dates are raw stored values. Charts, formatting and images were not interpreted.');
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
const presentation = xml(await read('ppt/presentation.xml')), rels = await relationships('ppt/presentation.xml');
|
|
176
|
+
const slides = all(presentation, 'sldId');
|
|
177
|
+
for (const [index, slide] of slides.slice(0, workerData.maxPages).entries()) {
|
|
178
|
+
if (truncated)
|
|
179
|
+
break;
|
|
180
|
+
const path = rels.get(slide.attrs['r:id']);
|
|
181
|
+
if (!path)
|
|
182
|
+
throw new Error('Missing slide relation');
|
|
183
|
+
const content = xml(await read(path));
|
|
184
|
+
emit(`\n[Slide ${index + 1}]\n` + all(content, 'p').map(p => all(p, 't').map(text).join('')).join('\n'));
|
|
185
|
+
for (const target of (await relationships(path)).values())
|
|
186
|
+
if (/\/notesSlides\/notesSlide\d+\.xml$/.test(target))
|
|
187
|
+
emit('\n[Speaker notes]\n' + all(xml(await read(target)), 'p').map(p => all(p, 't').map(text).join('')).join('\n'));
|
|
188
|
+
}
|
|
189
|
+
if (slides.length > workerData.maxPages)
|
|
190
|
+
truncated = true;
|
|
191
|
+
warnings.push('Slide text and speaker notes only; images, diagrams, animations and layout were not interpreted.');
|
|
192
|
+
}
|
|
193
|
+
if (truncated)
|
|
194
|
+
warnings.push('Output stopped at the requested character/page or cell limit.');
|
|
195
|
+
return { kind, text: output, complete: !truncated, warnings };
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
zip.close();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
main().then(result => parentPort.postMessage(result), () => parentPort.postMessage({ error: true }));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { MooTeamError } from './errors.js';
|
|
3
|
+
export function extractDocument(bytes, options, timeoutMs) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
let finished = false;
|
|
6
|
+
const worker = new Worker(new URL('./document-worker.js', import.meta.url), { workerData: { bytes, ...options }, resourceLimits: { maxOldGenerationSizeMb: 256 }, stdout: true, stderr: true });
|
|
7
|
+
worker.stdout.resume();
|
|
8
|
+
worker.stderr.resume();
|
|
9
|
+
const done = (error, result) => {
|
|
10
|
+
if (finished)
|
|
11
|
+
return;
|
|
12
|
+
finished = true;
|
|
13
|
+
clearTimeout(timer);
|
|
14
|
+
void worker.terminate();
|
|
15
|
+
if (error)
|
|
16
|
+
reject(error);
|
|
17
|
+
else
|
|
18
|
+
resolve(result);
|
|
19
|
+
};
|
|
20
|
+
const timer = setTimeout(() => done(new MooTeamError('DOCUMENT_TIMEOUT', 'Document extraction exceeded the configured timeout.')), timeoutMs);
|
|
21
|
+
worker.once('message', result => result.error ? done(new MooTeamError('DOCUMENT_EXTRACTION_FAILED', 'Document is corrupt, encrypted, unsupported, or exceeds parser limits.')) : done(undefined, result));
|
|
22
|
+
worker.once('error', () => done(new MooTeamError('DOCUMENT_EXTRACTION_FAILED', 'Document parser failed.')));
|
|
23
|
+
worker.once('exit', () => { if (!finished)
|
|
24
|
+
done(new MooTeamError('DOCUMENT_EXTRACTION_FAILED', 'Document parser exited before completing.')); });
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
3
|
+
import { z } from 'zod/v4';
|
|
4
|
+
import { readLocalFile, withLocalLock, writeLocalFile } from './local-state.js';
|
|
5
|
+
const hash = (v) => createHash('sha256').update(JSON.stringify(v) ?? 'null').digest('base64url').slice(0, 22);
|
|
6
|
+
const canonicalText = (v) => ({ markdown: v.markdown, links: v.links, files: v.inlineFileIds, mentions: v.mentions.map((m) => m.userId) });
|
|
7
|
+
const pairs = z.record(z.string(), z.string().length(22));
|
|
8
|
+
const snapshotSchema = z.object({ at: z.string().datetime(), fields: pairs, comments: pairs, files: pairs, status: z.object({ status: z.string(), statusId: z.number().nullable() }) });
|
|
9
|
+
const changesSchema = z.object({ at: z.string().datetime(), fields: z.array(z.string()), comments: z.object({ added: z.array(z.string()), edited: z.array(z.string()), noLongerVisible: z.array(z.string()) }), files: z.object({ added: z.array(z.string()), edited: z.array(z.string()), noLongerVisible: z.array(z.string()) }) });
|
|
10
|
+
const stateSchema = z.object({ version: z.literal(1), company: z.string(), tasks: z.record(z.string(), z.object({ snapshot: snapshotSchema, events: z.array(changesSchema).max(20) })) });
|
|
11
|
+
// Retain full current comments in RAM only, so returned windows do not affect comparisons.
|
|
12
|
+
export const observations = new WeakMap();
|
|
13
|
+
function snapshot(context) {
|
|
14
|
+
const observation = observations.get(context);
|
|
15
|
+
if (!observation)
|
|
16
|
+
throw new Error('Missing observation');
|
|
17
|
+
const t = context.task;
|
|
18
|
+
const fields = { title: t.title, description: canonicalText(t.description), status: t.status, workflowStatus: t.workflowStatus.statusId, assignee: t.assignee.userId, creator: t.creator.userId, coPerformer: t.coPerformer.userId, priority: t.priority, project: t.projectId, parent: t.parentTaskId, checklist: t.checklist, labels: t.labels.map((l) => l.labelId), startDate: t.startDate, endDate: t.endDate };
|
|
19
|
+
return { at: observation.at, fields: Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, hash(v)])), comments: Object.fromEntries(observation.comments.map(c => [String(c.commentId), hash([c.author.userId, c.parentId, c.replyToCommentId, canonicalText(c.body)])])), files: Object.fromEntries(context.attachments.map((f) => [`${f.source.kind}:${f.source.id}:${f.fileId}`, hash([f.name, f.sizeBytes, f.mimeType, f.createdAt, f.uploadedBy])])), status: { status: t.status, statusId: t.workflowStatus.statusId } };
|
|
20
|
+
}
|
|
21
|
+
function compare(a, b) {
|
|
22
|
+
return { added: Object.keys(b).filter(k => !(k in a)), edited: Object.keys(b).filter(k => k in a && a[k] !== b[k]), noLongerVisible: Object.keys(a).filter(k => !(k in b)) };
|
|
23
|
+
}
|
|
24
|
+
function compactChange(change) {
|
|
25
|
+
const compact = (values) => ({ added: values.added.slice(0, 100), edited: values.edited.slice(0, 100), noLongerVisible: values.noLongerVisible.slice(0, 100), counts: { added: values.added.length, edited: values.edited.length, noLongerVisible: values.noLongerVisible.length }, idsTruncated: Object.values(values).some(v => v.length > 100) });
|
|
26
|
+
return { ...change, comments: compact(change.comments), files: compact(change.files) };
|
|
27
|
+
}
|
|
28
|
+
export class HistoryService {
|
|
29
|
+
config;
|
|
30
|
+
log;
|
|
31
|
+
constructor(config, log) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.log = log;
|
|
34
|
+
}
|
|
35
|
+
async observe(context) {
|
|
36
|
+
const base = { baselineUpdated: false, baselineCreated: false, warnings: [] };
|
|
37
|
+
if (!this.config.historyFile)
|
|
38
|
+
return { ...base, enabled: false };
|
|
39
|
+
if (!context.comments.sourceComplete)
|
|
40
|
+
return { ...base, enabled: true, warnings: ['Incomplete source: previous complete history baseline preserved.'] };
|
|
41
|
+
try {
|
|
42
|
+
return await withLocalLock(this.config.historyFile, async () => {
|
|
43
|
+
const maxBytes = this.config.historyMaxBytes ?? 1048576;
|
|
44
|
+
const bytes = await readLocalFile(this.config.historyFile, maxBytes);
|
|
45
|
+
const state = bytes ? stateSchema.parse(JSON.parse(gunzipSync(bytes, { maxOutputLength: 16 * 1024 * 1024 }).toString('utf8'))) : { version: 1, company: this.config.company, tasks: {} };
|
|
46
|
+
if (state.company !== this.config.company)
|
|
47
|
+
throw new Error('Workspace mismatch');
|
|
48
|
+
const current = snapshot(context), key = String(context.task.taskId);
|
|
49
|
+
const cutoff = Date.now() - (this.config.historyRetentionDays ?? 90) * 86400000;
|
|
50
|
+
for (const [k, entry] of Object.entries(state.tasks))
|
|
51
|
+
if (Date.parse(entry.snapshot.at) < cutoff)
|
|
52
|
+
delete state.tasks[k];
|
|
53
|
+
const previous = state.tasks[key];
|
|
54
|
+
if (previous && previous.snapshot.at > current.at)
|
|
55
|
+
return { ...base, enabled: true, warnings: ['A newer observation is already stored; this late read did not replace it.'] };
|
|
56
|
+
const changes = { at: current.at, fields: previous ? Object.keys(current.fields).filter(k => previous.snapshot.fields[k] !== current.fields[k]) : [], comments: compare(previous?.snapshot.comments ?? current.comments, current.comments), files: compare(previous?.snapshot.files ?? current.files, current.files) };
|
|
57
|
+
const changed = changes.fields.length || Object.values(changes.comments).some(v => v.length) || Object.values(changes.files).some(v => v.length);
|
|
58
|
+
const events = [...(previous?.events ?? []), ...(changed ? [changes] : [])].slice(-20);
|
|
59
|
+
state.tasks[key] = { snapshot: current, events };
|
|
60
|
+
const oldest = Object.keys(state.tasks).filter(k => k !== key).sort((a, b) => state.tasks[a].snapshot.at.localeCompare(state.tasks[b].snapshot.at));
|
|
61
|
+
let packed;
|
|
62
|
+
while (true) {
|
|
63
|
+
const text = JSON.stringify(state);
|
|
64
|
+
packed = gzipSync(text, { level: 9 });
|
|
65
|
+
if (packed.length <= maxBytes && Buffer.byteLength(text) <= 16 * 1024 * 1024 && Object.keys(state.tasks).length <= (this.config.historyMaxTasks ?? 200))
|
|
66
|
+
break;
|
|
67
|
+
const evict = oldest.shift();
|
|
68
|
+
if (evict)
|
|
69
|
+
delete state.tasks[evict];
|
|
70
|
+
else if (state.tasks[key].events.length)
|
|
71
|
+
state.tasks[key].events.shift();
|
|
72
|
+
else
|
|
73
|
+
return { ...base, enabled: true, warnings: ['Task fingerprint exceeds the history storage budget; previous baseline preserved.'] };
|
|
74
|
+
}
|
|
75
|
+
await writeLocalFile(this.config.historyFile, packed);
|
|
76
|
+
this.log('debug', 'history.saved', { taskId: context.task.taskId, bytes: packed.length, tasks: Object.keys(state.tasks).length });
|
|
77
|
+
return { ...base, enabled: true, baselineUpdated: true, baselineCreated: !previous, previousReadAt: previous?.snapshot.at ?? null, currentReadAt: current.at, changed: Boolean(changed), changes: compactChange(changes), statusBefore: previous?.snapshot.status ?? null, statusNow: current.status, recentEvents: events.slice(-5).map(compactChange), storedBytes: packed.length };
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
this.log('warn', 'history.unavailable');
|
|
82
|
+
return { ...base, enabled: true, warnings: ['Local history could not be read or updated (busy, corrupt, or inaccessible). Task content is still available; history was preserved.'] };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, open, rename, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { MooTeamError } from './errors.js';
|
|
5
|
+
export async function readLocalFile(path, maxBytes) {
|
|
6
|
+
let file;
|
|
7
|
+
try {
|
|
8
|
+
file = await open(path, 'r');
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.code === 'ENOENT')
|
|
12
|
+
return null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
17
|
+
let length = 0;
|
|
18
|
+
while (length < buffer.length) {
|
|
19
|
+
const { bytesRead } = await file.read(buffer, length, buffer.length - length, null);
|
|
20
|
+
if (!bytesRead)
|
|
21
|
+
break;
|
|
22
|
+
length += bytesRead;
|
|
23
|
+
}
|
|
24
|
+
if (length > maxBytes)
|
|
25
|
+
throw new MooTeamError('LOCAL_STATE_LIMIT', 'Local state exceeds its configured size limit.');
|
|
26
|
+
return buffer.subarray(0, length);
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
await file.close();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function withLocalLock(path, action) {
|
|
33
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
let lock;
|
|
35
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
36
|
+
try {
|
|
37
|
+
lock = await open(path + '.lock', 'wx', 0o600);
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'EEXIST')
|
|
42
|
+
throw error;
|
|
43
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (!lock)
|
|
47
|
+
throw new MooTeamError('LOCAL_STATE_BUSY', 'Local state is locked by another process. Retry; if a process crashed, remove its .lock file after stopping all MCP processes.');
|
|
48
|
+
try {
|
|
49
|
+
return await action();
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await lock.close();
|
|
53
|
+
await unlink(path + '.lock');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Caller holds the lock. At most one bounded temporary copy exists. */
|
|
57
|
+
export async function writeLocalFile(path, bytes) {
|
|
58
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
const file = await open(temp, 'wx', 0o600);
|
|
61
|
+
try {
|
|
62
|
+
await file.writeFile(bytes);
|
|
63
|
+
await file.sync();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await file.close();
|
|
67
|
+
}
|
|
68
|
+
await rename(temp, path);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await unlink(temp).catch(() => { });
|
|
72
|
+
}
|
|
73
|
+
}
|