@joe-broadhead/opencode-history 0.0.0 → 0.0.2
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 +35 -0
- package/README.md +24 -9
- package/assets/icon.svg +1 -0
- package/dist/index.js +358 -155
- package/dist/tui.js +27 -9
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
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.2] - 2026-11-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Jelly shows the plugin's one-line summary ("Search, read and recall your past OpenCode sessions.") and its icon, a neutral clock with a back arrow (`assets/icon.svg`, now in the package). A Jelly release that does not know these two fields refuses the declaration, so this version needs a Jelly that accepts them.
|
|
10
|
+
|
|
11
|
+
## [0.0.1] - 2026-11-04
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- OpenCode 2.2.5 is a tested host, next to 2.2.3 and 2.2.4.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- README sections on updating the plugin, and on what to run after installing it.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- The status RPC (`history.sessions` `status`) uses the field names every plugin's status RPC shares:
|
|
23
|
+
- `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.
|
|
24
|
+
- `next` is now `nextStep`, always present, and `Nothing to do.` when nothing is needed.
|
|
25
|
+
- `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.
|
|
26
|
+
- 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.
|
|
27
|
+
- 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 `{}`.
|
|
28
|
+
- `SESSION_UNKNOWN` and `SESSION_AGENT_REQUIRED` have the next step every plugin uses: create a session that names an agent and pass its id.
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- 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.
|
|
35
|
+
- `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.
|
|
36
|
+
- `/history-reindex` with an unknown argument answers in the usual error format: `INPUT_INVALID: /history-reindex takes no argument or rebuild. Next step: …`.
|
|
37
|
+
- `/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.
|
|
38
|
+
- `history_pin` keeps the whole pin within `maxChars`. Before, its header line came on top, so `maxChars: 500` pinned about 730 characters.
|
|
39
|
+
|
|
5
40
|
## [0.0.0] - 2026-11-03
|
|
6
41
|
|
|
7
42
|
### 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.
|
|
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
|
-
|
|
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` |
|
|
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
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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/assets/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48"><rect width="48" height="48" rx="12" fill="#5B5F7A"/><g fill="none" stroke="#FFFFFF" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12.5 24a12 12 0 1 1 3.5 8.5"/><path d="M8.5 20.5l4 3.5 4-3.5"/><path d="M25 17.5V24l4.5 3"/></g></svg>
|
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,
|
|
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 =
|
|
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.",
|
|
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.",
|
|
6456
|
-
SESSION_AGENT_REQUIRED: ["That session names no agent, so there are no rules to run the tool under.",
|
|
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({
|
|
7141
|
-
|
|
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
|
-
|
|
7153
|
-
}, ["version", "host", "
|
|
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.
|
|
7166
|
+
var VERSION = true ? "0.0.2" : 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,
|
|
7430
|
+
const { index, mismatch } = view;
|
|
7414
7431
|
return lines2(
|
|
7415
7432
|
"History status",
|
|
7416
|
-
`Connection: ${view.
|
|
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: ${
|
|
7429
|
-
`Next step: ${view.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
7494
|
-
|
|
7495
|
-
if (
|
|
7496
|
-
|
|
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({
|
|
7809
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
7892
|
-
|
|
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
|
-
|
|
7901
|
-
|
|
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
|
|
8042
|
+
let size2;
|
|
8008
8043
|
try {
|
|
8009
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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:
|
|
8136
|
-
...Object.fromEntries(tools.map((tool) => [tool.name, { input:
|
|
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 (
|
|
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
|
|
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
|
|
8262
|
+
function stateOf(view) {
|
|
8197
8263
|
if (view.mismatch || view.index.source === "context") return "failed";
|
|
8198
|
-
|
|
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 = {
|
|
8279
|
+
const base = { index, ...schema.file !== schema.build ? { mismatch: schema } : {} };
|
|
8214
8280
|
const view = {
|
|
8215
8281
|
version: version2,
|
|
8216
|
-
host,
|
|
8217
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
-
/**
|
|
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.
|
|
8614
|
-
|
|
8615
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9456
|
-
return now - Number(duration2[1]) *
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
10807
|
-
|
|
10808
|
-
|
|
10809
|
-
|
|
10810
|
-
|
|
10811
|
-
|
|
10812
|
-
|
|
10813
|
-
|
|
10814
|
-
const
|
|
10815
|
-
|
|
10816
|
-
return
|
|
10817
|
-
}
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
10822
|
-
|
|
10823
|
-
|
|
10824
|
-
|
|
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
|
-
|
|
10827
|
-
|
|
10828
|
-
|
|
10829
|
-
|
|
10830
|
-
|
|
10831
|
-
|
|
10832
|
-
|
|
10833
|
-
|
|
10834
|
-
|
|
10835
|
-
|
|
10836
|
-
|
|
10837
|
-
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
|
|
10841
|
-
|
|
10842
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
10906
|
-
if (
|
|
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
|
-
|
|
10920
|
-
|
|
10921
|
-
if (stop === "cancelled") return reject(new HistoryError("CANCELLED"));
|
|
11076
|
+
child.stdin.destroy();
|
|
11077
|
+
let answer;
|
|
10922
11078
|
try {
|
|
10923
|
-
|
|
10924
|
-
} catch
|
|
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.
|
|
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
|
-
|
|
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
|
|
10956
|
-
|
|
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
|
|
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
|
-
|
|
11291
|
-
|
|
11292
|
-
|
|
11293
|
-
|
|
11294
|
-
|
|
11295
|
-
|
|
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,
|
|
53
|
+
const { index, mismatch } = view;
|
|
39
54
|
return lines(
|
|
40
55
|
"History status",
|
|
41
|
-
`Connection: ${view.
|
|
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: ${
|
|
54
|
-
`Next step: ${view.
|
|
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({
|
|
75
|
-
|
|
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
|
-
|
|
87
|
-
}, ["version", "host", "
|
|
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.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "Unofficial OpenCode plugin: search, read and recall your past OpenCode sessions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|
|
26
|
+
"assets/icon.svg",
|
|
26
27
|
"skills",
|
|
27
28
|
"README.md",
|
|
28
29
|
"LICENSE",
|
|
@@ -34,6 +35,8 @@
|
|
|
34
35
|
},
|
|
35
36
|
"jelly": {
|
|
36
37
|
"title": "History",
|
|
38
|
+
"summary": "Search, read and recall your past OpenCode sessions.",
|
|
39
|
+
"icon": "assets/icon.svg",
|
|
37
40
|
"tools": [
|
|
38
41
|
{
|
|
39
42
|
"name": "history.search",
|
|
@@ -107,7 +110,7 @@
|
|
|
107
110
|
"typecheck": "tsc --noEmit",
|
|
108
111
|
"test": "vitest run --maxWorkers=2",
|
|
109
112
|
"build": "node scripts/build.mjs",
|
|
110
|
-
"test:host": "
|
|
113
|
+
"test:host": "node scripts/test-host.mjs",
|
|
111
114
|
"test:tui": "npx -y bun@1.4.4 test test/tui",
|
|
112
115
|
"test:pack": "npx -y bun@1.4.4 test test/pack",
|
|
113
116
|
"bench": "vitest run -c vitest.bench.config.ts --silent=false",
|