conductor-remote 1.39.0 → 1.41.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,8 +13,8 @@
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-CHhZJBVH.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-UopM0hHW.css">
16
+ <script type="module" crossorigin src="/assets/index-BSDnFvjd.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-95IdnA_r.css">
18
18
  <link rel="manifest" href="/manifest.webmanifest"></head>
19
19
  <body>
20
20
  <div id="root"></div>
package/dist/push-sw.js CHANGED
@@ -13,6 +13,25 @@
13
13
  // 2. **A tap focuses the existing window** rather than navigating it. The app is a
14
14
  // token-gated SPA; `openWindow` on a live client would remount the whole thing and
15
15
  // throw away in-progress composer text, so we focus and post a route instead.
16
+ // 3. **The route is also parked in Cache Storage**, because on iOS neither of the two
17
+ // direct routes survives. A backgrounded home-screen web app is resumed on whatever
18
+ // screen it was left on — `openWindow`'s path is ignored, and a `postMessage` to a
19
+ // frozen page is dropped (WebKit, reported from iOS 17.1 through 18.x and still
20
+ // open). The cache outlives both, so the app reads its target when it comes back to
21
+ // the front; see `usePushRouting` in web/src/hooks.ts.
22
+
23
+ /** One entry, overwritten per tap: only the newest tap can still be waiting to land. */
24
+ const ROUTE_CACHE = 'push-route'
25
+ const ROUTE_KEY = '/__push-route'
26
+
27
+ async function parkRoute(url) {
28
+ try {
29
+ const cache = await caches.open(ROUTE_CACHE)
30
+ await cache.put(ROUTE_KEY, new Response(JSON.stringify({ url, ts: Date.now() })))
31
+ } catch {
32
+ // Storage refused it (quota, a private window). The two direct routes below still stand.
33
+ }
34
+ }
16
35
 
