dsh-session-index 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +144 -2
- package/bin/session-index.mjs +92 -0
- package/cordis.patch.yml +6 -0
- package/index.js +105 -0
- package/lib/args.js +20 -0
- package/lib/build.js +487 -0
- package/lib/read.js +100 -0
- package/lib/refresh.js +114 -0
- package/lib/store.js +308 -0
- package/lib/tools.js +297 -0
- package/package.json +82 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 John Lam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-session-index
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A derived SQLite index over **DeepSeek Harness session logs**, as a plugin: full-text search, listing, reading, and an
|
|
4
|
+
incremental rebuild — offered as a **service** and as **agent-facing tools**.
|
|
5
|
+
|
|
6
|
+
It is a Node program with a SQLite file. It imports **no dsh modules at all**: only `node:sqlite`,
|
|
7
|
+
`node:child_process`, `node:fs`, `node:os`, `node:path` and `node:url`. What it knows about dsh is dsh's session
|
|
8
|
+
*format*, which it reads.
|
|
9
|
+
|
|
10
|
+
## Why this exists
|
|
11
|
+
|
|
12
|
+
The harness already ships a SQLite FTS index (`@deepseek-ai/dsh-session-query-sqlite`) and agent tools that use it
|
|
13
|
+
(`@deepseek-ai/dsh-tool-session-query`). **We tried to use them and it broke a deployment**: enabling that index made
|
|
14
|
+
`api-session-controller` fail to start, its own code calling `provider.searchSessions`, and the cause was never
|
|
15
|
+
reproduced — a probe of the same profile on another port booted cleanly. The deployment left the harness index at
|
|
16
|
+
`openAt: never`, which also means the harness's five session tools have no backend to answer from.
|
|
17
|
+
|
|
18
|
+
So this package carries the capability instead. The history is in the parent repository's findings register (`F98`,
|
|
19
|
+
`F96`, `F99`, `F102`, `F103`), and it is written here so that a reader of this package alone knows why it is not just a
|
|
20
|
+
wrapper around the thing that already exists.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
dsh plugin --profile <profile> add github:johnlam1968/dsh-session-index
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
or as a dependency plus a bundle entry in the profile's `package.json`:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{ "dependencies": { "dsh-session-index": "github:johnlam1968/dsh-session-index" },
|
|
32
|
+
"dsh": { "profile": { "bundles": ["...", "dsh-session-index"] } } }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The row it inserts is `session-index`, which provides the service and registers the tools:
|
|
36
|
+
|
|
37
|
+
```yaml
|
|
38
|
+
- insert:
|
|
39
|
+
- id: session-index
|
|
40
|
+
name: 'dsh-session-index'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`Config`: `path` (the store; default `$DSH_HOME/session-index.db`), `sessionsDir` (default `$DSH_HOME/sessions`),
|
|
44
|
+
`tokenizer` (`trigram` | `unicode61` | `none`).
|
|
45
|
+
|
|
46
|
+
## The service
|
|
47
|
+
|
|
48
|
+
`ctx.localSessionIndex` — the capability without the schema: no SQL, no table names and no file layout cross the
|
|
49
|
+
boundary, so a consumer cannot come to depend on a store that is derived and free to change.
|
|
50
|
+
|
|
51
|
+
| method | what it answers |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `search(term, {limit})` | sessions whose **text, reasoning, tool results or tool-call arguments** match |
|
|
54
|
+
| `find(term, {limit})` | sessions whose **title, id or working directory** match |
|
|
55
|
+
| `list({cwd, search, limit})` | what the store holds |
|
|
56
|
+
| `read(id, {kinds, lastMessages, offset, messageChars})` | one session's conversation, from its log |
|
|
57
|
+
| `meta()` | what the store says about itself: `text_indexed`, `search_mode`, `tokenizer`, `fts_rows`, `sessions` |
|
|
58
|
+
| `refresh({timeoutMs})` | an incremental rebuild in a **child process** |
|
|
59
|
+
| `build({withText, incremental, tokenizer})` | a rebuild **in this process** — for a script or a migration |
|
|
60
|
+
| `row(id)` | one stored row, for a caller that wants the file behind an id |
|
|
61
|
+
|
|
62
|
+
## The agent-facing tools
|
|
63
|
+
|
|
64
|
+
| tool | the question it answers |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `session_index_list` | what is in the store: ids, titles, working directories, size, and whether a row is a SUBAGENT run. `subagents: include\|exclude\|only` filters them (measured: 318 of 499 rows are worker runs), and the answer always reports the mixture |
|
|
67
|
+
| `session_index_read` | one session's conversation, as it was said — with how much session there is and what was left out |
|
|
68
|
+
| `session_index_search` | where a phrase appears, and **which mechanism** answered |
|
|
69
|
+
| `session_index_refresh` | bring the store current, incrementally, in a child process |
|
|
70
|
+
|
|
71
|
+
### What they deliberately do NOT do
|
|
72
|
+
|
|
73
|
+
**No measurement semantics.** The observer plugin that consumes this one composes the *subject a judgement would see*
|
|
74
|
+
and marks the *OBSERVE allow-list*; both are facts about measuring a session, not about storing one. A session library
|
|
75
|
+
that carried them would be useless to a deployment that measures nothing. Concretely, this package has:
|
|
76
|
+
|
|
77
|
+
* no `state`/composer output, no judge's character budget;
|
|
78
|
+
* no allow-list, no "which sessions will be measured";
|
|
79
|
+
* no readings, no probabilities, no trace.
|
|
80
|
+
|
|
81
|
+
### Where the tools came from, and what is next
|
|
82
|
+
|
|
83
|
+
`list` and `read` were added when this plugin was split out of the observer, because a session library that cannot be
|
|
84
|
+
asked what it holds, or what a session said, is not usable on its own. Remarks for the next reader:
|
|
85
|
+
|
|
86
|
+
* **`session_index_stats`** is not a tool; `meta()` carries the same facts and `list` reports the counts. Add it only
|
|
87
|
+
if a model has a question that neither answers.
|
|
88
|
+
* **Live sessions**: `read` reads the *persisted* log, and `list` reads the *index*, so a session still being written
|
|
89
|
+
is only as current as the last build. `refresh` is the way to close that gap, and a `session_index_read` of a *live*
|
|
90
|
+
in-memory session would need the harness's `sessionQuery` service — a dsh dependency this package deliberately does
|
|
91
|
+
not take.
|
|
92
|
+
* **Other harnesses** (pi, minimax-code, zeroclaw, Hermes) write their own formats; the reader is dsh-format-specific
|
|
93
|
+
and a second format is a second reader, not a flag.
|
|
94
|
+
* **The service is live but NOT CATALOGUED**, measured after the first real deployment: `cordis_inspect_query
|
|
95
|
+
{ platform: 'host', provider: 'Service', method: 'listService' }` lists the harness's own services (including
|
|
96
|
+
`sessionQuery`) and does not list `localSessionIndex`, while `listTools` does list all four of this plugin's tools.
|
|
97
|
+
The catalogue appears to hold services that carry a declared contract, and this one is provided as a plain object --
|
|
98
|
+
a consumer can still inject it by name, but it is not discoverable or typed through the catalogue. Declaring it
|
|
99
|
+
properly is the first item for the next reader.
|
|
100
|
+
|
|
101
|
+
## The store
|
|
102
|
+
|
|
103
|
+
Derived and droppable: a refresh recreates it from the session logs, and nothing in it is a source of truth. Tables:
|
|
104
|
+
`sessions`, `messages`, `tool_calls`, `tool_results`, `search_fts` (the FTS5 mirror), `meta` (the mode receipts).
|
|
105
|
+
|
|
106
|
+
**The tokenizer decides what a query MEANS**, so it is chosen and recorded in `meta`:
|
|
107
|
+
|
|
108
|
+
| tokenizer | `MATCH` finds | store size (measured: 499 sessions, 121.5 MB of text) |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `trigram` (**default**) | substrings, exactly like a `LIKE` scan | 617.8 MB |
|
|
111
|
+
| `unicode61` | words and phrases only — `oice-prox` finds nothing | 353.3 MB |
|
|
112
|
+
| `none` (`build --no-fts`) | the scan over the stored tables | 154.7 MB |
|
|
113
|
+
|
|
114
|
+
A query **shorter than three characters** is answered by the scan and reports `like`, because three is the smallest run
|
|
115
|
+
a trigram index stores.
|
|
116
|
+
|
|
117
|
+
## Measured costs (one host, 499 sessions)
|
|
118
|
+
|
|
119
|
+
| what | cost |
|
|
120
|
+
|---|---|
|
|
121
|
+
| full build with text and the trigram mirror | 562 s, 617.8 MB, 91,158 mirrored rows |
|
|
122
|
+
| refresh of ONE living 33 MB session | **2.7 s** — refold 2.2 s (decode 0.9, fold 1.0, insert 0.4), mirror **0.4 s** (append-only) |
|
|
123
|
+
| search | 4–13 ms (mirror) against 242 ms (scan) |
|
|
124
|
+
|
|
125
|
+
Two fixes are recorded in the numbers rather than in prose: writing a refolded session in **one transaction** took the
|
|
126
|
+
insert phase from 41.3 s to 0.3 s, and maintaining the FTS mirror **per session** took a one-session refresh from
|
|
127
|
+
110 s to 56 s. The mirror step is now **APPEND-ONLY**, and the remedy this README used to name was retired by measurement
|
|
128
|
+
(`F106`): `DELETE … WHERE session_id = ?` on a 15,037-row session costs **4,087 ms**, deleting the same rows **by rowid**
|
|
129
|
+
costs **4,129 ms**, and the lookup a `session_id → rowid` map would replace costs **92 ms** — the seconds are FTS5's
|
|
130
|
+
trigram index work, not the search for the rows. So the work was removed instead of the lookup: `search_fts` carries
|
|
131
|
+
`seq`, `mirror_state(session_id, high_water)` records how far each session's mirror was built, and maintenance appends
|
|
132
|
+
**only the rows above that mark** — which a DSH log makes safe, because it only ever grows. A session with no receipt,
|
|
133
|
+
a log that shrank, or a row with no `seq` is **replaced** rather than guessed at, and the build reports which path ran
|
|
134
|
+
(`ftsAppended` / `ftsReplaced` / `ftsUnchanged`). `SCHEMA_VERSION` 4 costs one full rebuild, once.
|
|
135
|
+
|
|
136
|
+
## Tests
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
npm test # 28 tests: the store, the refresh, the plugin, the four tools
|
|
140
|
+
npm run coverage
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// THE COMMAND LINE: build, find, search and stats, over the store in `../lib/`.
|
|
3
|
+
//
|
|
4
|
+
// It is deliberately thin. Everything it does is a call into `lib/build.js` (the builder) or `lib/store.js` (the read
|
|
5
|
+
// side), so the CLI cannot drift from what the plugin's service does -- the same functions answer both.
|
|
6
|
+
|
|
7
|
+
import { statSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { DEFAULT_INDEX, DEFAULT_SESSIONS_DIR, buildIndex, readCounts } from '../lib/build.js'
|
|
10
|
+
import { findSessions, searchSessions } from '../lib/store.js'
|
|
11
|
+
|
|
12
|
+
const ms = (t) => `${(Number(process.hrtime.bigint() - t) / 1e6).toFixed(0)} ms`
|
|
13
|
+
|
|
14
|
+
async function main(argv) {
|
|
15
|
+
const [command, ...rest] = argv
|
|
16
|
+
const flag = (name, fallback) => {
|
|
17
|
+
const at = rest.indexOf(`--${name}`)
|
|
18
|
+
return at === -1 ? fallback : rest[at + 1]
|
|
19
|
+
}
|
|
20
|
+
const out = flag('out', DEFAULT_INDEX)
|
|
21
|
+
if (command === 'build') {
|
|
22
|
+
const started = process.hrtime.bigint()
|
|
23
|
+
const result = buildIndex({
|
|
24
|
+
sessionsDir: flag('sessions', DEFAULT_SESSIONS_DIR),
|
|
25
|
+
out,
|
|
26
|
+
withText: rest.includes('--text'),
|
|
27
|
+
incremental: rest.includes('--incremental'),
|
|
28
|
+
// `--no-fts` builds the same store WITHOUT the mirror: a real store that searches by scan, which is how the
|
|
29
|
+
// scan path stays exercised rather than assumed.
|
|
30
|
+
fts: !rest.includes('--no-fts'),
|
|
31
|
+
tokenizer: flag('tokenizer', 'trigram'),
|
|
32
|
+
onProgress: (done, total) => process.stderr.write(` ${done}/${total}\r`),
|
|
33
|
+
})
|
|
34
|
+
console.log(`${result.sessions} session(s) in the store; refolded ${result.refolded}, skipped ${result.skipped} unchanged`
|
|
35
|
+
+ `${result.schemaMoved ? ' (the SCHEMA moved, so every receipt was void)' : ''}`
|
|
36
|
+
+ `${result.modeChanged && !result.schemaMoved ? ' (the TEXT MODE changed, so every receipt was void)' : ''}`
|
|
37
|
+
+ `, in ${ms(started)} -> ${result.out}`)
|
|
38
|
+
console.log(` cost: refold ${(result.refoldMs / 1000).toFixed(1)} s (decode ${(result.decodeMs / 1000).toFixed(1)} s, fold ${(result.foldMs / 1000).toFixed(1)} s, insert ${(result.insertMs / 1000).toFixed(1)} s), mirror ${(result.mirrorMs / 1000).toFixed(1)} s`)
|
|
39
|
+
console.log(` search: ${result.searchMode}${result.searchMode === 'fts5' ? ` (${result.tokenizer}), ${result.ftsRows} mirrored row(s)${result.ftsRebuilt ? ' (rebuilt whole)'
|
|
40
|
+
: result.ftsMaintained > 0 ? ` (maintained for ${result.ftsMaintained} session(s): ${result.ftsAppended} appended, ${result.ftsReplaced} replaced, ${result.ftsUnchanged} unchanged)`
|
|
41
|
+
: ' (unchanged)'}` : ' -- no FTS5 mirror, text is matched by scan'}`)
|
|
42
|
+
console.log(` with a session/title event: ${result.titled} | without: ${result.untitled}` +
|
|
43
|
+
(result.untitled > 0 ? ' (those are the ones a title service must fold per request)' : ''))
|
|
44
|
+
console.log(` size: ${(statSync(out).size / 1048576).toFixed(1)} MB`)
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
if (command === 'find') {
|
|
48
|
+
const term = rest.find((a) => !a.startsWith('--') && a !== flag('out', null) && a !== flag('limit', null))
|
|
49
|
+
if (term === undefined) { console.error('find needs a term'); return 2 }
|
|
50
|
+
const started = process.hrtime.bigint()
|
|
51
|
+
const rows = await findSessions(term, { path: out, limit: Number(flag('limit', 20)) })
|
|
52
|
+
console.log(`${rows.length} session(s) matching ${JSON.stringify(term)} in ${ms(started)}`)
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
console.log(` ${row.id}`)
|
|
55
|
+
console.log(` title: ${row.title === null ? '(none)' : row.title}${row.title_source === null ? '' : ` [${row.title_source}]`}`)
|
|
56
|
+
console.log(` cwd: ${row.cwd ?? '?'}`)
|
|
57
|
+
console.log(` ${row.asks} asks, ${row.messages} messages, ${row.tool_calls} tool calls, ${new Date(Number(row.created_at ?? 0)).toISOString().slice(0, 16)}`)
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
if (command === 'search') {
|
|
62
|
+
const term = rest.find((a) => !a.startsWith('--') && a !== flag('out', null) && a !== flag('limit', null))
|
|
63
|
+
if (term === undefined) { console.error('search needs a term'); return 2 }
|
|
64
|
+
const started = process.hrtime.bigint()
|
|
65
|
+
const found = await searchSessions(term, { path: out, limit: Number(flag('limit', 20)) })
|
|
66
|
+
console.log(`${found.total} session(s) matching ${JSON.stringify(found.term)} in ${ms(started)}`
|
|
67
|
+
+ (found.textIndexed
|
|
68
|
+
? ` (${found.searchMode === 'fts5-trigram' ? 'FTS5 trigram mirror' : found.searchMode === 'fts5' ? 'FTS5 word mirror' : 'LIKE scan'}: message text, reasoning, tool results and tool arguments)`
|
|
69
|
+
: ' -- CONVERSATION TEXT IS NOT INDEXED in this store, so only titles, ids and directories were compared; rebuild with --text to search what was said and what tools returned'))
|
|
70
|
+
for (const row of found.rows) {
|
|
71
|
+
console.log(` ${row.id}`)
|
|
72
|
+
console.log(` title: ${row.title === null || row.title === undefined ? '(none)' : row.title}`)
|
|
73
|
+
console.log(` cwd: ${row.cwd ?? '?'}`)
|
|
74
|
+
console.log(` matched in: ${row.matchedIn}${row.hits > 0 ? ` (${row.hits} message(s))` : ''}`)
|
|
75
|
+
if (row.snippet !== undefined && row.snippet !== '') console.log(` …${row.snippet.split('\n').join(' ')}…`)
|
|
76
|
+
}
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
if (command === 'stats') {
|
|
80
|
+
const one = readCounts(out)
|
|
81
|
+
const size = (statSync(out).size / 1048576).toFixed(1)
|
|
82
|
+
console.log(`${one.sessions} session(s), ${one.titled} titled, ${one.asks} asks, ${one.shadowed} shadowed message(s), ${size} MB`)
|
|
83
|
+
return 0
|
|
84
|
+
}
|
|
85
|
+
console.error('usage: session-index.mjs build|find|search|stats [--out FILE] [--sessions DIR] [--text] [--incremental] [--limit N]')
|
|
86
|
+
return 2
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// THE EXIT CODE, NOT `process.exit`. Measured: with stdout redirected to a file or a pipe, `process.exit()` after the
|
|
90
|
+
// summary was built threw the summary away -- the rebuild's own lines vanished from its log while the build had
|
|
91
|
+
// succeeded. Setting the code lets the process end when the loop drains, so everything written is flushed.
|
|
92
|
+
if (process.argv[1] !== undefined && import.meta.url.endsWith(process.argv[1].split('/').pop())) process.exitCode = await main(process.argv.slice(2))
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# A HOST ROW: the store is a property of the deployment, not of one agent's preset. The config is omitted on purpose --
|
|
2
|
+
# every default is the one a plain deployment wants (the store beside the harness home, `trigram` tokenization, the
|
|
3
|
+
# harness's own sessions directory), and a default restated in two places is a default that drifts.
|
|
4
|
+
- insert:
|
|
5
|
+
- id: session-index
|
|
6
|
+
name: 'dsh-session-index'
|
package/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// DSH-SESSION-INDEX: a session store of our own, as its own plugin.
|
|
2
|
+
//
|
|
3
|
+
// WHAT IT IS. A derived SQLite database over DSH session logs: FTS5 search (message text, reasoning, tool results and
|
|
4
|
+
// tool-call arguments), title/cwd lookup, reading a session's conversation, and an INCREMENTAL rebuild. It provides a
|
|
5
|
+
// `localSessionIndex` SERVICE and four agent-facing tools, and it imports no dsh modules at all.
|
|
6
|
+
//
|
|
7
|
+
// WHY IT EXISTS. The harness ships its own SQLite index (`@deepseek-ai/dsh-session-query-sqlite`) and its own agent
|
|
8
|
+
// tools (`@deepseek-ai/dsh-tool-session-query`). Enabling that index in a live deployment made
|
|
9
|
+
// `api-session-controller` fail to start, and the cause was never reproduced; the deployment left it at
|
|
10
|
+
// `openAt: never` and this store carries the capability instead. That history is the reason this package exists and is
|
|
11
|
+
// written down here so a reader of it alone does not have to discover it.
|
|
12
|
+
|
|
13
|
+
import { Service } from '@deepseek-ai/cordis'
|
|
14
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
15
|
+
import { defaultIndexPath, findSessions, listSessions, metaOf, searchSessions, sessionRow, subagentCounts } from './lib/store.js'
|
|
16
|
+
import { refreshIndex } from './lib/refresh.js'
|
|
17
|
+
import { readSession } from './lib/read.js'
|
|
18
|
+
import { createTools } from './lib/tools.js'
|
|
19
|
+
|
|
20
|
+
const name = 'session-index'
|
|
21
|
+
|
|
22
|
+
/** The service name a consumer injects. */
|
|
23
|
+
export const SESSION_INDEX_SERVICE = 'localSessionIndex'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* THE HOST SERVICES THIS PLUGIN REACHES, declared rather than discovered (the audit's rule (c), `F107`): `tools` is the
|
|
27
|
+
* whole of it. `test/session-index-plugin.test.js` checks this against the source rather than trusting it.
|
|
28
|
+
*/
|
|
29
|
+
export const HOST_SERVICES = ['tools']
|
|
30
|
+
|
|
31
|
+
const Config = Schema.object({
|
|
32
|
+
path: Schema.string().description('The derived store. Droppable: a refresh recreates it.').default(defaultIndexPath()),
|
|
33
|
+
sessionsDir: Schema.string().description('Where the harness keeps its session logs. Defaults to `$DSH_HOME/sessions`.').default(''),
|
|
34
|
+
tokenizer: Schema.string().description('`trigram` (substring search, largest), `unicode61` (words), or `none` for no mirror.').default('trigram'),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The capability, as an object a plugin can call.
|
|
39
|
+
*
|
|
40
|
+
* EXACTLY WHAT A CALLER NEEDS, AND NOTHING ABOUT HOW IT IS STORED: no SQL, no table names and no file layout cross this
|
|
41
|
+
* boundary, so a consumer cannot come to depend on a schema that is free to change because the store is derived.
|
|
42
|
+
*/
|
|
43
|
+
export function createSessionIndex({ path = defaultIndexPath(), sessionsDir = undefined, tokenizer = 'trigram' } = {}) {
|
|
44
|
+
const options = sessionsDir === '' || sessionsDir === undefined ? {} : { sessionsDir }
|
|
45
|
+
return {
|
|
46
|
+
path,
|
|
47
|
+
search: (term, { limit = 20 } = {}) => searchSessions(term, { path, limit }),
|
|
48
|
+
find: (term, { limit = 20 } = {}) => findSessions(term, { path, limit }),
|
|
49
|
+
list: ({ cwd = null, search = null, subagents = 'include', limit = 20 } = {}) => listSessions({ path, cwd, search, subagents, limit }),
|
|
50
|
+
read: (id, options = {}) => readSession(id, { path, ...options }),
|
|
51
|
+
row: (id) => sessionRow(id, { path }),
|
|
52
|
+
meta: () => metaOf(path),
|
|
53
|
+
// HOW MANY OF THE ROWS ARE SUBAGENT RUNS, so a list can say what it is a list of (ROADMAP §14.4 item 3).
|
|
54
|
+
counts: () => subagentCounts({ path }),
|
|
55
|
+
refresh: ({ timeoutMs = 120000 } = {}) => refreshIndex({ out: path, ...options, timeoutMs }),
|
|
56
|
+
// THE BUILDER IS IMPORTED LAZILY: `lib/build.js` loads `node:sqlite` at module scope, and loading an experimental
|
|
57
|
+
// built-in while a host starts should not be a plugin's side effect.
|
|
58
|
+
build: async ({ withText = true, incremental = false, tokenizer: wanted = tokenizer } = {}) => {
|
|
59
|
+
const { buildIndex } = await import('./lib/build.js')
|
|
60
|
+
return buildIndex({ out: path, withText, incremental, tokenizer: wanted, ...options })
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* THE SERVICE, IN THE DOCUMENTED FORM.
|
|
67
|
+
*
|
|
68
|
+
* A compliance audit (`docs/findings.md`, F107) measured the consequence of providing it as a plain object: it is
|
|
69
|
+
* reachable by injection, and it does NOT appear in the live Service catalogue -- `listService` answers "no catalogued
|
|
70
|
+
* Service named localSessionIndex" -- where the harness's own services (`sessionQuery`, `sessionController`) do. The
|
|
71
|
+
* class form is what the contract asks for (`services-events.md` 2.1): a `Service` subclass whose constructor names the
|
|
72
|
+
* key, so `ctx.localSessionIndex` is a registered service rather than an anonymous value.
|
|
73
|
+
*
|
|
74
|
+
* THE IMPLEMENTATION IS UNCHANGED, and `createSessionIndex` stays: the same factory a test can call directly, with the
|
|
75
|
+
* class delegating to it, so the catalogue entry costs nothing in behaviour.
|
|
76
|
+
*/
|
|
77
|
+
export class LocalSessionIndex extends Service {
|
|
78
|
+
constructor(ctx, config = {}) {
|
|
79
|
+
super(ctx, SESSION_INDEX_SERVICE)
|
|
80
|
+
Object.assign(this, createSessionIndex({
|
|
81
|
+
path: config?.path ?? defaultIndexPath(),
|
|
82
|
+
sessionsDir: config?.sessionsDir ?? undefined,
|
|
83
|
+
tokenizer: config?.tokenizer ?? 'trigram',
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function apply(ctx, config) {
|
|
89
|
+
// MOUNTED, NOT PROVIDED: `ctx.plugin` runs the class's constructor, which registers the service under its key.
|
|
90
|
+
ctx.plugin(LocalSessionIndex, { ...(config ?? {}) })
|
|
91
|
+
// THE TOOLS ARE THE SAME CAPABILITY, for a model -- and they read the LIVE service, so a row that is replaced (or
|
|
92
|
+
// never mounts) is not answered by a stale reference.
|
|
93
|
+
const live = {
|
|
94
|
+
get path() { return ctx.get(SESSION_INDEX_SERVICE)?.path },
|
|
95
|
+
}
|
|
96
|
+
for (const method of ['search', 'find', 'list', 'read', 'row', 'meta', 'counts', 'refresh', 'build']) {
|
|
97
|
+
live[method] = (...args) => ctx.get(SESSION_INDEX_SERVICE)?.[method](...args)
|
|
98
|
+
}
|
|
99
|
+
ctx.inject(['tools'], (child) => {
|
|
100
|
+
if (child.tools === undefined || typeof child.tools.register !== 'function') return
|
|
101
|
+
for (const defined of createTools({ service: live })) child.tools.register(defined)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { name, Config, apply }
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A SMALL ARGUMENT CHECK, because the runtime's schema subset does not refuse an unknown key by itself.
|
|
2
|
+
//
|
|
3
|
+
// Measured, in the plugin this was extracted from: a caller passed an argument the row did not declare and the answer
|
|
4
|
+
// was about the missing parameter rather than about the call, which reads as a bug in the tool. So an unknown key is
|
|
5
|
+
// NAMED, and so is a wrong type -- the same rule as everywhere else here: refuse by name rather than guess.
|
|
6
|
+
|
|
7
|
+
export function checkAgainst(schema, args, toolName) {
|
|
8
|
+
const properties = schema?.properties ?? {}
|
|
9
|
+
for (const key of Object.keys(args)) {
|
|
10
|
+
if (properties[key] === undefined) throw new Error(`${toolName}: unknown parameter \`${key}\``)
|
|
11
|
+
}
|
|
12
|
+
for (const [key, spec] of Object.entries(properties)) {
|
|
13
|
+
const value = args[key]
|
|
14
|
+
if (value === undefined) continue
|
|
15
|
+
if (spec.type === 'number' && (typeof value !== 'number' || Number.isNaN(value))) throw new Error(`${toolName}: \`${key}\` must be a number`)
|
|
16
|
+
if (spec.type === 'string' && typeof value !== 'string') throw new Error(`${toolName}: \`${key}\` must be a string`)
|
|
17
|
+
if (spec.type === 'array' && !Array.isArray(value)) throw new Error(`${toolName}: \`${key}\` must be an array`)
|
|
18
|
+
if (spec.enum !== undefined && !spec.enum.includes(value)) throw new Error(`${toolName}: \`${key}\` must be one of ${spec.enum.join(', ')}`)
|
|
19
|
+
}
|
|
20
|
+
}
|