@nestr/mcp 0.1.105 → 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.
- package/README.md +33 -1
- package/SECURITY.md +97 -0
- package/build/api/client.d.ts +264 -43
- package/build/api/client.d.ts.map +1 -1
- package/build/api/client.js +202 -38
- package/build/api/client.js.map +1 -1
- package/build/help/topics.d.ts.map +1 -1
- package/build/help/topics.js +116 -1
- package/build/help/topics.js.map +1 -1
- package/build/tools/index.d.ts +2861 -636
- package/build/tools/index.d.ts.map +1 -1
- package/build/tools/index.js +636 -59
- package/build/tools/index.js.map +1 -1
- package/package.json +4 -3
package/build/tools/index.js
CHANGED
|
@@ -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 = [];
|
|
@@ -492,6 +536,93 @@ export function nestrWebBase(apiBase) {
|
|
|
492
536
|
return base.replace(/\/api\/?$/, "").replace(/\/+$/, "") || "https://app.nestr.io";
|
|
493
537
|
}
|
|
494
538
|
const NESTR_WEB_BASE = nestrWebBase(process.env.NESTR_API_BASE);
|
|
539
|
+
/**
|
|
540
|
+
* Turn the tool-level `hints` argument into what the API should be sent.
|
|
541
|
+
*
|
|
542
|
+
* `false` means none. A level passes through. `undefined` and `true` both take the
|
|
543
|
+
* caller-appropriate default, which differs by call shape on purpose: a single read
|
|
544
|
+
* wants the teaching prose, a listing wants it once from nestr_help rather than once
|
|
545
|
+
* per row.
|
|
546
|
+
*/
|
|
547
|
+
export function resolveHintLevel(value, fallback) {
|
|
548
|
+
if (value === false)
|
|
549
|
+
return false;
|
|
550
|
+
if (value === "full" || value === "summary")
|
|
551
|
+
return value;
|
|
552
|
+
return fallback;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Append `strict:true` unless the caller already asked for it.
|
|
556
|
+
*
|
|
557
|
+
* Without it an unrecognised operator, label or field filter is silently dropped and the
|
|
558
|
+
* search returns a broader result that looks like a real answer. That is fine for
|
|
559
|
+
* browsing and wrong for counting, so the tool exposes it as a flag rather than making
|
|
560
|
+
* every caller remember the operator.
|
|
561
|
+
*/
|
|
562
|
+
export function withStrict(query, strict) {
|
|
563
|
+
if (!strict)
|
|
564
|
+
return query;
|
|
565
|
+
if (/(^|\s)strict:true(\s|$)/i.test(query))
|
|
566
|
+
return query;
|
|
567
|
+
return `${query} strict:true`;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Reduce an OpenAPI document to something a model can read.
|
|
571
|
+
*
|
|
572
|
+
* The whole document is far too large to return, and returning nothing useful is how a
|
|
573
|
+
* caller ends up guessing whether an endpoint exists. So: an operation index by default,
|
|
574
|
+
* one operation in full when asked for by path, and a keyword filter in between. The
|
|
575
|
+
* counts matter as much as the rows — "0 of 94 operations match 'duration'" is the
|
|
576
|
+
* answer to "does the API store meeting duration", and it is a real negative rather than
|
|
577
|
+
* a failed search.
|
|
578
|
+
*/
|
|
579
|
+
export function summariseApiSpec(spec, filter = {}) {
|
|
580
|
+
const paths = (spec?.paths || {});
|
|
581
|
+
const allPaths = Object.keys(paths);
|
|
582
|
+
if (filter.path) {
|
|
583
|
+
const match = allPaths.find((p) => { return p === filter.path; })
|
|
584
|
+
|| allPaths.find((p) => { return p.toLowerCase() === filter.path.toLowerCase(); });
|
|
585
|
+
if (!match) {
|
|
586
|
+
return {
|
|
587
|
+
found: false,
|
|
588
|
+
requested: filter.path,
|
|
589
|
+
totalPaths: allPaths.length,
|
|
590
|
+
note: "No such path in this deployment's spec. This is a definitive negative, not a failed lookup.",
|
|
591
|
+
// Intentionally broad rather than precise: the first path segment, so
|
|
592
|
+
// `/nests/{id}/nonsense` suggests everything under `/nests/`. A caller who got the
|
|
593
|
+
// path wrong usually has the resource right, and a wide list they can scan beats a
|
|
594
|
+
// narrow one that misses the route they meant. Capped so it stays readable.
|
|
595
|
+
didYouMean: allPaths.filter((p) => { return p.includes(filter.path.split("/")[1] || ""); }).slice(0, 10),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
return { found: true, path: match, operations: paths[match] };
|
|
599
|
+
}
|
|
600
|
+
const rows = [];
|
|
601
|
+
for (const p of allPaths) {
|
|
602
|
+
for (const [method, op] of Object.entries(paths[p] || {})) {
|
|
603
|
+
if (typeof op !== "object" || op === null)
|
|
604
|
+
continue;
|
|
605
|
+
const summary = (op.summary || op.description || "").split("\n")[0].slice(0, 160);
|
|
606
|
+
rows.push({ method: method.toUpperCase(), path: p, summary });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const needle = filter.search?.toLowerCase();
|
|
610
|
+
const matched = needle
|
|
611
|
+
? rows.filter((r) => {
|
|
612
|
+
return r.path.toLowerCase().includes(needle) || r.summary.toLowerCase().includes(needle);
|
|
613
|
+
})
|
|
614
|
+
: rows;
|
|
615
|
+
return {
|
|
616
|
+
totalOperations: rows.length,
|
|
617
|
+
matchedOperations: matched.length,
|
|
618
|
+
...(needle ? { query: filter.search } : {}),
|
|
619
|
+
...(needle && matched.length === 0
|
|
620
|
+
? { note: `No operation mentions "${filter.search}". This deployment does not serve one, which is a definitive answer rather than a failed search.` }
|
|
621
|
+
: {}),
|
|
622
|
+
operations: matched.slice(0, 200),
|
|
623
|
+
...(matched.length > 200 ? { truncated: true } : {}),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
495
626
|
export function enrichHints(data) {
|
|
496
627
|
if (!data || typeof data !== "object")
|
|
497
628
|
return data;
|
|
@@ -690,6 +821,30 @@ const coerceFromJson = (schema) => z.preprocess((val) => {
|
|
|
690
821
|
}
|
|
691
822
|
return val;
|
|
692
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
|
+
});
|
|
693
848
|
// Coerce an integer-array param to number[] even when a client serialises it as
|
|
694
849
|
// a string — e.g. a stale/cached tool schema that doesn't know the array type
|
|
695
850
|
// sends "[4,5,6]", "4,5,6", or a bare 4. Non-numeric tokens are dropped and the
|
|
@@ -723,9 +878,73 @@ const MENTION_DESC = "Supports HTML and @mentions. Mentions MUST use literal cur
|
|
|
723
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.";
|
|
724
879
|
const PURPOSE_DESC = "Only for workspaces, circles and roles: a short aspirational statement. Details belong in description, not here. Supports HTML.";
|
|
725
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
|
+
};
|
|
927
|
+
const HINTS_DESC = "Contextual hints. 'summary' keeps the per-nest signal (type, severity, count, url, and the "
|
|
928
|
+
+ "`query` that finds every other nest with the same problem) and drops the fixed teaching prose "
|
|
929
|
+
+ "and endpoint list. 'full' is the whole payload. false for none.";
|
|
726
930
|
const STRIP_DESCRIPTION = "Strip description fields to shrink the response. Use for bulk or index reads.";
|
|
727
931
|
const SORT_DESCRIPTION = "Sort field: title, createdAt, updatedAt, due, activityAt, order. Prefix '-' to reverse. For 'recently active' use '-activityAt' (includes children), not '-updatedAt' (own edits only).";
|
|
728
932
|
// Tool input schemas using Zod
|
|
933
|
+
/**
|
|
934
|
+
* `hints` is a level, not a flag. `summary` keeps what varies per nest and drops the
|
|
935
|
+
* teaching prose and endpoint list, which are identical for every nest of a type; `full`
|
|
936
|
+
* is the whole payload. Booleans still work: the API reads a bare `true` on a listing as
|
|
937
|
+
* `summary`, which is the difference between one paragraph and fifty copies of it.
|
|
938
|
+
*/
|
|
939
|
+
const hintLevelSchema = z.union([z.boolean(), z.enum(["full", "summary"])]).optional();
|
|
940
|
+
const hintFilterSchemas = {
|
|
941
|
+
hintTypes: coerceFromJson(z.array(z.string())).optional()
|
|
942
|
+
.describe("Keep only these hint types, e.g. ['project_waiting_no_reason','unassigned_role']."),
|
|
943
|
+
minSeverity: z.enum(["info", "suggestion", "warning", "alert"]).optional()
|
|
944
|
+
.describe("Drop hints below this severity. 'warning' is the useful floor when sweeping."),
|
|
945
|
+
};
|
|
946
|
+
const linkedUsersSchema = z.boolean().optional()
|
|
947
|
+
.describe("Resolve every user id in the results to a full user, returned once in linked.users.");
|
|
729
948
|
export const schemas = {
|
|
730
949
|
listWorkspaces: z.object({
|
|
731
950
|
search: z.string().optional().describe("Search query to filter workspaces"),
|
|
@@ -790,6 +1009,8 @@ export const schemas = {
|
|
|
790
1009
|
search: z.object({
|
|
791
1010
|
workspaceId: z.string().describe("Workspace ID to search in"),
|
|
792
1011
|
query: z.string().describe("Search query"),
|
|
1012
|
+
strict: z.boolean().optional().describe("Reject the search if any operator, label or field filter in it was not recognised, instead of silently returning a broader result. Use it whenever you are counting rather than browsing: without it a typo'd filter reads as a real, larger answer."),
|
|
1013
|
+
linkedUsers: linkedUsersSchema,
|
|
793
1014
|
sort: z.string().optional().describe(`${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.`),
|
|
794
1015
|
limit: z.number().optional().describe("Max results per page. Omit on first call to see meta.total count."),
|
|
795
1016
|
page: z.number().optional().describe("Page number (1-indexed) for pagination"),
|
|
@@ -798,7 +1019,8 @@ export const schemas = {
|
|
|
798
1019
|
getNest: z.object({
|
|
799
1020
|
nestId: z.string().describe("Nest ID. Supports comma-separated IDs to fetch multiple nests in one call (e.g., 'id1,id2,id3') — returns an array instead of a single object. Keep total URL under 2000 chars to avoid HTTP limits."),
|
|
800
1021
|
fieldsMetaData: z.boolean().optional().describe("Set to true to include field schema metadata (e.g., available options for project.status)"),
|
|
801
|
-
hints:
|
|
1022
|
+
hints: hintLevelSchema.describe("Contextual hints: 'full' (default on a single read), 'summary', or false. 'summary' keeps type, severity, count, url and the sibling query, and drops the teaching prose and endpoints. Hints surface signals like unassigned roles, stale projects or unread comments."),
|
|
1023
|
+
...hintFilterSchemas,
|
|
802
1024
|
provenance: z.boolean().optional().describe("Single-nest only. Include field/property provenance: which label (and circle context) defines each field and property."),
|
|
803
1025
|
rights: z.boolean().optional().describe("Single-nest only. Include the caller's composed rights on the nest plus a deny trace naming the profiles that block each op."),
|
|
804
1026
|
forUser: z.string().optional().describe("Single nest, with rights=true. Rights for this user id instead of the caller. Caller must be a nest admin."),
|
|
@@ -811,12 +1033,25 @@ export const schemas = {
|
|
|
811
1033
|
}),
|
|
812
1034
|
getNestChildren: z.object({
|
|
813
1035
|
nestId: z.string().describe("Parent nest ID"),
|
|
1036
|
+
search: z.string().optional().describe("Search scoped to this nest, full operator syntax. Ask for the subset you want rather than fetching every child and filtering: 'label:role' for a circle's roles, 'label:project fields.project.status:Waiting' for its waiting projects. depth:1 is applied when you set no depth, and the response says so in appliedDefaults."),
|
|
814
1037
|
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
815
1038
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
816
1039
|
page: z.number().optional().describe("Page number for pagination"),
|
|
817
|
-
hints:
|
|
1040
|
+
hints: hintLevelSchema.describe("Contextual hints: 'summary' (default on a listing), 'full', or false. A listing at 'full' repeats the same teaching prose once per row."),
|
|
1041
|
+
...hintFilterSchemas,
|
|
1042
|
+
linkedUsers: linkedUsersSchema,
|
|
818
1043
|
_listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Tasks for Website Redesign\"). Omit for default."),
|
|
819
1044
|
}),
|
|
1045
|
+
hintsRollup: z.object({
|
|
1046
|
+
nestId: z.string().describe("Nest to roll up. A circle or workspace root is the useful scope."),
|
|
1047
|
+
hintTypes: coerceFromJson(z.array(z.string())).optional().describe("Count only these hint types."),
|
|
1048
|
+
minSeverity: z.enum(["info", "suggestion", "warning", "alert"]).optional().describe("Drop rules below this severity."),
|
|
1049
|
+
sampleSize: z.number().optional().describe("Example nests per type. Default 10, max 100, 0 for counts only."),
|
|
1050
|
+
}),
|
|
1051
|
+
apiSpec: z.object({
|
|
1052
|
+
search: z.string().optional().describe("Filter operations by keyword against path and summary, e.g. 'meeting' or 'tension'."),
|
|
1053
|
+
path: z.string().optional().describe("Return the full schema for one path, e.g. '/nests/{id}/children'."),
|
|
1054
|
+
}),
|
|
820
1055
|
createNest: z.object({
|
|
821
1056
|
parentId: z.string().describe("Parent nest ID (workspace, circle, or project)"),
|
|
822
1057
|
title: z.string().describe("Title of the new nest (plain text, HTML stripped)"),
|
|
@@ -832,16 +1067,7 @@ export const schemas = {
|
|
|
832
1067
|
}),
|
|
833
1068
|
updateNest: z.object({
|
|
834
1069
|
nestId: z.string().describe("Nest ID to update"),
|
|
835
|
-
|
|
836
|
-
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."),
|
|
837
|
-
purpose: z.string().optional().describe(PURPOSE_DESC),
|
|
838
|
-
parentId: z.string().optional().describe("New parent ID (move nest to different location, e.g., move inbox item to a role or project)"),
|
|
839
|
-
labels: coerceFromJson(z.array(z.string())).optional().describe("Label IDs to set (e.g., ['project'] to convert an item into a project)"),
|
|
840
|
-
fields: coerceFromJson(z.record(z.unknown())).optional().describe("Field updates (e.g., { 'project.status': 'Current' })"),
|
|
841
|
-
users: coerceFromJson(z.array(z.string())).optional().describe("User IDs to assign"),
|
|
842
|
-
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."),
|
|
843
|
-
due: z.string().optional().describe("Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time."),
|
|
844
|
-
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,
|
|
845
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."),
|
|
846
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."),
|
|
847
1073
|
workspaceId: z.string().optional().describe("Workspace ID. Required when updating accountabilities or domains on roles/circles."),
|
|
@@ -880,6 +1106,7 @@ export const schemas = {
|
|
|
880
1106
|
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
881
1107
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
882
1108
|
page: z.number().optional().describe("Page number for pagination"),
|
|
1109
|
+
linkedUsers: linkedUsersSchema,
|
|
883
1110
|
}),
|
|
884
1111
|
listUserRoles: z.object({
|
|
885
1112
|
userId: z.string().optional().describe("User ID to look up. Omit for yourself. Requires workspaceId when set."),
|
|
@@ -914,6 +1141,7 @@ export const schemas = {
|
|
|
914
1141
|
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
915
1142
|
limit: z.number().optional().describe("Max results per page. Omit to see full count in meta.total."),
|
|
916
1143
|
page: z.number().optional().describe("Page number for pagination"),
|
|
1144
|
+
linkedUsers: linkedUsersSchema,
|
|
917
1145
|
_listTitle: z.string().optional().describe("Short descriptive title for the list UI (e.g., \"Engineering projects\"). Omit for default."),
|
|
918
1146
|
}),
|
|
919
1147
|
getComments: z.object({
|
|
@@ -949,6 +1177,12 @@ export const schemas = {
|
|
|
949
1177
|
getWorkspaceApps: z.object({
|
|
950
1178
|
workspaceId: z.string().describe("Workspace ID"),
|
|
951
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
|
+
}),
|
|
952
1186
|
// Inbox tools (require OAuth token)
|
|
953
1187
|
listInbox: z.object({
|
|
954
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."),
|
|
@@ -1008,6 +1242,30 @@ export const schemas = {
|
|
|
1008
1242
|
workspaceId: z.string().describe("Workspace ID"),
|
|
1009
1243
|
nestIds: coerceFromJson(z.array(z.string())).describe("Array of nest IDs in the desired order"),
|
|
1010
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
|
+
}),
|
|
1011
1269
|
// Daily plan (requires OAuth token)
|
|
1012
1270
|
getDailyPlan: z.object({}),
|
|
1013
1271
|
// Current user identity (requires OAuth token)
|
|
@@ -1058,7 +1316,6 @@ export const schemas = {
|
|
|
1058
1316
|
sort: z.string().optional().describe(SORT_DESCRIPTION),
|
|
1059
1317
|
limit: z.number().optional().describe("Max results to return"),
|
|
1060
1318
|
page: z.number().optional().describe("Page number for pagination"),
|
|
1061
|
-
order: z.string().optional().describe("Deprecated alias of sort"),
|
|
1062
1319
|
}),
|
|
1063
1320
|
updateTension: z.object({
|
|
1064
1321
|
nestId: z.string().describe("ID of the circle or role the tension belongs to"),
|
|
@@ -1369,12 +1626,14 @@ export const toolDefinitions = [
|
|
|
1369
1626
|
},
|
|
1370
1627
|
{
|
|
1371
1628
|
name: "nestr_search",
|
|
1372
|
-
description: "Search nests in a workspace. Supports operators like label:, assignee:, createdby:, completed:, in:, sort:. createdby: accepts me, an email address, or a user id. Always use completed:false for active work. See nestr_help('search') for full syntax. Results carry user ids; when presenting to a person, show names and/or emails
|
|
1629
|
+
description: "Search nests in a workspace. Supports operators like label:, assignee:, createdby:, completed:, in:, sort:. createdby: accepts me, an email address, or a user id. Always use completed:false for active work. See nestr_help('search') for full syntax. Results carry user ids; when presenting to a person, show names and/or emails. Pass `linkedUsers: true` to get them all resolved in the same request rather than calling nestr_get_user per id.",
|
|
1373
1630
|
inputSchema: {
|
|
1374
1631
|
type: "object",
|
|
1375
1632
|
properties: {
|
|
1376
1633
|
workspaceId: { type: "string", description: "Workspace ID to search in" },
|
|
1377
1634
|
query: { type: "string", description: "Search query with optional operators (e.g., 'label:role', 'assignee:me completed:false')" },
|
|
1635
|
+
strict: { type: "boolean", description: "Reject the search when any operator, label or field filter in it was not recognised, rather than silently returning a broader result. Use it whenever you are counting rather than browsing: without it, a mistyped filter comes back as a real and larger answer with nothing to say the filter was dropped." },
|
|
1636
|
+
linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. Prefer this over calling nestr_get_user per id." },
|
|
1378
1637
|
sort: { type: "string", description: `${SORT_DESCRIPTION} Takes precedence over sort:/sort-order: operators in the query.` },
|
|
1379
1638
|
limit: { type: "number", description: "Max results per page. Omit on the first call so meta.total shows the match count." },
|
|
1380
1639
|
page: { type: "number", description: "Page number (1-indexed) for fetching additional pages" },
|
|
@@ -1395,7 +1654,9 @@ export const toolDefinitions = [
|
|
|
1395
1654
|
properties: {
|
|
1396
1655
|
nestId: { type: "string", description: "Nest ID, or comma-separated IDs for a batch (e.g. 'id1,id2'). Keep the URL under 2000 chars." },
|
|
1397
1656
|
fieldsMetaData: { type: "boolean", description: "Set to true to include field schema metadata (available options, field types)" },
|
|
1398
|
-
hints: { type: "boolean",
|
|
1657
|
+
hints: { type: ["string", "boolean"], enum: ["summary", "full", true, false], description: `${HINTS_DESC} Defaults to 'full' on a single read, which is what you want when you asked about one nest.` },
|
|
1658
|
+
hintTypes: { type: "array", items: { type: "string" }, description: "Keep only these hint types." },
|
|
1659
|
+
minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop hints below this severity." },
|
|
1399
1660
|
stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
|
|
1400
1661
|
provenance: { type: "boolean", description: "Single nest. Which label and circle context defines each field and property, e.g. why a role has a given icon." },
|
|
1401
1662
|
rights: { type: "boolean", description: "Single nest. The caller's composed rights (self read/update/delete) plus a deny trace naming what blocks each op, and why." },
|
|
@@ -1422,15 +1683,19 @@ export const toolDefinitions = [
|
|
|
1422
1683
|
},
|
|
1423
1684
|
{
|
|
1424
1685
|
name: "nestr_get_nest_children",
|
|
1425
|
-
description: "Get children of a nest.
|
|
1686
|
+
description: "Get children of a nest, or just the ones you want. Pass `search` with the full operator syntax to ask for a subset rather than fetching everything and filtering: `search: 'label:role'` returns a circle's roles in one call. Paginated at 50 per page; read meta.total for the match count.",
|
|
1426
1687
|
inputSchema: {
|
|
1427
1688
|
type: "object",
|
|
1428
1689
|
properties: {
|
|
1429
1690
|
nestId: { type: "string", description: "Parent nest ID" },
|
|
1691
|
+
search: { type: "string", description: "Search scoped to this nest, full operator syntax. Ask for the subset you want instead of fetching every child and filtering: `label:role` for a circle's roles, `label:project fields.project.status:Waiting` for its waiting projects. `depth:1` is applied when you set no depth and the response says so in `appliedDefaults`; pass `depth:2` or higher to reach further down." },
|
|
1430
1692
|
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
1431
1693
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
1432
1694
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
1433
|
-
hints: { type: "boolean",
|
|
1695
|
+
hints: { type: ["string", "boolean"], enum: ["summary", "full", true, false], description: `${HINTS_DESC} Defaults to 'summary' here: a listing at 'full' repeats the same paragraph once per row.` },
|
|
1696
|
+
hintTypes: { type: "array", items: { type: "string" }, description: "Keep only these hint types." },
|
|
1697
|
+
minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop hints below this severity." },
|
|
1698
|
+
linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
|
|
1434
1699
|
stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
|
|
1435
1700
|
_listTitle: { type: "string", description: "Short descriptive title for the list UI header (e.g., \"Tasks for Website Redesign\", \"API project sub-tasks\"). Include the parent name for context." },
|
|
1436
1701
|
},
|
|
@@ -1440,6 +1705,33 @@ export const toolDefinitions = [
|
|
|
1440
1705
|
// The completable list app should only be used when results are confirmed to be completable items.
|
|
1441
1706
|
...readOnly,
|
|
1442
1707
|
},
|
|
1708
|
+
{
|
|
1709
|
+
name: "nestr_hints_rollup",
|
|
1710
|
+
description: "Count hints across a whole circle or workspace in one call. A hint on a single nest says \"this one has this problem\"; this says how many have it, which is the question you actually ask of a circle. Answers \"how healthy is this circle\", \"how many projects are waiting with no reason\", \"how many roles have nobody in them\" without reading every nest. Each result carries type, severity, count and a small sample to open. Read `notComputed`: it names every hint type this cannot count in one query, so a type listed there is unknown rather than zero. Do not sum counts across types, one nest can carry several; ask for a single hintTypes when you want a number that adds up.",
|
|
1711
|
+
inputSchema: {
|
|
1712
|
+
type: "object",
|
|
1713
|
+
properties: {
|
|
1714
|
+
nestId: { type: "string", description: "Nest to roll up. A circle or the workspace root is the useful scope." },
|
|
1715
|
+
hintTypes: { type: "array", items: { type: "string" }, description: "Count only these hint types. Omit for every type this can compute." },
|
|
1716
|
+
minSeverity: { type: "string", enum: ["info", "suggestion", "warning", "alert"], description: "Drop rules below this severity. 'warning' is the useful floor for \"what needs attention\"." },
|
|
1717
|
+
sampleSize: { type: "number", description: "Example nests per type. Default 10, max 100, 0 for counts only." },
|
|
1718
|
+
},
|
|
1719
|
+
required: ["nestId"],
|
|
1720
|
+
},
|
|
1721
|
+
...readOnly,
|
|
1722
|
+
},
|
|
1723
|
+
{
|
|
1724
|
+
name: "nestr_api_spec",
|
|
1725
|
+
description: "The deployment's own OpenAPI document, so you can check whether the API has something rather than concluding \"I did not find it\" and guessing. Call with no arguments for the operation index (method, path, one-line summary). `search` filters by keyword against path and summary. `path` returns the full schema for one operation, including its parameters. Use this to answer \"is there an endpoint for X\" with certainty, and note that a negative here is a real negative: if it is not in the spec, this deployment does not serve it. For the search query language rather than the HTTP surface, use nestr_help('search'); for a filter you are unsure of, `strict: true` on nestr_search tells you whether it applied.",
|
|
1726
|
+
inputSchema: {
|
|
1727
|
+
type: "object",
|
|
1728
|
+
properties: {
|
|
1729
|
+
search: { type: "string", description: "Filter operations by keyword against path and summary, e.g. 'meeting', 'tension', 'duration'." },
|
|
1730
|
+
path: { type: "string", description: "Return the full schema for one path, e.g. '/nests/{id}/children'." },
|
|
1731
|
+
},
|
|
1732
|
+
},
|
|
1733
|
+
...readOnly,
|
|
1734
|
+
},
|
|
1443
1735
|
{
|
|
1444
1736
|
name: "nestr_create_nest",
|
|
1445
1737
|
description: `Create a nest under a parent. Labels define the type, e.g. ['project'], ['role']. ${PRIME_LABEL_RULE} Sprint/epic/milestone never pair; stories link to those via graph relations. In established workspaces prefer the tension flow for governance. Meetings attach to a circle that ALREADY exists — the workspace anchor circle counts, and is the right parent when no sub-circle fits. Never create a circle to hold a meeting. Use \`['meeting','circle-meeting']\` for a tactical meeting, \`['meeting','governance']\` for a governance meeting, and set \`due\` to the start time. See nestr_help('labels') and nestr_help('meetings').`,
|
|
@@ -1494,36 +1786,7 @@ export const toolDefinitions = [
|
|
|
1494
1786
|
type: "object",
|
|
1495
1787
|
properties: {
|
|
1496
1788
|
nestId: { type: "string", description: "Nest ID to update" },
|
|
1497
|
-
|
|
1498
|
-
description: { type: "string", description: CONTENT_DESC },
|
|
1499
|
-
purpose: { type: "string", description: PURPOSE_DESC },
|
|
1500
|
-
parentId: { type: "string", description: "New parent ID (move nest to different location)" },
|
|
1501
|
-
labels: {
|
|
1502
|
-
type: "array",
|
|
1503
|
-
items: { type: "string" },
|
|
1504
|
-
description: "Label IDs to set (e.g., ['project'] to convert an item into a project)",
|
|
1505
|
-
},
|
|
1506
|
-
fields: {
|
|
1507
|
-
type: "object",
|
|
1508
|
-
description: "Field updates (e.g., { 'project.status': 'Current' })",
|
|
1509
|
-
},
|
|
1510
|
-
users: {
|
|
1511
|
-
type: "array",
|
|
1512
|
-
items: { type: "string" },
|
|
1513
|
-
description: "User IDs to assign",
|
|
1514
|
-
},
|
|
1515
|
-
data: {
|
|
1516
|
-
type: "object",
|
|
1517
|
-
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.",
|
|
1518
|
-
},
|
|
1519
|
-
due: {
|
|
1520
|
-
type: "string",
|
|
1521
|
-
description: "Due date (ISO format). For projects/tasks: deadline. For roles: re-election date. For meetings: start time.",
|
|
1522
|
-
},
|
|
1523
|
-
completed: {
|
|
1524
|
-
type: "boolean",
|
|
1525
|
-
description: "Mark task as completed (root-level field, not in fields). Note: Projects use fields['project.status'] = 'Done' instead.",
|
|
1526
|
-
},
|
|
1789
|
+
...NEST_UPDATE_FIELD_PROPERTIES,
|
|
1527
1790
|
accountabilities: {
|
|
1528
1791
|
type: "array",
|
|
1529
1792
|
items: { type: "string" },
|
|
@@ -1545,7 +1808,7 @@ export const toolDefinitions = [
|
|
|
1545
1808
|
},
|
|
1546
1809
|
{
|
|
1547
1810
|
name: "nestr_delete_nest",
|
|
1548
|
-
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.",
|
|
1549
1812
|
inputSchema: {
|
|
1550
1813
|
type: "object",
|
|
1551
1814
|
properties: {
|
|
@@ -1659,6 +1922,7 @@ export const toolDefinitions = [
|
|
|
1659
1922
|
limit: { type: "number", description: "Omit on first call to see meta.total count" },
|
|
1660
1923
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
1661
1924
|
stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
|
|
1925
|
+
linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
|
|
1662
1926
|
},
|
|
1663
1927
|
required: ["workspaceId"],
|
|
1664
1928
|
},
|
|
@@ -1874,6 +2138,7 @@ export const toolDefinitions = [
|
|
|
1874
2138
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
1875
2139
|
stripDescription: { type: "boolean", description: STRIP_DESCRIPTION },
|
|
1876
2140
|
_listTitle: { type: "string", description: "Short descriptive title for the list UI header (e.g., \"Engineering projects\", \"All projects\"). Omit for default." },
|
|
2141
|
+
linkedUsers: { type: "boolean", description: "Resolve every user id in the results to a full user, returned once in `linked.users`. One request instead of one per id." },
|
|
1877
2142
|
},
|
|
1878
2143
|
required: ["workspaceId"],
|
|
1879
2144
|
},
|
|
@@ -1891,6 +2156,7 @@ export const toolDefinitions = [
|
|
|
1891
2156
|
oneOf: [{ type: "number" }, { type: "string", enum: ["all"] }],
|
|
1892
2157
|
description: "How deep below the context nest to look for comments. 0 (default) returns only comments directly on this nest; N includes comments on descendants up to N levels deep; 'all' includes comments on this nest and every descendant.",
|
|
1893
2158
|
},
|
|
2159
|
+
unread: { type: "boolean", description: "true for comments you have not read, false for the ones you have. Omit for all. Combine with depth to sweep a circle for what you have missed." },
|
|
1894
2160
|
},
|
|
1895
2161
|
required: ["nestId"],
|
|
1896
2162
|
},
|
|
@@ -2131,6 +2397,94 @@ export const toolDefinitions = [
|
|
|
2131
2397
|
},
|
|
2132
2398
|
...mutating,
|
|
2133
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
|
+
},
|
|
2134
2488
|
// Daily plan (requires OAuth token)
|
|
2135
2489
|
{
|
|
2136
2490
|
name: "nestr_get_daily_plan",
|
|
@@ -2331,9 +2685,6 @@ export const toolDefinitions = [
|
|
|
2331
2685
|
sort: { type: "string", description: SORT_DESCRIPTION },
|
|
2332
2686
|
limit: { type: "number", description: "Max results to return" },
|
|
2333
2687
|
page: { type: "number", description: "Page number (1-indexed)" },
|
|
2334
|
-
// The legacy `order` alias is deliberately not advertised — the Zod
|
|
2335
|
-
// schema still accepts it so existing callers keep working, but new
|
|
2336
|
-
// clients should only learn the canonical `sort` param.
|
|
2337
2688
|
},
|
|
2338
2689
|
required: ["nestId"],
|
|
2339
2690
|
},
|
|
@@ -2822,6 +3173,20 @@ export const toolDefinitions = [
|
|
|
2822
3173
|
},
|
|
2823
3174
|
...mutating,
|
|
2824
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
|
+
},
|
|
2825
3190
|
{
|
|
2826
3191
|
name: "nestr_get_nest_files",
|
|
2827
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.",
|
|
@@ -3219,11 +3584,12 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3219
3584
|
case "nestr_search": {
|
|
3220
3585
|
const parsed = schemas.search.parse(args);
|
|
3221
3586
|
const directives = extractSearchDirectives(parsed.query);
|
|
3222
|
-
const results = await client.searchWorkspace(parsed.workspaceId, parsed.query, {
|
|
3587
|
+
const results = await client.searchWorkspace(parsed.workspaceId, withStrict(parsed.query, parsed.strict), {
|
|
3223
3588
|
sort: parsed.sort ?? directives.sort,
|
|
3224
3589
|
limit: parsed.limit ?? directives.limit,
|
|
3225
3590
|
page: parsed.page,
|
|
3226
3591
|
cleanText: true,
|
|
3592
|
+
linkedUsers: parsed.linkedUsers,
|
|
3227
3593
|
});
|
|
3228
3594
|
return formatResult(completableResponse(compactResponse(results), "search", parsed._listTitle || `Search: ${parsed.query}`));
|
|
3229
3595
|
}
|
|
@@ -3232,7 +3598,9 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3232
3598
|
const nest = await client.getNest(parsed.nestId, {
|
|
3233
3599
|
cleanText: true,
|
|
3234
3600
|
fieldsMetaData: parsed.fieldsMetaData,
|
|
3235
|
-
hints: parsed.hints
|
|
3601
|
+
hints: resolveHintLevel(parsed.hints, "full"),
|
|
3602
|
+
hintTypes: parsed.hintTypes,
|
|
3603
|
+
minSeverity: parsed.minSeverity,
|
|
3236
3604
|
provenance: parsed.provenance,
|
|
3237
3605
|
rights: parsed.rights,
|
|
3238
3606
|
forUser: parsed.forUser,
|
|
@@ -3256,14 +3624,32 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3256
3624
|
case "nestr_get_nest_children": {
|
|
3257
3625
|
const parsed = schemas.getNestChildren.parse(args);
|
|
3258
3626
|
const children = await client.getNestChildren(parsed.nestId, {
|
|
3627
|
+
search: parsed.search,
|
|
3259
3628
|
sort: parsed.sort,
|
|
3260
3629
|
limit: parsed.limit,
|
|
3261
3630
|
page: parsed.page,
|
|
3262
3631
|
cleanText: true,
|
|
3263
|
-
hints: parsed.hints
|
|
3632
|
+
hints: resolveHintLevel(parsed.hints, "summary"),
|
|
3633
|
+
hintTypes: parsed.hintTypes,
|
|
3634
|
+
minSeverity: parsed.minSeverity,
|
|
3635
|
+
linkedUsers: parsed.linkedUsers,
|
|
3264
3636
|
});
|
|
3265
3637
|
return formatResult(completableResponse(compactResponse(enrichHints(children)), "children", parsed._listTitle || "Sub-items"));
|
|
3266
3638
|
}
|
|
3639
|
+
case "nestr_hints_rollup": {
|
|
3640
|
+
const parsed = schemas.hintsRollup.parse(args);
|
|
3641
|
+
const rollup = await client.getHintsRollup(parsed.nestId, {
|
|
3642
|
+
hintTypes: parsed.hintTypes,
|
|
3643
|
+
minSeverity: parsed.minSeverity,
|
|
3644
|
+
sampleSize: parsed.sampleSize,
|
|
3645
|
+
});
|
|
3646
|
+
return formatResult(rollup);
|
|
3647
|
+
}
|
|
3648
|
+
case "nestr_api_spec": {
|
|
3649
|
+
const parsed = schemas.apiSpec.parse(args);
|
|
3650
|
+
const spec = await client.getApiSpec();
|
|
3651
|
+
return formatResult(summariseApiSpec(spec, parsed));
|
|
3652
|
+
}
|
|
3267
3653
|
case "nestr_create_nest": {
|
|
3268
3654
|
const parsed = schemas.createNest.parse(args);
|
|
3269
3655
|
validatePrimeLabels(parsed.labels);
|
|
@@ -3350,8 +3736,17 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3350
3736
|
}
|
|
3351
3737
|
case "nestr_delete_nest": {
|
|
3352
3738
|
const parsed = schemas.deleteNest.parse(args);
|
|
3353
|
-
await client.deleteNest(parsed.nestId);
|
|
3354
|
-
|
|
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
|
+
});
|
|
3355
3750
|
}
|
|
3356
3751
|
case "nestr_add_comment": {
|
|
3357
3752
|
const parsed = schemas.addComment.parse(args);
|
|
@@ -3400,6 +3795,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3400
3795
|
limit: parsed.limit,
|
|
3401
3796
|
page: parsed.page,
|
|
3402
3797
|
cleanText: true,
|
|
3798
|
+
linkedUsers: parsed.linkedUsers,
|
|
3403
3799
|
});
|
|
3404
3800
|
return formatResult(compactResponse(roles, "role"));
|
|
3405
3801
|
}
|
|
@@ -3456,6 +3852,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3456
3852
|
limit: parsed.limit,
|
|
3457
3853
|
page: parsed.page,
|
|
3458
3854
|
cleanText: true,
|
|
3855
|
+
linkedUsers: parsed.linkedUsers,
|
|
3459
3856
|
});
|
|
3460
3857
|
return formatResult(completableResponse(compactResponse(projects), "projects", parsed._listTitle || "Projects"));
|
|
3461
3858
|
}
|
|
@@ -3662,6 +4059,100 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3662
4059
|
const result = await client.bulkReorder(parsed.workspaceId, parsed.nestIds);
|
|
3663
4060
|
return formatResult({ message: "Nests reordered successfully", nests: result });
|
|
3664
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
|
+
}
|
|
3665
4156
|
// Label management
|
|
3666
4157
|
case "nestr_add_label": {
|
|
3667
4158
|
const parsed = schemas.addLabel.parse(args);
|
|
@@ -3838,9 +4329,7 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
3838
4329
|
case "nestr_list_tensions": {
|
|
3839
4330
|
const parsed = schemas.listTensions.parse(args);
|
|
3840
4331
|
const tensions = await client.listTensions(parsed.nestId, parsed.search, {
|
|
3841
|
-
|
|
3842
|
-
// by the API (which reads `sort`), so route both through sort.
|
|
3843
|
-
sort: parsed.sort ?? parsed.order,
|
|
4332
|
+
sort: parsed.sort,
|
|
3844
4333
|
limit: parsed.limit,
|
|
3845
4334
|
page: parsed.page,
|
|
3846
4335
|
cleanText: true,
|
|
@@ -4142,6 +4631,94 @@ async function _handleToolCall(client, name, args, context) {
|
|
|
4142
4631
|
...result,
|
|
4143
4632
|
});
|
|
4144
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
|
+
}
|
|
4145
4722
|
case "nestr_get_nest_files": {
|
|
4146
4723
|
const parsed = schemas.getNestFiles.parse(args);
|
|
4147
4724
|
const files = await client.getNestFiles(parsed.nestId);
|