17
36
  self.addEventListener('push', event => {
18
37
  const fallback = { title: 'Conductor Remote', body: 'Something changed in a workspace.', url: '/', tag: 'conductor' }
@@ -28,8 +47,9 @@ self.addEventListener('push', event => {
28
47
  event.waitUntil(
29
48
  self.registration.showNotification(data.title, {
30
49
  body: data.body,
31
- // Tagged per workspace by the relay: a chatty agent replaces its own notification
32
- // instead of stacking. `renotify` keeps the replacement audible.
50
+ // Tagged per chat by the relay: a chatty agent replaces its own notification
51
+ // instead of stacking, while a sibling chat keeps its own (they open different
52
+ // screens). `renotify` keeps the replacement audible.
33
53
  tag: data.tag,
34
54
  renotify: true,
35
55
  icon: '/icon-192.png',
@@ -45,18 +65,23 @@ self.addEventListener('notificationclick', event => {
45
65
  const url = (event.notification.data && event.notification.data.url) || '/'
46
66
  event.waitUntil(
47
67
  (async () => {
68
+ // Park first. Everything below can succeed and still leave the phone on the wrong
69
+ // screen, and this is the copy the app reads when it wakes up.
70
+ await parkRoute(url)
48
71
  const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true })
49
72
  for (const client of clients) {
50
73
  if (new URL(client.url).origin !== self.location.origin) continue
74
+ // Handled in web/src/hooks.ts (usePushRouting) — an in-app route change, so the
75
+ // token gate and React state survive the tap. Posted before the focus, since a
76
+ // refused focus is no reason to skip a message the page may well receive.
77
+ client.postMessage({ type: 'push-navigate', url })
51
78
  try {
52
79
  await client.focus()
53
- // Handled in web/src/hooks.ts (usePushRouting) — an in-app route change,
54
- // so the token gate and React state survive the tap.
55
- client.postMessage({ type: 'push-navigate', url })
56
- return
57
80
  } catch {
58
- // focus() can be refused (no user activation on some platforms) — fall through to openWindow
81
+ // focus() can be refused (no user activation on some platforms); on iOS the
82
+ // system foregrounds the web app on the tap regardless.
59
83
  }
84
+ return
60
85
  }
61
86
  await self.clients.openWindow(url)
62
87
  })()
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:"890f6a70e97d144f4d025254e90892a1"},{url:"index.html",revision:"79c117a92e7635dd0174f5aeb1f3940c"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-UopM0hHW.css",revision:null},{url:"assets/index-CHhZJBVH.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:"9a6849700ab5e2d2b416d9108090e5af"},{url:"assets/workbox-window.prod.es5-BBnX5xw4.js",revision:null},{url:"assets/index-BSDnFvjd.js",revision:null},{url:"assets/index-95IdnA_r.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\//]}))});
@@ -218,6 +218,15 @@ export async function notifyDevice(id, message) {
218
218
  export function deviceCount() {
219
219
  return load().devices.length;
220
220
  }
221
+ /**
222
+ * Where a tapped notification lands. The chat id rides along because the phone
223
+ * otherwise picks the tab itself (Conductor's active session, else the first one)
224
+ * — and on a multi-chat workspace that is rarely the chat that just finished.
225
+ * Kept here so the notifier and the parked-prompt queue can't drift apart.
226
+ */
227
+ export function chatRoute(workspaceId, sessionId) {
228
+ return `/w/${workspaceId}?session=${encodeURIComponent(sessionId)}`;
229
+ }
221
230
  /** Collapse a transcript entry to one lock-screen line: no code fences, no blank runs. */
222
231
  function oneLine(text) {
223
232
  return clip(text
@@ -287,9 +296,11 @@ async function fire(reads, sessionId, kind, state) {
287
296
  const sent = await notifyAll({
288
297
  title: state.repoName ? `${where} — ${state.repoName}` : where,
289
298
  body,
290
- // Per workspace, so a chatty agent replaces its own notification instead of stacking.
291
- tag: state.workspaceId,
292
- url: `/w/${state.workspaceId}`,
299
+ // Per chat, so a chatty agent replaces its own notification instead of stacking —
300
+ // and two chats in one workspace stay separately tappable, since each now lands
301
+ // somewhere different.
302
+ tag: sessionId,
303
+ url: chatRoute(state.workspaceId, sessionId),
293
304
  kind,
294
305
  ts: Date.now()
295
306
  });
@@ -4,6 +4,29 @@ import path from 'node:path';
4
4
  import { describeRepoIcon, resolveRepoIcon } from "./icons.js";
5
5
  import { parseMessage } from "./transcript.js";
6
6
  const worktreeCache = new Map();
7
+ /**
8
+ * Neutralize LIKE's own wildcards in a user's search word. `queryTokens` already
9
+ * strips everything but letters, digits and `_` — and `_` is LIKE's single-character
10
+ * wildcard, so "add_set" would quietly also match "addaset" without this.
11
+ */
12
+ function escapeLike(s) {
13
+ return s.replace(/[\\%_]/g, m => `\\${m}`);
14
+ }
15
+ /** Shared row → `SearchWorkspace` shape for the two search reads below. */
16
+ function toSearchWorkspace(r) {
17
+ return {
18
+ id: r.id,
19
+ workspace_name: r.workspace_name,
20
+ pr_title: r.pr_title,
21
+ branch: r.branch,
22
+ directory_name: r.directory_name,
23
+ state: r.state,
24
+ updated_at: r.updated_at,
25
+ repo_name: r.repo_name,
26
+ icon: describeRepoIcon({ icon: r.repo_icon, repoRoot: r.repo_root, remoteUrl: r.remote_url }),
27
+ archived: r.state === 'archived'
28
+ };
29
+ }
7
30
  /**
8
31
  * Conductor's sidebar title for a workspace: manual name → PR title → humanized
9
32
  * branch → worktree codename → id. Deliberately a second copy of the web's
@@ -131,6 +154,55 @@ export class Reads {
131
154
  icon: describeRepoIcon({ icon: r.icon, repoRoot: r.root_path, remoteUrl: r.remote_url })
132
155
  }));
133
156
  }
157
+ /**
158
+ * Map chat ids to the workspace that owns them, archived included.
159
+ *
160
+ * `listWorkspaces` filters to `state IN ('ready','setting_up')`, which is right for
161
+ * the sidebar and wrong for search: the whole point of searching the transcript is
162
+ * to reach work you finished and put away. One query for the whole hit set, because
163
+ * a search fans out over up to 300 chunks and a per-chat lookup would be 300 of them.
164
+ */
165
+ searchTargets(sessionIds) {
166
+ const out = new Map();
167
+ if (!sessionIds.length)
168
+ return out;
169
+ const holes = sessionIds.map(() => '?').join(',');
170
+ const rows = this.db.query(`SELECT s.id AS session_id, s.title AS session_title,
171
+ w.id, w.workspace_name, w.pr_title, w.branch, w.directory_name, w.state, w.updated_at,
172
+ r.name AS repo_name, r.icon AS repo_icon, r.root_path AS repo_root, r.remote_url AS remote_url
173
+ FROM sessions s
174
+ JOIN workspaces w ON w.id = s.workspace_id
175
+ LEFT JOIN repos r ON r.id = w.repository_id
176
+ WHERE s.id IN (${holes})`, sessionIds);
177
+ for (const r of rows)
178
+ out.set(r.session_id, { sessionTitle: r.session_title, workspace: toSearchWorkspace(r) });
179
+ return out;
180
+ }
181
+ /**
182
+ * Workspaces whose own identity matches every token — name, PR title, branch,
183
+ * worktree codename or repo. Archived included, same reason as `searchTargets`.
184
+ *
185
+ * This is the half of search the transcript index cannot do. A workspace named for
186
+ * the thing you are looking for may never have said those words in its chat, and
187
+ * one whose chat is empty has no chunks at all.
188
+ */
189
+ findWorkspacesByName(tokens, limit = 20) {
190
+ if (!tokens.length)
191
+ return [];
192
+ const fields = ['w.workspace_name', 'w.pr_title', 'w.branch', 'w.directory_name', 'r.name'];
193
+ // AND across tokens, OR across fields: "auk lamp" should find the lamp workspace in
194
+ // the auk repo, where no single column holds both words.
195
+ const where = tokens.map(() => `(${fields.map(f => `${f} LIKE ? ESCAPE '\\'`).join(' OR ')})`).join(' AND ');
196
+ const params = tokens.flatMap(t => fields.map(() => `%${escapeLike(t)}%`));
197
+ const rows = this.db.query(`SELECT w.id, w.workspace_name, w.pr_title, w.branch, w.directory_name, w.state, w.updated_at,
198
+ r.name AS repo_name, r.icon AS repo_icon, r.root_path AS repo_root, r.remote_url AS remote_url
199
+ FROM workspaces w
200
+ LEFT JOIN repos r ON r.id = w.repository_id
201
+ WHERE ${where}
202
+ ORDER BY (w.state = 'archived'), w.updated_at DESC
203
+ LIMIT ?`, [...params, limit]);
204
+ return rows.map(toSearchWorkspace);
205
+ }
134
206
  /** Resolve a repo's icon by its name (the sidebar avatar) — null if the repo or icon is unknown. */
135
207
  resolveRepoIcon(repoName) {
136
208
  const rows = this.db.query('SELECT root_path FROM repos WHERE name = ? LIMIT 1', [
@@ -0,0 +1,333 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { parseMessage } from "./transcript.js";
5
+ /**
6
+ * Full-text search over the chat history, in a sidecar DB the relay owns.
7
+ *
8
+ * Two facts decide the whole shape of this file.
9
+ *
10
+ * **Prose is 1.2% of the transcript.** Measured over this Mac's 1.7M
11
+ * `session_messages` rows (3,106 MB of `content`): tool_result output is 799 MB,
12
+ * `type:"system"` frames 522 MB, tool_use arguments 162 MB, thinking 77 MB — and
13
+ * what a person would ever search for, the assistant's own words plus the prompts
14
+ * they typed, is **38 MB**. So this indexes prose only. A grep of the raw column
15
+ * would be a 3 GB scan to search 38 MB, and it would rank a file dump the agent
16
+ * happened to `cat` above the sentence that explained the decision.
17
+ *
18
+ * **`node:sqlite` ships FTS5.** Porter stemming, `bm25()`, `snippet()`, `NEAR()`
19
+ * all work on the bundled SQLite (3.51.2), so the index costs no runtime
20
+ * dependency — which the tarball rule requires (see CLAUDE.md ▸ Traps). Measured
21
+ * on the full history: 7.6s to build, 111,079 chunks, queries in 1–7ms.
22
+ *
23
+ * The index is **never** written into `conductor.db`. That handle is read-only and
24
+ * stays that way; this opens its own file under the relay's state dir, and it is
25
+ * disposable — delete it and the next start rebuilds it.
26
+ */
27
+ /** Bump to force a rebuild: a tokenizer or extraction change makes every stored chunk wrong. */
28
+ const SCHEMA_VERSION = 1;
29
+ /**
30
+ * Source rows advanced per tick. The cursor moves by *scanned* rowid rather than
31
+ * matched rowid, so a caught-up index re-scans nothing — get that wrong and every
32
+ * idle poll re-reads the 3 GB tail looking for rows it already rejected.
33
+ */
34
+ const WINDOW_ROWS = 4000;
35
+ /** Yield to the event loop between batches: the backfill is ~19ms of blocking work per window. */
36
+ const BACKFILL_PAUSE_MS = 5;
37
+ /** Once caught up, look for new messages at about the rate a chat produces them. */
38
+ const IDLE_POLL_MS = 15_000;
39
+ /** A pathological single message can't be allowed to dominate the index. */
40
+ const MAX_CHUNK_CHARS = 64_000;
41
+ /** How many chunks a query ranks before they are folded into workspaces. */
42
+ const CHUNK_LIMIT = 300;
43
+ /**
44
+ * Snippet highlight markers. Control characters rather than brackets: they survive
45
+ * JSON, they need no escaping on the way to the phone, and no transcript contains
46
+ * them, so `web/src/lib/format.ts` can split on them without a parser. The client
47
+ * must not render them literally — see `splitSnippet`.
48
+ */
49
+ export const HIT_OPEN = '\u0001';
50
+ export const HIT_CLOSE = '\u0002';
51
+ /**
52
+ * Turn a phone query into an FTS5 MATCH expression.
53
+ *
54
+ * Every token is quoted, because FTS5 reads `-`, `*`, `:`, `(`, `AND`, `NEAR` and
55
+ * friends as syntax: an unquoted apostrophe or hyphen is a *parse error*, not a
56
+ * poor result, so a raw query would fail rather than under-match. Tokens are OR'd
57
+ * because someone reaching for a workspace on a phone is recalling it, not
58
+ * filtering it — BM25 is what puts the message matching four of four words above
59
+ * the one matching one, and requiring all four would return nothing whenever a
60
+ * single word is misremembered.
61
+ *
62
+ * The last token gets a prefix `*` so search-as-you-type matches mid-word, but only
63
+ * from three characters: `"a"*` matches a large fraction of the index and would
64
+ * spend the whole query budget on a keystroke that means nothing yet.
65
+ */
66
+ export function matchQuery(raw) {
67
+ const tokens = raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu);
68
+ if (!tokens?.length)
69
+ return null;
70
+ const terms = tokens.map(t => `"${t}"`);
71
+ const last = tokens[tokens.length - 1];
72
+ if (!/\s$/.test(raw) && last.length >= 3)
73
+ terms[terms.length - 1] = `"${last}"*`;
74
+ return terms.join(' OR ');
75
+ }
76
+ /** The tokens `matchQuery` will search for — what a caller matches names against. */
77
+ export function queryTokens(raw) {
78
+ return raw.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [];
79
+ }
80
+ export class SearchIndex {
81
+ source;
82
+ file;
83
+ db = null;
84
+ openError = null;
85
+ cursor = 0;
86
+ caughtUp = false;
87
+ timer = null;
88
+ sourceMax = 0;
89
+ constructor(source, file) {
90
+ this.source = source;
91
+ this.file = file;
92
+ }
93
+ /** Open (or rebuild) the sidecar and start indexing in the background. */
94
+ start() {
95
+ try {
96
+ this.open();
97
+ }
98
+ catch (err) {
99
+ // A search index is a convenience; failing to open one must never stop the relay
100
+ // serving state, transcripts or sends. Report it on /api/search instead.
101
+ this.openError = err instanceof Error ? err.message : String(err);
102
+ console.warn(`⚠ search index unavailable (${this.openError}) — /api/search will report it`);
103
+ return;
104
+ }
105
+ this.schedule(0);
106
+ }
107
+ stop() {
108
+ if (this.timer)
109
+ clearTimeout(this.timer);
110
+ this.timer = null;
111
+ }
112
+ open() {
113
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
114
+ const db = new DatabaseSync(this.file);
115
+ db.exec('PRAGMA journal_mode = WAL');
116
+ db.exec('PRAGMA synchronous = NORMAL');
117
+ // A dev relay on another port shares this file with the LaunchAgent's. WAL lets them
118
+ // both read; the writer that loses waits rather than throwing away its batch, and a
119
+ // tick that still fails is retried by the scheduler with nothing lost (the cursor
120
+ // only advances on commit).
121
+ db.exec('PRAGMA busy_timeout = 5000');
122
+ db.exec('CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL)');
123
+ const version = Number(this.readMeta(db, 'version') ?? 0);
124
+ if (version !== SCHEMA_VERSION) {
125
+ db.exec('DROP TABLE IF EXISTS chunks');
126
+ db.exec(`
127
+ CREATE VIRTUAL TABLE chunks USING fts5(
128
+ body,
129
+ session_id UNINDEXED,
130
+ src_rowid UNINDEXED,
131
+ role UNINDEXED,
132
+ at UNINDEXED,
133
+ tokenize='porter unicode61'
134
+ )
135
+ `);
136
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('version', String(SCHEMA_VERSION));
137
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', '0');
138
+ if (version)
139
+ console.log(`search index schema ${version} → ${SCHEMA_VERSION}, rebuilding`);
140
+ }
141
+ this.db = db;
142
+ this.cursor = Number(this.readMeta(db, 'cursor') ?? 0);
143
+ }
144
+ readMeta(db, key) {
145
+ const row = db.prepare('SELECT v FROM meta WHERE k = ?').get(key);
146
+ return row?.v ?? null;
147
+ }
148
+ schedule(ms) {
149
+ if (this.timer)
150
+ clearTimeout(this.timer);
151
+ this.timer = setTimeout(() => {
152
+ let more = false;
153
+ try {
154
+ more = this.tick();
155
+ }
156
+ catch (err) {
157
+ console.warn(`⚠ search index tick failed: ${err instanceof Error ? err.message : err}`);
158
+ }
159
+ this.schedule(more ? BACKFILL_PAUSE_MS : IDLE_POLL_MS);
160
+ }, ms);
161
+ this.timer.unref?.();
162
+ }
163
+ /**
164
+ * Index one window of source rows. Returns true while there is more to do.
165
+ *
166
+ * The window is picked by rowid *before* the prose filter runs, so the cursor
167
+ * advances past rows that hold nothing worth indexing. Filtering first and
168
+ * advancing to the last match instead would leave a caught-up index re-scanning
169
+ * every tool_result between the last prose row and the end of the table, every
170
+ * poll, forever.
171
+ */
172
+ tick() {
173
+ const db = this.db;
174
+ if (!db)
175
+ return false;
176
+ const window = this.source.query('SELECT rowid FROM session_messages WHERE rowid > ? ORDER BY rowid LIMIT ?', [this.cursor, WINDOW_ROWS]);
177
+ if (!window.length) {
178
+ this.caughtUp = true;
179
+ return false;
180
+ }
181
+ const end = window[window.length - 1].rowid;
182
+ // Only rows that can hold prose: a plain-text prompt, or a frame carrying a text
183
+ // block. Everything else is tool plumbing and 98.8% of the bytes.
184
+ const rows = this.source.query(`SELECT rowid, id, session_id, role, content, full_message, created_at, sent_at, queue_order
185
+ FROM session_messages
186
+ WHERE rowid > ? AND rowid <= ? AND session_id IS NOT NULL
187
+ AND (role = 'user' OR content LIKE '%"type":"text"%')
188
+ ORDER BY rowid`, [this.cursor, end]);
189
+ const insert = db.prepare('INSERT INTO chunks(body, session_id, src_rowid, role, at) VALUES (?, ?, ?, ?, ?)');
190
+ db.exec('BEGIN');
191
+ try {
192
+ for (const row of rows) {
193
+ // Reuse the transcript parser rather than a second JSON walk: it already knows
194
+ // that text inside a `type:"user"` frame is injected context and not the user's
195
+ // words, and indexing something the chat view would never show is how a search
196
+ // result becomes impossible to find once you open it.
197
+ for (const entry of parseMessage(row, null)) {
198
+ if (entry.role !== 'user' && entry.role !== 'assistant')
199
+ continue;
200
+ const body = entry.text.trim();
201
+ if (!body)
202
+ continue;
203
+ insert.run(body.slice(0, MAX_CHUNK_CHARS), row.session_id, row.rowid, entry.role, entry.ts);
204
+ }
205
+ }
206
+ db.prepare('INSERT OR REPLACE INTO meta(k, v) VALUES (?, ?)').run('cursor', String(end));
207
+ db.exec('COMMIT');
208
+ }
209
+ catch (err) {
210
+ db.exec('ROLLBACK');
211
+ throw err;
212
+ }
213
+ this.cursor = end;
214
+ return true;
215
+ }
216
+ status() {
217
+ if (this.openError)
218
+ return { chunks: 0, ready: false, progress: 0, error: this.openError };
219
+ const db = this.db;
220
+ if (!db)
221
+ return { chunks: 0, ready: false, progress: 0 };
222
+ const chunks = Number(db.prepare('SELECT COUNT(*) c FROM chunks').get().c);
223
+ if (this.caughtUp)
224
+ return { chunks, ready: true, progress: 1 };
225
+ // Only re-read the source's high-water mark while backfilling; it costs a query
226
+ // and the answer only matters for the progress bar.
227
+ if (!this.sourceMax) {
228
+ const max = this.source.query('SELECT MAX(rowid) m FROM session_messages')[0]?.m;
229
+ this.sourceMax = max ?? 0;
230
+ }
231
+ const progress = this.sourceMax ? Math.min(1, this.cursor / this.sourceMax) : 0;
232
+ return { chunks, ready: false, progress };
233
+ }
234
+ /** Top matching chunks, best first. Empty when the query has no searchable tokens. */
235
+ search(raw, limit = CHUNK_LIMIT) {
236
+ const db = this.db;
237
+ if (!db)
238
+ return [];
239
+ const match = matchQuery(raw);
240
+ if (!match)
241
+ return [];
242
+ let rows;
243
+ try {
244
+ rows = db
245
+ .prepare(`SELECT session_id, src_rowid, role, at, -bm25(chunks) AS score,
246
+ snippet(chunks, 0, ?, ?, '…', 24) AS snippet
247
+ FROM chunks WHERE chunks MATCH ? ORDER BY bm25(chunks) LIMIT ?`)
248
+ .all(HIT_OPEN, HIT_CLOSE, match, limit);
249
+ }
250
+ catch (err) {
251
+ // A MATCH that still fails to parse is a bug in matchQuery, not user error —
252
+ // report it rather than showing an empty result that looks like "no matches".
253
+ throw new Error(`search failed for ${JSON.stringify(match)}: ${err instanceof Error ? err.message : err}`);
254
+ }
255
+ return rows.map(r => ({
256
+ sessionId: r.session_id,
257
+ srcRowid: Number(r.src_rowid),
258
+ role: r.role === 'user' ? 'user' : 'assistant',
259
+ at: r.at,
260
+ score: Number(r.score),
261
+ snippet: r.snippet
262
+ }));
263
+ }
264
+ }
265
+ const SNIPPETS_PER_RESULT = 3;
266
+ /**
267
+ * Fold chunk hits up into workspaces.
268
+ *
269
+ * Chunk-level results are the wrong unit here: one long conversation produces a
270
+ * dozen and buries every other workspace. What to do with those dozen is the whole
271
+ * ranking decision, and it was measured rather than guessed — searching this Mac's
272
+ * history for "removing adding lamp manual", where the right answer is a chat that
273
+ * says "Add by name is gone. Removed the form":
274
+ *
275
+ * summing every hit → right answer ranks 9th
276
+ * best single hit → 5th
277
+ * sum of the top 3 → 5th, and steadier across other queries
278
+ *
279
+ * Summing everything ranks by *volume*: a 32-message conversation about lamps beat
280
+ * the four messages that actually removed the feature. So only the best
281
+ * `SNIPPETS_PER_RESULT` hits score, which caps what repetition can buy and makes
282
+ * the number mean something the user can check — the score is exactly the strength
283
+ * of the snippets shown under the row. `hits` still counts them all.
284
+ *
285
+ * Hits whose session no longer resolves to a workspace are dropped; a result nobody
286
+ * can open is worse than one fewer result.
287
+ */
288
+ export function foldHits(hits, resolve) {
289
+ const byWorkspace = new Map();
290
+ for (const hit of hits) {
291
+ const workspace = resolve(hit.sessionId);
292
+ if (!workspace)
293
+ continue;
294
+ let entry = byWorkspace.get(workspace.id);
295
+ if (!entry) {
296
+ entry = {
297
+ workspace,
298
+ sessionId: null,
299
+ hits: 0,
300
+ score: 0,
301
+ at: null,
302
+ snippets: [],
303
+ byName: false,
304
+ bestBySession: new Map()
305
+ };
306
+ byWorkspace.set(workspace.id, entry);
307
+ }
308
+ entry.hits++;
309
+ if (!entry.at || hit.at > entry.at)
310
+ entry.at = hit.at;
311
+ entry.bestBySession.set(hit.sessionId, Math.max(entry.bestBySession.get(hit.sessionId) ?? 0, hit.score));
312
+ // `hits` arrives in BM25 order, so the first few of a workspace are its best few:
313
+ // scoring and snippeting the same slice needs no second sort.
314
+ if (entry.snippets.length < SNIPPETS_PER_RESULT) {
315
+ entry.score += hit.score;
316
+ entry.snippets.push({ sessionId: hit.sessionId, role: hit.role, at: hit.at, text: hit.snippet });
317
+ }
318
+ }
319
+ const results = [];
320
+ for (const entry of byWorkspace.values()) {
321
+ const { bestBySession, ...rest } = entry;
322
+ let sessionId = null;
323
+ let best = -Infinity;
324
+ for (const [id, score] of bestBySession) {
325
+ if (score <= best)
326
+ continue;
327
+ best = score;
328
+ sessionId = id;
329
+ }
330
+ results.push({ ...rest, sessionId });
331
+ }
332
+ return results.sort((a, b) => b.score - a.score);
333
+ }
@@ -12,10 +12,11 @@ import { workspaceDiff } from "./git.js";
12
12
  import { installLogCapture, isManaged, LOG_FILE_NAMES, logFiles, processStartedAt, recentLogs, redactSecrets, tailLogFile } from "./logbuf.js";
13
13
  import { mergePr } from "./merge.js";
14
14
  import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
15
- import { notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
15
+ import { chatRoute, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
16
16
  import { ParkedPromptQueue } from "./parked.js";
17
17
  import { attachPrStatus } from "./pr.js";
18
18
  import { Reads } from "./reads.js";
19
+ import { foldHits, queryTokens, SearchIndex } from "./search.js";
19
20
  import { readSettings, writeSettings } from "./settings.js";
20
21
  import { driftWarningLines, tailscaleBin } from "./tailscale.js";
21
22
  import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
@@ -27,6 +28,11 @@ const cfg = loadConfig();
27
28
  const db = new ConductorDb(cfg.dbPath);
28
29
  const reads = new Reads(db, cfg.workspacesRoot);
29
30
  const actuator = pickActuator(cfg.writeStrategy);
31
+ // Full-text index over the chat prose, in the relay's own sidecar DB — never in
32
+ // Conductor's (see src/search.ts). It backfills in the background and is disposable:
33
+ // deleting the file rebuilds it on the next start.
34
+ const search = new SearchIndex(db, path.join(stateDir(), 'search.db'));
35
+ search.start();
30
36
  // A windowless Conductor that ignores reopen *and* a Dock click can only be fixed
31
37
  // by restarting it — and quitting takes any agent mid-turn down with it. So the
32
38
  // write path may only do that while nothing is working, which is a DB fact, not
@@ -288,7 +294,7 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
288
294
  body: error ? `Parked prompt failed: ${error}` : `Sent after unlock: ${preview}`,
289
295
  // Per chat, so a second parked prompt replaces the first's notification.
290
296
  tag: `parked-${entry.sessionId}`,
291
- url: `/w/${entry.workspaceId}`,
297
+ url: chatRoute(entry.workspaceId, entry.sessionId),
292
298
  kind: error ? 'error' : 'done',
293
299
  ts: Date.now()
294
300
  });
@@ -418,6 +424,49 @@ const server = http.createServer(async (req, res) => {
418
424
  update
419
425
  });
420
426
  }
427
+ // GET /api/search?q= — find a workspace by its name or by what was said in its chats.
428
+ //
429
+ // Two sources, merged. `findWorkspacesByName` matches the workspace's own identity
430
+ // and wins ties, because someone who types a name wants that workspace and not the
431
+ // twelve chats that mention it. The transcript index answers the harder question —
432
+ // "which workspace did I do this in" — and is the only one that can, since the
433
+ // words you remember are usually the agent's, not the branch's.
434
+ //
435
+ // Both reach archived workspaces. That is the point: 1,846 of the 1,886 here are
436
+ // archived, so a search limited to the live sidebar would miss almost everything.
437
+ if (req.method === 'GET' && pathname === '/api/search') {
438
+ const q = url.searchParams.get('q') ?? '';
439
+ // 12, not 50: an OR query over common words ("add", "remove") has a long weak tail,
440
+ // and past the first screenful nobody scrolls — they retype instead.
441
+ const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? 12) || 12));
442
+ const index = search.status();
443
+ const tokens = queryTokens(q);
444
+ if (!tokens.length)
445
+ return json(req, res, 200, { query: q, results: [], index });
446
+ const hits = search.search(q);
447
+ const targets = reads.searchTargets([...new Set(hits.map(h => h.sessionId))]);
448
+ const fromChats = foldHits(hits, sid => targets.get(sid)?.workspace ?? null);
449
+ const remaining = new Map(fromChats.map(r => [r.workspace.id, r]));
450
+ const merged = [];
451
+ for (const workspace of reads.findWorkspacesByName(tokens, limit)) {
452
+ const evidence = remaining.get(workspace.id);
453
+ remaining.delete(workspace.id);
454
+ // Keep the chat evidence when there is any: the snippet is what tells you this
455
+ // is the right "fix-lamp-thing" out of three with similar names.
456
+ merged.push(evidence
457
+ ? { ...evidence, byName: true }
458
+ : { workspace, sessionId: null, hits: 0, score: 0, at: null, snippets: [], byName: true });
459
+ }
460
+ merged.push(...remaining.values());
461
+ return json(req, res, 200, {
462
+ query: q,
463
+ index,
464
+ results: merged.slice(0, limit).map(r => ({
465
+ ...r,
466
+ sessionTitle: r.sessionId ? (targets.get(r.sessionId)?.sessionTitle ?? null) : null
467
+ }))
468
+ });
469
+ }
421
470
  // GET /api/repos — repos a new workspace can be created in
422
471
  if (req.method === 'GET' && pathname === '/api/repos') {
423
472
  return json(req, res, 200, { repos: reads.listRepos() });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conductor-remote",
3
- "version": "1.39.0",
3
+ "version": "1.41.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.",