mooteam-mcp 0.2.0 → 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 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
- - Returns supported images as MCP image content, PDF embedded text and UTF-8 text.
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 optional history |
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
- The server exposes only reads. It cannot post comments, change tasks, track time
79
- or upload files. Access is limited by the configured Moo.team account's permissions.
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
- Images have a 5 MiB output limit. PDF extraction reads embedded text, with no OCR
83
- or interpretation of diagrams. Word, Excel, archives, audio and video are listed
84
- but not extracted in this release. External links are retained without fetching them.
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
 
@@ -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\/\d+|comments|user-profiles|task-statuses|projects\/\d+|activity-logs\/task\/\d+|files\/\d+)$/;
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) {
@@ -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 (Buffer.from(bytes.subarray(0, 5)).toString('ascii') === '%PDF-') {
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('utf-8', { fatal: true }).decode(bytes);
43
+ text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
32
44
  }
33
45
  catch {
34
- throw new MooTeamError('UNSUPPORTED_ENCODING', 'This file is not valid UTF-8 text. Its contents were not decoded.');
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: 'This version reads PNG/JPEG/WebP/GIF images, PDF embedded text and UTF-8 text files. Other files are identified but their contents are not extracted.' };
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
- const worker = new Worker(new URL('./pdf-worker.js', import.meta.url), { workerData: { bytes, maxPages, maxCharacters }, resourceLimits: { maxOldGenerationSizeMb: 256 }, stdout: true, stderr: true });
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 extract text from this PDF; it may be encrypted or unsupported.'));
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', code => { clearTimeout(timeout); if (code !== 0)
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/cli.js CHANGED
@@ -5,12 +5,13 @@ import { ApiClient } from './api-client.js';
5
5
  import { publicError } from './errors.js';
6
6
  import { createLogger } from './logger.js';
7
7
  import { createServer } from './server.js';
8
+ import { version } from './version.js';
8
9
  const args = process.argv.slice(2);
9
10
  if (args.includes('--help') || args.includes('-h')) {
10
- process.stdout.write(`mooteam-mcp 0.2.0 — read-only Moo.team API server\n\nUsage: mooteam-mcp [--check | --version | --help]\n\nWithout arguments, starts the MCP server over stdio. No browser required.\n\nConfiguration: MOOTEAM_API_TOKEN and MOOTEAM_COMPANY_ALIAS environment variables,\nor JSON file at ${defaultConfigPath()}\nwith { "token": "...", "company": "..." }.\nMOOTEAM_CONFIG_FILE overrides this path. Environment credentials override the file.\nMOOTEAM_FILE_TOKEN is optional, used only if Bearer-authenticated file access fails.\n\n--check validates credentials with a read-only API request.\nLOG_LEVEL=debug|info|warn|error|silent controls stderr logging.\n`);
11
+ process.stdout.write(`mooteam-mcp ${version} — read-only Moo.team API server\n\nUsage: mooteam-mcp [--check | --version | --help]\n\nWithout arguments, starts the MCP server over stdio. No browser required.\n\nConfiguration: MOOTEAM_API_TOKEN and MOOTEAM_COMPANY_ALIAS environment variables,\nor JSON file at ${defaultConfigPath()}\nwith { "token": "...", "company": "..." }.\nMOOTEAM_CONFIG_FILE overrides this path. Environment credentials override the file.\nMOOTEAM_FILE_TOKEN is optional, used only if Bearer-authenticated file access fails.\n\n--check validates credentials with a read-only API request.\nLOG_LEVEL=debug|info|warn|error|silent controls stderr logging.\n`);
11
12
  }
12
13
  else if (args.includes('--version')) {
13
- process.stdout.write('0.2.0\n');
14
+ process.stdout.write(version + '\n');
14
15
  }
15
16
  else {
16
17
  try {
@@ -23,7 +24,7 @@ else {
23
24
  process.stdout.write('Moo.team API authentication OK (read-only).\n');
24
25
  }
25
26
  else {
26
- log('info', 'server.start', { version: '0.2.0', transport: 'stdio' });
27
+ log('info', 'server.start', { version, transport: 'stdio' });
27
28
  const handle = serveStdio(() => createServer(config));
28
29
  for (const signal of ['SIGINT', 'SIGTERM'])
29
30
  process.once(signal, () => { void handle.close().then(() => process.exit(0)); });
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
- return { token, company, fileToken, rolesFile, 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
+ 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
+ }
@@ -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
+ }