@nestr/mcp 0.1.106 → 0.1.107

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.
@@ -390,6 +390,43 @@ const HINT_ENDPOINT_TOOL_MAPPINGS = [
390
390
  pathParamNames: ["nestId", "tensionId"],
391
391
  bodyParams: new Set([]),
392
392
  },
393
+ // Recurrence. One occurrence (`/recurrence/:instant`) before the series routes.
394
+ {
395
+ method: "DELETE",
396
+ pattern: /^\/nests\/([^/]+)\/recurrence\/([^/]+)\/?$/,
397
+ tool: "nestr_skip_occurrence",
398
+ pathParamNames: ["nestId", "instant"],
399
+ bodyParams: new Set([]),
400
+ queryParams: { scope: "scope" },
401
+ numericParams: new Set(["instant"]),
402
+ },
403
+ {
404
+ method: "PATCH",
405
+ pattern: /^\/nests\/([^/]+)\/recurrence\/([^/]+)\/?$/,
406
+ tool: "nestr_update_occurrence",
407
+ pathParamNames: ["nestId", "instant"],
408
+ bodyParams: new Set([
409
+ "title", "description", "purpose", "parentId", "labels",
410
+ "fields", "users", "data", "due", "completed",
411
+ ]),
412
+ numericParams: new Set(["instant"]),
413
+ },
414
+ {
415
+ method: "GET",
416
+ pattern: /^\/nests\/([^/]+)\/recurrence\/?$/,
417
+ tool: "nestr_list_occurrences",
418
+ pathParamNames: ["nestId"],
419
+ bodyParams: new Set([]),
420
+ queryParams: { direction: "direction", cursor: "cursor", limit: "limit" },
421
+ numericParams: new Set(["cursor", "limit"]),
422
+ },
423
+ {
424
+ method: "DELETE",
425
+ pattern: /^\/nests\/([^/]+)\/recurrence\/?$/,
426
+ tool: "nestr_delete_series",
427
+ pathParamNames: ["nestId"],
428
+ bodyParams: new Set([]),
429
+ },
393
430
  ];
