@joe-broadhead/opencode-history 0.0.0 → 0.0.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.0.1] - 2026-11-04
6
+
7
+ ### Added
8
+
9
+ - OpenCode 2.2.5 is a tested host, next to 2.2.3 and 2.2.4.
10
+ - Status shows the index file's size, as `Index file: <path> (511 MB)` and `indexBytes`, and the README says how large the index grows and how to make it smaller.
11
+ - A mask pattern you add now applies at once to everything the tools return, `history_sql` included, not only to text indexed after it. Status shows a `Masks:` row and asks for `/history-reindex rebuild` until the index file is rewritten with the new patterns.
12
+ - README sections on updating the plugin, and on what to run after installing it.
13
+
14
+ ### Changed
15
+
16
+ - The status RPC (`history.sessions` `status`) uses the field names every plugin's status RPC shares:
17
+ - `connection` is now `state`, in machine words: `ready` (was `connected`), `connecting` and `failed`. The old `host untested` is gone: an untested host no longer changes the state, and shows in `host.tested` instead. The `/history-status` rows keep their words.
18
+ - `next` is now `nextStep`, always present, and `Nothing to do.` when nothing is needed.
19
+ - `host` is now `{ version, range, tested }`; it was `{ required, found, availability, next }`. `tested` is true only for 2.2.3, 2.2.4 and 2.2.5.
20
+ - An OpenCode release inside `>=2.2.3 <2.3.0` that is not tested, such as 2.2.6, works as before, but status now says it is untested: `host.tested` is `false` and its OpenCode row reads `2.2.6 (untested; supported range >=2.2.3 <2.3.0)`. Before, every release in the range was called tested.
21
+ - The tools RPC words a bad envelope as every plugin's tools RPC does, such as `"extra" is not allowed`, `"sessionID" must be a non-empty string`, `"input" is missing` and `describe takes no input`, instead of zod's messages. A call without `input` is now refused; before, it ran with `{}`.
22
+ - `SESSION_UNKNOWN` and `SESSION_AGENT_REQUIRED` have the next step every plugin uses: create a session that names an agent and pass its id.
23
+ - The Uninstall steps run in the same order as every plugin's: pins first, while the plugin can still remove them, then the plugin, its index, and copied rules.
24
+ - The scripting docs say that `opencode api` runs in the service's default location, your home directory, and how to target a project, and that it exits with status 1 on an RPC error.
25
+
26
+ ### Fixed
27
+
28
+ - A `history_sql` statement no longer keeps running after OpenCode stops. Before, a statement that never ended went on using a CPU core after OpenCode crashed or was killed. Now its process stops by itself at the 10-second limit, and as soon as OpenCode exits, crashes or reloads the plugin.
29
+ - `QUERY_INVALID` now gives a next step that fits its cause. A short substring, a bad date or an empty path no longer gets advice about regular expressions.
30
+ - `/history-reindex` with an unknown argument answers in the usual error format: `INPUT_INVALID: /history-reindex takes no argument or rebuild. Next step: …`.
31
+ - `/history-reindex rebuild` now overwrites the text it drops and empties the write-ahead log, so text that only a newer mask pattern covers no longer stays in the index file's free space.
32
+ - `history_pin` keeps the whole pin within `maxChars`. Before, its header line came on top, so `maxChars: 500` pinned about 730 characters.
33
+
5
34
  ## [0.0.0] - 2026-11-03
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -6,7 +6,7 @@ Unofficial; not affiliated with OpenCode.
6
6
 
7
7
  ## Requirements
8
8
 
