formlab-mcp 0.6.36 → 0.6.38

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
@@ -37,7 +37,7 @@ Ask Claude (or another MCP client) things like:
37
37
  | `list_lots` | Inventory lots across ingredients (each a received batch with its own remaining balance, supplier, expiry, **unit cost**, **status** — `active` / `hold` / `quarantine` / `rejected`; held lots are blocked from consumption, with a `holdReason` — and the **approved source** it was received from: `source.supplier` + `source.manufacturer`, where recalls hinge on the manufacturer). Each lot also reports its **Certificate of Analysis** (`coa`: linked, or matched by lot number) and `coaMissing` for an in-stock lot without one. Filters: `ingredient_id`, `expiring_within_days`, `status` (e.g. `hold` to find lots held for a recall), `missing_coa` |
38
38
  | `list_suppliers` | Supplier / manufacturer records with roll-ups — ingredients approved, lots received / on hand (+ value at paid cost), qualified / trial / disqualified source counts, last receipt; lots count by supplier **and** by manufacturer |
39
39
  | `get_supplier` | One supplier in full: record, every ingredient it's an approved source for, every lot from it, and the **recall trace** (batches that consumed those lots → products, shipments, samples, sub-batches; lots on hold flagged) |
40
- | `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
40
+ | `list_documents` | The document register (same as the app's Documents page): ingredient SDS / PDS / CoA / other with lot, version, source and expiry; test and notebook attachments; per-record uploads and links (formulas, batches, samples, suppliers, equipment…). File **metadata** only, never the text inside a file, with who added each file (`addedBy`) and when. Filters: `type`, `record`, `linked`, `query`, `lot`, `format`, `expiry`, `expiring_within_days`, `without_expiry`, `uploaded_within_days`, `added_by`. A `summary` block carries the page's tile counts: expired, expiring in 30 days, SDS/CoA without expiry, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, added last 7 days |
41
41
  | `list_inventory` | Portfolio stock rollup — one row per ingredient with on-hand qty, summed lot balance, nearest expiry, and a low-stock flag (on-hand ≤ `reorderThreshold`, or zero). Filters: `low_stock_only`, `expiring_within_days`, `family` |
42
42
  | `list_batches` | Filtered list of production / lab-prep events |
43
43
  | `get_batch` | Full record + actual composition, measured/derived **actual volume** (mass ÷ density, never a sum of per-ingredient volumes) and **actual mass** (yield, else summed as-prepared inputs — mL rows through that ingredient's density — else measured volume × density), **estimatedVolume** (mass ÷ the formula's finished density when no actual exists, flagged `estimate: true`), **plannedMass** for volume-target batches (target × finished density) and **formulaFinishedDensity**, the **process** method + any **process-factor values** (DOE inputs), samples + blend lineage, and — as a finished-goods lot — its storage **`location`** + finished-goods **`stockLedger`** (shipments out + adjustments; `shippedQty` total) |
@@ -66,8 +66,8 @@ Ask Claude (or another MCP client) things like:
66
66
  | `get_test_panel` | One Test Panel: ordered parameters with resolved method UIDs + specs, plus `usage` (reports created from it) and `performanceByParameter` |
67
67
  | `list_projects` | Filtered list of projects (the buckets formulations are filed under) + per-project formulation count. Filters: `status`, `name_contains` |
68
68
  | `get_project` | Full project record + the formulations filed under it |
69
- | `list_notebook_entries` | ELN feed — dated authored notes attached to records. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until` |
70
- | `get_notebook_entry` | One notebook entry — full body, author, attachment metadata, resolved linked record |
69
+ | `list_notebook_entries` | ELN feed — dated authored notes attached to records. Append-only: a withdrawn note is kept with `retracted` = {at, by, reason}. Filters: `entity_type`, `entity_id`, `author`, `text_contains`, `since`, `until`, `retracted` |
70
+ | `get_notebook_entry` | One notebook entry — full body, author, attachment metadata, resolved linked record, and `retracted` when withdrawn |
71
71
 
