tautau-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,8 @@ An MCP (Model Context Protocol) stdio server that gives LLM clients — Claude C
5
5
  - **YouTube links** → transcript (captions-first, returns in seconds)
6
6
  - **Podcast / direct-audio URLs** → transcript (server-side speech-to-text, long jobs poll automatically)
7
7
  - **Local audio files** (mp3/wav/m4a/ogg/webm/mp4, up to 25MB) → transcript
8
+ - **Recording evidence** → bounded session transcripts, diagnostics, and collected assets
9
+ - **Asset library** → authenticated asset metadata and safe inline text previews
8
10
 
9
11
  No API key, no account: usage is anonymous and quota-tracked per IP by the tautau service (5 lifetime URL transcripts, 5 lifetime dictations). When you hit the wall the tools say so and point at [signup](https://tautau.xyz/login) / [pricing](https://tautau.xyz/pricing) — a free account gets a daily allowance.
10
12
 
@@ -57,6 +59,7 @@ Restart the client after editing config. Verify with `get_quota` (see below).
57
59
  | Variable | Default | Purpose |
58
60
  | --- | --- | --- |
59
61
  | `TAUTAU_API_BASE` | `https://tautau.xyz` | API base URL — only override for self-hosted/dev deployments |
62
+ | `TAUTAU_AUTH_FILE` | `~/.config/tautau/auth.json` | Override the local Firebase session file location |
60
63
 
61
64
  ## Tools
62
65
 
@@ -111,14 +114,52 @@ Returns the scan meta (page, status, bundle size, timings), the **service-side R
111
114
 
112
115
  Example: *"Fetch tautau scan 9f2c… and tell me why the voice command didn't click the button"*
113
116
 
117
+ ### `get_recording`
118
+
119
+ Read a public recording capability and its session evidence. The required `recordingId` is a 32-character lowercase hex id. The response includes the recording link, transcript, collection references, and bounded console, network, interaction, and pin evidence. Signed bundle URLs are fetched for the request and are never returned or saved.
120
+
121
+ ### `list_assets`
122
+
123
+ List the authenticated asset library, newest first.
124
+
125
+ | Argument | Type | Required | Notes |
126
+ | --- | --- | --- | --- |
127
+ | `limit` | number | no | Integer from 1 to 200; defaults to 50 |
128
+
129
+ ### `get_asset`
130
+
131
+ Read one authenticated asset using its 32-character lowercase hex `assetId`. Ready assets include a current media URL. Text-like files (`text/plain`, `text/csv`, `application/json`, `application/xml`, and `image/svg+xml`) are included inline only when they are no larger than 32KiB. Binary data is never embedded in an MCP response.
132
+
114
133
  ### `auth_login` / `auth_status` / `auth_logout`
115
134
 
116
- `auth_login` opens your browser for Google/email sign-in (same Firebase auth as the web app). The page hands the tokens to a **loopback callback** the MCP server is listening on — tokens never leave your machine. The session is stored at `~/.config/tautau/auth.json` (mode 0600) and auto-refreshes. `auth_status` shows who you're signed in as; `auth_logout` deletes the session.
135
+ `auth_login` opens your browser for Google/email sign-in (same Firebase auth as the web app). The page hands the tokens to a **loopback callback** the MCP server is listening on — tokens never leave your machine. The session is stored at `TAUTAU_AUTH_FILE` or, by default, `~/.config/tautau/auth.json` (mode 0600) and auto-refreshes. `auth_status` shows who you're signed in as; `auth_logout` deletes the session. `list_assets` and `get_asset` require this session.
117
136
 
118
137
  ### `list_scans`
119
138
 
120
139
  Requires `auth_login`. Lists your page scans, newest first (id, status, time, size, page). Pair with `get_scan` to debug: *"List my tautau scans and pull the latest one — why did the page command fail?"*
121
140
 
141
+ ### `suggest_page_action`
142
+
143
+ Interpret a natural-language page command (*"click sign in"*, *"type hello into search"*) against a page snapshot and return ONE server-validated action (`click`/`input`/`select`/`scroll`/`none`, index bounds-checked — out-of-range coerces to `none` with a reason, never an error).
144
+
145
+ | Argument | Type | Required | Notes |
146
+ | --- | --- | --- | --- |
147
+ | `command` | string | yes | 1–500 chars |
148
+ | `scanId` | string | no | Build the snapshot from this scan's bundle (preferred — same source triage uses) |
149
+ | `snapshot` | string | no | Page text with `[n]` prefixes, ≤32KiB (when no scanId) |
150
+ | `url` / `title` | string | no | Page overrides |
151
+
152
+ Requires `auth_login` (page actions are uid-gated). Execute the returned action in your own browser, then re-scan and verify with `diff_scans` — the full loop is worked in `docs/agent-loop.md` in the repo.
153
+
154
+ ### `diff_scans`
155
+
156
+ Diff two scan captures for QA: changed pages, added/removed headings and assets, new vs resolved console errors and failed requests. Anonymous (public capabilities).
157
+
158
+ | Argument | Type | Required | Notes |
159
+ | --- | --- | --- | --- |
160
+ | `scanIdA` | string | yes | Before scan (32-char hex) |
161
+ | `scanIdB` | string | yes | After scan (32-char hex) |
162
+
122
163
  ## Quotas & anonymity
123
164
 
124
165
  - The server sends no credentials; tautau tracks anonymous usage by client IP.
@@ -133,12 +174,13 @@ Requires `auth_login`. Lists your page scans, newest first (id, status, time, si
133
174
  ```bash
134
175
  cd mcp
135
176
  npm install
136
- npm run smoke # boots the server over stdio, lists tools, calls get_quota (read-only)
177
+ npm run build
178
+ npm run smoke # boots the server over stdio and runs read-only probes
137
179
  ```
138
180
 
139
181
  From the repo root: `task mcp:smoke`.
140
182
 
141
- Plain ESM JavaScript, zero build step, one runtime dependency (`@modelcontextprotocol/sdk`). The smoke test never burns transcribe quota only `get_quota` and tool-listing run.
183
+ The typed recording and asset modules compile to `dist/` with strict NodeNext TypeScript. `npm start` builds before launching, and `npm publish` builds through `prepublishOnly`. The smoke test never burns transcribe quota. It only lists tools and calls `get_quota` plus safe not-found/auth checks.
142
184
 
143
185
  ## Marketplace listings
144
186
 
@@ -0,0 +1,57 @@
1
+ import { ToolContext, ToolResult } from './tool-types.js';
2
+ export declare function runSuggestPageAction(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
3
+ export declare function runDiffScans(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
4
+ export declare const agentToolDefinitions: ({
5
+ name: string;
6
+ description: string;
7
+ inputSchema: {
8
+ type: string;
9
+ properties: {
10
+ command: {
11
+ type: string;
12
+ description: string;
13
+ };
14
+ scanId: {
15
+ type: string;
16
+ description: string;
17
+ };
18
+ snapshot: {
19
+ type: string;
20
+ description: string;
21
+ };
22
+ url: {
23
+ type: string;
24
+ description: string;
25
+ };
26
+ title: {
27
+ type: string;
28
+ description: string;
29
+ };
30
+ scanIdA?: undefined;
31
+ scanIdB?: undefined;
32
+ };
33
+ required: string[];
34
+ };
35
+ } | {
36
+ name: string;
37
+ description: string;
38
+ inputSchema: {
39
+ type: string;
40
+ properties: {
41
+ scanIdA: {
42
+ type: string;
43
+ description: string;
44
+ };
45
+ scanIdB: {
46
+ type: string;
47
+ description: string;
48
+ };
49
+ command?: undefined;
50
+ scanId?: undefined;
51
+ snapshot?: undefined;
52
+ url?: undefined;
53
+ title?: undefined;
54
+ };
55
+ required: string[];
56
+ };
57
+ })[];
@@ -0,0 +1,244 @@
1
+ import { apiError, fail, isId, jsonResponse, newest, ok, redactText, redactUrl, MAX_EVIDENCE_ITEMS, MAX_METADATA_BYTES, } from './tool-types.js';
2
+ // Agent action loop primitives: observe (get_scan) → suggest (this module)
3
+ // → execute (the caller's own browser) → verify (diff_scans + re-scan).
4
+ // suggest needs a signed-in session (PageAgentService is uid-gated);
5
+ // diff works on public capabilities like get_scan.
6
+ const MAX_SNAPSHOT_LINES = 100;
7
+ const MAX_SNAPSHOT_BYTES = 8 * 1024;
8
+ const MAX_LIST_ITEMS = 20;
9
+ function asObj(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
+ }
12
+ function asList(value) {
13
+ return Array.isArray(value) ? value.filter((v) => Boolean(v && typeof v === 'object')) : [];
14
+ }
15
+ async function scanBundle(context, id) {
16
+ let meta;
17
+ try {
18
+ meta = await context.fetch(`${context.apiBase}/api/scan/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(15_000) });
19
+ }
20
+ catch (error) {
21
+ return fail(`Could not reach ${context.apiBase}: ${error instanceof Error ? error.message : String(error)}`);
22
+ }
23
+ if (!meta.ok)
24
+ return apiError(meta, context.apiBase);
25
+ const rawMeta = await jsonResponse(meta);
26
+ const sessionUrl = asObj(rawMeta).sessionUrl;
27
+ if (typeof sessionUrl !== 'string' || !sessionUrl)
28
+ return fail('Scan has no session bundle yet (still collecting?)');
29
+ try {
30
+ const res = await context.fetch(sessionUrl, { signal: AbortSignal.timeout(30_000) });
31
+ if (!res.ok)
32
+ return fail('Session bundle unreadable (link may have expired)');
33
+ const bundle = await jsonResponse(res);
34
+ if (!bundle || Array.isArray(bundle))
35
+ return fail('tautau returned an invalid session bundle');
36
+ return bundle;
37
+ }
38
+ catch (error) {
39
+ return fail(`Could not fetch session bundle: ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ }
42
+ /** Latest snapshot per URL pathname (same pairing rule as the /compare page). */
43
+ function snapshotsByUrl(bundle) {
44
+ const out = new Map();
45
+ for (const snap of asList(bundle.snapshots)) {
46
+ const raw = typeof snap.url === 'string' ? snap.url : '';
47
+ let key = raw.split('?')[0];
48
+ try {
49
+ const u = new URL(raw);
50
+ key = `${u.origin}${u.pathname}`;
51
+ }
52
+ catch {
53
+ /* keep fallback */
54
+ }
55
+ out.set(key, snap);
56
+ }
57
+ return out;
58
+ }
59
+ /** [i]<tag> text lines over the latest interactive index (extension format). */
60
+ function snapshotLines(bundle) {
61
+ const snaps = [...snapshotsByUrl(bundle).values()];
62
+ const snap = snaps[snaps.length - 1];
63
+ if (!snap)
64
+ return { lines: '', url: '', title: '' };
65
+ const interactive = asList(asObj(snap.outline).interactive).slice(0, MAX_SNAPSHOT_LINES);
66
+ const lines = interactive
67
+ .map((el) => {
68
+ const i = typeof el.i === 'number' ? el.i : '?';
69
+ const tag = typeof el.tag === 'string' ? el.tag : '?';
70
+ const text = typeof el.text === 'string' ? el.text.slice(0, 80) : '';
71
+ return `[${i}]<${tag}> ${text}`.trim();
72
+ })
73
+ .join('\n')
74
+ .slice(0, MAX_SNAPSHOT_BYTES);
75
+ return {
76
+ lines,
77
+ url: typeof snap.url === 'string' ? redactUrl(snap.url) : '',
78
+ title: typeof snap.title === 'string' ? redactText(snap.title, 200) : '',
79
+ };
80
+ }
81
+ export async function runSuggestPageAction(context, args) {
82
+ const command = typeof args.command === 'string' ? args.command.trim() : '';
83
+ if (!command || command.length > 500)
84
+ return fail('command must be 1..500 chars');
85
+ if (!context.authFetch)
86
+ return fail('Not signed in — run auth_login first (page actions need your account).');
87
+ let snapshot = typeof args.snapshot === 'string' ? args.snapshot : '';
88
+ let url = typeof args.url === 'string' ? args.url : '';
89
+ let title = typeof args.title === 'string' ? args.title : '';
90
+ const scanId = typeof args.scanId === 'string' ? args.scanId : '';
91
+ if (scanId) {
92
+ if (!isId(scanId))
93
+ return fail('scanId must be a 32-character lowercase hex id');
94
+ const bundle = await scanBundle(context, scanId);
95
+ if ('content' in bundle)
96
+ return bundle;
97
+ const built = snapshotLines(bundle);
98
+ if (!built.lines)
99
+ return fail('Scan bundle has no interactive snapshot to act on');
100
+ snapshot = built.lines;
101
+ url = url || built.url;
102
+ title = title || built.title;
103
+ }
104
+ if (!snapshot || snapshot.length > 32 * 1024)
105
+ return fail('snapshot is required (page text with [n] prefixes, ≤32KiB) — get it from get_scan or the page itself');
106
+ let res;
107
+ try {
108
+ res = await context.authFetch('/api/page-command', {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ command, url, title, snapshot }),
112
+ signal: AbortSignal.timeout(60_000),
113
+ });
114
+ }
115
+ catch (error) {
116
+ return fail(`Could not reach ${context.apiBase}: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ if (!res)
119
+ return fail('Not signed in — run auth_login first.');
120
+ if (!res.ok)
121
+ return apiError(res, context.apiBase);
122
+ const body = await jsonResponse(res);
123
+ const action = asObj(body);
124
+ return ok({
125
+ action: typeof action.action === 'string' ? action.action : 'none',
126
+ index: typeof action.index === 'number' ? action.index : -1,
127
+ selector: typeof action.selector === 'string' ? action.selector : undefined,
128
+ text: typeof action.text === 'string' ? redactText(action.text, 500) : undefined,
129
+ direction: typeof action.direction === 'string' ? action.direction : undefined,
130
+ reason: typeof action.reason === 'string' ? redactText(action.reason, 500) : undefined,
131
+ provider: typeof action.provider === 'string' ? action.provider : undefined,
132
+ });
133
+ }
134
+ function textSet(events, pred) {
135
+ const out = new Set();
136
+ for (const e of events) {
137
+ const v = pred(e);
138
+ if (v)
139
+ out.add(v);
140
+ }
141
+ return out;
142
+ }
143
+ function diffSets(before, after) {
144
+ return {
145
+ added: [...after].filter((x) => !before.has(x)).slice(0, MAX_LIST_ITEMS),
146
+ removed: [...before].filter((x) => !after.has(x)).slice(0, MAX_LIST_ITEMS),
147
+ };
148
+ }
149
+ export async function runDiffScans(context, args) {
150
+ const a = typeof args.scanIdA === 'string' ? args.scanIdA : '';
151
+ const b = typeof args.scanIdB === 'string' ? args.scanIdB : '';
152
+ if (!isId(a) || !isId(b))
153
+ return fail('scanIdA and scanIdB must be 32-character lowercase hex ids');
154
+ if (a === b)
155
+ return fail('scanIdA and scanIdB are the same scan');
156
+ const [bundleA, bundleB] = await Promise.all([scanBundle(context, a), scanBundle(context, b)]);
157
+ if ('content' in bundleA)
158
+ return bundleA;
159
+ if ('content' in bundleB)
160
+ return bundleB;
161
+ const snapsA = snapshotsByUrl(bundleA);
162
+ const snapsB = snapshotsByUrl(bundleB);
163
+ const urls = [...new Set([...snapsA.keys(), ...snapsB.keys()])].sort();
164
+ const pages = [];
165
+ let assetsAdded = 0;
166
+ let assetsRemoved = 0;
167
+ for (const url of urls) {
168
+ const before = snapsA.get(url);
169
+ const after = snapsB.get(url);
170
+ if (!before || !after) {
171
+ pages.push({ url, onlyIn: !before ? 'after' : 'before' });
172
+ continue;
173
+ }
174
+ const head = (s) => asList(asObj(s.outline).headings).map((h) => `h${typeof h.level === 'number' ? h.level : '?'}: ${typeof h.text === 'string' ? h.text.slice(0, 80) : ''}`);
175
+ const headings = diffSets(new Set(head(before)), new Set(head(after)));
176
+ const assetNames = (s) => asList(s.assets).map((x) => (typeof x.name === 'string' ? x.name : '')).filter(Boolean);
177
+ const assets = diffSets(new Set(assetNames(before)), new Set(assetNames(after)));
178
+ assetsAdded += assets.added.length;
179
+ assetsRemoved += assets.removed.length;
180
+ const changed = headings.added.length > 0 || headings.removed.length > 0 || assets.added.length > 0 || assets.removed.length > 0 ||
181
+ (typeof before.title === 'string' ? before.title : '') !== (typeof after.title === 'string' ? after.title : '');
182
+ if (changed)
183
+ pages.push({ url, headingsAdded: headings.added, headingsRemoved: headings.removed, assetsAdded: assets.added, assetsRemoved: assets.removed });
184
+ }
185
+ const errText = (e) => {
186
+ if ((e.kind === 'console' && e.level === 'error') || e.kind === 'error') {
187
+ return typeof e.text === 'string' ? redactText(e.text.trim(), MAX_METADATA_BYTES) : null;
188
+ }
189
+ return null;
190
+ };
191
+ const failText = (e) => {
192
+ if (e.kind !== 'network')
193
+ return null;
194
+ if (typeof e.status === 'number' && e.status < 400)
195
+ return null;
196
+ return `${typeof e.method === 'string' ? e.method.toUpperCase() : 'GET'} ${typeof e.url === 'string' ? redactUrl(e.url) : '(unknown)'} → ${typeof e.status === 'number' ? e.status : 'failed'}`;
197
+ };
198
+ const errors = diffSets(textSet(asList(bundleA.events), errText), textSet(asList(bundleB.events), errText));
199
+ const failures = diffSets(textSet(asList(bundleA.events), failText), textSet(asList(bundleB.events), failText));
200
+ return ok({
201
+ scans: { a, b },
202
+ summary: {
203
+ pagesChanged: pages.length,
204
+ assetsAdded,
205
+ assetsRemoved,
206
+ newErrors: errors.added.length,
207
+ newFailures: failures.added.length,
208
+ },
209
+ pages: newest(pages, MAX_EVIDENCE_ITEMS).items,
210
+ newConsoleErrors: errors.added,
211
+ resolvedConsoleErrors: errors.removed,
212
+ newFailedRequests: failures.added,
213
+ resolvedFailedRequests: failures.removed,
214
+ });
215
+ }
216
+ export const agentToolDefinitions = [
217
+ {
218
+ name: 'suggest_page_action',
219
+ description: 'Interpret a natural-language page command ("click sign in", "type hello into search") against a page snapshot and return ONE structured action (click/input/select/scroll/none with index bounds-checked). Needs auth_login. Prefer scanId (snapshot built from the scan bundle); or pass snapshot text with [n] prefixes directly.',
220
+ inputSchema: {
221
+ type: 'object',
222
+ properties: {
223
+ command: { type: 'string', description: 'Voice-style command, 1..500 chars' },
224
+ scanId: { type: 'string', description: 'Scan id to build the snapshot from (optional if snapshot given)' },
225
+ snapshot: { type: 'string', description: 'Page text with [n] prefixes, ≤32KiB (optional if scanId given)' },
226
+ url: { type: 'string', description: 'Page URL override' },
227
+ title: { type: 'string', description: 'Page title override' },
228
+ },
229
+ required: ['command'],
230
+ },
231
+ },
232
+ {
233
+ name: 'diff_scans',
234
+ description: 'Diff two scan captures for QA: changed pages, added/removed headings and assets, new vs resolved console errors and failed requests. Anonymous (public capabilities).',
235
+ inputSchema: {
236
+ type: 'object',
237
+ properties: {
238
+ scanIdA: { type: 'string', description: 'Before scan id (32-hex)' },
239
+ scanIdB: { type: 'string', description: 'After scan id (32-hex)' },
240
+ },
241
+ required: ['scanIdA', 'scanIdB'],
242
+ },
243
+ },
244
+ ];
@@ -0,0 +1,32 @@
1
+ import { ToolContext, ToolResult } from './tool-types.js';
2
+ export declare function runListAssets(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
3
+ export declare function runGetAsset(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
4
+ export declare const assetToolDefinitions: ({
5
+ name: string;
6
+ description: string;
7
+ inputSchema: {
8
+ type: string;
9
+ properties: {
10
+ limit: {
11
+ type: string;
12
+ description: string;
13
+ };
14
+ assetId?: undefined;
15
+ };
16
+ required?: undefined;
17
+ };
18
+ } | {
19
+ name: string;
20
+ description: string;
21
+ inputSchema: {
22
+ type: string;
23
+ properties: {
24
+ assetId: {
25
+ type: string;
26
+ description: string;
27
+ };
28
+ limit?: undefined;
29
+ };
30
+ required: string[];
31
+ };
32
+ })[];
@@ -0,0 +1,195 @@
1
+ import { apiError, DEFAULT_ASSET_LIST, fail, fitMetadata, jsonResponse, MAX_ASSET_LIST, MAX_TEXT_BYTES, numberValue, ok, parseAssetLimit, redactUrl, requireId, truncateUtf8, } from './tool-types.js';
2
+ const INLINE_TYPES = new Set(['text/plain', 'text/csv', 'application/json', 'application/xml', 'image/svg+xml']);
3
+ function pathForAsset(id) {
4
+ return `/api/assets/${encodeURIComponent(id)}`;
5
+ }
6
+ function mediaPathForAsset(id) {
7
+ return `${pathForAsset(id)}/media`;
8
+ }
9
+ function assetMetadata(raw) {
10
+ const source = raw && typeof raw === 'object' ? raw : {};
11
+ const result = {
12
+ id: typeof source.id === 'string' ? source.id : '',
13
+ type: typeof source.type === 'string' ? source.type.slice(0, 256) : '',
14
+ status: typeof source.status === 'string' ? source.status.slice(0, 64) : '',
15
+ createdAt: typeof source.createdAt === 'string' ? source.createdAt.slice(0, 128) : '',
16
+ };
17
+ const bytes = numberValue(source.bytes);
18
+ if (bytes !== undefined)
19
+ result.bytes = bytes;
20
+ const sourceUrl = redactUrl(source.url);
21
+ if (sourceUrl)
22
+ result.sourceUrl = sourceUrl;
23
+ if (typeof source.domain === 'string' && source.domain)
24
+ result.sourceDomain = source.domain.slice(0, 2048);
25
+ if (typeof source.mediaUrl === 'string' && source.status === 'ready')
26
+ result.mediaUrl = source.mediaUrl;
27
+ const fitted = fitMetadata(result);
28
+ return { value: fitted.value, truncated: fitted.truncated };
29
+ }
30
+ async function requireOwner(context, path) {
31
+ if (!context.authFetch)
32
+ return fail('This tool requires auth_login. Run auth_login first.');
33
+ try {
34
+ const response = await context.authFetch(path, { signal: AbortSignal.timeout(15_000) });
35
+ if (!response)
36
+ return fail('Not signed in. Run auth_login first.');
37
+ return response;
38
+ }
39
+ catch (error) {
40
+ return fail(`Could not reach ${context.apiBase}: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ }
43
+ export async function runListAssets(context, args) {
44
+ const limit = parseAssetLimit(args.limit);
45
+ if (typeof limit !== 'number')
46
+ return limit;
47
+ const response = await requireOwner(context, `/api/assets?limit=${limit}`);
48
+ if (!('status' in response))
49
+ return response;
50
+ if (!response.ok)
51
+ return apiError(response, context.apiBase);
52
+ const body = await jsonResponse(response);
53
+ const rawAssets = Array.isArray(body)
54
+ ? body
55
+ : body && typeof body === 'object' && Array.isArray(body.assets)
56
+ ? body.assets
57
+ : null;
58
+ if (!rawAssets)
59
+ return fail('tautau returned an invalid asset list');
60
+ // The owner endpoint is newest-first; preserve that order while applying the
61
+ // requested result bound.
62
+ const bounded = rawAssets.length > limit
63
+ ? { items: rawAssets.slice(0, Math.min(limit, MAX_ASSET_LIST)), truncated: true }
64
+ : { items: rawAssets, truncated: false };
65
+ const assets = bounded.items.map((asset) => assetMetadata(asset));
66
+ const result = {
67
+ assets: assets.map((asset) => asset.value),
68
+ count: assets.length,
69
+ originalCount: rawAssets.length,
70
+ };
71
+ if (bounded.truncated || assets.some((asset) => asset.truncated))
72
+ result.truncated = true;
73
+ return ok(result);
74
+ }
75
+ async function currentMediaUrl(context, id) {
76
+ if (!context.authFetch)
77
+ return {};
78
+ try {
79
+ const response = await context.authFetch(mediaPathForAsset(id), {
80
+ redirect: 'manual',
81
+ signal: AbortSignal.timeout(15_000),
82
+ });
83
+ if (!response)
84
+ return {};
85
+ return { url: response.headers.get('location') || undefined, response };
86
+ }
87
+ catch {
88
+ return {};
89
+ }
90
+ }
91
+ async function readInlineText(response) {
92
+ const contentType = (response.headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase();
93
+ if (!INLINE_TYPES.has(contentType))
94
+ return { truncated: false, bytes: 0 };
95
+ if (!response.body) {
96
+ const bytes = new Uint8Array(await response.arrayBuffer());
97
+ const text = new TextDecoder().decode(bytes);
98
+ return { text: truncateUtf8(text, MAX_TEXT_BYTES), truncated: bytes.byteLength > MAX_TEXT_BYTES, bytes: bytes.byteLength };
99
+ }
100
+ const reader = response.body.getReader();
101
+ const chunks = [];
102
+ let total = 0;
103
+ let truncated = false;
104
+ try {
105
+ while (total <= MAX_TEXT_BYTES) {
106
+ const next = await reader.read();
107
+ if (next.done)
108
+ break;
109
+ const chunk = next.value;
110
+ const remaining = MAX_TEXT_BYTES + 1 - total;
111
+ chunks.push(chunk.slice(0, remaining));
112
+ total += chunk.byteLength;
113
+ if (total > MAX_TEXT_BYTES) {
114
+ truncated = true;
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ finally {
120
+ await reader.cancel().catch(() => undefined);
121
+ }
122
+ const bytes = new Uint8Array(Math.min(total, MAX_TEXT_BYTES + 1));
123
+ let offset = 0;
124
+ for (const chunk of chunks) {
125
+ bytes.set(chunk.slice(0, bytes.length - offset), offset);
126
+ offset += Math.min(chunk.byteLength, bytes.length - offset);
127
+ }
128
+ const text = new TextDecoder().decode(bytes);
129
+ return { text: truncateUtf8(text, MAX_TEXT_BYTES), truncated, bytes: total };
130
+ }
131
+ export async function runGetAsset(context, args) {
132
+ const id = requireId(args.assetId, 'assetId');
133
+ if (typeof id !== 'string')
134
+ return id;
135
+ const response = await requireOwner(context, pathForAsset(id));
136
+ if (!('status' in response))
137
+ return response;
138
+ if (!response.ok)
139
+ return apiError(response, context.apiBase);
140
+ const body = await jsonResponse(response);
141
+ const rawAsset = body && !Array.isArray(body) && body.asset ? body.asset : body;
142
+ if (!rawAsset || typeof rawAsset !== 'object')
143
+ return fail('tautau returned an invalid asset response');
144
+ const metadata = assetMetadata(rawAsset);
145
+ // Owner metadata may contain an older signed URL. Always use the dedicated
146
+ // media route for the URL returned by this tool.
147
+ delete metadata.value.mediaUrl;
148
+ const result = { asset: metadata.value };
149
+ let truncated = metadata.truncated;
150
+ const source = rawAsset;
151
+ const type = typeof source.type === 'string' ? source.type.split(';', 1)[0].trim().toLowerCase() : '';
152
+ const declaredBytes = numberValue(source.bytes);
153
+ if (source.status === 'ready') {
154
+ const media = await currentMediaUrl(context, id);
155
+ if (media.url)
156
+ result.asset.mediaUrl = media.url;
157
+ if (INLINE_TYPES.has(type) && (declaredBytes === undefined || declaredBytes <= MAX_TEXT_BYTES) && media.url) {
158
+ try {
159
+ const inlineResponse = await context.fetch(media.url, { signal: AbortSignal.timeout(15_000) });
160
+ if (inlineResponse.ok) {
161
+ const inline = await readInlineText(inlineResponse);
162
+ if (inline.text !== undefined)
163
+ result.text = inline.text;
164
+ result.originalByteCount = declaredBytes ?? inline.bytes;
165
+ truncated ||= inline.truncated;
166
+ }
167
+ }
168
+ catch {
169
+ // Metadata and the current media URL remain useful if inline reading fails.
170
+ }
171
+ }
172
+ }
173
+ if (truncated)
174
+ result.truncated = true;
175
+ return ok(result);
176
+ }
177
+ export const assetToolDefinitions = [
178
+ {
179
+ name: 'list_assets',
180
+ description: 'List your tautau asset library with bounded owner metadata. Requires auth_login.',
181
+ inputSchema: {
182
+ type: 'object',
183
+ properties: { limit: { type: 'number', description: `Maximum results, from 1 to ${MAX_ASSET_LIST}; default ${DEFAULT_ASSET_LIST}` } },
184
+ },
185
+ },
186
+ {
187
+ name: 'get_asset',
188
+ description: 'Read one owner asset with bounded metadata and a current media URL. Requires auth_login; binary bytes are never inline.',
189
+ inputSchema: {
190
+ type: 'object',
191
+ properties: { assetId: { type: 'string', description: '32-character lowercase hex asset id' } },
192
+ required: ['assetId'],
193
+ },
194
+ },
195
+ ];
@@ -0,0 +1,32 @@
1
+ import { ToolContext, ToolResult } from './tool-types.js';
2
+ export declare function runGetRecording(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
3
+ export declare function runGetScan(context: ToolContext, args: Record<string, unknown>): Promise<ToolResult>;
4
+ export declare const recordingToolDefinitions: ({
5
+ name: string;
6
+ description: string;
7
+ inputSchema: {
8
+ type: string;
9
+ properties: {
10
+ recordingId: {
11
+ type: string;
12
+ description: string;
13
+ };
14
+ scanId?: undefined;
15
+ };
16
+ required: string[];
17
+ };
18
+ } | {
19
+ name: string;
20
+ description: string;
21
+ inputSchema: {
22
+ type: string;
23
+ properties: {
24
+ scanId: {
25
+ type: string;
26
+ description: string;
27
+ };
28
+ recordingId?: undefined;
29
+ };
30
+ required?: undefined;
31
+ };
32
+ })[];