conductor-remote 1.56.0 → 1.57.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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Conductor Remote</title>
14
14
  <!-- Runs before the module bundle so it can catch a stale shell that fails to boot. -->
15
15
  <script src="/self-heal.js"></script>
16
- <script type="module" crossorigin src="/assets/index-BsLMvcdL.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-DzCE_FWE.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-C2BviSOi.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
package/dist/sw.js CHANGED
@@ -1 +1 @@
1
- if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"f66ad1b1b0c84f86b9ef507e9f4ab92d"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-C2BviSOi.css",revision:null},{url:"assets/index-BsLMvcdL.js",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
1
+ if(!self.define){let e,s={};const i=(i,n)=>(i=new URL(i+".js",n).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(n,r)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(s[o])return;let l={};const t=e=>i(e,o),c={module:{uri:o},exports:l,require:t};s[o]=Promise.all(n.map(e=>c[e]||t(e))).then(e=>(r(...e),l))}}define(["./workbox-9c191d2f"],function(e){"use strict";importScripts("/push-sw.js"),self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.clientsClaim(),e.precacheAndRoute([{url:"self-heal.js",revision:"49bd63adb25a09341f8d2610e8bd3c76"},{url:"push-sw.js",revision:"e1e682e2e5e88fa03b7808ae9db8e098"},{url:"index.html",revision:"000fa930603fb0277be9ebdef5a79be3"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-DzCE_FWE.js",revision:null},{url:"assets/index-C2BviSOi.css",revision:null},{url:"apple-touch-icon.png",revision:"2b9301416b880d45d4bb655f2600d1f2"},{url:"icon-192.png",revision:"c5e01ac58768627e18ee7b8b6a9239ef"},{url:"icon-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon-maskable-512.png",revision:"a40638c55e310312457a621c9a0002c8"},{url:"icon.svg",revision:"c1aee186821798733dd477e69a0ef243"},{url:"manifest.webmanifest",revision:"cf88fbc5755108a7fe0616fa160a8a15"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api\//]}))});
@@ -0,0 +1,19 @@
1
+ /**
2
+ * An opaque, copyable pointer to one source message in a Conductor chat.
3
+ *
4
+ * Search stores `session_messages.rowid` already. Keep that implementation detail
5
+ * out of the MCP contract so the cursor can change shape later without teaching
6
+ * agents to do arithmetic on it.
7
+ */
8
+ export function chatCursor(rowid) {
9
+ if (!Number.isSafeInteger(rowid) || rowid < 1)
10
+ throw new Error('chat cursor rowid must be a positive integer');
11
+ return `m${rowid.toString(36)}`;
12
+ }
13
+ /** Decode a cursor supplied back to `read_chat`; null means it was not one of ours. */
14
+ export function parseChatCursor(cursor) {
15
+ if (!/^m[0-9a-z]+$/.test(cursor))
16
+ return null;
17
+ const rowid = Number.parseInt(cursor.slice(1), 36);
18
+ return Number.isSafeInteger(rowid) && rowid > 0 ? rowid : null;
19
+ }
@@ -25,6 +25,7 @@
25
25
  * router it would otherwise have to be carved out of, and it keeps *one* code path:
26
26
  * a tool cannot behave differently over HTTP than it does over stdio.
27
27
  */
28
+ import { chatCursor, parseChatCursor } from "./chat-cursor.js";
28
29
  import { routes } from "./routes.js";
29
30
  import { HIT_CLOSE, HIT_OPEN, workspaceTitle } from "./shared.js";
30
31
  /** Versions we know how to speak. The client's choice wins when we know it. */
@@ -44,6 +45,42 @@ function unmark(text) {
44
45
  function clip(text, max) {
45
46
  return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more chars]` : text;
46
47
  }
48
+ /** A true output bound: unlike `clip`, the truncation marker is inside `max`. */
49
+ function clipExact(text, max) {
50
+ if (text.length <= max)
51
+ return text;
52
+ const suffix = '… [truncated]';
53
+ if (max <= suffix.length)
54
+ return text.slice(0, max);
55
+ return `${text.slice(0, max - suffix.length)}${suffix}`;
56
+ }
57
+ /**
58
+ * Fit a transcript inside one MCP result without dropping either side of a nearby
59
+ * read. Short entries keep their full text; the longest entries share what remains.
60
+ */
61
+ function boundedTranscript(header, entries, maxChars) {
62
+ const prefix = header.length ? `${header.join('\n')}\n\n` : '';
63
+ if (!entries.length)
64
+ return clipExact(prefix.trim(), maxChars);
65
+ const separators = Math.max(0, entries.length - 1) * 2;
66
+ const available = Math.max(1, maxChars - prefix.length - separators);
67
+ const fullLength = entries.reduce((sum, entry) => sum + entry.length, 0);
68
+ if (fullLength <= available)
69
+ return `${prefix}${entries.join('\n\n')}`;
70
+ // Find the largest per-entry ceiling whose clipped rows fit. Entries shorter than
71
+ // it return their unused share to the longer ones, unlike a fixed equal split.
72
+ let low = 1;
73
+ let high = Math.max(...entries.map(entry => entry.length));
74
+ while (low < high) {
75
+ const mid = Math.ceil((low + high) / 2);
76
+ const used = entries.reduce((sum, entry) => sum + Math.min(entry.length, mid), 0);
77
+ if (used <= available)
78
+ low = mid;
79
+ else
80
+ high = mid - 1;
81
+ }
82
+ return clipExact(`${prefix}${entries.map(entry => clipExact(entry, low)).join('\n\n')}`, maxChars);
83
+ }
47
84
  function plural(n, one, many = `${one}s`) {
48
85
  return `${n} ${n === 1 ? one : many}`;
49
86
  }
@@ -75,7 +112,7 @@ export function createTools(call) {
75
112
  return [
76
113
  {
77
114
  name: 'search_chats',
78
- description: 'Full-text search every Conductor chat on this Mac, archived workspaces included, and get back the workspaces that discussed it with the matching excerpts. Use this to answer "which workspace did I do X in" or "what did we decide about X". Searches the prompts the user typed, the agent replies and the agent\'s reasoning, not tool output. Each excerpt is tagged [user], [assistant] or [thinking] — a [thinking] hit is reasoning the agent never said out loud, so do not quote it back as its answer. Results carry workspace_id and session_id for read_chat.',
115
+ description: 'Full-text search every Conductor chat on this Mac, archived workspaces included, and get back the workspaces that discussed it with the matching excerpts. Use this to answer "which workspace did I do X in" or "what did we decide about X". Searches the prompts the user typed, the agent replies and the agent\'s reasoning, not tool output. Each excerpt is tagged [user], [assistant] or [thinking] — a [thinking] hit is reasoning the agent never said out loud, so do not quote it back as its answer. Every excerpt carries its own session_id and cursor for a bounded nearby read_chat.',
79
116
  inputSchema: {
80
117
  type: 'object',
81
118
  properties: {
@@ -104,20 +141,32 @@ export function createTools(call) {
104
141
  lines.push(`workspace_id: ${w.id}${r.sessionId ? ` session_id: ${r.sessionId}` : ''}`);
105
142
  if (r.hits)
106
143
  lines.push(`${r.hits} matching message${r.hits === 1 ? '' : 's'}:`);
107
- for (const s of r.snippets)
144
+ for (const s of r.snippets) {
108
145
  lines.push(` [${s.role}] ${clip(unmark(s.text), 400)}`);
146
+ lines.push(` session_id: ${s.sessionId} cursor: ${s.cursor}`);
147
+ }
109
148
  }
110
149
  return lines.join('\n').trim();
111
150
  }
112
151
  },
113
152
  {
114
153
  name: 'read_chat',
115
- description: 'Read a Conductor chat transcript by session_id, newest messages last. Works for archived workspaces, which is how you read work that has been put away. Tool calls and tool output are summarised to one line each; the prose is verbatim.',
154
+ description: 'Read a bounded Conductor chat transcript by session_id, newest messages last. Pass a cursor from search_chats or a prior read_chat as near; before and after expand either direction or both together. For a [thinking] search hit, set include_thinking true to include the matching block. Without near, returns the trailing entries. Works for archived workspaces. Tool calls and failed tool output are summarised to one line each; prose is verbatim unless the output budget truncates it.',
116
155
  inputSchema: {
117
156
  type: 'object',
118
157
  properties: {
119
158
  session_id: { type: 'string', description: 'From search_chats or list_chats.' },
120
- limit: { type: 'number', description: 'How many trailing entries to return (default 40, max 400).' },
159
+ near: { type: 'string', description: 'A cursor from search_chats or a prior read_chat.' },
160
+ before: { type: 'number', description: 'Entries before near to return (default 6, max 100).' },
161
+ after: { type: 'number', description: 'Entries after near to return (default 6, max 100).' },
162
+ limit: {
163
+ type: 'number',
164
+ description: 'Trailing entries when near is omitted (default 12, max 400).'
165
+ },
166
+ max_chars: {
167
+ type: 'number',
168
+ description: 'Hard result-size budget (default 12000, min 1000, max 40000).'
169
+ },
121
170
  include_thinking: { type: 'boolean', description: 'Include the agent’s reasoning (default false).' },
122
171
  include_tools: {
123
172
  type: 'boolean',
@@ -128,7 +177,15 @@ export function createTools(call) {
128
177
  },
129
178
  run: async (args) => {
130
179
  const sessionId = need(args, 'session_id');
131
- const limit = Math.min(400, Math.max(1, num(args.limit) ?? 40));
180
+ const near = str(args.near);
181
+ const anchor = near ? parseChatCursor(near) : null;
182
+ if (near && anchor === null)
183
+ throw new Error('near must be a cursor returned by search_chats');
184
+ const count = (value, fallback) => Math.min(100, Math.max(0, Math.floor(num(value) ?? fallback)));
185
+ const before = count(args.before, 6);
186
+ const after = count(args.after, 6);
187
+ const limit = Math.min(400, Math.max(1, Math.floor(num(args.limit) ?? 12)));
188
+ const maxChars = Math.min(40_000, Math.max(1_000, Math.floor(num(args.max_chars) ?? 12_000)));
132
189
  // One flag each, because they answer different questions: tools are what the agent
133
190
  // *did*, thinking is why it did it. Reading both off `include_tools` meant the only
134
191
  // way to see the reasoning was to take the tool churn along with it.
@@ -137,19 +194,47 @@ export function createTools(call) {
137
194
  const data = await call(`${routes.messages.path(sessionId)}?after=0`, {
138
195
  timeoutMs: 30_000
139
196
  });
197
+ if (anchor !== null && !data.entries.some(entry => entry.rowid === anchor)) {
198
+ throw new Error('near cursor is not in that session');
199
+ }
140
200
  const wanted = data.entries.filter(e => e.role === 'thinking' ? includeThinking : e.role === 'tool' ? includeTools : true);
141
201
  if (!wanted.length)
142
202
  return `no messages in session ${sessionId}`;
143
- const tail = wanted.slice(-limit);
144
- const head = tail.length < wanted.length ? [`(last ${tail.length} of ${wanted.length} entries)`] : [];
145
- return [
146
- ...head,
147
- ...tail.map(e => {
148
- if (e.role === 'tool')
149
- return `[tool ${e.tool ?? ''}] ${clip(e.text, 200)}${e.detail ? ` — ${e.detail}` : ''}`;
150
- return `[${e.role}] ${clip(e.text, 4000)}`;
151
- })
152
- ].join('\n\n');
203
+ let selected;
204
+ const head = [];
205
+ if (anchor === null) {
206
+ selected = wanted.slice(-limit);
207
+ if (selected.length < wanted.length) {
208
+ head.push(`(last ${selected.length} of ${wanted.length} entries · older_cursor: ${chatCursor(selected[0].rowid)})`);
209
+ }
210
+ }
211
+ else {
212
+ const older = wanted.filter(entry => entry.rowid < anchor);
213
+ const at = wanted.filter(entry => entry.rowid === anchor);
214
+ const newer = wanted.filter(entry => entry.rowid > anchor);
215
+ selected = [...(before ? older.slice(-before) : []), ...at, ...newer.slice(0, after)];
216
+ const parts = [
217
+ `near ${near}`,
218
+ `${Math.min(before, older.length)} before`,
219
+ `${at.length} at`,
220
+ `${Math.min(after, newer.length)} after`
221
+ ];
222
+ if (older.length > before && selected[0])
223
+ parts.push(`older_cursor: ${chatCursor(selected[0].rowid)}`);
224
+ if (newer.length > after && selected.at(-1)) {
225
+ parts.push(`newer_cursor: ${chatCursor(selected.at(-1).rowid)}`);
226
+ }
227
+ head.push(`(${parts.join(' · ')})`);
228
+ }
229
+ if (!selected.length) {
230
+ return boundedTranscript(head, ['(the matching entry is excluded by the current role filters)'], maxChars);
231
+ }
232
+ const rendered = selected.map(entry => {
233
+ if (entry.role === 'tool')
234
+ return `[tool ${entry.tool ?? ''}] ${clip(entry.text, 200)}${entry.detail ? ` — ${entry.detail}` : ''}`;
235
+ return `[${entry.role}] ${entry.text}`;
236
+ });
237
+ return boundedTranscript(head, rendered, maxChars);
153
238
  }
154
239
  },
155
240
  {
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { DatabaseSync } from 'node:sqlite';
4
+ import { chatCursor } from "./chat-cursor.js";
4
5
  import { HIT_CLOSE, HIT_OPEN } from "./shared.js";
5
6
  import { parseMessage } from "./transcript.js";
6
7
  /**
@@ -335,7 +336,13 @@ export function foldHits(hits, resolve) {
335
336
  // scoring and snippeting the same slice needs no second sort.
336
337
  if (entry.snippets.length < SNIPPETS_PER_RESULT) {
337
338
  entry.score += hit.score;
338
- entry.snippets.push({ sessionId: hit.sessionId, role: hit.role, at: hit.at, text: hit.snippet });
339
+ entry.snippets.push({
340
+ sessionId: hit.sessionId,
341
+ cursor: chatCursor(hit.srcRowid),
342
+ role: hit.role,
343
+ at: hit.at,
344
+ text: hit.snippet
345
+ });
339
346
  }
340
347
  }
341
348
  const results = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.56.0",
3
+ "version": "1.57.0",
4
4
  "type": "module",
5
5
  "packageManager": "yarn@4.15.0",
6
6
  "description": "Phone control panel for local Conductor agents. Reads ride SQLite + git; prompts ride Conductor's own dispatch path.",
@@ -50,7 +50,7 @@
50
50
  "typecheck": "tsc -p tsconfig.json",
51
51
  "lint": "biome check .",
52
52
  "fix": "biome check . --fix",
53
- "verify": "yarn typecheck && yarn lint && yarn check:pr && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:utf8-target && yarn check:file-links && yarn check:model-cache && yarn check:model-labels && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify && yarn check:online",
53
+ "verify": "yarn typecheck && yarn lint && yarn check:pr && yarn check:imports && yarn check:routes && yarn check:attachments && yarn check:applescript && yarn check:config && yarn check:utf8-target && yarn check:file-links && yarn check:model-cache && yarn check:mcp-chat-read && yarn check:model-labels && yarn check:transcript-actions && yarn check:nosleep && yarn check:uilock && yarn check:firstprompt && yarn check:sendonce && yarn check:notify && yarn check:online",
54
54
  "release": "semantic-release",
55
55
  "prepack": "yarn build && yarn build:node",
56
56
  "postinstall": "husky || true",
@@ -61,7 +61,9 @@
61
61
  "check:file-links": "node scripts/check-file-links.ts",
62
62
  "check:firstprompt": "node scripts/check-firstprompt.ts",
63
63
  "check:model-cache": "node scripts/check-model-cache.ts",
64
+ "check:mcp-chat-read": "node scripts/check-mcp-chat-read.ts",
64
65
  "check:model-labels": "node scripts/check-model-labels.ts",
66
+ "check:transcript-actions": "node scripts/check-transcript-actions.ts",
65
67
  "check:sendonce": "node scripts/check-sendonce.ts",
66
68
  "check:notify": "node scripts/check-notify.ts",
67
69
  "check:online": "node scripts/check-online.ts",