72
72
  ## Install
73
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "formlab-mcp",
3
- "version": "0.6.36",
3
+ "version": "0.6.38",
4
4
  "mcpName": "io.github.juliu1980/formlab-mcp",
5
5
  "description": "Read-only Model Context Protocol server for FormLab \u2014 lets Claude (and other MCP clients) read and analyze your FormLab data, from a local export file OR your live cloud workspace.",
6
6
  "type": "module",
package/server.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.juliu1980/formlab-mcp",
4
4
  "description": "Read-only MCP for FormLab — let Claude query your formulation lab. Free reads a local export; Pro connects to your live cloud workspace with a dedicated read-only token.",
5
- "version": "0.6.36",
5
+ "version": "0.6.38",
6
6
  "websiteUrl": "https://formvix.com/mcp",
7
7
  "repository": {
8
8
  "url": "https://github.com/juliu1980/FormLab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "npm",
15
15
  "identifier": "formlab-mcp",
16
- "version": "0.6.36",
16
+ "version": "0.6.38",
17
17
  "runtimeHint": "npx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -164,8 +164,8 @@ const list_test_results = {
164
164
  const found = (t.measurements || []).some(m => {
165
165
  if (args.parameter && !norm(m.parameter).includes(pn)) return false;
166
166
  if (hasRange) {
167
- const v = parseFloat(m.value);
168
- if (!Number.isFinite(v)) return false;
167
+ const v = _mcpRepNumber(m); // a curve's final value, not its point count (13a)
168
+ if (v == null) return false;
169
169
  if (args.value_min != null && v < args.value_min) return false;
170
170
  if (args.value_max != null && v > args.value_max) return false;
171
171
  }
@@ -352,7 +352,9 @@ const get_doe_matrix = {
352
352
  const cellParamValue = (r, col) => {
353
353
  const ms = r.measurements.filter(m => m.parameter === col.id && (col.cond == null || _mcpCondSig(m) === col.cond));
354
354
  if (!ms.length) return null;
355
- const nums = ms.map(m => parseFloat(m.value)).filter(v => isFinite(v));
355
+ // The app's DOE Matrix numbers (13a): a curve's final value, a
356
+ // distribution's D50, an ordinal's 1-based rank; text → its first reading.
357
+ const nums = ms.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
356
358
  if (!nums.length) return ms[0].value ?? null;
357
359
  const mean = nums.reduce((s, v) => s + v, 0) / nums.length;
358
360
  return +mean.toFixed(4);
@@ -405,18 +407,44 @@ const get_doe_matrix = {
405
407
  // so a parameter measured 3× on one occasion counts once in failure rates and
406
408
  // coverage, exactly as Test Analytics does.
407
409
  const FAIL_MIN_N = 5; // app: _TA_FAIL_MIN_N — rates on fewer readings are "low n"
408
- function _mcpRepNumber(m) {
409
- const type = m.type || 'scalar';
410
- if (type === 'time_series' && Array.isArray(m.points) && m.points.length) {
410
+ // One number per measurement — the app's flMeasNum (js/utils.js, 13a), kept
411
+ // in step. Never parseFloat(m.value): a saved time series / distribution has
412
+ // "12 points" / "8 bins" there, and an ordinal label "6B" parses to 6.
413
+ // scalar → value; time_series → opts.ts ('final' default | 'initial' | 'peak');
414
+ // distribution → opts.dist ('D50' default, a named bin only — no stand-in);
415
+ // ordinal → null, or with opts.ordinal === 'rank' its 1-based scale position;
416
+ // categorical → null.
417
+ function _mcpMeasType(m) {
418
+ const T = ['scalar', 'ordinal', 'categorical', 'time_series', 'distribution'];
419
+ if (m && m.type && T.includes(m.type)) return m.type;
420
+ return m && m.valueType === 'Categorical' ? 'categorical' : 'scalar';
421
+ }
422
+ function _mcpRepNumber(m, opts) {
423
+ const o = opts || {};
424
+ if (!m) return null;
425
+ const type = _mcpMeasType(m);
426
+ if (type === 'time_series') {
427
+ if (!Array.isArray(m.points) || !m.points.length) return null;
411
428
  const agg = _mcpTsAggregate(m.points);
412
- const last = agg[agg.length - 1];
413
- return last ? last.mean : null;
429
+ if (!agg.length) return null;
430
+ const means = agg.map(g => g.mean);
431
+ const red = o.ts || 'final';
432
+ const v = red === 'initial' ? means[0] : red === 'peak' ? Math.max(...means) : means[means.length - 1];
433
+ return isFinite(v) ? v : null;
414
434
  }
415
- if (type === 'distribution' && Array.isArray(m.bins) && m.bins.length) {
416
- const d50 = m.bins.find(b => String(b.label || '').toUpperCase().replace(/\s/g, '') === 'D50');
417
- const v = d50 ? parseFloat(d50.value) : NaN;
435
+ if (type === 'distribution') {
436
+ if (!Array.isArray(m.bins) || !m.bins.length) return null;
437
+ const want = String(o.dist || 'D50').toUpperCase();
438
+ const b = m.bins.find(x => String((x && x.label) || '').toUpperCase().replace(/\s/g, '') === want);
439
+ const v = b ? parseFloat(b.value) : NaN;
418
440
  return isFinite(v) ? v : null;
419
441
  }
442
+ if (type === 'ordinal') {
443
+ if (o.ordinal !== 'rank' || !Array.isArray(m.scale)) return null;
444
+ const i = m.scale.map(x => String(x).trim().toUpperCase()).indexOf(String(m.value == null ? '' : m.value).trim().toUpperCase());
445
+ return i >= 0 ? i + 1 : null;
446
+ }
447
+ if (type === 'categorical') return null;
420
448
  const v = parseFloat(m.value);
421
449
  return isFinite(v) ? v : null;
422
450
  }
@@ -1153,6 +1181,7 @@ const get_stability = {
1153
1181
  const paramLc = (args.parameter || '').trim().toLowerCase();
1154
1182
  if (!paramLc) return { error: 'parameter is required.' };
1155
1183
  const points = [];
1184
+ const skipped = { ordinal: 0, categorical: 0 };
1156
1185
  let specMin = null, specMax = null, unit = '';
1157
1186
  // Each reading keeps the spec it was judged by (reading → panel row →
1158
1187
  // method). Mirrors the app: one band only when they all agree; 2+ distinct
@@ -1165,11 +1194,12 @@ const get_stability = {
1165
1194
  if (!Number.isFinite(date.getTime())) return;
1166
1195
  (t.measurements || []).forEach(m => {
1167
1196
  if ((m.parameter || '').trim().toLowerCase() !== paramLc) return;
1168
- let value = parseFloat(m.value);
1169
- if (!Number.isFinite(value) && Array.isArray(m.points) && m.points.length) {
1170
- const last = m.points[m.points.length - 1]; value = parseFloat(last && (last.value ?? last.y)); // time-series → final
1171
- }
1172
- if (!Number.isFinite(value)) return;
1197
+ // As the app's Stability chart (13a): a curve → its final value (not
1198
+ // "12 points" → 12), and ordinal / text readings left out and counted.
1199
+ const _ty = _mcpMeasType(m);
1200
+ if (_ty === 'ordinal' || _ty === 'categorical') { skipped[_ty]++; return; }
1201
+ const value = _mcpRepNumber(m);
1202
+ if (value == null) return;
1173
1203
  if (m.unit && !unit) unit = m.unit;
1174
1204
  const rs = _resolveSpec(m, t.templateId), ac = rs.spec;
1175
1205
  const sp = (ac && (Number.isFinite(ac.min) || Number.isFinite(ac.max))) ? { min: Number.isFinite(ac.min) ? ac.min : null, max: Number.isFinite(ac.max) ? ac.max : null } : null;
@@ -1208,7 +1238,7 @@ const get_stability = {
1208
1238
  points.push({ date: new Date(g[0].day), value, cond: g[0].cond, spec: (g.find(r => r.spec) || {}).spec || null, readings: g.length });
1209
1239
  });
1210
1240
  points.sort((a, b) => (a.date - b.date) || a.cond.localeCompare(b.cond));
1211
- if (!points.length) return { error: `No dated "${args.parameter}" measurements on ${entity.uid || entity.id}.` };
1241
+ if (!points.length) return { error: `No dated numeric "${args.parameter}" measurements on ${entity.uid || entity.id}.${skipped.ordinal || skipped.categorical ? ` ${skipped.ordinal} ordinal and ${skipped.categorical} text reading(s) exist but have no number to trend — use list_test_results / find_failures for their pass/fail.` : ''}` };
1212
1242
 
1213
1243
  const spec = { min: specMin, max: specMax };
1214
1244
  // Target shelf life: the formula's own Shelf life, else the workspace
@@ -1275,14 +1305,14 @@ const get_stability = {
1275
1305
 
1276
1306
  return {
1277
1307
  entity: { type: entityType, uid: entity.uid, name: entity.name || entity.uid },
1278
- parameter: args.parameter, unit, spec, ...(specVaries ? { specVaries, specNote: 'Readings were judged against different specs (different panels): no single band, no shelf-life projection; each reading keeps its own pass/fail.' } : {}), pointCount: points.length, byCondition: split, series,
1308
+ parameter: args.parameter, unit, spec, ...((skipped.ordinal || skipped.categorical) ? { skipped, skippedNote: 'Ordinal (ranked scale) and text readings have no number to trend and are left out, as in the app.' } : {}), ...(specVaries ? { specVaries, specNote: 'Readings were judged against different specs (different panels): no single band, no shelf-life projection; each reading keeps its own pass/fail.' } : {}), pointCount: points.length, byCondition: split, series,
1279
1309
  targetShelfLife: target ? { months: target.months, source: target.source } : null,
1280
1310
  note: 'shelfLife.fromT0Months / ci95FromT0Months are the projected shelf life counted from the first test (T0): point estimate and the conservative (ICH Q1E) 95%-bound crossing. remainingMonths / ci95Months are the same, counted from the last test. verdict compares against targetShelfLife: "short" = the trend crosses spec before the target, "marginal" = the trend clears it but the 95% bound does not, "meets" = both clear it; null = no target set (then concern is by time left: medium <3 mo or overdue, else info). A projection never reaches \"high\": high is reserved for a MEASURED out-of-spec value (spec.latestInSpec=false). short/overdue → medium, marginal/meets → info. overdue=true means projectedCrossDate is already past today (time left runs from the last test), so the sample is probably out of spec now — recommend a re-test. reportable=false means the app shows no projection (R² < 0.3, crossing already past, or beyond 5 years). Numbers are computed here — narrate them, do not recompute.',
1281
1311
  };
1282
1312
  },
1283
1313
  };
1284
1314
 
1285
- export { _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1315
+ export { _mcpRepNumber, _mcpMeasType, _evalPassFail, _resolveSpec, _shelfLife, _shelfLifeAssess, _mcpCondTags, _mcpArmNames, _mcpCondDefaultScore };
1286
1316
  export const tools = {
1287
1317
  list_test_results,
1288
1318
  get_test_result,
@@ -27,6 +27,7 @@
27
27
  // ============================================================
28
28
 
29
29
  import { getStore, resolveById, flattenComposition } from '../data.js';
30
+ import { _mcpRepNumber } from './analytics.js';
30
31
 
31
32
  // ---- composition helper -----------------------------------------------------
32
33
  // Ingredient wt-% map for one formula, mirroring the app's _asmIngredientMap:
@@ -344,12 +345,20 @@ function _roleCandidates(forms, roleMaps) {
344
345
  });
345
346
  return out.sort((a, b) => b.score - a.score);
346
347
  }
347
- // A formula's value for a measured parameter: the mean of every numeric
348
- // reading of it across the formula's samples (the app's _asmFormulaValueForCol).
348
+ // A formula's value for a measured parameter, as the app's
349
+ // _asmFormulaValueForCol (13a): readings of the primary run condition (the one
350
+ // with the most readings), each as the DOE Matrix reads it (a curve's final
351
+ // value, a distribution's D50, an ordinal's rank), then their mean.
349
352
  function _paramValue(f, param, db) {
350
353
  const sids = new Set((db.samples || []).filter(s => s.formulationId === f.id).map(s => s.id));
351
- const vals = [];
352
- (db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) { const v = parseFloat(m.value); if (!isNaN(v)) vals.push(v); } }); });
354
+ const ms = [];
355
+ (db.testResults || []).forEach(t => { if (!sids.has(t.sampleId)) return; (t.measurements || []).forEach(m => { if (m.parameter === param) ms.push(m); }); });
356
+ const sig = (m) => { const c = m && m.conditions; return c && typeof c === 'object' ? Object.keys(c).filter(k => c[k] != null && c[k] !== '').sort().map(k => k + '=' + c[k]).join('; ') : ''; };
357
+ const byCond = new Map();
358
+ ms.forEach(m => { if (_mcpRepNumber(m) == null) return; const k = sig(m); byCond.set(k, (byCond.get(k) || 0) + 1); });
359
+ const primary = [...byCond.entries()].sort((a, b) => b[1] - a[1])[0];
360
+ const pool = primary ? ms.filter(m => sig(m) === primary[0]) : ms;
361
+ const vals = pool.map(m => _mcpRepNumber(m, { ordinal: 'rank' })).filter(v => v != null);
353
362
  return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
354
363
  }
355
364
  function _spearman(xs, ys) {
@@ -60,7 +60,7 @@ function allDocuments(db) {
60
60
  lot: d.lotNumber || '', version: d.version != null ? String(d.version) : '', source: d.source || '',
61
61
  expiryDate: d.expiryDate || null, expiryStatus: ex.status, daysToExpiry: ex.daysToExpiry,
62
62
  sizeKb: Number.isFinite(+d.size) && d.size != null ? Math.round(+d.size / 102.4) / 10 : null,
63
- uploadedAt: d.uploadedAt || null,
63
+ uploadedAt: d.uploadedAt || null, addedBy: d.addedBy || null,
64
64
  });
65
65
  }));
66
66
  const testsById = new Map((db.testResults || []).map(t => [t.id, t]));
@@ -71,7 +71,7 @@ function allDocuments(db) {
71
71
  record: 'test', linked: (t ? (t.uid || t.id) : 'Test') + (a.measurementParameter ? ' · ' + a.measurementParameter : ''), linkedId: t ? (t.uid || t.id) : null,
72
72
  lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
73
73
  sizeKb: Number.isFinite(+a.fileSize) && a.fileSize != null ? Math.round(+a.fileSize / 102.4) / 10 : null,
74
- uploadedAt: a.uploadedAt || null,
74
+ uploadedAt: a.uploadedAt || null, addedBy: a.addedBy || null,
75
75
  });
76
76
  });
77
77
  (db.notebookEntries || []).filter(live).forEach(ne => (ne.attachments || []).forEach(att => {
@@ -83,7 +83,7 @@ function allDocuments(db) {
83
83
  record: 'notebook', linked: rec ? (rec.name || rec.uid || '') : (ne.entityType || 'note'), linkedId: rec ? (rec.uid || rec.id) : null,
84
84
  lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
85
85
  sizeKb: Number.isFinite(+att.size) && att.size != null ? Math.round(+att.size / 102.4) / 10 : null,
86
- uploadedAt: att.uploadedAt || null,
86
+ uploadedAt: att.uploadedAt || null, addedBy: att.addedBy || ne.author || null, // a note's file is its author's
87
87
  });
88
88
  }));