394
431
  /**
395
432
  * Strip optional host + /api prefix so we match against canonical routes, and split the
@@ -436,6 +473,13 @@ export function translateEndpoint(endpoint) {
436
473
  value === "true" || value === "false" ? value === "true" : value;
437
474
  }
438
475
  }
476
+ // An instant or a limit arrives as URL text; only an all-digit value is a number
477
+ // (a cursor may also be an ISO date, which stays a string).
478
+ mapping.numericParams?.forEach((name) => {
479
+ const value = parametersExample[name];
480
+ if (typeof value === "string" && /^-?\d+$/.test(value))
481
+ parametersExample[name] = Number(value);
482
+ });
439
483
  if (mapping.extraParams)
440
484
  Object.assign(parametersExample, mapping.extraParams);
441
485
  const droppedFields = [];
@@ -777,6 +821,30 @@ const coerceFromJson = (schema) => z.preprocess((val) => {
777
821
  }
778
822
  return val;
779
823
  }, schema);
824
+ // The instant an occurrence keys on, as integer epoch milliseconds. An ISO-8601 date
825
+ // is accepted only with an explicit Z or offset: without one this host's timezone
826
+ // would silently decide the instant.
827
+ const occurrenceInstant = z.union([z.number(), z.string()]).transform((val, ctx) => {
828
+ const refuse = (message) => {
829
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message });
830
+ return z.NEVER;
831
+ };
832
+ const useListing = "Use the `instant` value from nestr_list_occurrences (integer epoch milliseconds)";
833
+ if (typeof val === "number") {
834
+ return Number.isInteger(val) ? val : refuse(`not an instant: ${val}. ${useListing}.`);
835
+ }
836
+ const text = val.trim();
837
+ if (/^-?\d+$/.test(text))
838
+ return Number(text);
839
+ const at = Date.parse(text);
840
+ if (!Number.isFinite(at)) {
841
+ return refuse(`not an instant: "${val}". ${useListing}, or an ISO-8601 date with a Z or an offset.`);
842
+ }
843
+ if (!/T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})$/i.test(text)) {
844
+ return refuse(`"${val}" has no timezone, so which instant it means depends on where this server runs. ${useListing}, or give the date with a Z or an offset.`);
845
+ }
846
+ return at;
847
+ });
780
848
  // Coerce an integer-array param to number[] even when a client serialises it as
781
849
  // a string — e.g. a stale/cached tool schema that doesn't know the array type
782
850
  // sends "[4,5,6]", "4,5,6", or a bare 4. Non-numeric tokens are dropped and the
@@ -810,6 +878,52 @@ const MENTION_DESC = "Supports HTML and @mentions. Mentions MUST use literal cur
810
878
  const PRIME_LABEL_RULE = "At most ONE prime label per nest (project, tension, role, circle, anchor-circle, meeting, metric, goal, result, checklist, feedback, userstory, sprint, epic, milestone): they are the nest's identity and cannot coexist. Only exception: userstory may pair with project.";
811
879
  const PURPOSE_DESC = "Only for workspaces, circles and roles: a short aspirational statement. Details belong in description, not here. Supports HTML.";
812
880
  const CONTENT_DESC = "The primary content field: details, context, acceptance criteria. Structured data goes in fields, progress in comments. Supports Markdown and HTML.";
881
+ // The editable fields of PATCH /nests/:id, shared by nestr_update_nest and
882
+ // nestr_update_occurrence so the two cannot drift apart.
883
+ const nestUpdateFieldSchemas = {
884
+ title: z.string().optional().describe("New title (plain text, HTML stripped)"),
885
+ description: z.string().optional().describe("The primary content field — use for project details, task context, acceptance criteria, and any detailed information. Supports Markdown and HTML."),
886
+ purpose: z.string().optional().describe(PURPOSE_DESC),
887
+ parentId: z.string().optional().describe("New parent ID (move nest to different location, e.g., move inbox item to a role or project)"),
888
+ labels: coerceFromJson(z.array(z.string())).optional().describe("Label IDs to set (e.g., ['project'] to convert an item into a project)"),
889
+ fields: coerceFromJson(z.record(z.unknown())).optional().describe("Field updates (e.g., { 'project.status': 'Current' })"),
890
+ users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign"),
891
+ data: coerceFromJson(z.record(z.unknown())).optional().describe("Key-value data store shared with Nestr internals — never overwrite existing keys. Namespace your own data under 'mcp.' (e.g., { 'mcp.lastSync': '...' }). For AI knowledge persistence, use skills instead."),
892
+ due: z.string().optional().describe("Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time."),
893
+ completed: z.boolean().optional().describe("Mark task as completed (root-level field, not in fields). Note: Projects use fields['project.status'] = 'Done' instead."),
894
+ };
895
+ const NEST_UPDATE_FIELD_PROPERTIES = {
896
+ title: { type: "string", description: "New title (plain text, HTML tags stripped)" },
897
+ description: { type: "string", description: CONTENT_DESC },
898
+ purpose: { type: "string", description: PURPOSE_DESC },
899
+ parentId: { type: "string", description: "New parent ID (move nest to different location)" },
900
+ labels: {
901
+ type: "array",
902
+ items: { type: "string" },
903
+ description: "Label IDs to set (e.g., ['project'] to convert an item into a project)",
904
+ },
905
+ fields: {
906
+ type: "object",
907
+ description: "Field updates (e.g., { 'project.status': 'Current' })",
908
+ },
909
+ users: {
910
+ type: "array",
911
+ items: { type: "string" },
912
+ description: "User IDs to assign",
913
+ },
914
+ data: {
915
+ type: "object",
916
+ description: "Key-value data store shared with Nestr internals — never overwrite existing keys. Namespace your own data under 'mcp.' (e.g., { 'mcp.lastSync': '...' }). For AI knowledge persistence, use skills instead.",
917
+ },
918
+ due: {
919
+ type: "string",
920
+ description: "Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time.",
921
+ },
922
+ completed: {
923
+ type: "boolean",
924
+ description: "Mark task as completed (root-level field, not in fields). Note: Projects use fields['project.status'] = 'Done' instead.",
925
+ },
926
+ };
813
927
  const HINTS_DESC = "Contextual hints. 'summary' keeps the per-nest signal (type, severity, count, url, and the "
814
928
  + "`query` that finds every other nest with the same problem) and drops the fixed teaching prose "
815
929
  + "and endpoint list. 'full' is the whole payload. false for none.";
@@ -953,16 +1067,7 @@ export const schemas = {
953
1067
  }),
954
1068
  updateNest: z.object({
955
1069
  nestId: z.string().describe("Nest ID to update"),
956
- title: z.string().optional().describe("New title (plain text, HTML stripped)"),
957
- description: z.string().optional().describe("The primary content field — use for project details, task context, acceptance criteria, and any detailed information. Supports Markdown and HTML."),
958
- purpose: z.string().optional().describe(PURPOSE_DESC),
959
- parentId: z.string().optional().describe("New parent ID (move nest to different location, e.g., move inbox item to a role or project)"),
960
- labels: coerceFromJson(z.array(z.string())).optional().describe("Label IDs to set (e.g., ['project'] to convert an item into a project)"),
961
- fields: coerceFromJson(z.record(z.unknown())).optional().describe("Field updates (e.g., { 'project.status': 'Current' })"),
962
- users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign"),
963
- data: coerceFromJson(z.record(z.unknown())).optional().describe("Key-value data store shared with Nestr internals — never overwrite existing keys. Namespace your own data under 'mcp.' (e.g., { 'mcp.lastSync': '...' }). For AI knowledge persistence, use skills instead."),
964
- due: z.string().optional().describe("Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time."),
965
- completed: z.boolean().optional().describe("Mark task as completed (root-level field, not in fields). Note: Projects use fields['project.status'] = 'Done' instead."),
1070
+ ...nestUpdateFieldSchemas,
966
1071
  accountabilities: coerceFromJson(z.array(z.string())).optional().describe("Accountability titles for roles/circles (replaces existing). Only used when updating a role or circle. Requires workspaceId."),
967
1072
  domains: coerceFromJson(z.array(z.string())).optional().describe("Domain titles for roles/circles (replaces existing). Only used when updating a role or circle. Requires workspaceId."),
968
1073
  workspaceId: z.string().optional().describe("Workspace ID. Required when updating accountabilities or domains on roles/circles."),
@@ -1072,6 +1177,12 @@ export const schemas = {
1072
1177
  getWorkspaceApps: z.object({
1073
1178
  workspaceId: z.string().describe("Workspace ID"),
1074
1179
  }),
1180
+ workspaceDocs: z.object({
1181
+ workspaceId: z.string().optional(),
1182
+ search: z.string().optional(),
1183
+ fileId: z.string().optional(),
1184
+ offset: z.number().optional(),
1185
+ }),
1075
1186
  // Inbox tools (require OAuth token)
1076
1187
  listInbox: z.object({
1077
1188
  completedAfter: z.string().optional().describe("Include completed items from this date (ISO format). If omitted, only non-completed items are returned. For reordering, this default is usually sufficient — nestr_reorder_inbox only requires the IDs of items you want to reposition."),
@@ -1131,6 +1242,30 @@ export const schemas = {
1131
1242
  workspaceId: z.string().describe("Workspace ID"),
1132
1243
  nestIds: coerceFromJson(z.array(z.string())).describe("Array of nest IDs in the desired order"),
1133
1244
  }),
1245
+ // Schedule / recurrence
1246
+ setRecurrence: z.object({
1247
+ nestId: z.string().describe("Nest ID to set or remove recurrence on"),
1248
+ rrule: z.string().nullable().describe("RFC-5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10') to set recurrence, or null to remove it. Required: pass null explicitly to remove rather than omitting the field."),
1249
+ }),
1250
+ listOccurrences: z.object({
1251
+ nestId: z.string().describe("The series, or any occurrence of it. Both resolve to the same series."),
1252
+ direction: z.enum(["future", "past"]).optional().describe("'future' (default) lists upcoming occurrences, soonest first. 'past' lists history, most recent first."),
1253
+ cursor: z.union([z.number(), z.string()]).optional().describe("Walk outward from this instant, exclusive. Pass back the nextCursor from the previous page. Defaults to now."),
1254
+ limit: z.number().optional().describe("Occurrences per page. Default 10, capped at 50."),
1255
+ }),
1256
+ skipOccurrence: z.object({
1257
+ nestId: z.string().describe("The series, or any occurrence of it."),
1258
+ instant: occurrenceInstant.describe("Which occurrence. Use the `instant` value from nestr_list_occurrences verbatim."),
1259
+ scope: z.enum(["occurrence", "following"]).optional().describe("'occurrence' (default) skips this one and never ends the series. 'following' deletes this one and every later one."),
1260
+ }),
1261
+ updateOccurrence: z.object({
1262
+ nestId: z.string().describe("The series, or any occurrence of it."),
1263
+ instant: occurrenceInstant.describe("Which occurrence. Use the `instant` value from nestr_list_occurrences verbatim."),
1264
+ ...nestUpdateFieldSchemas,
1265
+ }),
1266
+ deleteSeries: z.object({
1267
+ nestId: z.string().describe("The series, or any occurrence of it."),
1268
+ }),
1134
1269
  // Daily plan (requires OAuth token)
1135
1270
  getDailyPlan: z.object({}),
1136
1271
  // Current user identity (requires OAuth token)
@@ -1651,36 +1786,7 @@ export const toolDefinitions = [
1651
1786
  type: "object",
1652
1787
  properties: {
1653
1788
  nestId: { type: "string", description: "Nest ID to update" },
1654
- title: { type: "string", description: "New title (plain text, HTML tags stripped)" },
1655
- description: { type: "string", description: CONTENT_DESC },
1656
- purpose: { type: "string", description: PURPOSE_DESC },
1657
- parentId: { type: "string", description: "New parent ID (move nest to different location)" },
1658
- labels: {
1659
- type: "array",
1660
- items: { type: "string" },
1661
- description: "Label IDs to set (e.g., ['project'] to convert an item into a project)",
1662
- },
1663
- fields: {
1664
- type: "object",
1665
- description: "Field updates (e.g., { 'project.status': 'Current' })",
1666
- },
1667
- users: {
1668
- type: "array",
1669
- items: { type: "string" },
1670
- description: "User IDs to assign",
1671
- },
1672
- data: {
1673
- type: "object",
1674
- description: "Key-value data store shared with Nestr internals — never overwrite existing keys. Namespace your own data under 'mcp.' (e.g., { 'mcp.lastSync': '...' }). For AI knowledge persistence, use skills instead.",
1675
- },
1676
- due: {
1677
- type: "string",
1678
- description: "Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time.",
1679
- },
1680
- completed: {
1681
- type: "boolean",
1682
- description: "Mark task as completed (root-level field, not in fields). Note: Projects use fields['project.status'] = 'Done' instead.",
1683
- },
1789
+ ...NEST_UPDATE_FIELD_PROPERTIES,
1684
1790
  accountabilities: {
1685
1791
  type: "array",
1686
1792
  items: { type: "string" },
@@ -1702,7 +1808,7 @@ export const toolDefinitions = [
1702
1808
  },
1703
1809
  {
1704
1810
  name: "nestr_delete_nest",
1705
- description: "Delete a nest. For governance items in established workspaces, use tensions instead.",
1811
+ description: "Delete a nest. For governance items in established workspaces, use tensions instead. On a recurring item this deletes only that one nest and never ends the series: deleting an occurrence skips it, and deleting the series nest hands the series to its next occurrence, which carries on as the series. To skip an occurrence use nestr_skip_occurrence, to delete it and every later one use nestr_skip_occurrence with scope 'following', and to delete the whole series use nestr_delete_series. The delete is checked against the rights of the person behind the token, including a workspace-bound OAuth or agent token, so a caller who could not delete the nest themselves is refused with 403.",
1706
1812
  inputSchema: {
1707
1813
  type: "object",
1708
1814
  properties: {
@@ -2291,6 +2397,94 @@ export const toolDefinitions = [
2291
2397
  },
2292
2398
  ...mutating,
2293
2399
  },
2400
+ {
2401
+ name: "nestr_set_recurrence",
2402
+ description: "Set or remove a task/project/meeting's recurrence rule. Pass an RFC-5545 RRULE string (e.g. 'FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=10') to set it, or rrule: null to remove it. Setting a rule creates NO nests: occurrences are computed from the rule and stay virtual until something touches one (completing it, moving its dates, opening it), at which point that occurrence alone becomes a real nest. Do not tell the user their occurrences have been created. The rule expands from the nest's start, or its due when it has no start, and is refused when it has neither, so set a date first. An invalid RRULE is rejected before anything is written. Removing the rule stops future occurrences and keeps anything already materialized, detached from the series. Do not use this to leave out dates: bounding the series with a COUNT or UNTIL and creating a second recurring nest after the gap leaves two nests with the same title, and a later change to the pattern reaches only one of them. To skip an occurrence, use nestr_skip_occurrence; to edit one occurrence, nestr_update_occurrence.",
2403
+ inputSchema: {
2404
+ type: "object",
2405
+ properties: {
2406
+ nestId: { type: "string", description: "Nest ID to set or remove recurrence on" },
2407
+ rrule: {
2408
+ type: ["string", "null"],
2409
+ description: "RFC-5545 RRULE string to set recurrence, or null to remove it. Required: pass null explicitly rather than omitting the field.",
2410
+ },
2411
+ },
2412
+ required: ["nestId", "rrule"],
2413
+ },
2414
+ ...mutating,
2415
+ },
2416
+ {
2417
+ name: "nestr_list_occurrences",
2418
+ description: "List the occurrences of a recurring task, project or meeting, so you can name the one you want to act on. This is the ONLY way to see them: occurrences beyond the next one are virtual, meaning the rule produces the instant and no nest exists for it, so nestr_search and nestr_get_nest_children find nothing and asking them is not evidence the occurrences are missing. Returns one page mixing both kinds in date order, each entry carrying `instant` (epoch milliseconds, the key that identifies one occurrence), `virtual` (false means a real nest exists and `nestId` names it), `excluded` (this instant is skipped by the series) and `completed`. Cursor-paged, not page-paged: a rule with no end produces occurrences forever, so there is no total. Pass `nextCursor` back as `cursor` while `hasMore` is true, and `direction: 'past'` to read history. What to do with an `instant`: skip that occurrence with nestr_skip_occurrence (scope 'following' deletes it and every later one), edit that one occurrence with nestr_update_occurrence, or delete the whole series with nestr_delete_series.",
2419
+ inputSchema: {
2420
+ type: "object",
2421
+ properties: {
2422
+ nestId: { type: "string", description: "The series, or any occurrence of it. Both resolve to the same series." },
2423
+ direction: {
2424
+ type: "string",
2425
+ enum: ["future", "past"],
2426
+ description: "'future' (default) lists upcoming occurrences, soonest first. 'past' lists history, most recent first.",
2427
+ },
2428
+ cursor: {
2429
+ type: ["number", "string"],
2430
+ description: "Walk outward from this instant, exclusive. Pass back nextCursor from the previous page. Defaults to now.",
2431
+ },
2432
+ limit: { type: "number", description: "Occurrences per page. Default 10, capped at 50." },
2433
+ },
2434
+ required: ["nestId"],
2435
+ },
2436
+ ...readOnly,
2437
+ },
2438
+ {
2439
+ name: "nestr_skip_occurrence",
2440
+ description: "Skip ONE occurrence of a recurring series, or with scope 'following' delete it and every later one. With scope 'occurrence' (the default) it is for the person away that week, the meeting cancelled once, the task that does not apply this time. It excludes that single instant and nothing else. It does NOT end the series (unless the occurrence skipped is the first and nothing follows it, see below), does not change the rule, does not move any other occurrence, and does not destroy history: every past occurrence stays exactly as it was, and the skipped instant stays visible in nestr_list_occurrences marked `excluded`, so the skip is a visible decision rather than a silent gap. If the occurrence already exists as a real nest it is deleted along with the exclusion. The first occurrence is the series item itself: skipping it deletes that nest and hands the series to its next occurrence, which is the series nest from then on, and a series with nothing after it ends; the response then carries `restoreId`, and `seriesId` only when the series carries on. Skipping an instant that is already skipped succeeds and changes nothing. This is NOT the same as splitting the series in two: bounding the rule with a COUNT and creating a second recurring nest after the gap leaves two nests with the same title, and a later edit to the pattern reaches only one of them. Use this instead. With scope 'following' it deletes this occurrence and every later one: the series is split at the instant, the occurrences before it keep their history under a rule that now ends there, and cutting at the first occurrence ends the whole series. It answers with `restoreId`, the nest to restore in the Nestr app: restoring it brings back that nest only, and the occurrences deleted with it are restored separately. `instant` must be an occurrence the series actually has: read it from nestr_list_occurrences and pass it through unchanged. An instant off by a second or by a timezone is refused, not silently accepted. A skip needs update rights on the series, plus delete rights on the occurrence if it already exists as a nest. Skipping the first occurrence deletes the series item itself, so it needs delete rights on the series item. It is refused for workspaces and for governance items unless the caller is a governance admin. Scope 'following' needs delete rights on the series and on every occurrence it removes, and a refusal writes nothing.",
2441
+ inputSchema: {
2442
+ type: "object",
2443
+ properties: {
2444
+ nestId: { type: "string", description: "The series, or any occurrence of it." },
2445
+ instant: {
2446
+ type: ["number", "string"],
2447
+ description: "Which occurrence: the `instant` value from nestr_list_occurrences, in epoch milliseconds. An ISO-8601 date is accepted only with a Z or an offset, and must be the exact instant the rule produces.",
2448
+ },
2449
+ scope: {
2450
+ type: "string",
2451
+ enum: ["occurrence", "following"],
2452
+ description: "'occurrence' (default) skips this one occurrence and never ends the series. 'following' deletes this occurrence and every later one, keeping the history before it.",
2453
+ },
2454
+ },
2455
+ required: ["nestId", "instant"],
2456
+ },
2457
+ ...destructive,
2458
+ },
2459
+ {
2460
+ name: "nestr_update_occurrence",
2461
+ description: "Edit ONE occurrence of a recurring series: move this week's meeting, retitle one instance, assign one occurrence to someone else. Takes the same edit fields as nestr_update_nest, but not accountabilities, domains or workspaceId. If the occurrence is still virtual it is materialized first, then the changes are applied to that occurrence only; the rule and every other occurrence are untouched. With no fields it only materializes the occurrence. Answers with the nest: use its `_id` with every other nest tool from then on. Not all-or-nothing: the occurrence is materialized before the changes are applied, so if the edit is refused the occurrence may already exist as a real nest, and nestr_list_occurrences shows its `nestId`. The first occurrence is the series item itself and is refused here: change it with nestr_update_nest on the series nest, which also changes occurrences not created yet. A skipped occurrence is refused too, since there is nothing to change. To change the pattern of the whole series, use nestr_set_recurrence instead. `instant` must be an occurrence the series actually has: read it from nestr_list_occurrences and pass it through unchanged.",
2462
+ inputSchema: {
2463
+ type: "object",
2464
+ properties: {
2465
+ nestId: { type: "string", description: "The series, or any occurrence of it." },
2466
+ instant: {
2467
+ type: ["number", "string"],
2468
+ description: "Which occurrence: the `instant` value from nestr_list_occurrences, in epoch milliseconds. An ISO-8601 date is accepted only with a Z or an offset, and must be the exact instant the rule produces.",
2469
+ },
2470
+ ...NEST_UPDATE_FIELD_PROPERTIES,
2471
+ },
2472
+ required: ["nestId", "instant"],
2473
+ },
2474
+ ...mutating,
2475
+ },
2476
+ {
2477
+ name: "nestr_delete_series",
2478
+ description: "Delete a whole recurring series: the series nest and every occurrence, past ones included. Use only when the series and its history should go. To stop repeating but keep the nests, use nestr_set_recurrence with rrule: null. To skip one occurrence, or remove one and every later one while keeping history, use nestr_skip_occurrence. Answers with `restoreId`, the series nest: restoring it in the Nestr app brings back that nest only, and the occurrences deleted with it are restored separately. Needs delete rights on the series and on every occurrence it removes, and a refusal writes nothing. Refused when the nest has no recurrence.",
2479
+ inputSchema: {
2480
+ type: "object",
2481
+ properties: {
2482
+ nestId: { type: "string", description: "The series, or any occurrence of it." },
2483
+ },
2484
+ required: ["nestId"],
2485
+ },
2486
+ ...destructive,
2487
+ },
2294
2488
  // Daily plan (requires OAuth token)
2295
2489
  {
2296
2490
  name: "nestr_get_daily_plan",
@@ -2979,6 +3173,20 @@ export const toolDefinitions = [
2979
3173
  },
2980
3174
  ...mutating,
2981
3175
  },
3176
+ {
3177
+ name: "nestr_workspace_docs",
3178
+ description: "The workspace's reference documents: an organisation constitution, a staff handbook, an onboarding guide, whatever this workspace uploaded for its agents to draw on. CALL THIS WITH NO ARGUMENTS FIRST to see what exists — the index lists each document with a description of what it holds and when to read it, and costs almost nothing. Then `search` across them for the passage that answers a question, or `fileId` to read one document. Prefer `search` over reading a whole document: the large ones run past half a million characters, and the answer is usually one section. These are the organisation's own words about how it works, so they outrank your general knowledge about how organisations work; where a document covers the question, quote it rather than reasoning from first principles. Nothing here is guaranteed to exist: a workspace that uploaded nothing returns an empty index, which is an answer, not an error. Auth: any valid token with access to the workspace.",
3179
+ inputSchema: {
3180
+ type: "object",
3181
+ properties: {
3182
+ workspaceId: { type: "string", description: "Workspace whose documents to read. Omit when you can only reach one." },
3183
+ search: { type: "string", description: "Find the passages across all the documents that match this. Use words the document would use, not the user's phrasing." },
3184
+ fileId: { type: "string", description: "Read one document, from the index or from a search hit." },
3185
+ offset: { type: "number", description: "With fileId, continue from a previous nextOffset, or from a search hit's offset." },
3186
+ },
3187
+ },
3188
+ ...readOnly,
3189
+ },
2982
3190
  {
2983
3191
  name: "nestr_get_nest_files",
2984
3192
  description: "List a nest's file attachments. Images pasted into the nest's text are deliberately excluded — they belong to the text that references them; the inline_images hint counts those and their ids come from the references in the content. A comment ID works too — files are keyed by nestId, so pass a comment ID to see files attached to that comment. Returns each file's id, name, contentType and size. Use nestr_read_file with a returned id to read one (images come back as viewable image content). Auth: any valid token with access to the nest.",
@@ -3528,8 +3736,17 @@ async function _handleToolCall(client, name, args, context) {
3528
3736
  }
3529
3737
  case "nestr_delete_nest": {
3530
3738
  const parsed = schemas.deleteNest.parse(args);
3531
- await client.deleteNest(parsed.nestId);
3532
- return formatResult({ message: `Nest ${parsed.nestId} deleted successfully` });
3739
+ const response = await client.deleteNest(parsed.nestId);
3740
+ const restoreId = response?.data?.restoreId;
3741
+ // A recurring target answers with a recurring_series hint naming the series routes.
3742
+ const hints = Array.isArray(response?.hints) && response.hints.length > 0
3743
+ ? enrichHints({ hints: response.hints }).hints
3744
+ : undefined;
3745
+ return formatResult({
3746
+ message: `Nest ${parsed.nestId} deleted successfully`,
3747
+ ...(restoreId ? { restoreId } : {}),
3748
+ ...(hints ? { hints } : {}),
3749
+ });
3533
3750
  }
3534
3751
  case "nestr_add_comment": {
3535
3752
  const parsed = schemas.addComment.parse(args);
@@ -3842,6 +4059,100 @@ async function _handleToolCall(client, name, args, context) {
3842
4059
  const result = await client.bulkReorder(parsed.workspaceId, parsed.nestIds);
3843
4060
  return formatResult({ message: "Nests reordered successfully", nests: result });
3844
4061
  }
4062
+ case "nestr_set_recurrence": {
4063
+ const parsed = schemas.setRecurrence.parse(args);
4064
+ const result = await client.setRecurrence(parsed.nestId, parsed.rrule);
4065
+ // The server returns the stored rule and nothing else. It does not
4066
+ // create occurrence nests, so there is no count to report: saying one
4067
+ // would tell the user work exists that does not.
4068
+ const message = "removed" in result
4069
+ ? "Recurrence removed. Future occurrences stop; anything already materialized is kept, detached from the series."
4070
+ : `Recurrence set to ${result.rrule}. Occurrences stay virtual until one is touched.`;
4071
+ return formatResult({ message, recurrence: result });
4072
+ }
4073
+ case "nestr_list_occurrences": {
4074
+ const parsed = schemas.listOccurrences.parse(args);
4075
+ const page = await client.listOccurrences(parsed.nestId, {
4076
+ direction: parsed.direction,
4077
+ cursor: parsed.cursor,
4078
+ limit: parsed.limit,
4079
+ });
4080
+ if (!page.seriesId) {
4081
+ return formatResult({
4082
+ message: "This nest has no recurrence rule, so it has no occurrences. Set one with nestr_set_recurrence.",
4083
+ ...page,
4084
+ });
4085
+ }
4086
+ // Named, because the count an agent reports back is the count of rows it
4087
+ // can see, not the size of the series: an open-ended rule has no end and
4088
+ // the route returns no total.
4089
+ const virtual = page.occurrences.filter((o) => o.virtual).length;
4090
+ const message = `${page.occurrences.length} occurrence(s) on this page, ${virtual} of them virtual (no nest exists yet).`
4091
+ + " Act on one by its `instant`: nestr_skip_occurrence to skip it, nestr_update_occurrence to edit it."
4092
+ + (page.hasMore ? " More beyond this page: pass nextCursor back as cursor." : "");
4093
+ return formatResult({ message, ...page });
4094
+ }
4095
+ case "nestr_skip_occurrence": {
4096
+ const parsed = schemas.skipOccurrence.parse(args);
4097
+ const result = await client.skipOccurrence(parsed.nestId, parsed.instant, parsed.scope);
4098
+ const at = new Date(parsed.instant).toISOString();
4099
+ if (result && "deleted" in result) {
4100
+ const restore = `Restoring nest ${result.restoreId} in the Nestr app brings back that nest only; the occurrences deleted with it are restored separately.`;
4101
+ // A cut at the first occurrence leaves no history, so the whole series is gone.
4102
+ const message = result.restoreId === result.seriesId
4103
+ ? `The cut at ${at} was the first occurrence, so the whole series was deleted. ${restore}`
4104
+ : `Occurrence at ${at} and every later one deleted. The history before it is kept, under a rule that now ends there. ${restore}`;
4105
+ return formatResult({ message, occurrence: result });
4106
+ }
4107
+ if (result?.restoreId) {
4108
+ const restore = `Restoring nest ${result.restoreId} in the Nestr app brings back that nest only; the occurrences deleted with it are restored separately.`;
4109
+ const outcome = result.seriesId
4110
+ ? `so it was deleted and the series continues from nest ${result.seriesId}.`
4111
+ : "so it was deleted, and nothing followed it, so the series has ended.";
4112
+ return formatResult({
4113
+ message: `Occurrence at ${at} skipped. The first occurrence is the series item itself, ${outcome} ${restore}`,
4114
+ occurrence: result,
4115
+ });
4116
+ }
4117
+ return formatResult({
4118
+ message: `Occurrence at ${at} skipped. The series continues: the rule is unchanged and every other occurrence, past and future, is untouched.`,
4119
+ occurrence: result,
4120
+ });
4121
+ }
4122
+ case "nestr_update_occurrence": {
4123
+ const { nestId, instant, ...updates } = schemas.updateOccurrence.parse(args);
4124
+ validatePrimeLabels(updates.labels);
4125
+ updates.labels = ensureMeetingModifier(updates.labels);
4126
+ const edited = Object.values(updates).some((value) => value !== undefined);
4127
+ try {
4128
+ const nest = await client.updateOccurrence(nestId, instant, {
4129
+ ...updates,
4130
+ data: updates.data,
4131
+ });
4132
+ const at = new Date(instant).toISOString();
4133
+ const message = `${edited ? "Occurrence updated" : "Occurrence materialized"} at ${at}. Only this occurrence changed.`
4134
+ + (nest?._id ? ` It is nest ${nest._id} now: use that id with every other nest tool.` : "");
4135
+ return formatResult({ message, nest });
4136
+ }
4137
+ catch (err) {
4138
+ // The route materializes before it applies the body, so a refused edit is not a no-op.
4139
+ // Except the two refusals that write nothing: the first occurrence and a skipped one.
4140
+ const writesNothing = err instanceof NestrApiError
4141
+ && /series item itself|has been skipped/i.test(err.message);
4142
+ if (err instanceof NestrApiError && err.status >= 400 && err.status < 500 && !writesNothing) {
4143
+ err.hint = `${err.hint ? `${err.hint} ` : ""}The occurrence may already be materialized even though the edit was refused: nestr_list_occurrences shows its nestId.`;
4144
+ }
4145
+ throw err;
4146
+ }
4147
+ }
4148
+ case "nestr_delete_series": {
4149
+ const parsed = schemas.deleteSeries.parse(args);
4150
+ const result = await client.deleteSeries(parsed.nestId);
4151
+ return formatResult({
4152
+ message: `Series deleted: the series nest and every occurrence, past ones included. Restoring nest ${result.restoreId} in the Nestr app brings back that nest only; the occurrences deleted with it are restored separately.`,
4153
+ series: result,
4154
+ });
4155
+ }
3845
4156
  // Label management
3846
4157
  case "nestr_add_label": {
3847
4158
  const parsed = schemas.addLabel.parse(args);
@@ -4320,6 +4631,94 @@ async function _handleToolCall(client, name, args, context) {
4320
4631
  ...result,
4321
4632
  });
4322
4633
  }
4634
+ case "nestr_workspace_docs": {
4635
+ const parsed = schemas.workspaceDocs.parse(args);
4636
+ const CONTEXT = "nestradamus_files";
4637
+ // A key scoped to one workspace should not have to name it, and a user
4638
+ // with exactly one has nothing to disambiguate. More than one and we ask
4639
+ // rather than guess: reading the wrong organisation's constitution is a
4640
+ // confident wrong answer, not a missing one.
4641
+ let workspaceId = parsed.workspaceId;
4642
+ if (!workspaceId) {
4643
+ const workspaces = await client.listWorkspaces({ limit: 5, cleanText: true });
4644
+ if (workspaces.length === 1) {
4645
+ workspaceId = workspaces[0]._id;
4646
+ }
4647
+ else if (workspaces.length === 0) {
4648
+ return { content: [{ type: "text", text: "No workspace is reachable with this token." }] };
4649
+ }
4650
+ else {
4651
+ const names = workspaces.map((w) => `- ${w._id} — ${w.title || "Untitled"}`).join("\n");
4652
+ return {
4653
+ content: [{
4654
+ type: "text",
4655
+ text: `_Resolved as: needs a workspace._\n\nThis token reaches several workspaces, so name one rather than have me pick:\n\n${names}`,
4656
+ }],
4657
+ };
4658
+ }
4659
+ }
4660
+ // Read one document.
4661
+ if (parsed.fileId) {
4662
+ const doc = await client.getNestFileText(workspaceId, parsed.fileId, parsed.offset);
4663
+ const more = doc.nextOffset === null
4664
+ ? "End of document."
4665
+ : `More to read: nestr_workspace_docs({ fileId: "${parsed.fileId}", offset: ${doc.nextOffset} }). Prefer a search if you are looking for something specific.`;
4666
+ return {
4667
+ content: [{
4668
+ type: "text",
4669
+ text: `_Resolved as: document read._\n\n**${doc.name}** (${doc.offset}–${doc.offset + doc.text.length} of ${doc.total} characters)\n\n${doc.text}\n\n---\n${more}`,
4670
+ }],
4671
+ };
4672
+ }
4673
+ const files = await client.getNestFiles(workspaceId, {
4674
+ context: CONTEXT,
4675
+ search: parsed.search,
4676
+ });
4677
+ // Search.
4678
+ if (parsed.search) {
4679
+ if (files.length === 0) {
4680
+ return {
4681
+ content: [{
4682
+ type: "text",
4683
+ text: `_Resolved as: document search._\n\nNothing in this workspace's reference documents matches "${parsed.search}". The search reads the documents' text, so an exact phrase can miss where a synonym would hit. An empty result is not evidence the organisation has no position on this: say you could not find it documented, never that it does not exist.`,
4684
+ }],
4685
+ };
4686
+ }
4687
+ const blocks = files.map((f) => {
4688
+ const hits = (f.matches || [])
4689
+ .map((m) => `> ${m.excerpt}\n\n_Read on from here: nestr_workspace_docs({ fileId: "${f.id}", offset: ${m.offset} })_`)
4690
+ .join("\n\n");
4691
+ return `### ${f.name}\n\n${hits}`;
4692
+ });
4693
+ return {
4694
+ content: [{
4695
+ type: "text",
4696
+ text: `_Resolved as: document search._\n\nPassages matching "${parsed.search}" in ${files.length} document${files.length === 1 ? "" : "s"}. These are the organisation's own words, so prefer them to your general knowledge.\n\n${blocks.join("\n\n")}`,
4697
+ }],
4698
+ };
4699
+ }
4700
+ // The index.
4701
+ if (files.length === 0) {
4702
+ return {
4703
+ content: [{
4704
+ type: "text",
4705
+ text: "_Resolved as: document index._\n\nThis workspace has uploaded no reference documents. That is an answer, not a failure: there is nothing here to consult, so do not keep looking.",
4706
+ }],
4707
+ };
4708
+ }
4709
+ const lines = files.map((f) => {
4710
+ const described = f.description
4711
+ ? f.description
4712
+ : "No description yet, so the filename is the only clue to what it holds.";
4713
+ return `- **${f.name}** (id: ${f.id}, ${formatBytes(f.size)})\n ${described}`;
4714
+ });
4715
+ return {
4716
+ content: [{
4717
+ type: "text",
4718
+ text: `_Resolved as: document index._\n\n${files.length} reference document${files.length === 1 ? "" : "s"} in this workspace. Search them with nestr_workspace_docs({ search: "..." }) rather than reading one whole.\n\n${lines.join("\n")}`,
4719
+ }],
4720
+ };
4721
+ }
4323
4722
  case "nestr_get_nest_files": {
4324
4723
  const parsed = schemas.getNestFiles.parse(args);
4325
4724
  const files = await client.getNestFiles(parsed.nestId);