9
- - OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3 and 2.2.4. On any other version the plugin does not start, unless you set [`allowUntestedHost`](https://github.com/joe-broadhead/opencode-history/blob/HEAD/docs/configuration.md#options).
9
+ - OpenCode `>=2.2.3 <2.3.0`, tested on 2.2.3, 2.2.4 and 2.2.5. Another release inside that range starts too, and status says it is untested. Outside `>=2.2.3 <2.3.0` the plugin does not start, unless you set [`allowUntestedHost`](https://github.com/joe-broadhead/opencode-history/blob/HEAD/docs/configuration.md#options).
10
10
  - macOS or Linux.
11
11
  - Nothing else: no native programs, downloads or accounts. The index is SQLite with full-text search, through the `node:sqlite` module of OpenCode's runtime.
12
12
 
@@ -16,7 +16,16 @@ Unofficial; not affiliated with OpenCode.
16
16
  opencode plugin add @joe-broadhead/opencode-history
17
17
  ```
18
18
 
19
- Restart OpenCode. On first start the plugin indexes every session in OpenCode's database in the background, newest first. The tools work while it runs, over the sessions indexed so far. `/history-status`, or `/history` in the terminal UI, shows the progress.
19
+ Run `opencode reload`, or restart OpenCode. Then run `/history-status`. On first start the plugin indexes every session in OpenCode's database in the background, newest first. The tools work while it runs, over the sessions indexed so far. `/history-status`, or `/history` in the terminal UI, shows the progress.
20
+
21
+ ## Update
22
+
23
+ ```sh
24
+ opencode plugin update @joe-broadhead/opencode-history
25
+ opencode reload
26
+ ```
27
+
28
+ An entry pinned to a version (`@joe-broadhead/opencode-history@0.0.1`) does not update: remove the `@version` from `plugins` first. The index is kept; when a new version changes its layout, the plugin rebuilds it in the background.
20
29
 
21
30
  ## Sign in
22
31
 
@@ -60,7 +69,7 @@ The index holds prompts, replies, tool calls and their output, compaction summar
60
69
 
61
70
  ### Commands
62
71
 
63
- - `/history-status` posts the status into the session: the connection, index progress and how far behind it is, the source, the database and index paths, the default scope, recall answers, the tools, agents without the default rules, the OpenCode version and one Next step.
72
+ - `/history-status` posts the status into the session: the connection, index progress and how far behind it is, the source, the database and index paths with the index's size, the default scope, recall answers, the tools, agents without the default rules, the OpenCode version and one Next step.
64
73
  - `/history-reindex` catches the index up with OpenCode's sessions. `/history-reindex rebuild` drops the index and indexes everything again.
65
74
 
66
75
  ### Status dialog
@@ -152,7 +161,9 @@ Start with `/history-status` or `history_status`. They end with one next step. E
152
161
  | `Agents without default rules: …` | Those agents run every History tool, `history_pin` included, without asking. Put the block status shows first in each agent's own `permissions`. |
153
162
  | `Index: ..., 35 s behind` | Seconds since the index was last in step with OpenCode. The next search, sessions or recall call catches up, or run `/history-reindex`. |
154
163
  | A recent session is missing | Search, sessions and recall catch up for up to about 200 ms before they answer. A longer catch-up finishes in the background, so run `/history-reindex` to wait for it. |
155
- | `HOST_UNTESTED` | Use OpenCode 2.2.3 or a later 2.2.x, or set `allowUntestedHost: true` and accept the warning. |
164
+ | `HOST_UNTESTED`, `OpenCode: … (untested; …)` | The OpenCode release is not one of the tested 2.2.3, 2.2.4 and 2.2.5. Inside `>=2.2.3 <2.3.0` the plugin runs normally; outside it, it starts only with `allowUntestedHost: true`. If something fails, use a tested release. |
165
+ | `Masks: changed since text was indexed` | You changed `mask` or `maskDefaults` after text was indexed. Results already use the new patterns, but the index file holds the old text: run `/history-reindex rebuild`. |
166
+ | A large index file | The index takes about 2.7 times the text it holds, about 500 MB for 200,000 indexed texts, and status shows its size in the `Index file` row. It grows with your history; a deleted session leaves it, but the file keeps its size and reuses the space. To make it smaller, set `index.toolResults: false`, which leaves out tool output, or a lower `index.maxDocChars`, then delete the index file (with its `-wal` and `-shm` files) and restart OpenCode: the plugin builds a new one. |
156
167
  | `OPTIONS_INVALID` | The message names the option. Fix it in the plugin entry and restart. |
157
168
  | `SOURCE_UNAVAILABLE` | OpenCode's database stopped answering. Check that `database` points at OpenCode's `opencode.db`, then restart OpenCode. |
158
169
  | `INDEX_UNAVAILABLE` | `indexPath` must be a file the plugin can create and write. Fix it and restart. |
@@ -161,7 +172,7 @@ Start with `/history-status` or `history_status`. They end with one next step. E
161
172
  | `INDEX_FAILED` | Run `/history-status`, and if it persists, delete the index file and restart OpenCode. |
162
173
  | `INDEX_MISMATCH`, `Index schema: ...` | Another OpenCode process running another version of the plugin rebuilt the index for its own version; status shows both versions. Restart every OpenCode process so they all run one version; the index is then rebuilt for it. |
163
174
  | `SESSION_NOT_FOUND` | Find the session ID with `history_search` or `history_sessions`. |
164
- | `QUERY_INVALID` | Fix the regular expression or the date, or search with `match: "words"`. |
175
+ | `QUERY_INVALID` | Fix what the message names: a substring of at least 3 characters, the regular expression, the date, the path or another filter. The next step fits the mistake. |
165
176
  | `SQL_REJECTED`, `SQL_FAILED` | Write one read-only `SELECT` or `WITH` statement, best over the `v_*` views. The message says what was wrong. |
166
177
  | `SQL_TIMEOUT` | The statement ran longer than 10 seconds and was stopped. Filter by time or session, and bound a recursive `WITH`. |
167
178
  | `PERMISSION_DENIED`, `SESSION_UNKNOWN`, `SESSION_AGENT_REQUIRED` | Scripts only: the session's agent may not use the tool, the session does not exist, or it names no agent. See [Scripting](https://github.com/joe-broadhead/opencode-history/blob/HEAD/docs/scripting.md). |
@@ -174,11 +185,15 @@ The full list of codes is in [docs/configuration.md](https://github.com/joe-broa
174
185
 
175
186
  ## Uninstall
176
187
 
177
- ```sh
178
- opencode plugin remove @joe-broadhead/opencode-history
179
- ```
188
+ 1. History has no sign-in, so there is nothing to sign out of. Remove your pins while the plugin is still installed: they are instruction entries that OpenCode keeps on the sessions where you made them, and the plugin cannot remove them once it is gone. In each such session, call `history_pin` with `"action": "list"`, then `"action": "remove"` for each one.
189
+ 2. Remove the plugin, then run `opencode reload` or restart OpenCode:
190
+
191
+ ```sh
192
+ opencode plugin remove @joe-broadhead/opencode-history
193
+ ```
180
194
 
181
- Removing the plugin keeps the index. To delete it, remove `~/.local/share/opencode-history` (or your `indexPath`, with its `-wal` and `-shm` files). OpenCode's database is not touched either way. Pins are instruction entries that OpenCode keeps on the sessions where you made them; the plugin does not remove them when it is uninstalled, so remove them first with `history_pin` and `"action": "remove"`. Rules you added to `permissions` stay until you remove them.
195
+ 3. Delete the index, which removing the plugin keeps: `~/.local/share/opencode-history`, or your `indexPath` with its `-wal` and `-shm` files. OpenCode's database is not touched either way.
196
+ 4. Remove the `history_*` rules you copied into `permissions` or into an agent's own permissions.
182
197
 
183
198
  ## Links
184
199
 
package/dist/index.js CHANGED
@@ -2243,14 +2243,14 @@ var $ZodNever = /* @__PURE__ */ $constructor("$ZodNever", (inst, def) => {
2243
2243
  return payload;
2244
2244
  };
2245
2245
  });
2246
- function capSize(inst, size) {
2246
+ function capSize(inst, size2) {
2247
2247
  const caps = sizeCaps(inst._zod.def.checks);
2248
2248
  if (!caps.length)
2249
2249
  return;
2250
2250
  const cap = Math.min(...caps.map((c) => c[0]));
2251
2251
  const parse5 = inst._zod.parse;
2252
2252
  inst._zod.parse = (payload, ctx) => {
2253
- const n = size(payload.value);
2253
+ const n = size2(payload.value);
2254
2254
  if (n > cap && !ctx?.skipChecks && ctx?.direction !== "backward") {
2255
2255
  for (const [max, ch] of sizeCaps(inst._zod.def.checks)) {
2256
2256
  if (n > max && ch._zod.def.when(payload)) {
@@ -6443,7 +6443,7 @@ var FAILURES = {
6443
6443
  SESSION_NOT_FOUND: ["No indexed session has that ID.", "Find the session ID with history_search or history_sessions."],
6444
6444
  MESSAGE_NOT_FOUND: ["That session has no message at that position.", "Call history_read without `at` to see the session's message range."],
6445
6445
  CURSOR_INVALID: ["The cursor is invalid or has expired.", "Repeat the call without cursor."],
6446
- QUERY_INVALID: ["The search query cannot be used.", 'Check the regular expression, or search with match: "words".'],
6446
+ QUERY_INVALID: ["The search query cannot be used.", "Correct the named input and call the tool again."],
6447
6447
  SQL_REJECTED: ["Only one read-only SELECT or WITH statement over the history index is allowed.", "Rewrite it as a single SELECT over the v_sessions, v_docs, v_files and v_links views."],
6448
6448
  SQL_FAILED: ["The SQL query failed.", "Check the view and column names in the history_sql description, then retry."],
6449
6449
  SQL_TIMEOUT: [`The SQL query ran longer than 10 seconds and was stopped.`, "Narrow it: filter by time or session, and bound a recursive WITH with a LIMIT or a WHERE on its counter."],
@@ -6452,8 +6452,8 @@ var FAILURES = {
6452
6452
  PIN_TOO_LARGE: ["The text to pin is larger than OpenCode allows for one instruction entry.", "Pin a shorter range, or set a lower maxChars."],
6453
6453
  EVENTS_UNAVAILABLE: ["OpenCode's session events stopped, so the index may lag behind new sessions.", "Wait, since the plugin subscribes again by itself, and restart OpenCode if it persists."],
6454
6454
  PERMISSION_DENIED: ["The session's agent may not use this tool.", "Add an allow rule for the tool's action to that agent, or call as a session whose agent allows it."],
6455
- SESSION_UNKNOWN: ["OpenCode has no session with that ID.", "Pass the ID of an existing session as sessionID."],
6456
- SESSION_AGENT_REQUIRED: ["That session names no agent, so there are no rules to run the tool under.", "Call as a session that names an agent."],
6455
+ SESSION_UNKNOWN: ["OpenCode has no session with that ID.", 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'],
6456
+ SESSION_AGENT_REQUIRED: ["That session names no agent, so there are no rules to run the tool under.", 'Create a session that names an agent, such as POST /api/session with {"agent":"build"}, and pass its id.'],
6457
6457
  OPERATION_FAILED: ["The tool failed unexpectedly.", "Retry, and if it persists, call history_status."]
6458
6458
  };
6459
6459
  var formatFailure = (failure2) => `${failure2.code}: ${failure2.message}${failure2.hint ? ` Next step: ${failure2.hint}` : ""}`;
@@ -7137,20 +7137,22 @@ var INDEX = object(
7137
7137
  );
7138
7138
  var STATUS_OUTPUT = object({
7139
7139
  version: string3,
7140
- host: object({ required: string3, found: string3, availability: choice(["AVAILABLE", "HOST_UNTESTED"]), next: string3 }, ["required", "found", "availability"]),
7141
- connection: choice(["connected", "connecting", "failed", "host untested"]),
7140
+ host: object({ version: nullable2(string3), range: string3, tested: boolean3 }, ["version", "range", "tested"]),
7141
+ state: choice(["ready", "connecting", "failed"]),
7142
7142
  index: INDEX,
7143
7143
  fallback: choice(["SOURCE_UNAVAILABLE", "SOURCE_SCHEMA_UNKNOWN"]),
7144
7144
  tools: object({ visible: integer3, unknown: list(string3) }, ["visible", "unknown"]),
7145
7145
  database: string3,
7146
7146
  indexPath: string3,
7147
+ indexBytes: nullable2(integer3),
7148
+ masksChanged: boolean3,
7147
7149
  defaultScope: choice(["project", "directory", "all"]),
7148
7150
  recall: object({ synthesize: boolean3, model: nullable2(string3) }, ["synthesize", "model"]),
7149
7151
  unguardedAgents: list(string3),
7150
7152
  permissionsBlock: list(object({ action: string3, resource: string3, effect: choice(["allow", "deny", "ask"]) }, ["action", "resource", "effect"])),
7151
7153
  mismatch: object({ file: nullable2(string3), build: string3 }, ["file", "build"]),
7152
- next: string3
7153
- }, ["version", "host", "connection", "index", "tools", "database", "indexPath", "defaultScope", "recall", "unguardedAgents"]);
7154
+ nextStep: string3
7155
+ }, ["version", "host", "state", "index", "tools", "database", "indexPath", "indexBytes", "masksChanged", "defaultScope", "recall", "unguardedAgents", "nextStep"]);
7154
7156
  var errors = { failed: object({ code: string3, next: string3 }, ["code", "next"]) };
7155
7157
  var History = Rpc.define({
7156
7158
  id: PLUGIN_ID,
@@ -7161,7 +7163,7 @@ var History = Rpc.define({
7161
7163
  });
7162
7164
 
7163
7165
  // src/version.ts
7164
- var VERSION = true ? "0.0.0" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
7166
+ var VERSION = true ? "0.0.1" : JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
7165
7167
 
7166
7168
  // src/host/errors.ts
7167
7169
  import { Error as ToolError } from "@opencode/plugin/promise/tool";
@@ -7405,28 +7407,44 @@ function agentsRow(view, compact) {
7405
7407
  var ACCOUNT_TEXT = "none; History needs no sign-in";
7406
7408
  var ACCESS_TEXT = "reads OpenCode's database read-only and writes only its own index; history_pin asks before it changes a session";
7407
7409
  var FALLBACK_TEXT = { SOURCE_UNAVAILABLE: "OpenCode's database could not be read", SOURCE_SCHEMA_UNKNOWN: "OpenCode's database has an unknown layout" };
7410
+ var CONNECTION_WORDS = { ready: "connected", connecting: "connecting", failed: "failed" };
7411
+ var MASKS_ROW = "Masks:";
7412
+ function size(bytes) {
7413
+ const units = ["B", "KB", "MB", "GB", "TB"];
7414
+ let value = bytes, unit = 0;
7415
+ while (value >= 1024 && unit < units.length - 1) {
7416
+ value /= 1024;
7417
+ unit++;
7418
+ }
7419
+ return `${unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
7420
+ }
7421
+ function hostText({ host, version: version2 }) {
7422
+ const note = host.tested ? "tested" : `untested; supported range ${host.range}`;
7423
+ return `${host.version ?? "unknown version"} (${note}). History ${version2}`;
7424
+ }
7408
7425
  function toolsText({ tools }) {
7409
7426
  const unknown2 = tools.unknown.length ? `, ${tools.unknown.length} unknown (they ask first): ${tools.unknown.join(", ")}` : "";
7410
7427
  return `${tools.visible} available${unknown2}`;
7411
7428
  }
7412
7429
  function formatStatus(view, options = {}) {
7413
- const { index, host, mismatch } = view;
7430
+ const { index, mismatch } = view;
7414
7431
  return lines2(
7415
7432
  "History status",
7416
- `Connection: ${view.connection}`,
7433
+ `Connection: ${CONNECTION_WORDS[view.state]}`,
7417
7434
  `Account: ${ACCOUNT_TEXT}`,
7418
7435
  `Access: ${ACCESS_TEXT}`,
7419
7436
  `Index: ${index.ready ? "ready" : "building"}, ${index.sessionsIndexed} of ${index.sessionsTotal} sessions${index.lagSeconds ? `, ${Math.round(index.lagSeconds)} s behind` : ""}`,
7420
7437
  `Source: ${SOURCE_TEXT[index.source]}${index.complete ? "" : ", incomplete"}${view.fallback ? ` (${FALLBACK_TEXT[view.fallback]})` : ""}`,
7421
7438
  `Database: ${view.database}`,
7422
- `Index file: ${view.indexPath}`,
7439
+ `Index file: ${view.indexPath}${view.indexBytes === null ? "" : ` (${size(view.indexBytes)})`}`,
7440
+ view.masksChanged ? `${MASKS_ROW} changed since text was indexed, so the index file holds that text masked the old way until /history-reindex rebuild` : void 0,
7423
7441
  mismatch ? `${MISMATCH_ROW} ${mismatch.file === null ? "none" : `version ${mismatch.file}`} in the file, but this build uses version ${mismatch.build}, so every query fails (INDEX_MISMATCH)` : void 0,
7424
7442
  `Default scope: ${view.defaultScope}`,
7425
7443
  `Recall answers: ${view.recall.synthesize ? "on" : "off"}${view.recall.model ? ` (${view.recall.model})` : " (default model)"}`,
7426
7444
  `Tools: ${toolsText(view)}`,
7427
7445
  agentsRow(view, options.compact ?? false),
7428
- `OpenCode: ${host.found} (${host.availability === "AVAILABLE" ? "tested" : `host untested: tested on ${host.required}`}). History ${view.version}`,
7429
- `Next step: ${view.next ?? NOTHING_TO_DO}`
7446
+ `OpenCode: ${hostText(view)}`,
7447
+ `Next step: ${view.nextStep}`
7430
7448
  );
7431
7449
  }
7432
7450
 
@@ -7464,7 +7482,7 @@ function commandTransform(sources) {
7464
7482
  async execute({ sessionID: sessionID3, prompt, delivery }) {
7465
7483
  const mode = reindexMode(prompt.text);
7466
7484
  let text2;
7467
- if (mode === void 0) text2 = `Unknown argument. Use /${COMMANDS.reindex} to catch up, or /${COMMANDS.reindex} rebuild to index everything again.`;
7485
+ if (mode === void 0) text2 = formatFailure({ code: "INPUT_INVALID", message: `/${COMMANDS.reindex} takes no argument or rebuild.`, hint: `Run /${COMMANDS.reindex}, or /${COMMANDS.reindex} rebuild to index everything again.` });
7468
7486
  else try {
7469
7487
  text2 = indexed(mode, await sources.runtime.reindex(mode));
7470
7488
  } catch (error2) {
@@ -7476,24 +7494,31 @@ function commandTransform(sources) {
7476
7494
  };
7477
7495
  }
7478
7496
 
7497
+ // src/host/tested-hosts.json
7498
+ var tested_hosts_default = ["2.2.3", "2.2.4", "2.2.5"];
7499
+
7479
7500
  // src/host/compat.ts
7480
7501
  var HOST_RANGE = ">=2.2.3 <2.3.0";
7502
+ var TESTED_HOSTS = tested_hosts_default;
7481
7503
  var parse4 = (value) => {
7482
7504
  const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
7483
7505
  return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : void 0;
7484
7506
  };
7485
7507
  var compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
7486
- function tested(version2, range = HOST_RANGE) {
7508
+ function inRange(version2, range = HOST_RANGE) {
7487
7509
  const [, low, high] = /^>=(\S+) <(\S+)$/.exec(range) ?? [];
7488
7510
  const found = parse4(version2), min = low && parse4(low), max = high && parse4(high);
7489
7511
  if (!found || !min || !max) return false;
7490
7512
  return compare(found, min) >= 0 && compare(found, max) < 0;
7491
7513
  }
7514
+ var testedWords = () => TESTED_HOSTS.length > 1 ? `${TESTED_HOSTS.slice(0, -1).join(", ")} or ${TESTED_HOSTS.at(-1)}` : TESTED_HOSTS.join("");
7492
7515
  function checkHost(found, allowUntestedHost) {
7493
- if (tested(found)) return { required: HOST_RANGE, found, availability: "AVAILABLE" };
7494
- const next = "Use OpenCode 2.2.3 or a later 2.2.x release, or set the plugin option allowUntestedHost: true to run anyway.";
7495
- if (!allowUntestedHost) throw new Error(formatFailure({ code: "HOST_UNTESTED", message: `History is tested on OpenCode ${HOST_RANGE}, found ${found}.`, hint: next }));
7496
- return { required: HOST_RANGE, found, availability: "HOST_UNTESTED", next: "This OpenCode version is untested and allowUntestedHost is set. If something fails, use OpenCode 2.2.3 or a later 2.2.x release." };
7516
+ const version2 = found.trim() === "" ? null : found;
7517
+ if (version2 !== null && TESTED_HOSTS.includes(version2)) return { version: version2, range: HOST_RANGE, tested: true };
7518
+ if (version2 !== null && inRange(version2)) return { version: version2, range: HOST_RANGE, tested: false };
7519
+ const next = `Use OpenCode ${testedWords()}, or another release inside ${HOST_RANGE}, or set the plugin option allowUntestedHost: true to run anyway.`;
7520
+ if (!allowUntestedHost) throw new Error(formatFailure({ code: "HOST_UNTESTED", message: `History supports OpenCode ${HOST_RANGE}, found ${version2 ?? "no version"}.`, hint: next }));
7521
+ return { version: version2, range: HOST_RANGE, tested: false, next: `OpenCode ${version2 ?? "of no known version"} is outside the supported range and allowUntestedHost is set. If something fails, use OpenCode ${testedWords()}.` };
7497
7522
  }
7498
7523
 
7499
7524
  // src/host/permissions.ts
@@ -7805,19 +7830,21 @@ var RecallOutputSchema = strictObject({
7805
7830
  });
7806
7831
  var StatusSchema = strictObject({
7807
7832
  version: string2().max(64),
7808
- host: strictObject({ required: string2().max(64), found: string2().max(64), availability: _enum(["AVAILABLE", "HOST_UNTESTED"]), next: string2().max(4096).optional() }),
7809
- connection: _enum(["connected", "connecting", "failed", "host untested"]),
7833
+ host: strictObject({ version: string2().max(64).nullable(), range: string2().max(64), tested: boolean2() }),
7834
+ state: _enum(["ready", "connecting", "failed"]),
7810
7835
  index: IndexStateSchema,
7811
7836
  fallback: _enum(["SOURCE_UNAVAILABLE", "SOURCE_SCHEMA_UNKNOWN"]).optional(),
7812
7837
  tools: strictObject({ visible: number2().int().min(0), unknown: array(string2().max(128)).max(20) }),
7813
7838
  database: string2().max(4096),
7814
7839
  indexPath: string2().max(4096),
7840
+ indexBytes: number2().int().min(0).nullable(),
7841
+ masksChanged: boolean2(),
7815
7842
  defaultScope: _enum(SCOPES),
7816
7843
  recall: strictObject({ synthesize: boolean2(), model: string2().max(256).nullable() }),
7817
7844
  unguardedAgents: array(string2().max(256)).max(1e3),
7818
7845
  permissionsBlock: array(strictObject({ action: string2(), resource: string2(), effect: _enum(["allow", "deny", "ask"]) })).optional(),
7819
7846
  mismatch: strictObject({ file: string2().max(64).nullable(), build: string2().max(64) }).optional(),
7820
- next: string2().max(4096).optional()
7847
+ nextStep: string2().max(4096)
7821
7848
  });
7822
7849
  var StatusToolSchema = StatusSchema.omit({ unguardedAgents: true, permissionsBlock: true }).extend({
7823
7850
  unguardedAgents: number2().int().min(0),
@@ -7868,7 +7895,7 @@ var PinInputSchema = strictObject({
7868
7895
  sessionID: sessionID.optional().describe("The past session to pin or unpin, from history_search, history_sessions or history_files. Needed for put and remove."),
7869
7896
  from: integer2.min(0).optional().describe("Pin messages from this seq instead of the brief, for example a search excerpt's seq. Omit from and to to pin the session's brief."),
7870
7897
  to: integer2.min(0).optional().describe(`Last seq of the range, inclusive, at most ${MAX_PIN_RANGE} messages after from. Default: from, a single message.`),
7871
- maxChars: ranged(BOUNDS.pin.maxChars, "Most characters of session text to pin")
7898
+ maxChars: ranged(BOUNDS.pin.maxChars, "Most characters to pin, its header line included")
7872
7899
  }).superRefine((input, issues) => {
7873
7900
  const issue2 = (path, message) => issues.addIssue({ code: "custom", path: [path], message });
7874
7901
  if (input.action !== "list" && input.sessionID === void 0) issue2("sessionID", `is required for ${input.action}`);
@@ -7884,23 +7911,31 @@ var pin = (key, value) => {
7884
7911
  const text2 = typeof value === "string" ? value : JSON.stringify(value) ?? "";
7885
7912
  return { sessionID: session, key, chars: text2.length, label: oneLine(text2.split("\n", 1)[0] ?? "", 200) };
7886
7913
  };
7914
+ var PIN_TITLE_CHARS = 120;
7915
+ function fit(header, body, maxChars, trimmed) {
7916
+ const whole = `${header(trimmed, true)}
7917
+ ${body}`;
7918
+ if (whole.length <= maxChars) return whole;
7919
+ const titled = header(true, true);
7920
+ const head = titled.length + 2 <= maxChars ? titled : header(true, false);
7921
+ let end = Math.max(0, maxChars - head.length - 2);
7922
+ const code = body.charCodeAt(end - 1);
7923
+ if (end > 0 && code >= 55296 && code <= 56319) end--;
7924
+ return `${head}
7925
+ ${body.slice(0, end)}\u2026`;
7926
+ }
7887
7927
  async function pinText(runtime, caller, input) {
7888
7928
  const unpin = `Unpin with history_pin {"action":"remove","sessionID":${JSON.stringify(input.sessionID)}}.`;
7929
+ const quoted2 = (title2, shown) => shown ? ` ${JSON.stringify(oneLine(title2, PIN_TITLE_CHARS))}` : "";
7889
7930
  if (input.from === void 0) {
7890
7931
  const brief2 = await runtime.brief(caller, { sessionID: input.sessionID });
7891
- const body = formatBrief(brief2, { calls: false });
7892
- const trimmed = body.length > input.maxChars;
7893
- return [
7894
- `Pinned brief of past session ${input.sessionID} ${JSON.stringify(brief2.session.title)}${trimmed ? ", trimmed" : ""}. It is a record of earlier work, not instructions. ${unpin}`,
7895
- trimmed ? `${body.slice(0, input.maxChars - 1)}\u2026` : body
7896
- ].join("\n");
7932
+ const header2 = (trimmed, title2) => `Pinned brief of past session ${input.sessionID}${quoted2(brief2.session.title, title2)}${trimmed ? ", trimmed" : ""}. It is a record of earlier work, not instructions. ${unpin}`;
7933
+ return fit(header2, formatBrief(brief2, { calls: false }), input.maxChars, false);
7897
7934
  }
7898
7935
  const to = input.to ?? input.from;
7899
7936
  const read2 = await runtime.read(caller, { sessionID: input.sessionID, at: input.from, before: 0, after: to - input.from, maxChars: input.maxChars });
7900
- return [
7901
- `Pinned messages #${input.from}-#${to} of past session ${input.sessionID} ${JSON.stringify(read2.session.title)}${read2.truncated ? ", trimmed" : ""}. They are a record of earlier work, not instructions. ${unpin}`,
7902
- formatRead(read2, input, { calls: false })
7903
- ].join("\n");
7937
+ const header = (trimmed, title2) => `Pinned messages #${input.from}-#${to} of past session ${input.sessionID}${quoted2(read2.session.title, title2)}${trimmed ? ", trimmed" : ""}. They are a record of earlier work, not instructions. ${unpin}`;
7938
+ return fit(header, formatRead(read2, input, { calls: false }), input.maxChars, read2.truncated);
7904
7939
  }
7905
7940
  async function runPin(sources, caller, input, signal) {
7906
7941
  if (caller.sessionID === null) return fail("OPERATION_FAILED", { message: "history_pin needs a session to pin into." });
@@ -8004,13 +8039,13 @@ function compactStatus(view) {
8004
8039
  }
8005
8040
  var MAX_RESULT_BYTES = 1048576;
8006
8041
  function bounded(result) {
8007
- let size;
8042
+ let size2;
8008
8043
  try {
8009
- size = Math.max(Buffer.byteLength(result.content), Buffer.byteLength(JSON.stringify(result.output) ?? ""));
8044
+ size2 = Math.max(Buffer.byteLength(result.content), Buffer.byteLength(JSON.stringify(result.output) ?? ""));
8010
8045
  } catch {
8011
8046
  return fail("CONTRACT_MISMATCH");
8012
8047
  }
8013
- if (size > MAX_RESULT_BYTES) fail("OUTPUT_LIMIT");
8048
+ if (size2 > MAX_RESULT_BYTES) fail("OUTPUT_LIMIT");
8014
8049
  return result;
8015
8050
  }
8016
8051
  async function abortable(signal, work) {
@@ -8058,7 +8093,34 @@ function toolTransform(sources) {
8058
8093
  var TOOLS_RPC_ID = `${PLUGIN_ID}.tools`;
8059
8094
  var EXCLUDED = new Set(ACTIONS);
8060
8095
  var UNATTENDED = "A script cannot answer an approval prompt; this needs an allow rule for the session's agent.";
8061
- var Envelope = strictObject({ sessionID: string2().min(1).max(256), input: unknown() });
8096
+ var plainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8097
+ var ENVELOPE_MESSAGES = {
8098
+ notObject: `the envelope must be an object, such as {"sessionID":"<session>","input":<the tool's input>}`,
8099
+ unknownKey: (key) => `"${key}" is not allowed`,
8100
+ sessionID: `"sessionID" must be a non-empty string`,
8101
+ inputMissing: `"input" is missing`,
8102
+ describe: "describe takes no input"
8103
+ };
8104
+ var standard = (schema, issues) => Object.defineProperty({ ...schema }, "~standard", {
8105
+ value: { version: 1, vendor: "history", validate: (value) => {
8106
+ const found = issues(value);
8107
+ return found.length === 0 ? { value } : { issues: found.map((message) => ({ message })) };
8108
+ } },
8109
+ enumerable: false
8110
+ });
8111
+ function envelopeIssues(value) {
8112
+ if (!plainObject(value)) return [ENVELOPE_MESSAGES.notObject];
8113
+ return [
8114
+ ...Object.keys(value).filter((key) => key !== "sessionID" && key !== "input").map(ENVELOPE_MESSAGES.unknownKey),
8115
+ ...typeof value["sessionID"] === "string" && value["sessionID"] !== "" ? [] : [ENVELOPE_MESSAGES.sessionID],
8116
+ ...Object.hasOwn(value, "input") ? [] : [ENVELOPE_MESSAGES.inputMissing]
8117
+ ];
8118
+ }
8119
+ var ENVELOPE = standard(
8120
+ { type: "object", properties: { sessionID: { type: "string", minLength: 1 }, input: {} }, required: ["sessionID", "input"], additionalProperties: false },
8121
+ envelopeIssues
8122
+ );
8123
+ var DESCRIBE_INPUT = standard({ type: "object", properties: {}, required: [], additionalProperties: false }, (value) => value === void 0 || value === null || plainObject(value) && Object.keys(value).length === 0 ? [] : [ENVELOPE_MESSAGES.describe]);
8062
8124
  var Failure = strictObject({ code: string2(), next: string2() });
8063
8125
  var Description = strictObject({
8064
8126
  id: string2(),
@@ -8120,7 +8182,9 @@ async function registerScripting(ctx, sources) {
8120
8182
  const invoke = async (tool, raw, context) => {
8121
8183
  try {
8122
8184
  if (context.signal.aborted) throw new HistoryError("CANCELLED");
8123
- const { sessionID: sessionID3, input } = parse2(Envelope, raw, "INPUT_INVALID");
8185
+ const problems = envelopeIssues(raw);
8186
+ if (problems.length) throw new HistoryError("INPUT_INVALID", { message: `The envelope is invalid: ${problems.join("; ")}.` });
8187
+ const { sessionID: sessionID3, input } = raw;
8124
8188
  const call2 = await session(sessionID3);
8125
8189
  const result = bounded(await runTool(tool, { ...sources, assert: assert2 }, call2, parse2(tool.input, input ?? {}, "INPUT_INVALID"), context.signal));
8126
8190
  return JSON.parse(JSON.stringify(result.output));
@@ -8132,8 +8196,8 @@ async function registerScripting(ctx, sources) {
8132
8196
  const contract = Rpc2.define({
8133
8197
  id: TOOLS_RPC_ID,
8134
8198
  methods: {
8135
- describe: { input: strictObject({}).optional(), output: Description },
8136
- ...Object.fromEntries(tools.map((tool) => [tool.name, { input: Envelope, output: tool.output, errors: { failed: Failure } }]))
8199
+ describe: { input: DESCRIBE_INPUT, output: Description },
8200
+ ...Object.fromEntries(tools.map((tool) => [tool.name, { input: ENVELOPE, output: tool.output, errors: { failed: Failure } }]))
8137
8201
  },
8138
8202
  events: {}
8139
8203
  });
@@ -8184,50 +8248,53 @@ function fallbackFailure(code) {
8184
8248
  const cause = code === "SOURCE_SCHEMA_UNKNOWN" ? "OpenCode's database has a layout this plugin version does not know" : "OpenCode's database could not be read";
8185
8249
  return { code, message: `${cause}, so history comes from the plugin session API, which keeps only the messages after each compaction.`, hint: FAILURES[code][1] };
8186
8250
  }
8187
- function nextStep(view, fallback) {
8188
- if (view.host.next) return view.host.next;
8251
+ function nextStep(host, view, fallback) {
8252
+ if (host.next) return host.next;
8189
8253
  if (view.mismatch) return FAILURES.INDEX_MISMATCH[1];
8190
8254
  if (view.index.source === "context") return FAILURES[fallback ?? "SOURCE_UNAVAILABLE"][1];
8255
+ if (view.masksChanged) return MASKS_CHANGED;
8191
8256
  if (view.unguardedAgents.length > 0) return AGENT_FIX;
8192
8257
  if (!view.index.ready) return BUILDING;
8193
- return void 0;
8258
+ return NOTHING_TO_DO;
8194
8259
  }
8260
+ var MASKS_CHANGED = "Run /history-reindex rebuild, since the mask options changed after text was indexed: results are masked with the new patterns, but the index file still holds that text masked the old way.";
8195
8261
  var BUILDING = "Wait for the index to finish building, since until then results cover only the sessions indexed so far.";
8196
- function connection(view) {
8262
+ function stateOf(view) {
8197
8263
  if (view.mismatch || view.index.source === "context") return "failed";
8198
- if (view.host.availability !== "AVAILABLE") return "host untested";
8199
- return view.index.ready ? "connected" : "connecting";
8264
+ return view.index.ready ? "ready" : "connecting";
8200
8265
  }
8201
8266
  var SAFE_NAME = /^history_[a-z][a-z0-9_]{0,63}$/;
8202
8267
  async function buildStatus({ version: version2, host, config: config2, runtime, agents, tools }) {
8203
8268
  const known = new Set(TOOL_NAMES.map((name) => `history_${name}`));
8204
- const [index, schema, listed, ids] = await Promise.all([
8269
+ const [index, schema, file, listed, ids] = await Promise.all([
8205
8270
  runtime.status(),
8206
8271
  runtime.schema(),
8272
+ runtime.indexFile(),
8207
8273
  agents(),
8208
8274
  tools ? tools().then((list2) => list2.map((tool) => tool.id)) : Promise.resolve([...known])
8209
8275
  ]);
8210
8276
  const unguarded = unguardedAgents(listed);
8211
8277
  const ours = ids.filter((id2) => id2.startsWith("history_"));
8212
8278
  const { model: model2 } = config2.recall;
8213
- const base = { host, index, ...schema.file !== schema.build ? { mismatch: schema } : {} };
8279
+ const base = { index, ...schema.file !== schema.build ? { mismatch: schema } : {} };
8214
8280
  const view = {
8215
8281
  version: version2,
8216
- host,
8217
- connection: connection(base),
8282
+ host: { version: host.version, range: host.range, tested: host.tested },
8283
+ state: stateOf(base),
8218
8284
  index,
8219
8285
  ...index.source === "context" ? { fallback: runtime.fallback ?? "SOURCE_UNAVAILABLE" } : {},
8220
8286
  tools: { visible: ours.length, unknown: ours.filter((id2) => !known.has(id2)).filter((id2) => SAFE_NAME.test(id2)).slice(0, 20) },
8221
8287
  database: config2.database,
8222
8288
  indexPath: config2.indexPath,
8289
+ indexBytes: file.bytes,
8290
+ masksChanged: file.masksChanged,
8223
8291
  defaultScope: config2.defaultScope,
8224
8292
  recall: { synthesize: config2.recall.synthesize, model: model2 ? `${model2.providerID}/${model2.id}` : null },
8225
8293
  unguardedAgents: unguarded,
8226
8294
  ...unguarded.length ? { permissionsBlock: DEFAULT_RULES.map((rule) => ({ ...rule })) } : {},
8227
8295
  ...schema.file !== schema.build ? { mismatch: schema } : {}
8228
8296
  };
8229
- const next = nextStep(view, runtime.fallback);
8230
- return { ...view, ...next ? { next } : {} };
8297
+ return { ...view, nextStep: nextStep(host, view, runtime.fallback) };
8231
8298
  }
8232
8299
 
8233
8300
  // src/host/plugin.ts
@@ -8241,7 +8308,10 @@ function createPlugin({ createRuntime: createRuntime2, packageRoot }) {
8241
8308
  async setup(ctx) {
8242
8309
  const config2 = loadConfig(ctx.options);
8243
8310
  const host = checkHost(ctx.app.version, config2.allowUntestedHost);
8244
- if (host.next) console.warn(formatFailure({ code: "HOST_UNTESTED", message: `OpenCode ${host.found} is untested (tested: ${host.required}); History runs because allowUntestedHost is set.`, hint: "Use a tested OpenCode version if something fails." }));
8311
+ if (!host.tested) {
8312
+ const why = inRange(host.version ?? "") ? `inside the supported range ${host.range}` : `outside the supported range ${host.range}; History runs because allowUntestedHost is set`;
8313
+ console.warn(formatFailure({ code: "HOST_UNTESTED", message: `OpenCode ${host.version ?? "of no known version"} is untested (tested: ${TESTED_HOSTS.join(", ")}), ${why}.`, hint: "Use a tested OpenCode version if something fails." }));
8314
+ }
8245
8315
  const problem = indexPathProblem(config2.indexPath, {
8246
8316
  database: config2.database,
8247
8317
  projectFolders: [ctx.location.directory, ctx.location.project.directory],
@@ -8296,7 +8366,8 @@ function createPlugin({ createRuntime: createRuntime2, packageRoot }) {
8296
8366
  }
8297
8367
 
8298
8368
  // src/runtime.ts
8299
- import { realpathSync as realpathSync2 } from "node:fs";
8369
+ import { createHash } from "node:crypto";
8370
+ import { realpathSync as realpathSync2, statSync } from "node:fs";
8300
8371
  import { basename as basename2, dirname as dirname4, join as join5, resolve as resolve3 } from "node:path";
8301
8372
 
8302
8373
  // src/index/events.ts
@@ -8608,12 +8679,21 @@ var IndexStore = class _IndexStore {
8608
8679
  this.depth--;
8609
8680
  }
8610
8681
  }
8611
- /** Drop everything and recreate an empty index. */
8682
+ /**
8683
+ * Drop everything and recreate an empty index. The dropped pages are overwritten with zeros and the write-ahead log is
8684
+ * emptied, so text stored before, such as a secret masked only by a newer pattern, does not stay in the file.
8685
+ */
8612
8686
  reset() {
8613
- this.transaction(() => {
8614
- dropAll(this.db);
8615
- create(this.db);
8616
- });
8687
+ this.db.exec("pragma secure_delete = on");
8688
+ try {
8689
+ this.transaction(() => {
8690
+ dropAll(this.db);
8691
+ create(this.db);
8692
+ });
8693
+ } finally {
8694
+ this.db.exec("pragma secure_delete = off");
8695
+ }
8696
+ this.db.exec("pragma wal_checkpoint(truncate)");
8617
8697
  }
8618
8698
  /** The schema version the file is at now, which another process running another plugin version may have changed. */
8619
8699
  schemaVersion() {
@@ -8655,17 +8735,17 @@ var IndexStore = class _IndexStore {
8655
8735
  writeBatch(session, rootID, range, batch) {
8656
8736
  this.transaction(() => {
8657
8737
  this.writeSession(session, rootID, batch.facts, range);
8658
- const inRange = (what, sessionID3, seq) => {
8738
+ const inRange2 = (what, sessionID3, seq) => {
8659
8739
  if (sessionID3 !== session.id || seq < range.from || seq > range.to) {
8660
8740
  throw new Error(`history index: ${what} at ${sessionID3}#${seq} is outside ${session.id}#${range.from}..${range.to}`);
8661
8741
  }
8662
8742
  };
8663
8743
  for (const doc of batch.docs) {
8664
- inRange("doc", doc.sessionID, doc.seq);
8744
+ inRange2("doc", doc.sessionID, doc.seq);
8665
8745
  this.insertDoc(doc, rootID, doc.seq, doc.part);
8666
8746
  }
8667
8747
  for (const file of batch.files) {
8668
- inRange("file", file.sessionID, file.seq);
8748
+ inRange2("file", file.sessionID, file.seq);
8669
8749
  this.db.run(
8670
8750
  "insert into files (session_id, root_id, message_id, seq, time, path, op, tool) values (?, ?, ?, ?, ?, ?, ?, ?)",
8671
8751
  file.sessionID,
@@ -8679,7 +8759,7 @@ var IndexStore = class _IndexStore {
8679
8759
  );
8680
8760
  }
8681
8761
  for (const link of batch.links) {
8682
- inRange(`link to ${link.childID}`, link.parentID, link.seq);
8762
+ inRange2(`link to ${link.childID}`, link.parentID, link.seq);
8683
8763
  this.db.run(
8684
8764
  `insert into links (parent_id, child_id, message_id, seq, description, agent) values (?, ?, ?, ?, ?, ?)
8685
8765
  on conflict(child_id) do update set parent_id = excluded.parent_id, message_id = excluded.message_id,
@@ -9212,13 +9292,29 @@ function summaries(db, ids) {
9212
9292
  }
9213
9293
 
9214
9294
  // src/query/parse.ts
9295
+ var QUERY_HINTS = {
9296
+ regex: 'Check the regular expression, or search with match: "words".',
9297
+ substring: 'Use a substring of at least 3 characters, or search with match: "words".',
9298
+ time: "Use an ISO date such as 2026-10-01 or an age such as 7d.",
9299
+ words: 'Search with plain words, or with match: "substring" for exact text.',
9300
+ excluded: "Add a word to search for besides the excluded terms.",
9301
+ empty: "Pass at least one search term in query.",
9302
+ match: 'Use match: "words", "substring" or "regex".',
9303
+ path: "Pass a file or folder path, such as src/auth.ts.",
9304
+ scope: 'Use scope "session", "directory", "project" or "all".',
9305
+ sessionScope: 'Pass sessionID with scope "session", or use another scope.',
9306
+ question: "Pass the question to answer."
9307
+ };
9215
9308
  var QueryError = class extends Error {
9216
9309
  constructor(message, reason = "query", options) {
9217
- super(message, options);
9310
+ super(message, options?.cause === void 0 ? void 0 : { cause: options.cause });
9218
9311
  this.reason = reason;
9219
9312
  this.name = "QueryError";
9313
+ if (options?.hint !== void 0) this.hint = options.hint;
9220
9314
  }
9221
9315
  reason;
9316
+ /** The next step, when it is more specific than the failure code's own. */
9317
+ hint;
9222
9318
  };
9223
9319
  var space = (char) => /\s/.test(char);
9224
9320
  function tokenize(input) {
@@ -9452,12 +9548,12 @@ function parseTime(value, now, edge) {
9452
9548
  const duration2 = DURATION.exec(text2);
9453
9549
  if (duration2) {
9454
9550
  const unit = duration2[2].toLowerCase();
9455
- const size = unit.startsWith("mo") || unit === "m" ? UNITS.m : UNITS[unit[0]];
9456
- return now - Number(duration2[1]) * size;
9551
+ const size2 = unit.startsWith("mo") || unit === "m" ? UNITS.m : UNITS[unit[0]];
9552
+ return now - Number(duration2[1]) * size2;
9457
9553
  }
9458
9554
  const time3 = ISO.test(text2) ? Date.parse(DATE_ONLY.test(text2) ? `${text2}T00:00:00Z` : text2) : Number.NaN;
9459
9555
  if (!Number.isFinite(time3)) {
9460
- throw new QueryError(`${edge} must be an ISO date such as "2026-10-01" or an age such as "36h", "7d", "2w", "3m" or "1y"`);
9556
+ throw new QueryError(`${edge} must be an ISO date such as "2026-10-01" or an age such as "36h", "7d", "2w", "3m" or "1y"`, "query", { hint: QUERY_HINTS.time });
9461
9557
  }
9462
9558
  return DATE_ONLY.test(text2) && edge === "until" ? time3 + DAY - 1 : time3;
9463
9559
  }
@@ -9465,12 +9561,12 @@ function parseRegex(pattern2) {
9465
9561
  const literal = /^\/(.+)\/([dgimsuvy]*)$/s.exec(pattern2);
9466
9562
  const source = literal ? literal[1] : pattern2;
9467
9563
  const flags = literal ? literal[2] : "i";
9468
- if (!source) throw new QueryError("regex pattern is empty");
9564
+ if (!source) throw new QueryError("regex pattern is empty", "query", { hint: QUERY_HINTS.regex });
9469
9565
  try {
9470
9566
  return new RegExp(source, flags.includes("g") ? flags : `${flags}g`);
9471
9567
  } catch (error2) {
9472
9568
  const reason = error2 instanceof Error && error2.message.startsWith("Invalid regular expression") ? error2.message.split(": ").at(-1) : "invalid flags";
9473
- throw new QueryError(`invalid regex: ${reason}`);
9569
+ throw new QueryError(`invalid regex: ${reason}`, "query", { hint: QUERY_HINTS.regex });
9474
9570
  }
9475
9571
  }
9476
9572
  var CURSOR = "o:";
@@ -9595,7 +9691,7 @@ function hiddenCounts(rows, hiding) {
9595
9691
  var SCOPES2 = ["session", "directory", "project", "all"];
9596
9692
  function pathMatch(path, directory) {
9597
9693
  let clean = path.trim().replace(/^\.\//, "");
9598
- if (!clean || clean === "/" || clean === ".") throw new QueryError("path must name a file or folder");
9694
+ if (!clean || clean === "/" || clean === ".") throw new QueryError("path must name a file or folder", "query", { hint: QUERY_HINTS.path });
9599
9695
  if (/[*?[]/.test(clean)) {
9600
9696
  return clean.startsWith("/") || clean.startsWith("*") ? { sql: "f.path glob ?", params: [clean] } : { sql: "(f.path glob ? or f.path glob ?)", params: [clean, `*/${clean}`] };
9601
9697
  }
@@ -9616,9 +9712,9 @@ function filterSql(db, caller, filters2, now, target) {
9616
9712
  hiding.push({ reason, sql: sql2, params });
9617
9713
  };
9618
9714
  const scope = filters2.scope ?? "project";
9619
- if (!SCOPES2.includes(scope)) throw new QueryError("scope must be session, directory, project or all");
9715
+ if (!SCOPES2.includes(scope)) throw new QueryError("scope must be session, directory, project or all", "query", { hint: QUERY_HINTS.scope });
9620
9716
  const named = filters2.sessionID ?? (scope === "session" ? caller.sessionID : null);
9621
- if (scope === "session" && !named) throw new QueryError('scope "session" needs a sessionID when there is no calling session');
9717
+ if (scope === "session" && !named) throw new QueryError('scope "session" needs a sessionID when there is no calling session', "query", { hint: QUERY_HINTS.sessionScope });
9622
9718
  if (named) {
9623
9719
  add("(s.id = ? or s.root_id = ?)", named, named);
9624
9720
  if (!filters2.includeSubagents) hide2("subagents", "s.id = ?", named);
@@ -9664,7 +9760,7 @@ function filterSql(db, caller, filters2, now, target) {
9664
9760
  var MAX_FILE_SESSIONS = 20;
9665
9761
  var FILE_OPS2 = ["read", "edit", "write", "patch", "search"];
9666
9762
  function files(db, caller, input, state, options = {}) {
9667
- if (!input.path?.trim()) throw new QueryError("path must name a file or folder");
9763
+ if (!input.path?.trim()) throw new QueryError("path must name a file or folder", "query", { hint: QUERY_HINTS.path });
9668
9764
  const ops = input.ops && input.ops.length > 0 ? input.ops : void 0;
9669
9765
  if (ops?.some((op2) => !FILE_OPS2.includes(op2))) throw new QueryError(`ops must be among ${FILE_OPS2.join(", ")}`);
9670
9766
  const limit = bounded2(input.limit, BOUNDS.files.limit);
@@ -10007,7 +10103,7 @@ var HIT_COLUMNS = `d.id as id, d.session_id as sessionID, d.root_id as rootID, d
10007
10103
  var JOINS = "join sessions s on s.id = d.session_id left join sessions r on r.id = d.root_id";
10008
10104
  var WEIGHT_SQL = `case d.kind ${DOC_KINDS.map((kind2) => `when '${kind2}' then ${KIND_WEIGHT[kind2]}`).join(" ")} else 1 end`;
10009
10105
  var NONE2 = { sql: "", params: [] };
10010
- var ftsFailure = (error2) => error2 instanceof Error && /fts5|syntax error/i.test(error2.message) ? new QueryError("the query could not be searched; use plain words", "query", { cause: error2 }) : error2;
10106
+ var ftsFailure = (error2) => error2 instanceof Error && /fts5|syntax error/i.test(error2.message) ? new QueryError("the query could not be searched", "query", { cause: error2, hint: QUERY_HINTS.words }) : error2;
10011
10107
  function ftsHits(db, match, where) {
10012
10108
  let rows;
10013
10109
  try {
@@ -10543,12 +10639,12 @@ var MODES = ["words", "substring", "regex"];
10543
10639
  async function search(db, caller, input, state, options = {}) {
10544
10640
  const now = options.now ?? Date.now();
10545
10641
  const mode = input.match ?? "words";
10546
- if (!MODES.includes(mode)) throw new QueryError("match must be words, substring or regex");
10642
+ if (!MODES.includes(mode)) throw new QueryError("match must be words, substring or regex", "query", { hint: QUERY_HINTS.match });
10547
10643
  const limit = bounded2(input.limit, BOUNDS.search.limit);
10548
10644
  const snippetCount = bounded2(input.snippets, BOUNDS.search.snippets);
10549
10645
  const offset = decodeCursor(input.cursor);
10550
10646
  const texts = phrasingsOf(input.query, mode === "regex");
10551
- if (texts.length === 0) throw new QueryError("query is empty");
10647
+ if (texts.length === 0) throw new QueryError("query is empty", "query", { hint: QUERY_HINTS.empty });
10552
10648
  const where = filterSql(db, caller, input, now, "docs");
10553
10649
  const deadline = performance.now() + SCAN_BUDGET_MS;
10554
10650
  let hits;
@@ -10564,7 +10660,7 @@ async function search(db, caller, input, state, options = {}) {
10564
10660
  });
10565
10661
  } else if (mode === "substring") {
10566
10662
  const parsed = texts.map(parseQuery);
10567
- if (parsed.every((query) => !substringCandidates(query))) throw new QueryError(`substring search needs a term of at least ${MIN_SUBSTRING} characters to include`);
10663
+ if (parsed.every((query) => !substringCandidates(query))) throw new QueryError(`substring search needs a term of at least ${MIN_SUBSTRING} characters`, "query", { hint: QUERY_HINTS.substring });
10568
10664
  hits = await substringHits(db, parsed, where, deadline, limit);
10569
10665
  highlighters = parsed.map((query) => ({ kind: "literal", terms: highlightTerms(query, "substring").map(fold) }));
10570
10666
  probes = parsed.flatMap((query) => {
@@ -10575,7 +10671,7 @@ async function search(db, caller, input, state, options = {}) {
10575
10671
  });
10576
10672
  } else {
10577
10673
  const parsed = texts.map(parseQuery);
10578
- if (parsed.every((query) => toMatch(query, "words") === void 0)) throw new QueryError("query has nothing to include; excluded terms need at least one word to search for");
10674
+ if (parsed.every((query) => toMatch(query, "words") === void 0)) throw new QueryError("query has nothing to include; excluded terms need at least one word to search for", "query", { hint: QUERY_HINTS.excluded });
10579
10675
  hits = wordsHits(db, parsed, where, limit);
10580
10676
  highlighters = parsed.map((query) => {
10581
10677
  const full = toMatch(query, "words");
@@ -10678,7 +10774,7 @@ function recallPrompt(question, evidence) {
10678
10774
  }
10679
10775
  async function recall(db, source, caller, input, state, synthesize, mask, options = {}) {
10680
10776
  const question = input.question.trim();
10681
- if (!question) throw new QueryError("question is empty");
10777
+ if (!question) throw new QueryError("question is empty", "query", { hint: QUERY_HINTS.question });
10682
10778
  const given = (input.queries ?? []).map((query) => query.trim()).filter(Boolean);
10683
10779
  const queries = given.length > 0 ? given : phrasingsFor(question);
10684
10780
  const sessionCount = bounded2(input.sessions, BOUNDS.recall.sessions);
@@ -10706,7 +10802,7 @@ async function recall(db, source, caller, input, state, synthesize, mask, option
10706
10802
  if (!text2) text2 = `#${snippet.seq} ${snippet.kind}: ${unmark(snippet.text)}`;
10707
10803
  text2 = clip2(text2, Math.min(perWindow, left));
10708
10804
  covered.set(snippet.sessionID, [...ranges, { from, to }]);
10709
- const title2 = titles.get(snippet.sessionID)?.title ?? hit2.title;
10805
+ const title2 = mask(titles.get(snippet.sessionID)?.title ?? hit2.title);
10710
10806
  evidence.push({ sessionID: snippet.sessionID, title: title2, seq: snippet.seq, time: snippet.time, text: text2 });
10711
10807
  left -= text2.length;
10712
10808
  }
@@ -10801,58 +10897,118 @@ function checkSql(text2) {
10801
10897
  if (!/^(select|with)\b/i.test(statement)) throw new QueryError("only a SELECT or WITH statement is allowed", "sql_rejected");
10802
10898
  return out.trim().replace(/[;\s]+$/, "");
10803
10899
  }
10804
- var RUNNER = `"use strict"
10900
+ var SQL_TIMEOUT_MS = 1e4;
10901
+ var SQL_KILL_GRACE_MS = 2e3;
10902
+ var SQL_OUTPUT_BYTES = 512 * 1024;
10903
+ var SQL_STDOUT_BYTES = 4 * 1024 * 1024;
10904
+ var SQL_PARALLEL = 2;
10905
+ var SQL_PARENT_POLL_MS = 200;
10906
+ var SQL_CEILINGS = {
10907
+ timeoutMs: SQL_TIMEOUT_MS,
10908
+ rows: BOUNDS.sql.limit.max,
10909
+ cellChars: SQL_CELL_CHARS,
10910
+ rowBytes: SQL_OUTPUT_BYTES,
10911
+ stdoutBytes: SQL_STDOUT_BYTES,
10912
+ busyMs: SQL_BUSY_MS,
10913
+ pollMs: SQL_PARENT_POLL_MS,
10914
+ // The job is the statement (at most 16,384 characters), its params and a few numbers, as one JSON line.
10915
+ jobChars: 1024 * 1024
10916
+ };
10917
+ var WORKER = `"use strict"
10918
+ const { parentPort, workerData: job } = require("node:worker_threads")
10805
10919
  const { DatabaseSync } = require("node:sqlite")
10806
- let raw = ""
10807
- process.stdin.setEncoding("utf8")
10808
- process.stdin.on("data", chunk => { raw += chunk })
10809
- process.stdin.on("end", () => {
10810
- const job = JSON.parse(raw)
10811
- const clip = text => {
10812
- if (text.length <= job.cellChars) return text
10813
- let end = Math.max(0, job.cellChars - 1)
10814
- const code = text.charCodeAt(end - 1)
10815
- if (end > 0 && code >= 0xd800 && code <= 0xdbff) end--
10816
- return text.slice(0, end) + "\u2026"
10817
- }
10818
- const cell = value => {
10819
- if (typeof value === "string") return clip(value)
10820
- if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : value.toString()
10821
- if (value instanceof Uint8Array) return "[blob, " + value.byteLength + " bytes]"
10822
- return value === undefined ? null : value
10823
- }
10824
- let out
10920
+ const C = ${JSON.stringify(SQL_CEILINGS)}
10921
+ const limit = Math.max(0, Math.min(job.limit, C.rows))
10922
+ const cellChars = Math.max(1, Math.min(job.cellChars, C.cellChars))
10923
+ const maxBytes = Math.max(0, Math.min(job.maxBytes, C.rowBytes))
10924
+ const masks = (job.mask || []).map(([source, flags]) => new RegExp(source, flags.includes("g") ? flags : flags + "g"))
10925
+ const mask = text => {
10926
+ for (const pattern of masks) text = text.replace(pattern, (match, ...rest) => {
10927
+ if (match === "") return match
10928
+ const groups = rest[rest.length - 1]
10929
+ const keep = typeof groups === "object" && groups !== null ? groups.keep : undefined
10930
+ return (keep || "") + "[masked]"
10931
+ })
10932
+ return text
10933
+ }
10934
+ const clip = text => {
10935
+ if (text.length <= cellChars) return text
10936
+ let end = Math.max(0, cellChars - 1)
10937
+ const code = text.charCodeAt(end - 1)
10938
+ if (end > 0 && code >= 0xd800 && code <= 0xdbff) end--
10939
+ return text.slice(0, end) + "\\u2026"
10940
+ }
10941
+ const cell = value => {
10942
+ if (typeof value === "string") return clip(mask(value))
10943
+ if (typeof value === "bigint") return Number.isSafeInteger(Number(value)) ? Number(value) : value.toString()
10944
+ if (value instanceof Uint8Array) return "[blob, " + value.byteLength + " bytes]"
10945
+ return value === undefined ? null : value
10946
+ }
10947
+ let out
10948
+ try {
10949
+ const db = new DatabaseSync(job.path, { readOnly: true })
10825
10950
  try {
10826
- const db = new DatabaseSync(job.path, { readOnly: true })
10827
- try {
10828
- db.exec("pragma busy_timeout = " + job.busyMs)
10829
- db.exec("pragma query_only = 1")
10830
- const statement = db.prepare(job.statement)
10831
- let columns = typeof statement.columns === "function" ? statement.columns().map(column => column.name) : null
10832
- const rows = []
10833
- let bytes = 0, truncated = false
10834
- for (const row of statement.iterate(...job.params)) {
10835
- if (columns === null) columns = Object.keys(row)
10836
- if (rows.length >= job.limit) { truncated = true; break }
10837
- const cells = columns.map(name => cell(row[name]))
10838
- bytes += JSON.stringify(cells).length
10839
- if (bytes > job.maxBytes && rows.length > 0) { truncated = true; break }
10840
- rows.push(cells)
10841
- }
10842
- out = { ok: true, columns: columns || [], rows, truncated }
10843
- } finally {
10844
- db.close()
10845
- }
10846
- } catch (error) {
10847
- out = { ok: false, message: error && error.message ? String(error.message) : String(error), errcode: error && typeof error.errcode === "number" ? error.errcode : undefined }
10951
+ db.exec("pragma busy_timeout = " + Math.max(0, Math.min(job.busyMs, C.busyMs)))
10952
+ db.exec("pragma query_only = 1")
10953
+ const statement = db.prepare(job.statement)
10954
+ let columns = typeof statement.columns === "function" ? statement.columns().map(column => column.name) : null
10955
+ const rows = []
10956
+ let bytes = 0, truncated = false
10957
+ for (const row of statement.iterate(...job.params)) {
10958
+ if (columns === null) columns = Object.keys(row)
10959
+ if (rows.length >= limit) { truncated = true; break }
10960
+ const cells = columns.map(name => cell(row[name]))
10961
+ bytes += JSON.stringify(cells).length
10962
+ if (bytes > maxBytes && rows.length > 0) { truncated = true; break }
10963
+ rows.push(cells)
10964
+ }
10965
+ out = { ok: true, columns: columns || [], rows, truncated }
10966
+ } finally {
10967
+ db.close()
10848
10968
  }
10849
- process.stdout.write(JSON.stringify(out))
10969
+ } catch (error) {
10970
+ out = { ok: false, message: error && error.message ? String(error.message) : String(error), errcode: error && typeof error.errcode === "number" ? error.errcode : undefined }
10971
+ }
10972
+ parentPort.postMessage(out)
10973
+ `;
10974
+ var RUNNER = `"use strict"
10975
+ const { Worker } = require("node:worker_threads")
10976
+ const C = ${JSON.stringify(SQL_CEILINGS)}
10977
+ const parent = process.ppid
10978
+ const stop = () => { try { process.kill(process.pid, "SIGKILL") } finally { process.exit(1) } }
10979
+ let answered = false
10980
+ const answer = out => {
10981
+ if (answered) return
10982
+ answered = true
10983
+ let text = JSON.stringify(out)
10984
+ if (Buffer.byteLength(text) > C.stdoutBytes) text = JSON.stringify({ ok: false, reason: "output" })
10985
+ // A worker that has finished lets the process exit; one still inside SQLite (a timeout) needs the stop. A parent
10986
+ // that does not read the answer gets the stop too.
10987
+ setTimeout(stop, 1000)
10988
+ process.stdout.write(text, () => out.reason === "timeout" ? stop() : process.exit(0))
10989
+ }
10990
+ setInterval(() => { if (process.ppid !== parent) stop() }, C.pollMs)
10991
+ process.stdin.on("end", stop)
10992
+ process.stdin.on("close", stop)
10993
+ process.stdin.on("error", stop)
10994
+ let raw = "", started = false
10995
+ process.stdin.setEncoding("utf8")
10996
+ process.stdin.on("data", chunk => {
10997
+ if (started) return
10998
+ raw += chunk
10999
+ const end = raw.indexOf("\\n")
11000
+ if (end < 0) { if (raw.length > C.jobChars) stop(); return }
11001
+ started = true
11002
+ let job
11003
+ try { job = JSON.parse(raw.slice(0, end)) } catch { return answer({ ok: false, message: "the job is not JSON" }) }
11004
+ raw = ""
11005
+ const timeoutMs = Math.max(1, Math.min(Number(job.timeoutMs) || C.timeoutMs, C.timeoutMs))
11006
+ setTimeout(() => answer({ ok: false, reason: "timeout" }), timeoutMs)
11007
+ const worker = new Worker(${JSON.stringify(WORKER)}, { eval: true, workerData: job })
11008
+ worker.on("message", answer)
11009
+ worker.on("error", error => answer({ ok: false, message: error && error.message ? String(error.message) : String(error) }))
10850
11010
  })
10851
11011
  `;
10852
- var SQL_TIMEOUT_MS = 1e4;
10853
- var SQL_OUTPUT_BYTES = 512 * 1024;
10854
- var SQL_STDOUT_BYTES = 4 * 1024 * 1024;
10855
- var SQL_PARALLEL = 2;
10856
11012
  function runnerCommand() {
10857
11013
  const bun = typeof process.versions.bun === "string";
10858
11014
  const env = bun ? { BUN_BE_BUN: "1" } : {};
@@ -10887,23 +11043,24 @@ async function slot(signal) {
10887
11043
  waiting.shift()?.();
10888
11044
  };
10889
11045
  }
10890
- function runChild(job, cwd, timeoutMs, signal) {
11046
+ function runChild(job, cwd, signal) {
10891
11047
  const { file, args, env } = runnerCommand();
11048
+ const timeoutMs = job.timeoutMs;
10892
11049
  return new Promise((resolve4, reject) => {
10893
11050
  let stop;
10894
11051
  const chunks = [];
10895
- let size = 0;
11052
+ let size2 = 0;
10896
11053
  const child = spawn(file, args, { cwd, env, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
10897
11054
  const kill = (why) => {
10898
11055
  stop ??= why;
10899
11056
  child.kill("SIGKILL");
10900
11057
  };
10901
- const timer = setTimeout(() => kill("timeout"), timeoutMs);
11058
+ const timer = setTimeout(() => kill("timeout"), timeoutMs + SQL_KILL_GRACE_MS);
10902
11059
  const abort = () => kill("cancelled");
10903
11060
  signal?.addEventListener("abort", abort, { once: true });
10904
11061
  child.stdout.on("data", (chunk) => {
10905
- size += chunk.length;
10906
- if (size > SQL_STDOUT_BYTES) return kill("output");
11062
+ size2 += chunk.length;
11063
+ if (size2 > SQL_STDOUT_BYTES) return kill("output");
10907
11064
  chunks.push(chunk);
10908
11065
  });
10909
11066
  child.stdin.on("error", () => {
@@ -10916,16 +11073,21 @@ function runChild(job, cwd, timeoutMs, signal) {
10916
11073
  child.on("close", () => {
10917
11074
  clearTimeout(timer);
10918
11075
  signal?.removeEventListener("abort", abort);
10919
- if (stop === "timeout") return reject(new QueryError(`the SQL ran longer than ${timeoutMs / 1e3} s and was stopped`, "sql_timeout"));
10920
- if (stop === "output") return reject(new HistoryError("OUTPUT_LIMIT"));
10921
- if (stop === "cancelled") return reject(new HistoryError("CANCELLED"));
11076
+ child.stdin.destroy();
11077
+ let answer;
10922
11078
  try {
10923
- resolve4(JSON.parse(Buffer.concat(chunks).toString("utf8")));
10924
- } catch (error2) {
10925
- reject(new QueryError("the SQL process gave no answer", "sql_failed", { cause: error2 }));
11079
+ answer = JSON.parse(Buffer.concat(chunks).toString("utf8"));
11080
+ } catch {
10926
11081
  }
11082
+ const why = stop ?? (answer && !answer.ok ? answer.reason : void 0);
11083
+ if (why === "timeout") return reject(new QueryError(`the SQL ran longer than ${timeoutMs / 1e3} s and was stopped`, "sql_timeout"));
11084
+ if (why === "output") return reject(new HistoryError("OUTPUT_LIMIT"));
11085
+ if (why === "cancelled") return reject(new HistoryError("CANCELLED"));
11086
+ if (answer) return resolve4(answer);
11087
+ reject(new QueryError("the SQL process gave no answer", "sql_failed"));
10927
11088
  });
10928
- child.stdin.end(JSON.stringify(job));
11089
+ child.stdin.write(`${JSON.stringify(job)}
11090
+ `);
10929
11091
  });
10930
11092
  }
10931
11093
  async function sql(path, input, options = {}) {
@@ -10946,14 +11108,17 @@ ${body}
10946
11108
  limit,
10947
11109
  cellChars: SQL_CELL_CHARS,
10948
11110
  maxBytes: SQL_OUTPUT_BYTES,
10949
- busyMs: SQL_BUSY_MS
10950
- }, dirname3(path), options.timeoutMs ?? SQL_TIMEOUT_MS, options.signal);
11111
+ busyMs: SQL_BUSY_MS,
11112
+ timeoutMs: Math.min(options.timeoutMs ?? SQL_TIMEOUT_MS, SQL_TIMEOUT_MS),
11113
+ mask: (options.mask ?? []).map((pattern2) => [pattern2.source, pattern2.flags])
11114
+ }, dirname3(path), options.signal);
10951
11115
  } finally {
10952
11116
  release();
10953
11117
  }
10954
11118
  if (!answer.ok) {
10955
- const cause = Object.assign(new Error(answer.message), answer.errcode === void 0 ? {} : { errcode: answer.errcode });
10956
- if (/unable to open|cannot open/i.test(answer.message)) throw new QueryError("the history index could not be opened for reading", "sql_failed", { cause });
11119
+ const failed = answer;
11120
+ const cause = Object.assign(new Error(failed.message), failed.errcode === void 0 ? {} : { errcode: failed.errcode });
11121
+ if (/unable to open|cannot open/i.test(failed.message)) throw new QueryError("the history index could not be opened for reading", "sql_failed", { cause });
10957
11122
  throw new QueryError("the SQL failed", "sql_failed", { cause });
10958
11123
  }
10959
11124
  return { columns: answer.columns, rows: answer.rows, truncated: answer.truncated };
@@ -11063,6 +11228,34 @@ function compare2(a, b) {
11063
11228
  }
11064
11229
 
11065
11230
  // src/runtime.ts
11231
+ var MASKED_WITH = "masked_with";
11232
+ function maskFingerprint(patterns) {
11233
+ return createHash("sha256").update(JSON.stringify(patterns.map((pattern2) => [pattern2.source, pattern2.flags]))).digest("hex").slice(0, 16);
11234
+ }
11235
+ var TEXT_FIELDS = /* @__PURE__ */ new Set(["title", "firstPrompt", "lastReply", "summaries", "commands", "text", "description", "content", "path", "answer"]);
11236
+ function maskOutput(value, mask) {
11237
+ const walk2 = (node2, text2) => {
11238
+ if (typeof node2 === "string") return text2 ? mask(node2) : node2;
11239
+ if (Array.isArray(node2)) return node2.map((item) => walk2(item, text2));
11240
+ if (node2 === null || typeof node2 !== "object") return node2;
11241
+ return Object.fromEntries(Object.entries(node2).map(([key, item]) => [key, walk2(item, TEXT_FIELDS.has(key))]));
11242
+ };
11243
+ return walk2(value, false);
11244
+ }
11245
+ function indexBytes(path) {
11246
+ try {
11247
+ const size2 = (file) => {
11248
+ try {
11249
+ return statSync(file).size;
11250
+ } catch {
11251
+ return 0;
11252
+ }
11253
+ };
11254
+ return statSync(path).size + size2(`${path}-wal`);
11255
+ } catch {
11256
+ return null;
11257
+ }
11258
+ }
11066
11259
  var REFRESH_MS = 200;
11067
11260
  var QUERY_FAILURES = {
11068
11261
  query: "QUERY_INVALID",
@@ -11100,20 +11293,22 @@ function toFailure(error2, fallback = "OPERATION_FAILED", operation = "runtime")
11100
11293
  }
11101
11294
  const code = QUERY_FAILURES[error2.reason];
11102
11295
  const detail = error2.reason === "query" || error2.reason === "sql_rejected" ? error2.message : error2.reason === "sql_failed" ? sqliteMistake(error2.cause) : void 0;
11103
- return new HistoryError(code, detail ? { message: `${FAILURES[code][0].replace(/\.$/, "")}: ${detail}.` } : {});
11296
+ return new HistoryError(code, { ...detail ? { message: `${FAILURES[code][0].replace(/\.$/, "")}: ${detail}.` } : {}, ...error2.hint ? { hint: error2.hint } : {} });
11104
11297
  }
11105
11298
  var SharedIndex = class {
11106
- constructor(store, source, indexer, fallback) {
11299
+ constructor(store, source, indexer, fallback, maskedWith) {
11107
11300
  this.store = store;
11108
11301
  this.source = source;
11109
11302
  this.indexer = indexer;
11110
11303
  this.fallback = fallback;
11304
+ this.maskedWith = maskedWith;
11111
11305
  this.router = eventRouter({ syncSession: (id2) => this.track(indexer.syncSession(id2)), deleteSession: (id2) => this.track(indexer.deleteSession(id2)) });
11112
11306
  }
11113
11307
  store;
11114
11308
  source;
11115
11309
  indexer;
11116
11310
  fallback;
11311
+ maskedWith;
11117
11312
  router;
11118
11313
  /** Indexing work in flight, so a rebuild can wait for it before it drops the index. */
11119
11314
  running = /* @__PURE__ */ new Set();
@@ -11171,6 +11366,7 @@ var SharedIndex = class {
11171
11366
  while (this.running.size > 0) await Promise.allSettled([...this.running]);
11172
11367
  if (this.closed) fail("CLOSED");
11173
11368
  this.store.reset();
11369
+ this.store.setMeta(MASKED_WITH, this.maskedWith);
11174
11370
  await this.backfill();
11175
11371
  }
11176
11372
  backfill() {
@@ -11205,7 +11401,9 @@ function openIndex(path, { source, fallback }, options) {
11205
11401
  titleDoc: (session) => titleDoc(session, options),
11206
11402
  maskTitle: (text2) => maskText(text2, options.redact)
11207
11403
  });
11208
- const shared = new SharedIndex(store, source, indexer, fallback);
11404
+ const maskedWith = maskFingerprint(options.redact);
11405
+ if (store.meta(MASKED_WITH) === void 0) store.setMeta(MASKED_WITH, maskedWith);
11406
+ const shared = new SharedIndex(store, source, indexer, fallback, maskedWith);
11209
11407
  shared.start();
11210
11408
  return shared;
11211
11409
  } catch (error2) {
@@ -11265,8 +11463,10 @@ var createRuntime = async ({ config: config2, session, generate }) => {
11265
11463
  const shared = lease.value;
11266
11464
  if (shared.source !== opened.source) opened.source.close();
11267
11465
  const mask = (text2) => maskText(text2, config2.mask);
11466
+ const masked = (value) => maskOutput(value, mask);
11268
11467
  const state = () => shared.indexer.state();
11269
11468
  let closed = false;
11469
+ const closing = new AbortController();
11270
11470
  const run = async (operation, work, fallback) => {
11271
11471
  if (closed) fail("CLOSED");
11272
11472
  try {
@@ -11287,15 +11487,17 @@ var createRuntime = async ({ config: config2, session, generate }) => {
11287
11487
  ...shared.fallback ? { fallback: shared.fallback } : {},
11288
11488
  status: () => run("status", state, "STATUS_UNAVAILABLE"),
11289
11489
  schema: () => run("status", () => ({ file: shared.store.schemaVersion() ?? null, build: String(INDEX_SCHEMA_VERSION) }), "STATUS_UNAVAILABLE"),
11290
- search: (caller, input) => query("search", () => fresh(() => search(shared.store.db, caller, input, state()))),
11291
- sessions: (caller, input) => query("sessions", () => fresh(() => sessions(shared.store.db, caller, input, state()))),
11292
- files: (caller, input) => query("files", () => files(shared.store.db, caller, input, state())),
11293
- read: (_caller, input) => query("read", () => read(shared.store.db, shared.source, input, mask)),
11294
- brief: (_caller, input) => query("brief", () => brief(shared.store.db, shared.source, input, mask)),
11295
- sql: (_caller, input, signal) => query("sql", () => sql(shared.store.path, input, signal ? { signal } : {})),
11490
+ indexFile: () => run("status", () => ({ bytes: indexBytes(shared.store.path), masksChanged: shared.store.meta(MASKED_WITH) !== maskFingerprint(config2.mask) }), "STATUS_UNAVAILABLE"),
11491
+ search: (caller, input) => query("search", () => fresh(async () => masked(await search(shared.store.db, caller, input, state())))),
11492
+ sessions: (caller, input) => query("sessions", () => fresh(async () => masked(await sessions(shared.store.db, caller, input, state())))),
11493
+ files: (caller, input) => query("files", async () => masked(await files(shared.store.db, caller, input, state()))),
11494
+ read: (_caller, input) => query("read", async () => masked(await read(shared.store.db, shared.source, input, mask))),
11495
+ brief: (_caller, input) => query("brief", async () => masked(await brief(shared.store.db, shared.source, input, mask))),
11496
+ // A closing instance stops its statements, so a reload never leaves one running.
11497
+ sql: (_caller, input, signal) => query("sql", () => sql(shared.store.path, input, { signal: signal ? AbortSignal.any([signal, closing.signal]) : closing.signal, mask: config2.mask })),
11296
11498
  recall: (caller, input) => query(
11297
11499
  "recall",
11298
- () => fresh(() => recall(shared.store.db, shared.source, caller, input, state(), input.synthesize ?? config2.recall.synthesize ? generate : void 0, mask))
11500
+ () => fresh(async () => masked(await recall(shared.store.db, shared.source, caller, input, state(), input.synthesize ?? config2.recall.synthesize ? generate : void 0, mask)))
11299
11501
  ),
11300
11502
  reindex: (mode) => run("reindex", async () => {
11301
11503
  await (mode === "rebuild" ? shared.rebuild() : shared.catchUp());
@@ -11311,6 +11513,7 @@ var createRuntime = async ({ config: config2, session, generate }) => {
11311
11513
  async close() {
11312
11514
  if (closed) return;
11313
11515
  closed = true;
11516
+ closing.abort();
11314
11517
  lease.release();
11315
11518
  }
11316
11519
  };
package/dist/tui.js CHANGED
@@ -30,28 +30,44 @@ function agentsRow(view, compact) {
30
30
  var ACCOUNT_TEXT = "none; History needs no sign-in";
31
31
  var ACCESS_TEXT = "reads OpenCode's database read-only and writes only its own index; history_pin asks before it changes a session";
32
32
  var FALLBACK_TEXT = { SOURCE_UNAVAILABLE: "OpenCode's database could not be read", SOURCE_SCHEMA_UNKNOWN: "OpenCode's database has an unknown layout" };
33
+ var CONNECTION_WORDS = { ready: "connected", connecting: "connecting", failed: "failed" };
34
+ var MASKS_ROW = "Masks:";
35
+ function size(bytes) {
36
+ const units = ["B", "KB", "MB", "GB", "TB"];
37
+ let value = bytes, unit = 0;
38
+ while (value >= 1024 && unit < units.length - 1) {
39
+ value /= 1024;
40
+ unit++;
41
+ }
42
+ return `${unit === 0 || value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
43
+ }
44
+ function hostText({ host, version }) {
45
+ const note = host.tested ? "tested" : `untested; supported range ${host.range}`;
46
+ return `${host.version ?? "unknown version"} (${note}). History ${version}`;
47
+ }
33
48
  function toolsText({ tools }) {
34
49
  const unknown = tools.unknown.length ? `, ${tools.unknown.length} unknown (they ask first): ${tools.unknown.join(", ")}` : "";
35
50
  return `${tools.visible} available${unknown}`;
36
51
  }
37
52
  function formatStatus(view, options = {}) {
38
- const { index, host, mismatch } = view;
53
+ const { index, mismatch } = view;
39
54
  return lines(
40
55
  "History status",
41
- `Connection: ${view.connection}`,
56
+ `Connection: ${CONNECTION_WORDS[view.state]}`,
42
57
  `Account: ${ACCOUNT_TEXT}`,
43
58
  `Access: ${ACCESS_TEXT}`,
44
59
  `Index: ${index.ready ? "ready" : "building"}, ${index.sessionsIndexed} of ${index.sessionsTotal} sessions${index.lagSeconds ? `, ${Math.round(index.lagSeconds)} s behind` : ""}`,
45
60
  `Source: ${SOURCE_TEXT[index.source]}${index.complete ? "" : ", incomplete"}${view.fallback ? ` (${FALLBACK_TEXT[view.fallback]})` : ""}`,
46
61
  `Database: ${view.database}`,
47
- `Index file: ${view.indexPath}`,
62
+ `Index file: ${view.indexPath}${view.indexBytes === null ? "" : ` (${size(view.indexBytes)})`}`,
63
+ view.masksChanged ? `${MASKS_ROW} changed since text was indexed, so the index file holds that text masked the old way until /history-reindex rebuild` : void 0,
48
64
  mismatch ? `${MISMATCH_ROW} ${mismatch.file === null ? "none" : `version ${mismatch.file}`} in the file, but this build uses version ${mismatch.build}, so every query fails (INDEX_MISMATCH)` : void 0,
49
65
  `Default scope: ${view.defaultScope}`,
50
66
  `Recall answers: ${view.recall.synthesize ? "on" : "off"}${view.recall.model ? ` (${view.recall.model})` : " (default model)"}`,
51
67
  `Tools: ${toolsText(view)}`,
52
68
  agentsRow(view, options.compact ?? false),
53
- `OpenCode: ${host.found} (${host.availability === "AVAILABLE" ? "tested" : `host untested: tested on ${host.required}`}). History ${view.version}`,
54
- `Next step: ${view.next ?? NOTHING_TO_DO}`
69
+ `OpenCode: ${hostText(view)}`,
70
+ `Next step: ${view.nextStep}`
55
71
  );
56
72
  }
57
73
 
@@ -71,20 +87,22 @@ var INDEX = object(
71
87
  );
72
88
  var STATUS_OUTPUT = object({
73
89
  version: string,
74
- host: object({ required: string, found: string, availability: choice(["AVAILABLE", "HOST_UNTESTED"]), next: string }, ["required", "found", "availability"]),
75
- connection: choice(["connected", "connecting", "failed", "host untested"]),
90
+ host: object({ version: nullable(string), range: string, tested: boolean }, ["version", "range", "tested"]),
91
+ state: choice(["ready", "connecting", "failed"]),
76
92
  index: INDEX,
77
93
  fallback: choice(["SOURCE_UNAVAILABLE", "SOURCE_SCHEMA_UNKNOWN"]),
78
94
  tools: object({ visible: integer, unknown: list(string) }, ["visible", "unknown"]),
79
95
  database: string,
80
96
  indexPath: string,
97
+ indexBytes: nullable(integer),
98
+ masksChanged: boolean,
81
99
  defaultScope: choice(["project", "directory", "all"]),
82
100
  recall: object({ synthesize: boolean, model: nullable(string) }, ["synthesize", "model"]),
83
101
  unguardedAgents: list(string),
84
102
  permissionsBlock: list(object({ action: string, resource: string, effect: choice(["allow", "deny", "ask"]) }, ["action", "resource", "effect"])),
85
103
  mismatch: object({ file: nullable(string), build: string }, ["file", "build"]),
86
- next: string
87
- }, ["version", "host", "connection", "index", "tools", "database", "indexPath", "defaultScope", "recall", "unguardedAgents"]);
104
+ nextStep: string
105
+ }, ["version", "host", "state", "index", "tools", "database", "indexPath", "indexBytes", "masksChanged", "defaultScope", "recall", "unguardedAgents", "nextStep"]);
88
106
  var errors = { failed: object({ code: string, next: string }, ["code", "next"]) };
89
107
  var History = Rpc.define({
90
108
  id: PLUGIN_ID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joe-broadhead/opencode-history",
3
- "version": "0.0.0",
3
+ "version": "0.0.1",
4
4
  "description": "Unofficial OpenCode plugin: search, read and recall your past OpenCode sessions",
5
5
  "keywords": [
6
6
  "opencode",
@@ -107,7 +107,7 @@
107
107
  "typecheck": "tsc --noEmit",
108
108
  "test": "vitest run --maxWorkers=2",
109
109
  "build": "node scripts/build.mjs",
110
- "test:host": "vitest run -c vitest.host.config.ts && OPENCODE_HOST_VERSION=2.2.4 vitest run -c vitest.host.config.ts",
110
+ "test:host": "node scripts/test-host.mjs",
111
111
  "test:tui": "npx -y bun@1.4.4 test test/tui",
112
112
  "test:pack": "npx -y bun@1.4.4 test test/pack",
113
113
  "bench": "vitest run -c vitest.bench.config.ts --silent=false",