89
89
  Object.entries(ATTACH_COLL).forEach(([etype, coll]) => (db[coll] || []).filter(live).forEach(rec => (rec.fileAttachments || []).forEach(a => {
@@ -95,7 +95,7 @@ function allDocuments(db) {
95
95
  ...(isLink && a.url ? { url: a.url } : {}),
96
96
  lot: '', version: '', source: '', expiryDate: null, expiryStatus: 'none', daysToExpiry: null,
97
97
  sizeKb: (!isLink && Number.isFinite(+a.size) && a.size != null) ? Math.round(+a.size / 102.4) / 10 : null,
98
- uploadedAt: a.createdAt || null,
98
+ uploadedAt: a.createdAt || null, addedBy: a.addedBy || null,
99
99
  });
100
100
  })));
101
101
  return out;
@@ -121,7 +121,7 @@ function summary(db, docs) {
121
121
  const list_documents = {
122
122
  definition: {
123
123
  name: 'list_documents',
124
- description: 'The workspace document register — same list as the app\'s Documents page: ingredient SDS / PDS / CoA / other files (with lot, version, source, expiry), test-result and notebook attachments, and per-record uploads and links (formulas, batches, samples, suppliers, equipment…). Returns file METADATA only (never the text inside a file). Filters combine. The summary block matches the page\'s tiles: expired, expiring in 30 days, SDS/CoA without an expiry date, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, files added in the last 7 days. For the lots themselves use list_lots with missing_coa: true.',
124
+ description: 'The workspace document register — same list as the app\'s Documents page: ingredient SDS / PDS / CoA / other files (with lot, version, source, expiry), test-result and notebook attachments, and per-record uploads and links (formulas, batches, samples, suppliers, equipment…). Returns file METADATA only (never the text inside a file), including addedBy (who added it — null for files added before that was recorded) and uploadedAt. Filters combine. The summary block matches the page\'s tiles: expired, expiring in 30 days, SDS/CoA without an expiry date, ingredients missing an SDS (+ coverage %), lots in stock without a CoA, files added in the last 7 days. For the lots themselves use list_lots with missing_coa: true.',
125
125
  inputSchema: {
126
126
  type: 'object',
127
127
  properties: {
@@ -135,6 +135,7 @@ const list_documents = {
135
135
  expiring_within_days: { type: 'number', description: 'Only documents whose expiry date is within this many days (already expired included).' },
136
136
  without_expiry: { type: 'boolean', description: 'true = only SDS / CoA files with no expiry date (the "SDS / CoA without expiry" tile).' },
137
137
  uploaded_within_days: { type: 'number', description: 'Only files uploaded within this many days.' },
138
+ added_by: { type: 'string', description: 'Who added the file: case-insensitive substring of their email / name. Files from before this was recorded have none and never match.' },
138
139
  limit: { type: 'number', description: 'Max rows (default 200, max 1000).' },
139
140
  },
140
141
  },
@@ -156,6 +157,7 @@ const list_documents = {
156
157
  if (args.expiring_within_days != null && !(d.daysToExpiry != null && d.daysToExpiry <= args.expiring_within_days)) return false;
157
158
  if (args.without_expiry === true && !(d.record === 'ingredient' && d.expiryStatus === 'none' && (d.type === 'SDS' || d.type === 'COA'))) return false;
158
159
  if (args.uploaded_within_days != null && !(d.ageDays != null && d.ageDays <= args.uploaded_within_days)) return false;
160
+ if (args.added_by && !norm(d.addedBy).includes(norm(args.added_by))) return false;
159
161
  return true;
160
162
  }).sort((a, b) => String(b.uploadedAt || '').localeCompare(String(a.uploadedAt || '')));
161
163
  return { summary: summary(db, all), totalMatching: rows.length, returned: Math.min(rows.length, limit), documents: rows.slice(0, limit) };
package/tools/notebook.js CHANGED
@@ -46,6 +46,10 @@ function _trimEntry(e, db, { full = false } = {}) {
46
46
  updatedAt: e.updatedAt || null,
47
47
  author: e.author || '',
48
48
  attachmentCount: atts.length,
49
+ // Withdrawn by its author or the workspace owner (append-only notebook):
50
+ // the note is kept, but it is not a finding. null when not retracted.
51
+ retracted: (e.retracted && typeof e.retracted === 'object')
52
+ ? { at: e.retracted.at || null, by: e.retracted.by || '', reason: e.retracted.reason || '' } : null,
49
53
  };
50
54
  if (!full) return { ...base, body: (e.body || '').slice(0, 240) };
51
55
  return {
@@ -58,7 +62,7 @@ function _trimEntry(e, db, { full = false } = {}) {
58
62
  const list_notebook_entries = {
59
63
  definition: {
60
64
  name: 'list_notebook_entries',
61
- description: 'List ELN notebook entries — dated, authored notes attached to records — optionally filtered by the record they hang off (entity_type + entity_id), author, text, or date range. Newest first. Use get_notebook_entry for the full body + attachments.',
65
+ description: 'List ELN notebook entries — dated, authored notes attached to records — optionally filtered by the record they hang off (entity_type + entity_id), author, text, or date range. Newest first. Entries are append-only: a withdrawn one is kept with retracted = {at, by, reason} — treat it as withdrawn, not as a finding. Use get_notebook_entry for the full body + attachments.',
62
66
  inputSchema: {
63
67
  type: 'object',
64
68
  properties: {
@@ -68,6 +72,7 @@ const list_notebook_entries = {
68
72
  text_contains: { type: 'string', description: 'Case-insensitive substring on the note body.' },
69
73
  since: { type: 'string', description: 'ISO date/time — only entries at or after this timestamp.' },
70
74
  until: { type: 'string', description: 'ISO date/time — only entries at or before this timestamp.' },
75
+ retracted: { type: 'boolean', description: 'false = only notes that stand (leave out retracted ones); true = only retracted notes. Omit for both.' },
71
76
  limit: { type: 'number', description: 'Max rows (default 100, max 1000).' },
72
77
  },
73
78
  },
@@ -91,6 +96,7 @@ const list_notebook_entries = {
91
96
  if (wantEntityId && e.entityId !== wantEntityId) return false;
92
97
  if (args.author && !_norm(e.author).includes(_norm(args.author))) return false;
93
98
  if (args.text_contains && !_norm(e.body).includes(_norm(args.text_contains))) return false;
99
+ if (typeof args.retracted === 'boolean' && (!!(e.retracted && typeof e.retracted === 'object')) !== args.retracted) return false;
94
100
  if (sinceMs != null || untilMs != null) {
95
101
  const t = Date.parse(_entryTs(e));
96
102
  if (!isFinite(t)) return false;
@@ -110,7 +116,7 @@ const list_notebook_entries = {
110
116
  const get_notebook_entry = {
111
117
  definition: {
112
118
  name: 'get_notebook_entry',
113
- description: 'Get one ELN notebook entry by id — full note body, author, timestamp, the record it is attached to (resolved to uid/name), and attachment metadata (filename / type). Attachment binaries are not returned.',
119
+ description: 'Get one ELN notebook entry by id — full note body, author, timestamp, the record it is attached to (resolved to uid/name), attachment metadata (filename / type), and retracted = {at, by, reason} when the note was withdrawn. Attachment binaries are not returned.',
114
120
  inputSchema: {
115
121
  type: 'object',
116
122
  properties: { id: { type: 'string', description: 'Internal id of the notebook entry.' } },