tana-mcp-codemode 0.2.2 → 0.2.3
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/package.json +1 -1
- package/src/api/tana.ts +230 -34
- package/src/prompts.ts +2 -1
- package/src/sandbox/executor.ts +2 -0
package/package.json
CHANGED
package/src/api/tana.ts
CHANGED
|
@@ -25,6 +25,115 @@ import type {
|
|
|
25
25
|
} from "./types";
|
|
26
26
|
import { format } from "./format";
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Build deep-object query params (style: deepObject, explode: true).
|
|
30
|
+
* Used by search and getFieldOptions.
|
|
31
|
+
*/
|
|
32
|
+
function addQueryParams(params: string[], obj: Record<string, unknown>, prefix: string) {
|
|
33
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
34
|
+
const paramKey = prefix ? `${prefix}[${key}]` : key;
|
|
35
|
+
if (value === null || value === undefined) continue;
|
|
36
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
37
|
+
addQueryParams(params, value as Record<string, unknown>, paramKey);
|
|
38
|
+
} else if (Array.isArray(value)) {
|
|
39
|
+
value.forEach((item, i) => {
|
|
40
|
+
if (typeof item === "object") {
|
|
41
|
+
addQueryParams(params, item as Record<string, unknown>, `${paramKey}[${i}]`);
|
|
42
|
+
} else {
|
|
43
|
+
params.push(`${encodeURIComponent(`${paramKey}[${i}]`)}=${encodeURIComponent(String(item))}`);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
} else {
|
|
47
|
+
params.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse predetermined options: ` - Name <!-- node-id: ABC -->` */
|
|
53
|
+
function parseOptionsFromMarkdown(md: string): { id: string; name: string }[] {
|
|
54
|
+
const results: { id: string; name: string }[] = [];
|
|
55
|
+
const lines = md.split("\n");
|
|
56
|
+
let inOptions = false;
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
if (line.includes("**Options**:")) { inOptions = true; continue; }
|
|
59
|
+
if (inOptions) {
|
|
60
|
+
const match = line.match(/^\s+- (.+?)\s*<!--\s*node-id:\s*(\S+)\s*-->/);
|
|
61
|
+
if (match) {
|
|
62
|
+
results.push({ id: match[2], name: match[1] });
|
|
63
|
+
} else if (line.trim() && !line.startsWith(" ")) {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse `[name](tana:id)` references from markdown */
|
|
72
|
+
function parseRefsFromMarkdown(md: string): { id: string; name: string }[] {
|
|
73
|
+
const results: { id: string; name: string }[] = [];
|
|
74
|
+
const re = /\[([^\]]+)\]\(tana:([^)]+)\)/g;
|
|
75
|
+
let match;
|
|
76
|
+
while ((match = re.exec(md)) !== null) {
|
|
77
|
+
results.push({ id: match[2], name: match[1] });
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract option values for a specific field from node markdown.
|
|
84
|
+
* Scopes parsing to only the target field's section to avoid noise
|
|
85
|
+
* from other fields, tags, and node names.
|
|
86
|
+
*/
|
|
87
|
+
function parseFieldValues(md: string, fieldName: string): { id: string; name: string }[] {
|
|
88
|
+
const results: { id: string; name: string }[] = [];
|
|
89
|
+
const lines = md.split("\n");
|
|
90
|
+
let inField = false;
|
|
91
|
+
let fieldIndent = -1;
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
// Detect the field section: " - **FieldName**:" or " - **FieldName**: value <!-- node-id: xxx -->"
|
|
95
|
+
if (!inField && line.includes(`**${fieldName}**`)) {
|
|
96
|
+
inField = true;
|
|
97
|
+
fieldIndent = line.search(/\S/);
|
|
98
|
+
|
|
99
|
+
// Inline single-value: " - **Type**: negotiation <!-- node-id: xxx -->"
|
|
100
|
+
const inlineMatch = line.match(
|
|
101
|
+
new RegExp(`\\*\\*${fieldName}\\*\\*:\\s*(.+?)\\s*<!--\\s*node-id:\\s*(\\S+)\\s*-->`)
|
|
102
|
+
);
|
|
103
|
+
if (inlineMatch) {
|
|
104
|
+
results.push({ id: inlineMatch[2], name: inlineMatch[1].trim() });
|
|
105
|
+
}
|
|
106
|
+
// Also check for tana-link format: " - **Type**: [value](tana:id)"
|
|
107
|
+
const linkMatch = line.match(
|
|
108
|
+
new RegExp(`\\*\\*${fieldName}\\*\\*:\\s*\\[([^\\]]+)\\]\\(tana:([^)]+)\\)`)
|
|
109
|
+
);
|
|
110
|
+
if (linkMatch) {
|
|
111
|
+
results.push({ id: linkMatch[2], name: linkMatch[1] });
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (inField) {
|
|
117
|
+
const currentIndent = line.search(/\S/);
|
|
118
|
+
// Left the field's section (same or less indentation)
|
|
119
|
+
if (line.trim() && currentIndent <= fieldIndent) break;
|
|
120
|
+
|
|
121
|
+
// Child value: " - value <!-- node-id: xxx -->"
|
|
122
|
+
const childMatch = line.match(/^\s+- (.+?)\s*<!--\s*node-id:\s*(\S+)\s*-->/);
|
|
123
|
+
if (childMatch) {
|
|
124
|
+
results.push({ id: childMatch[2], name: childMatch[1].trim() });
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Child value (tana-link): " - [value](tana:id)"
|
|
128
|
+
const childLinkMatch = line.match(/^\s+- \[([^\]]+)\]\(tana:([^)]+)\)/);
|
|
129
|
+
if (childLinkMatch) {
|
|
130
|
+
results.push({ id: childLinkMatch[2], name: childLinkMatch[1] });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return results;
|
|
135
|
+
}
|
|
136
|
+
|
|
28
137
|
/**
|
|
29
138
|
* TanaAPI Interface
|
|
30
139
|
*
|
|
@@ -87,12 +196,11 @@ export interface TanaAPI {
|
|
|
87
196
|
};
|
|
88
197
|
|
|
89
198
|
fields: {
|
|
90
|
-
/** Set a field to
|
|
199
|
+
/** Set a field to option value(s). Pass string[] for multi-value fields. */
|
|
91
200
|
setOption(
|
|
92
201
|
nodeId: string,
|
|
93
202
|
attributeId: string,
|
|
94
|
-
optionId: string
|
|
95
|
-
mode?: "replace" | "append"
|
|
203
|
+
optionId: string | string[]
|
|
96
204
|
): Promise<{ success: boolean }>;
|
|
97
205
|
/** Set a field to a string value, or null to clear it */
|
|
98
206
|
setContent(
|
|
@@ -101,6 +209,11 @@ export interface TanaAPI {
|
|
|
101
209
|
content: string | null,
|
|
102
210
|
mode?: "replace" | "append"
|
|
103
211
|
): Promise<{ success: boolean }>;
|
|
212
|
+
/** Discover available options for an Options-type field */
|
|
213
|
+
getFieldOptions(
|
|
214
|
+
fieldId: string,
|
|
215
|
+
options?: { tagId?: string; workspaceId?: string; limit?: number }
|
|
216
|
+
): Promise<{ id: string; name: string }[]>;
|
|
104
217
|
};
|
|
105
218
|
|
|
106
219
|
calendar: {
|
|
@@ -142,30 +255,8 @@ export function createTanaAPI(
|
|
|
142
255
|
query: SearchQuery,
|
|
143
256
|
options?: SearchOptions
|
|
144
257
|
): Promise<SearchResult[]> {
|
|
145
|
-
// Build deep object query params (style: deepObject, explode: true)
|
|
146
258
|
const params: string[] = [];
|
|
147
|
-
|
|
148
|
-
function addQueryParams(obj: Record<string, unknown>, prefix: string) {
|
|
149
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
150
|
-
const paramKey = prefix ? `${prefix}[${key}]` : key;
|
|
151
|
-
if (value === null || value === undefined) continue;
|
|
152
|
-
if (typeof value === "object" && !Array.isArray(value)) {
|
|
153
|
-
addQueryParams(value as Record<string, unknown>, paramKey);
|
|
154
|
-
} else if (Array.isArray(value)) {
|
|
155
|
-
value.forEach((item, i) => {
|
|
156
|
-
if (typeof item === "object") {
|
|
157
|
-
addQueryParams(item as Record<string, unknown>, `${paramKey}[${i}]`);
|
|
158
|
-
} else {
|
|
159
|
-
params.push(`${encodeURIComponent(`${paramKey}[${i}]`)}=${encodeURIComponent(String(item))}`);
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
} else {
|
|
163
|
-
params.push(`${encodeURIComponent(paramKey)}=${encodeURIComponent(String(value))}`);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
addQueryParams(query as unknown as Record<string, unknown>, "query");
|
|
259
|
+
addQueryParams(params, query as unknown as Record<string, unknown>, "query");
|
|
169
260
|
if (options?.limit) params.push(`limit=${options.limit}`);
|
|
170
261
|
const effectiveWorkspaceIds = options?.workspaceIds
|
|
171
262
|
?? (defaultSearchWorkspaceIds?.length ? defaultSearchWorkspaceIds : undefined);
|
|
@@ -346,17 +437,48 @@ export function createTanaAPI(
|
|
|
346
437
|
},
|
|
347
438
|
|
|
348
439
|
fields: {
|
|
440
|
+
// Workaround: append mode is broken in Tana API (clears field instead of appending).
|
|
441
|
+
// For arrays, we set first value via API, then import remaining into the field tuple.
|
|
442
|
+
// See claudedocs/api-bugs.md
|
|
349
443
|
async setOption(
|
|
350
444
|
nodeId: string,
|
|
351
445
|
attributeId: string,
|
|
352
|
-
optionId: string,
|
|
353
|
-
|
|
446
|
+
optionId: string | string[],
|
|
447
|
+
_mode?: "replace" | "append"
|
|
354
448
|
): Promise<{ success: boolean }> {
|
|
355
|
-
const
|
|
449
|
+
const ids = Array.isArray(optionId) ? optionId : [optionId];
|
|
450
|
+
if (ids.length === 0) return { success: true };
|
|
451
|
+
|
|
452
|
+
// Step 1: set first value via API (creates the field tuple)
|
|
453
|
+
await client.post<{ nodeId: string; message: string }>(
|
|
356
454
|
`/nodes/${nodeId}/fields/${attributeId}/option`,
|
|
357
|
-
{ optionId, mode }
|
|
455
|
+
{ optionId: ids[0], mode: "replace" }
|
|
358
456
|
);
|
|
359
|
-
|
|
457
|
+
|
|
458
|
+
if (ids.length === 1) return { success: true };
|
|
459
|
+
|
|
460
|
+
// Step 2: find the field tuple via getChildren
|
|
461
|
+
const { children } = await client.get<Children>(
|
|
462
|
+
`/nodes/${nodeId}/children?limit=100`
|
|
463
|
+
);
|
|
464
|
+
let tupleId: string | null = null;
|
|
465
|
+
for (const child of children) {
|
|
466
|
+
if (child.docType === "tuple") {
|
|
467
|
+
const sub = await client.get<Children>(
|
|
468
|
+
`/nodes/${child.id}/children?limit=20`
|
|
469
|
+
);
|
|
470
|
+
if (sub.children.some(s => s.id === attributeId)) {
|
|
471
|
+
tupleId = child.id;
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (!tupleId) return { success: false };
|
|
477
|
+
|
|
478
|
+
// Step 3: import remaining values into the tuple
|
|
479
|
+
const refs = ids.slice(1).map(id => `- [[^${id}]]`).join("\n");
|
|
480
|
+
await client.post<ImportResult>(`/nodes/${tupleId}/import`, { content: refs });
|
|
481
|
+
return { success: true };
|
|
360
482
|
},
|
|
361
483
|
|
|
362
484
|
async setContent(
|
|
@@ -371,6 +493,74 @@ export function createTanaAPI(
|
|
|
371
493
|
);
|
|
372
494
|
return { success: !!result.nodeId };
|
|
373
495
|
},
|
|
496
|
+
|
|
497
|
+
async getFieldOptions(
|
|
498
|
+
fieldId: string,
|
|
499
|
+
options?: { tagId?: string; workspaceId?: string; limit?: number }
|
|
500
|
+
): Promise<{ id: string; name: string }[]> {
|
|
501
|
+
const result = await client.get<{ markdown: string }>(
|
|
502
|
+
`/nodes/${fieldId}?maxDepth=2`
|
|
503
|
+
);
|
|
504
|
+
const md = result.markdown;
|
|
505
|
+
|
|
506
|
+
// Pattern A: Predetermined options (field definition lists them)
|
|
507
|
+
if (md.includes("**Options**:")) {
|
|
508
|
+
return parseOptionsFromMarkdown(md);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Pattern B: Instance of supertag
|
|
512
|
+
if (md.includes("Options from supertag")) {
|
|
513
|
+
const sourceTagMatch = md.match(/\(tana:([^)]+)\)/);
|
|
514
|
+
if (sourceTagMatch) {
|
|
515
|
+
const params: string[] = [];
|
|
516
|
+
addQueryParams(params, { hasType: sourceTagMatch[1] } as Record<string, unknown>, "query");
|
|
517
|
+
if (options?.workspaceId) {
|
|
518
|
+
params.push(`workspaceIds[0]=${encodeURIComponent(options.workspaceId)}`);
|
|
519
|
+
}
|
|
520
|
+
if (options?.limit) params.push(`limit=${options.limit}`);
|
|
521
|
+
const results = await client.get<SearchResult[]>(`/nodes/search?${params.join("&")}`);
|
|
522
|
+
return results.map(r => ({ id: r.id, name: r.name }));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Pattern C: Source search node
|
|
527
|
+
const searchNodeMatch = md.match(/\*\*Options List\*\*:.*?\(tana:([^)]+)\)/);
|
|
528
|
+
if (searchNodeMatch) {
|
|
529
|
+
const searchResult = await client.get<{ markdown: string }>(
|
|
530
|
+
`/nodes/${searchNodeMatch[1]}?maxDepth=1`
|
|
531
|
+
);
|
|
532
|
+
return parseRefsFromMarkdown(searchResult.markdown);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Pattern D: Ad-hoc — sample from existing nodes that have this field set
|
|
536
|
+
// Extract field name from definition for scoped parsing
|
|
537
|
+
const fieldNameMatch = md.match(/^- (.+?) #field-definition/m);
|
|
538
|
+
if (!fieldNameMatch) return [];
|
|
539
|
+
|
|
540
|
+
const fieldName = fieldNameMatch[1];
|
|
541
|
+
const limit = options?.limit ?? 10;
|
|
542
|
+
const params: string[] = [];
|
|
543
|
+
const query: Record<string, unknown> = options?.tagId
|
|
544
|
+
? { and: [{ hasType: options.tagId }, { field: { fieldId, state: "set" } }] }
|
|
545
|
+
: { field: { fieldId, state: "set" } };
|
|
546
|
+
addQueryParams(params, query, "query");
|
|
547
|
+
if (options?.workspaceId) {
|
|
548
|
+
params.push(`workspaceIds[0]=${encodeURIComponent(options.workspaceId)}`);
|
|
549
|
+
}
|
|
550
|
+
params.push(`limit=${limit}`);
|
|
551
|
+
|
|
552
|
+
const searchResults = await client.get<SearchResult[]>(`/nodes/search?${params.join("&")}`);
|
|
553
|
+
const seen = new Map<string, string>();
|
|
554
|
+
for (const node of searchResults) {
|
|
555
|
+
const nodeMd = await client.get<{ markdown: string }>(
|
|
556
|
+
`/nodes/${node.id}?maxDepth=1`
|
|
557
|
+
);
|
|
558
|
+
for (const { id, name } of parseFieldValues(nodeMd.markdown, fieldName)) {
|
|
559
|
+
if (!seen.has(id)) seen.set(id, name);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return Array.from(seen, ([id, name]) => ({ id, name }));
|
|
563
|
+
},
|
|
374
564
|
},
|
|
375
565
|
|
|
376
566
|
calendar: {
|
|
@@ -389,9 +579,15 @@ export function createTanaAPI(
|
|
|
389
579
|
},
|
|
390
580
|
|
|
391
581
|
async import(parentNodeId: string, content: string): Promise<ImportResult> {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
582
|
+
// API returns { createdNodes: [{id,name}], ... } — normalize to ImportResult
|
|
583
|
+
const raw = await client.post<{
|
|
584
|
+
createdNodes: { id: string; name: string }[];
|
|
585
|
+
message: string;
|
|
586
|
+
}>(`/nodes/${parentNodeId}/import`, { content });
|
|
587
|
+
return {
|
|
588
|
+
success: true,
|
|
589
|
+
nodeIds: raw.createdNodes.map(n => n.id),
|
|
590
|
+
};
|
|
395
591
|
},
|
|
396
592
|
|
|
397
593
|
format(data: unknown): string {
|
package/src/prompts.ts
CHANGED
|
@@ -28,8 +28,9 @@ tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email
|
|
|
28
28
|
tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
|
|
29
29
|
|
|
30
30
|
### Fields
|
|
31
|
-
tana.fields.setOption(nodeId, attributeId, optionId
|
|
31
|
+
tana.fields.setOption(nodeId, attributeId, optionId) // optionId: string | string[] (array for multi-value)
|
|
32
32
|
tana.fields.setContent(nodeId, attributeId, content, mode?) // content: string | null (null clears field), mode: "replace" | "append"
|
|
33
|
+
tana.fields.getFieldOptions(fieldId, options?) → { id, name }[] // discover available options for a field
|
|
33
34
|
|
|
34
35
|
### Calendar
|
|
35
36
|
tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
|
package/src/sandbox/executor.ts
CHANGED
|
@@ -110,6 +110,8 @@ function createTrackedTanaAPI(
|
|
|
110
110
|
trackNodeId(nodeId);
|
|
111
111
|
return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
|
|
112
112
|
},
|
|
113
|
+
getFieldOptions: (...args) =>
|
|
114
|
+
track("fields.getFieldOptions", () => tana.fields.getFieldOptions(...args)),
|
|
113
115
|
},
|
|
114
116
|
|
|
115
117
|
calendar: {
|