dsh-plugin-sessiongraph-sqlite 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/lib/database.d.ts +71 -0
- package/lib/database.js +210 -0
- package/lib/index.d.ts +62 -0
- package/lib/index.js +126 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/queries.d.ts +65 -0
- package/lib/queries.js +430 -0
- package/lib/rows.d.ts +41 -0
- package/lib/rows.js +170 -0
- package/package.json +61 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CC ZHAO
|
|
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.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection ownership for the local-conversation index: where an index root's database lives, the
|
|
3
|
+
* schema-version gate every connection passes before serving a query, and the bounded pool that
|
|
4
|
+
* keeps recently queried roots open.
|
|
5
|
+
*
|
|
6
|
+
* The indexer writes `<indexRoot>/index.db` under a temp name and renames it into place, so a reader
|
|
7
|
+
* always sees a complete file. This store still opens READ-ONLY and never repairs, migrates, or
|
|
8
|
+
* writes anything: an index it cannot read is reported as corrupt rather than silently treated as
|
|
9
|
+
* empty, because "no match" and "cannot answer" must never look the same to the model.
|
|
10
|
+
* @module dsh-plugin-sessiongraph-sqlite/database
|
|
11
|
+
*/
|
|
12
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
13
|
+
/**
|
|
14
|
+
* The absolute path of an index root's database.
|
|
15
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
16
|
+
* @returns the database path, whether or not it exists.
|
|
17
|
+
*/
|
|
18
|
+
export declare function databasePath(indexRoot: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Whether an index root holds an index at all.
|
|
21
|
+
*
|
|
22
|
+
* A synchronous `stat` is deliberate: the seam asks every registered store this question on every
|
|
23
|
+
* query, so the probe must not open a database, parse a header, or touch SQLite. Existence is the
|
|
24
|
+
* claim; whether the file is readable is decided later, by {@link openIndex}, which fails loud.
|
|
25
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
26
|
+
* @returns true when `<indexRoot>/index.db` exists as a regular file.
|
|
27
|
+
*/
|
|
28
|
+
export declare function indexExists(indexRoot: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Open one index root's database read-only and verify its schema version.
|
|
31
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
32
|
+
* @returns the open, version-checked connection; the caller owns closing it.
|
|
33
|
+
* @throws SessiongraphError with code `SESSIONGRAPH_CORRUPT` when the file cannot be opened, is not
|
|
34
|
+
* SQLite, or was written at another schema version. Never returns an empty database as a stand-in:
|
|
35
|
+
* a silent empty result would be indistinguishable from "no such conversation".
|
|
36
|
+
*/
|
|
37
|
+
export declare function openIndex(indexRoot: string): DatabaseSync;
|
|
38
|
+
/**
|
|
39
|
+
* A bounded set of open index connections keyed by index root, evicting the least recently used one
|
|
40
|
+
* when full. Every connection the pool hands out stays valid until {@link close}: eviction and
|
|
41
|
+
* disposal both close connections, so a caller holds a connection only for the duration of one
|
|
42
|
+
* synchronous query.
|
|
43
|
+
*
|
|
44
|
+
* A connection is opened against one specific on-disk file. The indexer replaces that file wholesale
|
|
45
|
+
* (rename over the old path), and POSIX unlink semantics mean an already-open read-only connection
|
|
46
|
+
* keeps serving the REPLACED file's bytes indefinitely — reopening is the only way to see the new
|
|
47
|
+
* index. `acquire` detects that swap by comparing the device/inode recorded at open time against the
|
|
48
|
+
* path's current identity, so a cache hit never silently serves an index an indexing run has already
|
|
49
|
+
* superseded.
|
|
50
|
+
*/
|
|
51
|
+
export declare class IndexPool {
|
|
52
|
+
private readonly capacity;
|
|
53
|
+
private readonly open;
|
|
54
|
+
private disposed;
|
|
55
|
+
/**
|
|
56
|
+
* @param capacity - largest number of simultaneously open indexes; at least 1.
|
|
57
|
+
*/
|
|
58
|
+
constructor(capacity: number);
|
|
59
|
+
/**
|
|
60
|
+
* The connection for an index root, opening and caching it on first use. Reopens automatically
|
|
61
|
+
* when the file on disk is no longer the one the cached connection was opened against.
|
|
62
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
63
|
+
* @returns the open, version-checked connection, current as of this call.
|
|
64
|
+
*/
|
|
65
|
+
acquire(indexRoot: string): DatabaseSync;
|
|
66
|
+
/** Close every open connection; further {@link acquire} calls fail as `SESSIONGRAPH_UNAVAILABLE`. */
|
|
67
|
+
close(): void;
|
|
68
|
+
/** Close least-recently-used connections until the pool fits its capacity. */
|
|
69
|
+
private evictOverflow;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=database.d.ts.map
|
package/lib/database.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection ownership for the local-conversation index: where an index root's database lives, the
|
|
3
|
+
* schema-version gate every connection passes before serving a query, and the bounded pool that
|
|
4
|
+
* keeps recently queried roots open.
|
|
5
|
+
*
|
|
6
|
+
* The indexer writes `<indexRoot>/index.db` under a temp name and renames it into place, so a reader
|
|
7
|
+
* always sees a complete file. This store still opens READ-ONLY and never repairs, migrates, or
|
|
8
|
+
* writes anything: an index it cannot read is reported as corrupt rather than silently treated as
|
|
9
|
+
* empty, because "no match" and "cannot answer" must never look the same to the model.
|
|
10
|
+
* @module dsh-plugin-sessiongraph-sqlite/database
|
|
11
|
+
*/
|
|
12
|
+
import { statSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
15
|
+
import { INDEX_RELATIVE_PATH, META_SCHEMA_VERSION, SCHEMA_VERSION, SessiongraphError, } from 'dsh-plugin-sessiongraph-service';
|
|
16
|
+
/**
|
|
17
|
+
* The absolute path of an index root's database.
|
|
18
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
19
|
+
* @returns the database path, whether or not it exists.
|
|
20
|
+
*/
|
|
21
|
+
export function databasePath(indexRoot) {
|
|
22
|
+
return join(indexRoot, INDEX_RELATIVE_PATH);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether an index root holds an index at all.
|
|
26
|
+
*
|
|
27
|
+
* A synchronous `stat` is deliberate: the seam asks every registered store this question on every
|
|
28
|
+
* query, so the probe must not open a database, parse a header, or touch SQLite. Existence is the
|
|
29
|
+
* claim; whether the file is readable is decided later, by {@link openIndex}, which fails loud.
|
|
30
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
31
|
+
* @returns true when `<indexRoot>/index.db` exists as a regular file.
|
|
32
|
+
*/
|
|
33
|
+
export function indexExists(indexRoot) {
|
|
34
|
+
try {
|
|
35
|
+
return statSync(databasePath(indexRoot)).isFile();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Only the existence probe runs in the try. A missing file, a missing directory, or no
|
|
39
|
+
// permission to look all mean the same thing to the seam: this store cannot serve the root.
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read the schema version stamped into an open index.
|
|
45
|
+
* @param db - an open connection.
|
|
46
|
+
* @returns the recorded version.
|
|
47
|
+
* @throws SessiongraphError with code `SESSIONGRAPH_CORRUPT` when the row is missing or not an
|
|
48
|
+
* integer, because such a file cannot be read through this code's assumptions.
|
|
49
|
+
*/
|
|
50
|
+
function readSchemaVersion(db) {
|
|
51
|
+
let row;
|
|
52
|
+
try {
|
|
53
|
+
row = db.prepare('SELECT value FROM meta WHERE key = ?').get(META_SCHEMA_VERSION);
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
// A file that is not SQLite at all throws here rather than in the constructor (SQLite opens
|
|
57
|
+
// lazily and only validates the header on first read). That is still a corrupt index, not an
|
|
58
|
+
// unavailable one: `indexes()` already told the seam the file is there.
|
|
59
|
+
throw new SessiongraphError(`the session-graph index is not a readable SQLite database (${describe(cause)})`, 'SESSIONGRAPH_CORRUPT');
|
|
60
|
+
}
|
|
61
|
+
const raw = row?.['value'];
|
|
62
|
+
const version = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
63
|
+
if (!Number.isInteger(version)) {
|
|
64
|
+
throw new SessiongraphError('the session-graph index records no readable schema version', 'SESSIONGRAPH_CORRUPT');
|
|
65
|
+
}
|
|
66
|
+
return version;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Open one index root's database read-only and verify its schema version.
|
|
70
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
71
|
+
* @returns the open, version-checked connection; the caller owns closing it.
|
|
72
|
+
* @throws SessiongraphError with code `SESSIONGRAPH_CORRUPT` when the file cannot be opened, is not
|
|
73
|
+
* SQLite, or was written at another schema version. Never returns an empty database as a stand-in:
|
|
74
|
+
* a silent empty result would be indistinguishable from "no such conversation".
|
|
75
|
+
*/
|
|
76
|
+
export function openIndex(indexRoot) {
|
|
77
|
+
const path = databasePath(indexRoot);
|
|
78
|
+
let db;
|
|
79
|
+
try {
|
|
80
|
+
db = new DatabaseSync(path, { readOnly: true });
|
|
81
|
+
}
|
|
82
|
+
catch (cause) {
|
|
83
|
+
throw new SessiongraphError(`cannot open the session-graph index at "${path}" (${describe(cause)})`, 'SESSIONGRAPH_CORRUPT');
|
|
84
|
+
}
|
|
85
|
+
let version;
|
|
86
|
+
try {
|
|
87
|
+
version = readSchemaVersion(db);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
closeQuietly(db);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (version !== SCHEMA_VERSION) {
|
|
94
|
+
closeQuietly(db);
|
|
95
|
+
throw new SessiongraphError(`the session-graph index at "${path}" is schema version ${version}; this store reads version ${SCHEMA_VERSION}`, 'SESSIONGRAPH_CORRUPT');
|
|
96
|
+
}
|
|
97
|
+
return db;
|
|
98
|
+
}
|
|
99
|
+
/** Close without letting a secondary failure mask the error already being reported. */
|
|
100
|
+
function closeQuietly(db) {
|
|
101
|
+
try {
|
|
102
|
+
db.close();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// The connection is being abandoned because it is unusable; a close failure changes nothing.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** One diagnostic line from a caught value, without assuming it is an Error. */
|
|
109
|
+
function describe(cause) {
|
|
110
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A path's current on-disk identity, or `undefined` when it cannot be stat'd (most commonly: an
|
|
114
|
+
* indexing run's atomic replace is between removing the old file and renaming the new one into
|
|
115
|
+
* place). That absence is treated as "unknown, not gone" by every caller — a transient rebuild
|
|
116
|
+
* window must never look like a missing index.
|
|
117
|
+
* @param path - the file to identify.
|
|
118
|
+
*/
|
|
119
|
+
function identify(path) {
|
|
120
|
+
try {
|
|
121
|
+
const stats = statSync(path);
|
|
122
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Whether two identities name the same underlying file. */
|
|
129
|
+
function sameIdentity(a, b) {
|
|
130
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* A bounded set of open index connections keyed by index root, evicting the least recently used one
|
|
134
|
+
* when full. Every connection the pool hands out stays valid until {@link close}: eviction and
|
|
135
|
+
* disposal both close connections, so a caller holds a connection only for the duration of one
|
|
136
|
+
* synchronous query.
|
|
137
|
+
*
|
|
138
|
+
* A connection is opened against one specific on-disk file. The indexer replaces that file wholesale
|
|
139
|
+
* (rename over the old path), and POSIX unlink semantics mean an already-open read-only connection
|
|
140
|
+
* keeps serving the REPLACED file's bytes indefinitely — reopening is the only way to see the new
|
|
141
|
+
* index. `acquire` detects that swap by comparing the device/inode recorded at open time against the
|
|
142
|
+
* path's current identity, so a cache hit never silently serves an index an indexing run has already
|
|
143
|
+
* superseded.
|
|
144
|
+
*/
|
|
145
|
+
export class IndexPool {
|
|
146
|
+
capacity;
|
|
147
|
+
open = new Map();
|
|
148
|
+
disposed = false;
|
|
149
|
+
/**
|
|
150
|
+
* @param capacity - largest number of simultaneously open indexes; at least 1.
|
|
151
|
+
*/
|
|
152
|
+
constructor(capacity) {
|
|
153
|
+
this.capacity = capacity;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The connection for an index root, opening and caching it on first use. Reopens automatically
|
|
157
|
+
* when the file on disk is no longer the one the cached connection was opened against.
|
|
158
|
+
* @param indexRoot - directory the seam routed a request to.
|
|
159
|
+
* @returns the open, version-checked connection, current as of this call.
|
|
160
|
+
*/
|
|
161
|
+
acquire(indexRoot) {
|
|
162
|
+
if (this.disposed) {
|
|
163
|
+
throw new SessiongraphError('the session-graph store is disposed', 'SESSIONGRAPH_UNAVAILABLE');
|
|
164
|
+
}
|
|
165
|
+
const cached = this.open.get(indexRoot);
|
|
166
|
+
if (cached !== undefined) {
|
|
167
|
+
const current = identify(databasePath(indexRoot));
|
|
168
|
+
// `undefined` means the path is transiently unstatable — most likely an indexing run's replace
|
|
169
|
+
// is mid-flight between removing the old file and renaming the new one into place. Keep serving
|
|
170
|
+
// the connection we have rather than treating a rebuild-in-progress as a missing index.
|
|
171
|
+
if (current === undefined || sameIdentity(current, cached.identity)) {
|
|
172
|
+
// Re-insert to mark most recently used; Map iteration order is insertion order.
|
|
173
|
+
this.open.delete(indexRoot);
|
|
174
|
+
this.open.set(indexRoot, cached);
|
|
175
|
+
return cached.db;
|
|
176
|
+
}
|
|
177
|
+
cached.db.close();
|
|
178
|
+
this.open.delete(indexRoot);
|
|
179
|
+
}
|
|
180
|
+
const db = openIndex(indexRoot);
|
|
181
|
+
const identity = identify(databasePath(indexRoot));
|
|
182
|
+
// Only `undefined` when the file vanishes in the instant between opening it and this stat — an
|
|
183
|
+
// unprovokable race in tests. The sentinel never matches a real identity, so the next acquire()
|
|
184
|
+
// is forced to re-verify rather than trusting an unconfirmed connection indefinitely.
|
|
185
|
+
/* v8 ignore next */
|
|
186
|
+
this.open.set(indexRoot, { db, identity: identity ?? { dev: -1, ino: -1 } });
|
|
187
|
+
this.evictOverflow();
|
|
188
|
+
return db;
|
|
189
|
+
}
|
|
190
|
+
/** Close every open connection; further {@link acquire} calls fail as `SESSIONGRAPH_UNAVAILABLE`. */
|
|
191
|
+
close() {
|
|
192
|
+
this.disposed = true;
|
|
193
|
+
for (const { db } of this.open.values())
|
|
194
|
+
db.close();
|
|
195
|
+
this.open.clear();
|
|
196
|
+
}
|
|
197
|
+
/** Close least-recently-used connections until the pool fits its capacity. */
|
|
198
|
+
evictOverflow() {
|
|
199
|
+
while (this.open.size > this.capacity) {
|
|
200
|
+
// Map iteration starts at the least recently used entry, and `acquire` re-inserts on a hit,
|
|
201
|
+
// so the first entry is always the eviction candidate.
|
|
202
|
+
for (const [root, entry] of this.open) {
|
|
203
|
+
entry.db.close();
|
|
204
|
+
this.open.delete(root);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=database.js.map
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite store for `ctx.sessiongraph`. One plugin instance registers one store that answers every
|
|
3
|
+
* seam operation from the on-disk conversation index an indexer writes to
|
|
4
|
+
* `<indexRoot>/index.db`, opening each root read-only and keeping a bounded set of connections warm.
|
|
5
|
+
*
|
|
6
|
+
* The store is host-local by construction: `node:sqlite` opens a path on the machine running the
|
|
7
|
+
* harness, so availability is decided against the host filesystem rather than `ctx.fs`. Availability
|
|
8
|
+
* is a synchronous `stat` of the index file (the seam asks this on every query); the file is only
|
|
9
|
+
* opened when a query actually needs it.
|
|
10
|
+
*
|
|
11
|
+
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
|
12
|
+
* unregisters from `ctx.sessiongraph` first, then closes every open connection.
|
|
13
|
+
* @module dsh-plugin-sessiongraph-sqlite
|
|
14
|
+
*/
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
export { IndexPool, databasePath, indexExists, openIndex } from './database.ts';
|
|
18
|
+
export { toEntry, toHitSubject, toSessionSummary, toWorkspaceStat } from './rows.ts';
|
|
19
|
+
export type { IndexRow } from './rows.ts';
|
|
20
|
+
export { search, session, status, timeline, workspaces } from './queries.ts';
|
|
21
|
+
/** Cordis plugin name for loader diagnostics. */
|
|
22
|
+
export declare const name = "sessiongraph-sqlite";
|
|
23
|
+
/** Services required by this plugin. */
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
/** Branded identity this store reserves on the seam. */
|
|
26
|
+
export declare const DEFAULT_STORE_ID = "sessiongraph-sqlite";
|
|
27
|
+
/** Default number of index databases kept open at once. */
|
|
28
|
+
export declare const DEFAULT_MAX_OPEN_DATABASES = 4;
|
|
29
|
+
/** Default ceiling on one rendered search snippet, in characters. */
|
|
30
|
+
export declare const DEFAULT_MAX_SNIPPET_CHARS = 240;
|
|
31
|
+
/**
|
|
32
|
+
* Default index root, the one `dsh-plugin-sessiongraph-sessions` builds into. Used only when a
|
|
33
|
+
* request's `indexRoot` is empty — an explicit root always wins, so a deployment that moved the
|
|
34
|
+
* index is served from where it actually is.
|
|
35
|
+
*/
|
|
36
|
+
export declare const DEFAULT_INDEX_ROOT: string;
|
|
37
|
+
/** Plugin configuration: the bounds the seam's requests cannot express. */
|
|
38
|
+
export interface Config {
|
|
39
|
+
/**
|
|
40
|
+
* Largest number of index databases held open at once (default 4). The least recently queried
|
|
41
|
+
* connection closes when the limit is exceeded; a root queried again simply reopens.
|
|
42
|
+
*/
|
|
43
|
+
maxOpenDatabases?: number;
|
|
44
|
+
/**
|
|
45
|
+
* Largest excerpt rendered per search hit (default 240), in characters. Brackets marking matched
|
|
46
|
+
* terms are added after this bound, so they never shorten the visible text.
|
|
47
|
+
*/
|
|
48
|
+
maxSnippetChars?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Index root used when a request leaves `indexRoot` empty (default `~/.dsh/sessiongraph`). Kept
|
|
51
|
+
* configurable so a profile can move the index without rebuilding the plugin.
|
|
52
|
+
*/
|
|
53
|
+
defaultIndexRoot?: string;
|
|
54
|
+
}
|
|
55
|
+
export declare const Config: z<Config>;
|
|
56
|
+
/**
|
|
57
|
+
* Register the SQLite session-graph store.
|
|
58
|
+
* @param ctx - the plugin context (must inject `sessiongraph`).
|
|
59
|
+
* @param config - the resolved plugin configuration.
|
|
60
|
+
*/
|
|
61
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
62
|
+
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite store for `ctx.sessiongraph`. One plugin instance registers one store that answers every
|
|
3
|
+
* seam operation from the on-disk conversation index an indexer writes to
|
|
4
|
+
* `<indexRoot>/index.db`, opening each root read-only and keeping a bounded set of connections warm.
|
|
5
|
+
*
|
|
6
|
+
* The store is host-local by construction: `node:sqlite` opens a path on the machine running the
|
|
7
|
+
* harness, so availability is decided against the host filesystem rather than `ctx.fs`. Availability
|
|
8
|
+
* is a synchronous `stat` of the index file (the seam asks this on every query); the file is only
|
|
9
|
+
* opened when a query actually needs it.
|
|
10
|
+
*
|
|
11
|
+
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
|
|
12
|
+
* unregisters from `ctx.sessiongraph` first, then closes every open connection.
|
|
13
|
+
* @module dsh-plugin-sessiongraph-sqlite
|
|
14
|
+
*/
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import z from '@deepseek-ai/schemastery';
|
|
18
|
+
import { SessiongraphStoreId, assertNever } from 'dsh-plugin-sessiongraph-service';
|
|
19
|
+
import { IndexPool, indexExists } from "./database.js";
|
|
20
|
+
import { search, session, status, timeline, workspaces } from "./queries.js";
|
|
21
|
+
export { IndexPool, databasePath, indexExists, openIndex } from "./database.js";
|
|
22
|
+
export { toEntry, toHitSubject, toSessionSummary, toWorkspaceStat } from "./rows.js";
|
|
23
|
+
export { search, session, status, timeline, workspaces } from "./queries.js";
|
|
24
|
+
/** Cordis plugin name for loader diagnostics. */
|
|
25
|
+
export const name = 'sessiongraph-sqlite';
|
|
26
|
+
/** Services required by this plugin. */
|
|
27
|
+
export const inject = ['sessiongraph'];
|
|
28
|
+
/** Branded identity this store reserves on the seam. */
|
|
29
|
+
export const DEFAULT_STORE_ID = 'sessiongraph-sqlite';
|
|
30
|
+
/** Default number of index databases kept open at once. */
|
|
31
|
+
export const DEFAULT_MAX_OPEN_DATABASES = 4;
|
|
32
|
+
/** Default ceiling on one rendered search snippet, in characters. */
|
|
33
|
+
export const DEFAULT_MAX_SNIPPET_CHARS = 240;
|
|
34
|
+
/**
|
|
35
|
+
* Default index root, the one `dsh-plugin-sessiongraph-sessions` builds into. Used only when a
|
|
36
|
+
* request's `indexRoot` is empty — an explicit root always wins, so a deployment that moved the
|
|
37
|
+
* index is served from where it actually is.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_INDEX_ROOT = join(homedir(), '.dsh', 'sessiongraph');
|
|
40
|
+
export const Config = z.object({
|
|
41
|
+
maxOpenDatabases: z.number().default(DEFAULT_MAX_OPEN_DATABASES),
|
|
42
|
+
maxSnippetChars: z.number().default(DEFAULT_MAX_SNIPPET_CHARS),
|
|
43
|
+
defaultIndexRoot: z.string().default(DEFAULT_INDEX_ROOT),
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Register the SQLite session-graph store.
|
|
47
|
+
* @param ctx - the plugin context (must inject `sessiongraph`).
|
|
48
|
+
* @param config - the resolved plugin configuration.
|
|
49
|
+
*/
|
|
50
|
+
export function apply(ctx, config) {
|
|
51
|
+
const resolved = config;
|
|
52
|
+
assertPositiveInteger('maxOpenDatabases', resolved.maxOpenDatabases);
|
|
53
|
+
assertPositiveInteger('maxSnippetChars', resolved.maxSnippetChars);
|
|
54
|
+
if (resolved.defaultIndexRoot.trim() === '') {
|
|
55
|
+
throw new Error('sessiongraph-sqlite: defaultIndexRoot must be a non-empty path');
|
|
56
|
+
}
|
|
57
|
+
const pool = new IndexPool(resolved.maxOpenDatabases);
|
|
58
|
+
// `ctx.sessiongraph` is reached through `ctx.get` because the seam package does not (yet) augment
|
|
59
|
+
// `Context` with it; `inject` guarantees the service is present before `apply` runs.
|
|
60
|
+
const seam = ctx.get('sessiongraph');
|
|
61
|
+
const store = {
|
|
62
|
+
id: SessiongraphStoreId(DEFAULT_STORE_ID),
|
|
63
|
+
async indexes(indexRoot, signal) {
|
|
64
|
+
signal?.throwIfAborted();
|
|
65
|
+
return indexExists(resolveIndexRoot(resolved, indexRoot));
|
|
66
|
+
},
|
|
67
|
+
query(request, signal) {
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
return Promise.resolve(run(pool, resolved, request));
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
ctx.effect(function* () {
|
|
73
|
+
// Registered first, so it disposes LAST: the store unregisters before its connections close,
|
|
74
|
+
// and no query can be routed to a pool that is already shut.
|
|
75
|
+
yield () => {
|
|
76
|
+
pool.close();
|
|
77
|
+
};
|
|
78
|
+
yield seam.registerStore(store);
|
|
79
|
+
}, 'sessiongraph-sqlite');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The root a request targets: its own `indexRoot`, or the configured default when it left that empty.
|
|
83
|
+
* @param config - the resolved plugin configuration.
|
|
84
|
+
* @param indexRoot - the request's root.
|
|
85
|
+
* @returns the root to stat and open.
|
|
86
|
+
*/
|
|
87
|
+
function resolveIndexRoot(config, indexRoot) {
|
|
88
|
+
return indexRoot.trim() === '' ? config.defaultIndexRoot : indexRoot;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Answer one request from the pooled connection for its index root.
|
|
92
|
+
* @param pool - the store's connection pool.
|
|
93
|
+
* @param config - the resolved plugin configuration.
|
|
94
|
+
* @param request - the normalized seam request.
|
|
95
|
+
* @returns the result member matching `request.operation`.
|
|
96
|
+
*/
|
|
97
|
+
function run(pool, config, request) {
|
|
98
|
+
const indexRoot = resolveIndexRoot(config, request.indexRoot);
|
|
99
|
+
// `status` is the one operation that answers without an index, so it must not acquire a
|
|
100
|
+
// connection up front. `ctx.sessiongraph` routes only to a store that claimed the root, but a
|
|
101
|
+
// direct caller (a test, a future consumer) may still ask `status` about a root that has none.
|
|
102
|
+
if (request.operation === 'status') {
|
|
103
|
+
return status(pool, indexRoot);
|
|
104
|
+
}
|
|
105
|
+
const db = pool.acquire(indexRoot);
|
|
106
|
+
switch (request.operation) {
|
|
107
|
+
case 'search':
|
|
108
|
+
return search(db, request, config.maxSnippetChars);
|
|
109
|
+
case 'session':
|
|
110
|
+
return session(db, request);
|
|
111
|
+
case 'timeline':
|
|
112
|
+
return timeline(db, request);
|
|
113
|
+
case 'workspaces':
|
|
114
|
+
return workspaces(db, request);
|
|
115
|
+
/* v8 ignore next -- exhaustive over the seam's closed operation union; unreachable. */
|
|
116
|
+
default:
|
|
117
|
+
return assertNever(request);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
|
|
121
|
+
function assertPositiveInteger(field, value) {
|
|
122
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
123
|
+
throw new Error(`sessiongraph-sqlite: ${field} must be a positive integer`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `dsh-plugin-sessiongraph-sqlite`.
|
|
3
|
+
* @module dsh-plugin-sessiongraph-sqlite/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "sessiongraph-sqlite-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `dsh-plugin-sessiongraph-sqlite`.
|
|
3
|
+
* @module dsh-plugin-sessiongraph-sqlite/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = 'dsh-plugin-sessiongraph-sqlite';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'sessiongraph-sqlite-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: the store owns no mutable harness data and emits no event. Its connection
|
|
12
|
+
* pool is private, and the index it reads is a file another plugin writes, so there is no owned
|
|
13
|
+
* relation between two harness-visible values to compare.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => { };
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
/* jscpd:ignore-end */
|
|
23
|
+
//# sourceMappingURL=invariant.js.map
|
package/lib/queries.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five seam operations against one open index database. Every function here is synchronous:
|
|
3
|
+
* `node:sqlite` is a synchronous binding, so a query either completes within one turn or throws, and
|
|
4
|
+
* the store's async surface exists for the seam's contract rather than for I/O interleaving.
|
|
5
|
+
*
|
|
6
|
+
* User-supplied strings never reach a statement as SQL text. Every filter is a `?` binding; the only
|
|
7
|
+
* text shaped from input is a `LIKE` pattern, and its wildcard characters are escaped so a workspace
|
|
8
|
+
* path containing `%` or `_` still matches literally.
|
|
9
|
+
* @module dsh-plugin-sessiongraph-sqlite/queries
|
|
10
|
+
*/
|
|
11
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import type { SessiongraphSearchRequest, SessiongraphSearchResult, SessiongraphSessionRequest, SessiongraphSessionResult, SessiongraphStatusResult, SessiongraphTimelineRequest, SessiongraphTimelineResult, SessiongraphWorkspacesRequest, SessiongraphWorkspacesResult } from 'dsh-plugin-sessiongraph-service';
|
|
13
|
+
import type { IndexPool } from './database.ts';
|
|
14
|
+
/**
|
|
15
|
+
* Full-text search across indexed history.
|
|
16
|
+
*
|
|
17
|
+
* Ordering is `bm25`, then newest, then entry id. `bm25` is chosen over plain recency because a
|
|
18
|
+
* search result list is read top-down and the caller wants the best match first, not merely the most
|
|
19
|
+
* recent one. Ranking alone is NOT a total order: equally scored rows (extremely common for short
|
|
20
|
+
* entries and bigram queries) come back in whatever order the scan produced, which would make
|
|
21
|
+
* `LIMIT`/`OFFSET` paging skip and duplicate rows between pages. Appending `e.time DESC, e.id ASC`
|
|
22
|
+
* supplies a deterministic total order without displacing relevance, since `entry.id` is unique and
|
|
23
|
+
* UNIQUE breaks every remaining tie. That is also why this is the one operation whose page order is
|
|
24
|
+
* stable across requests against an unchanged index.
|
|
25
|
+
* @param db - the open index connection.
|
|
26
|
+
* @param request - the search query.
|
|
27
|
+
* @param maxSnippetChars - largest excerpt the plugin will render per hit.
|
|
28
|
+
* @returns the page of hits, the pre-pagination total, and whether anything was cut.
|
|
29
|
+
*/
|
|
30
|
+
export declare function search(db: DatabaseSync, request: SessiongraphSearchRequest, maxSnippetChars: number): SessiongraphSearchResult;
|
|
31
|
+
/**
|
|
32
|
+
* Read one session's metadata, a page of its entries in the session's own chronological order, and
|
|
33
|
+
* the sessions delegated from it.
|
|
34
|
+
* @param db - the open index connection.
|
|
35
|
+
* @param request - the session query.
|
|
36
|
+
* @returns the session summary, entry page, children, and whether more entries exist.
|
|
37
|
+
*/
|
|
38
|
+
export declare function session(db: DatabaseSync, request: SessiongraphSessionRequest): SessiongraphSessionResult;
|
|
39
|
+
/**
|
|
40
|
+
* List sessions newest-first by last activity.
|
|
41
|
+
* @param db - the open index connection.
|
|
42
|
+
* @param request - the timeline query.
|
|
43
|
+
* @returns the page of session summaries and whether more exist.
|
|
44
|
+
*/
|
|
45
|
+
export declare function timeline(db: DatabaseSync, request: SessiongraphTimelineRequest): SessiongraphTimelineResult;
|
|
46
|
+
/**
|
|
47
|
+
* Enumerate indexed workspaces, most recently active first.
|
|
48
|
+
* @param db - the open index connection.
|
|
49
|
+
* @param request - the workspaces query.
|
|
50
|
+
* @returns the page of workspace statistics and the pre-limit total.
|
|
51
|
+
*/
|
|
52
|
+
export declare function workspaces(db: DatabaseSync, request: SessiongraphWorkspacesRequest): SessiongraphWorkspacesResult;
|
|
53
|
+
/**
|
|
54
|
+
* Report the index's own state.
|
|
55
|
+
*
|
|
56
|
+
* This is the one operation that answers when no index exists: a caller that never indexed anything
|
|
57
|
+
* needs to learn that fact, and every other operation fails loud instead of pretending the store is
|
|
58
|
+
* empty. A file that exists but cannot be read is still `SESSIONGRAPH_CORRUPT` — `indexes()` already
|
|
59
|
+
* claimed the root, so quietly downgrading it to "not indexed" would hide a damaged index.
|
|
60
|
+
* @param pool - the store's connection pool.
|
|
61
|
+
* @param indexRoot - directory the seam routed the request to.
|
|
62
|
+
* @returns the index summary, or the zero result for a root that holds no index.
|
|
63
|
+
*/
|
|
64
|
+
export declare function status(pool: IndexPool, indexRoot: string): SessiongraphStatusResult;
|
|
65
|
+
//# sourceMappingURL=queries.d.ts.map
|
package/lib/queries.js
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The five seam operations against one open index database. Every function here is synchronous:
|
|
3
|
+
* `node:sqlite` is a synchronous binding, so a query either completes within one turn or throws, and
|
|
4
|
+
* the store's async surface exists for the seam's contract rather than for I/O interleaving.
|
|
5
|
+
*
|
|
6
|
+
* User-supplied strings never reach a statement as SQL text. Every filter is a `?` binding; the only
|
|
7
|
+
* text shaped from input is a `LIKE` pattern, and its wildcard characters are escaped so a workspace
|
|
8
|
+
* path containing `%` or `_` still matches literally.
|
|
9
|
+
* @module dsh-plugin-sessiongraph-sqlite/queries
|
|
10
|
+
*/
|
|
11
|
+
import { statSync } from 'node:fs';
|
|
12
|
+
import { META_INDEXED_AT, SessiongraphError, SessiongraphSessionId, highlightTerms, matchExpression, } from 'dsh-plugin-sessiongraph-service';
|
|
13
|
+
import { databasePath } from "./database.js";
|
|
14
|
+
import { toEntry, toHitSubject, toSessionSummary, toWorkspaceStat } from "./rows.js";
|
|
15
|
+
/** Every `session` column the summary projection reads. */
|
|
16
|
+
const SESSION_COLUMNS = [
|
|
17
|
+
'id',
|
|
18
|
+
'workspace',
|
|
19
|
+
'title',
|
|
20
|
+
'created_at',
|
|
21
|
+
'last_at',
|
|
22
|
+
'parent_session',
|
|
23
|
+
'origin',
|
|
24
|
+
'agent_preset',
|
|
25
|
+
'turns',
|
|
26
|
+
'entries',
|
|
27
|
+
].join(', ');
|
|
28
|
+
/** The one `entry` row shape every query below selects, so the projection never drifts by query. */
|
|
29
|
+
const ENTRY_COLUMNS = 'seq, time, kind, turn, text, clipped';
|
|
30
|
+
/**
|
|
31
|
+
* `source_path` prefix the indexer writes for a conversation that is still running, before its log
|
|
32
|
+
* exists on disk. Spelled out here instead of imported from the indexer package: a store depends only
|
|
33
|
+
* on the seam, and this is a value shared through the on-disk format, not through code. A session
|
|
34
|
+
* ends and is re-indexed from a real path, at which point its row no longer matches.
|
|
35
|
+
*/
|
|
36
|
+
const LIVE_SOURCE_LIKE = 'live://%';
|
|
37
|
+
/** Run a statement and return its rows already typed as raw records. */
|
|
38
|
+
function rows(db, sql, params = []) {
|
|
39
|
+
return db.prepare(sql).all(...params);
|
|
40
|
+
}
|
|
41
|
+
/** Read a `count(*) AS total` aggregate; an empty table yields 0, not undefined. */
|
|
42
|
+
function count(db, sql, params = []) {
|
|
43
|
+
const row = db.prepare(sql).get(...params);
|
|
44
|
+
const value = row?.['total'];
|
|
45
|
+
return typeof value === 'number' ? value : 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Coerce a request bound into a non-negative integer. A caller-supplied `limit`/`offset` is not
|
|
49
|
+
* trusted to be well formed — `LIMIT -1` would mean "no limit" in SQLite, which is exactly the
|
|
50
|
+
* unbounded answer the seam's `limit` exists to prevent.
|
|
51
|
+
* @param value - the requested bound.
|
|
52
|
+
* @returns the bound as a non-negative integer.
|
|
53
|
+
*/
|
|
54
|
+
function bound(value) {
|
|
55
|
+
if (value === undefined || !Number.isFinite(value))
|
|
56
|
+
return 0;
|
|
57
|
+
return Math.max(0, Math.floor(value));
|
|
58
|
+
}
|
|
59
|
+
/** Escape the wildcard characters in a string that becomes a `LIKE` pattern. */
|
|
60
|
+
function escapeLike(value) {
|
|
61
|
+
return value.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the workspace predicate for one side of a query.
|
|
65
|
+
*
|
|
66
|
+
* A path that ends with `/` is a PREFIX filter ("everything under this directory"); any other value
|
|
67
|
+
* is an exact `cwd` match. That distinction is the wrong way round from most APIs, and it is
|
|
68
|
+
* deliberate: a caller filtering on an exact workspace usually has the exact path already, while a
|
|
69
|
+
* prefix filter is the one that needs a marker to say "this is not a full path".
|
|
70
|
+
* @param column - the already-trusted SQL column reference (`s.workspace`).
|
|
71
|
+
* @param workspace - the requested workspace filter.
|
|
72
|
+
* @returns the clause and its bindings, or undefined when the filter is absent.
|
|
73
|
+
*/
|
|
74
|
+
function workspaceClause(column, workspace) {
|
|
75
|
+
if (workspace === undefined || workspace === '')
|
|
76
|
+
return undefined;
|
|
77
|
+
if (workspace.endsWith('/')) {
|
|
78
|
+
return { clause: `${column} LIKE ? ESCAPE '\\'`, params: [`${escapeLike(workspace)}%`] };
|
|
79
|
+
}
|
|
80
|
+
return { clause: `${column} = ?`, params: [workspace] };
|
|
81
|
+
}
|
|
82
|
+
/** Escape a literal string for `RegExp`, so a query token is never read as a pattern. */
|
|
83
|
+
function escapeRegExp(value) {
|
|
84
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Every case-insensitive occurrence of one term in one string. Case folding is done by the regex
|
|
88
|
+
* engine rather than by lowercasing the text: lowercasing can change a string's length, and the
|
|
89
|
+
* indices here are used to slice the ORIGINAL text.
|
|
90
|
+
* @param haystack - text to search.
|
|
91
|
+
* @param term - literal term to find.
|
|
92
|
+
* @returns non-overlapping spans in document order.
|
|
93
|
+
*/
|
|
94
|
+
function findSpans(haystack, term) {
|
|
95
|
+
if (term === '')
|
|
96
|
+
return [];
|
|
97
|
+
const spans = [];
|
|
98
|
+
for (const match of haystack.matchAll(new RegExp(escapeRegExp(term), 'gi'))) {
|
|
99
|
+
const start = match.index ?? 0;
|
|
100
|
+
spans.push({ start, end: start + match[0].length });
|
|
101
|
+
}
|
|
102
|
+
return spans;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Sort spans and merge the ones that touch, so adjacent matched terms render as one bracketed run
|
|
106
|
+
* (`[广告SDK]`) instead of a run of abutting brackets.
|
|
107
|
+
* @param spans - unmerged spans.
|
|
108
|
+
* @returns merged, ordered, non-overlapping spans.
|
|
109
|
+
*/
|
|
110
|
+
function mergeSpans(spans) {
|
|
111
|
+
const ordered = [...spans].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
112
|
+
const merged = [];
|
|
113
|
+
for (const span of ordered) {
|
|
114
|
+
const last = merged[merged.length - 1];
|
|
115
|
+
if (last !== undefined && span.start <= last.end) {
|
|
116
|
+
if (span.end > last.end)
|
|
117
|
+
merged[merged.length - 1] = { start: last.start, end: span.end };
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
merged.push(span);
|
|
121
|
+
}
|
|
122
|
+
return merged;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The span the snippet window centres on: the longest query token that actually occurs in the entry,
|
|
126
|
+
* at its first occurrence. `highlightTerms` returns tokens longest-first, so the first term with a
|
|
127
|
+
* match is that longest term.
|
|
128
|
+
* @param text - the indexed entry text.
|
|
129
|
+
* @param terms - highlight terms, longest first.
|
|
130
|
+
* @returns the anchor span, or undefined when no term occurs literally.
|
|
131
|
+
*/
|
|
132
|
+
function anchorSpan(text, terms) {
|
|
133
|
+
for (const term of terms) {
|
|
134
|
+
const [first] = findSpans(text, term);
|
|
135
|
+
if (first !== undefined)
|
|
136
|
+
return first;
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Cut a bounded excerpt around the first match and bracket every matched term inside it.
|
|
142
|
+
*
|
|
143
|
+
* The excerpt itself is at most `maxChars`; the brackets are added afterwards (and are not counted
|
|
144
|
+
* against the cap, so they never shorten what the caller sees). `clipped` reports that the excerpt
|
|
145
|
+
* is a window onto a longer entry, which is what the result's `truncated` flag needs to know.
|
|
146
|
+
* @param text - the full indexed entry text.
|
|
147
|
+
* @param terms - highlight terms, longest first.
|
|
148
|
+
* @param maxChars - largest excerpt length, in code units.
|
|
149
|
+
* @returns the rendered snippet and whether it was clipped.
|
|
150
|
+
*/
|
|
151
|
+
function renderSnippet(text, terms, maxChars) {
|
|
152
|
+
const cap = Math.max(1, Math.floor(maxChars));
|
|
153
|
+
const anchor = anchorSpan(text, terms);
|
|
154
|
+
const anchorLength = anchor === undefined ? 0 : anchor.end - anchor.start;
|
|
155
|
+
// Centre the window on the match, then pull it back inside the string; both clamps matter when the
|
|
156
|
+
// match is near either edge.
|
|
157
|
+
let start = anchor === undefined ? 0 : anchor.start - Math.floor((cap - Math.min(anchorLength, cap)) / 2);
|
|
158
|
+
start = Math.max(0, Math.min(start, Math.max(0, text.length - cap)));
|
|
159
|
+
let end = Math.min(text.length, start + cap);
|
|
160
|
+
// A match longer than the cap (a long Latin token) cannot fit whole: show its start rather than a
|
|
161
|
+
// tail slice the caller would not connect to the query.
|
|
162
|
+
if (anchor !== undefined && anchor.end > end) {
|
|
163
|
+
start = anchor.start;
|
|
164
|
+
end = Math.min(text.length, anchor.start + cap);
|
|
165
|
+
}
|
|
166
|
+
const excerpt = text.slice(start, end);
|
|
167
|
+
const spans = mergeSpans(terms.flatMap(term => findSpans(excerpt, term)));
|
|
168
|
+
let snippet = '';
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
for (const span of spans) {
|
|
171
|
+
snippet += `${excerpt.slice(cursor, span.start)}[${excerpt.slice(span.start, span.end)}]`;
|
|
172
|
+
cursor = span.end;
|
|
173
|
+
}
|
|
174
|
+
snippet += excerpt.slice(cursor);
|
|
175
|
+
return { snippet, clipped: start > 0 || end < text.length };
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Full-text search across indexed history.
|
|
179
|
+
*
|
|
180
|
+
* Ordering is `bm25`, then newest, then entry id. `bm25` is chosen over plain recency because a
|
|
181
|
+
* search result list is read top-down and the caller wants the best match first, not merely the most
|
|
182
|
+
* recent one. Ranking alone is NOT a total order: equally scored rows (extremely common for short
|
|
183
|
+
* entries and bigram queries) come back in whatever order the scan produced, which would make
|
|
184
|
+
* `LIMIT`/`OFFSET` paging skip and duplicate rows between pages. Appending `e.time DESC, e.id ASC`
|
|
185
|
+
* supplies a deterministic total order without displacing relevance, since `entry.id` is unique and
|
|
186
|
+
* UNIQUE breaks every remaining tie. That is also why this is the one operation whose page order is
|
|
187
|
+
* stable across requests against an unchanged index.
|
|
188
|
+
* @param db - the open index connection.
|
|
189
|
+
* @param request - the search query.
|
|
190
|
+
* @param maxSnippetChars - largest excerpt the plugin will render per hit.
|
|
191
|
+
* @returns the page of hits, the pre-pagination total, and whether anything was cut.
|
|
192
|
+
*/
|
|
193
|
+
export function search(db, request, maxSnippetChars) {
|
|
194
|
+
const match = matchExpression(request.query);
|
|
195
|
+
// A query with no searchable token cannot match anything, and `entry_fts MATCH ''` is a syntax
|
|
196
|
+
// error rather than "zero rows"; the empty answer is the honest one.
|
|
197
|
+
if (match === undefined)
|
|
198
|
+
return { operation: 'search', hits: [], total: 0, truncated: false };
|
|
199
|
+
// An explicitly empty `kinds` list means "none of them", not "no filter": SQL `IN ()` is a syntax
|
|
200
|
+
// error, so the empty answer is also the only correct one.
|
|
201
|
+
if (request.kinds !== undefined && request.kinds.length === 0) {
|
|
202
|
+
return { operation: 'search', hits: [], total: 0, truncated: false };
|
|
203
|
+
}
|
|
204
|
+
const filters = ['entry_fts MATCH ?'];
|
|
205
|
+
const params = [match];
|
|
206
|
+
if (request.kinds !== undefined) {
|
|
207
|
+
filters.push(`e.kind IN (${request.kinds.map(() => '?').join(', ')})`);
|
|
208
|
+
params.push(...request.kinds);
|
|
209
|
+
}
|
|
210
|
+
const workspace = workspaceClause('s.workspace', request.workspace);
|
|
211
|
+
if (workspace !== undefined) {
|
|
212
|
+
filters.push(workspace.clause);
|
|
213
|
+
params.push(...workspace.params);
|
|
214
|
+
}
|
|
215
|
+
if (request.after !== undefined) {
|
|
216
|
+
filters.push('e.time >= ?');
|
|
217
|
+
params.push(request.after);
|
|
218
|
+
}
|
|
219
|
+
if (request.before !== undefined) {
|
|
220
|
+
filters.push('e.time <= ?');
|
|
221
|
+
params.push(request.before);
|
|
222
|
+
}
|
|
223
|
+
const from = `FROM entry_fts
|
|
224
|
+
JOIN entry e ON e.id = entry_fts.rowid
|
|
225
|
+
JOIN session s ON s.id = e.session_id
|
|
226
|
+
WHERE ${filters.join(' AND ')}`;
|
|
227
|
+
const total = count(db, `SELECT count(*) AS total ${from}`, params);
|
|
228
|
+
const limit = bound(request.limit);
|
|
229
|
+
const offset = bound(request.offset);
|
|
230
|
+
if (limit === 0) {
|
|
231
|
+
return { operation: 'search', hits: [], total, truncated: total > offset };
|
|
232
|
+
}
|
|
233
|
+
const found = rows(db, `SELECT e.session_id AS session_id, s.title AS title, s.workspace AS workspace,
|
|
234
|
+
e.time AS time, e.kind AS kind, e.seq AS seq, e.turn AS turn, e.text AS text
|
|
235
|
+
${from}
|
|
236
|
+
ORDER BY bm25(entry_fts), e.time DESC, e.id ASC
|
|
237
|
+
LIMIT ? OFFSET ?`, [...params, limit, offset]);
|
|
238
|
+
const terms = highlightTerms(request.query);
|
|
239
|
+
let clipped = false;
|
|
240
|
+
const hits = found.map((row) => {
|
|
241
|
+
const value = row['text'];
|
|
242
|
+
if (typeof value !== 'string') {
|
|
243
|
+
throw new SessiongraphError('an indexed entry has a non-text "text"', 'SESSIONGRAPH_CORRUPT');
|
|
244
|
+
}
|
|
245
|
+
const rendered = renderSnippet(value, terms, maxSnippetChars);
|
|
246
|
+
if (rendered.clipped)
|
|
247
|
+
clipped = true;
|
|
248
|
+
return { ...toHitSubject(row), snippet: rendered.snippet, length: value.length };
|
|
249
|
+
});
|
|
250
|
+
return { operation: 'search', hits, total, truncated: total > offset + hits.length || clipped };
|
|
251
|
+
}
|
|
252
|
+
/** Read a `session.id` column, refusing anything that is not text. */
|
|
253
|
+
function sessionIdOf(row) {
|
|
254
|
+
const value = row['id'];
|
|
255
|
+
if (typeof value !== 'string') {
|
|
256
|
+
throw new SessiongraphError('an indexed session has a non-text "id"', 'SESSIONGRAPH_CORRUPT');
|
|
257
|
+
}
|
|
258
|
+
return SessiongraphSessionId(value);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Resolve a full session id or an unambiguous prefix.
|
|
262
|
+
*
|
|
263
|
+
* An exact id always wins, even when it is also a prefix of another id, so a caller holding the real
|
|
264
|
+
* id is never told it is ambiguous. A prefix that matches several sessions raises
|
|
265
|
+
* `SESSIONGRAPH_NOT_FOUND` with the candidates listed: the seam has no "ambiguous" code, the
|
|
266
|
+
* caller's remedy is the same as for a miss (narrow the reference), and the message carries the
|
|
267
|
+
* information needed to do that.
|
|
268
|
+
* @param db - the open index connection.
|
|
269
|
+
* @param wanted - the requested id or prefix.
|
|
270
|
+
* @returns the one session id it names.
|
|
271
|
+
*/
|
|
272
|
+
function resolveSessionId(db, wanted) {
|
|
273
|
+
const reference = wanted.trim();
|
|
274
|
+
if (reference === '') {
|
|
275
|
+
throw new SessiongraphError('a session id must be a non-empty string', 'SESSIONGRAPH_NOT_FOUND');
|
|
276
|
+
}
|
|
277
|
+
const exact = rows(db, 'SELECT id FROM session WHERE id = ?', [reference])[0];
|
|
278
|
+
if (exact !== undefined)
|
|
279
|
+
return sessionIdOf(exact);
|
|
280
|
+
const candidates = rows(db, `SELECT id FROM session WHERE id LIKE ? ESCAPE '\\' ORDER BY last_at DESC, id ASC LIMIT 8`, [`${escapeLike(reference)}%`]);
|
|
281
|
+
if (candidates.length === 0) {
|
|
282
|
+
throw new SessiongraphError(`no indexed session matches "${reference}"`, 'SESSIONGRAPH_NOT_FOUND');
|
|
283
|
+
}
|
|
284
|
+
const [only] = candidates;
|
|
285
|
+
if (only !== undefined && candidates.length === 1)
|
|
286
|
+
return sessionIdOf(only);
|
|
287
|
+
const listing = candidates.map(row => sessionIdOf(row)).join(', ');
|
|
288
|
+
throw new SessiongraphError(`session prefix "${reference}" is ambiguous; it matches several sessions: ${listing}`, 'SESSIONGRAPH_NOT_FOUND');
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Read one session's metadata, a page of its entries in the session's own chronological order, and
|
|
292
|
+
* the sessions delegated from it.
|
|
293
|
+
* @param db - the open index connection.
|
|
294
|
+
* @param request - the session query.
|
|
295
|
+
* @returns the session summary, entry page, children, and whether more entries exist.
|
|
296
|
+
*/
|
|
297
|
+
export function session(db, request) {
|
|
298
|
+
const id = resolveSessionId(db, request.sessionId);
|
|
299
|
+
const summaryRow = rows(db, `SELECT ${SESSION_COLUMNS} FROM session WHERE id = ?`, [id])[0];
|
|
300
|
+
if (summaryRow === undefined) {
|
|
301
|
+
// Unreachable unless the index is being rewritten between two statements; report it as a miss
|
|
302
|
+
// rather than fabricating a summary.
|
|
303
|
+
throw new SessiongraphError(`no indexed session matches "${request.sessionId}"`, 'SESSIONGRAPH_NOT_FOUND');
|
|
304
|
+
}
|
|
305
|
+
const summary = toSessionSummary(summaryRow);
|
|
306
|
+
const limit = bound(request.limit);
|
|
307
|
+
const offset = bound(request.offset);
|
|
308
|
+
const total = count(db, 'SELECT count(*) AS total FROM entry WHERE session_id = ?', [id]);
|
|
309
|
+
const entries = limit === 0
|
|
310
|
+
? []
|
|
311
|
+
: rows(db, `SELECT ${ENTRY_COLUMNS} FROM entry WHERE session_id = ? ORDER BY seq ASC LIMIT ? OFFSET ?`, [id, limit, offset]).map(toEntry);
|
|
312
|
+
const children = rows(db, 'SELECT id FROM session WHERE parent_session = ? ORDER BY created_at ASC, id ASC', [id]).map(row => sessionIdOf(row));
|
|
313
|
+
return {
|
|
314
|
+
operation: 'session',
|
|
315
|
+
session: summary,
|
|
316
|
+
entries,
|
|
317
|
+
children,
|
|
318
|
+
truncated: total > offset + entries.length,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* List sessions newest-first by last activity.
|
|
323
|
+
* @param db - the open index connection.
|
|
324
|
+
* @param request - the timeline query.
|
|
325
|
+
* @returns the page of session summaries and whether more exist.
|
|
326
|
+
*/
|
|
327
|
+
export function timeline(db, request) {
|
|
328
|
+
const limit = bound(request.limit);
|
|
329
|
+
if (limit === 0)
|
|
330
|
+
return { operation: 'timeline', sessions: [], truncated: false };
|
|
331
|
+
const filters = [];
|
|
332
|
+
const params = [];
|
|
333
|
+
const workspace = workspaceClause('s.workspace', request.workspace);
|
|
334
|
+
if (workspace !== undefined) {
|
|
335
|
+
filters.push(workspace.clause);
|
|
336
|
+
params.push(...workspace.params);
|
|
337
|
+
}
|
|
338
|
+
// `after`/`before` describe the session's span, not an entry's time: `after` is "still active at or
|
|
339
|
+
// after", `before` is "already started at or before", so a call can ask for sessions alive in a
|
|
340
|
+
// window without knowing their internal event ordering.
|
|
341
|
+
if (request.after !== undefined) {
|
|
342
|
+
filters.push('s.last_at >= ?');
|
|
343
|
+
params.push(request.after);
|
|
344
|
+
}
|
|
345
|
+
if (request.before !== undefined) {
|
|
346
|
+
filters.push('s.created_at <= ?');
|
|
347
|
+
params.push(request.before);
|
|
348
|
+
}
|
|
349
|
+
const where = filters.length === 0 ? '' : `WHERE ${filters.join(' AND ')}`;
|
|
350
|
+
// One extra row distinguishes "exactly `limit` sessions" from "there are more" without a second
|
|
351
|
+
// count query.
|
|
352
|
+
const found = rows(db, `SELECT ${SESSION_COLUMNS} FROM session s ${where} ORDER BY s.last_at DESC, s.id ASC LIMIT ?`, [...params, limit + 1]);
|
|
353
|
+
const truncated = found.length > limit;
|
|
354
|
+
return {
|
|
355
|
+
operation: 'timeline',
|
|
356
|
+
sessions: (truncated ? found.slice(0, limit) : found).map(toSessionSummary),
|
|
357
|
+
truncated,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Enumerate indexed workspaces, most recently active first.
|
|
362
|
+
* @param db - the open index connection.
|
|
363
|
+
* @param request - the workspaces query.
|
|
364
|
+
* @returns the page of workspace statistics and the pre-limit total.
|
|
365
|
+
*/
|
|
366
|
+
export function workspaces(db, request) {
|
|
367
|
+
const total = count(db, 'SELECT count(DISTINCT workspace) AS total FROM session');
|
|
368
|
+
const limit = bound(request.limit);
|
|
369
|
+
if (limit === 0)
|
|
370
|
+
return { operation: 'workspaces', workspaces: [], total };
|
|
371
|
+
const found = rows(db, `SELECT workspace,
|
|
372
|
+
count(*) AS sessions,
|
|
373
|
+
COALESCE(sum(entries), 0) AS entries,
|
|
374
|
+
min(created_at) AS first_at,
|
|
375
|
+
max(last_at) AS last_at
|
|
376
|
+
FROM session
|
|
377
|
+
GROUP BY workspace
|
|
378
|
+
ORDER BY max(last_at) DESC, workspace ASC
|
|
379
|
+
LIMIT ?`, [limit]).map(toWorkspaceStat);
|
|
380
|
+
return { operation: 'workspaces', workspaces: found, total };
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Report the index's own state.
|
|
384
|
+
*
|
|
385
|
+
* This is the one operation that answers when no index exists: a caller that never indexed anything
|
|
386
|
+
* needs to learn that fact, and every other operation fails loud instead of pretending the store is
|
|
387
|
+
* empty. A file that exists but cannot be read is still `SESSIONGRAPH_CORRUPT` — `indexes()` already
|
|
388
|
+
* claimed the root, so quietly downgrading it to "not indexed" would hide a damaged index.
|
|
389
|
+
* @param pool - the store's connection pool.
|
|
390
|
+
* @param indexRoot - directory the seam routed the request to.
|
|
391
|
+
* @returns the index summary, or the zero result for a root that holds no index.
|
|
392
|
+
*/
|
|
393
|
+
export function status(pool, indexRoot) {
|
|
394
|
+
const indexPath = databasePath(indexRoot);
|
|
395
|
+
let bytes = 0;
|
|
396
|
+
try {
|
|
397
|
+
bytes = statSync(indexPath).size;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return {
|
|
401
|
+
operation: 'status',
|
|
402
|
+
indexed: false,
|
|
403
|
+
indexPath,
|
|
404
|
+
sessions: 0,
|
|
405
|
+
liveSessions: 0,
|
|
406
|
+
entries: 0,
|
|
407
|
+
workspaces: 0,
|
|
408
|
+
indexedAt: undefined,
|
|
409
|
+
bytes: 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const db = pool.acquire(indexRoot);
|
|
413
|
+
const indexedAtRow = rows(db, 'SELECT value FROM meta WHERE key = ?', [META_INDEXED_AT])[0];
|
|
414
|
+
const raw = indexedAtRow?.['value'];
|
|
415
|
+
const parsed = typeof raw === 'string' ? Number(raw) : typeof raw === 'number' ? raw : Number.NaN;
|
|
416
|
+
return {
|
|
417
|
+
operation: 'status',
|
|
418
|
+
indexed: true,
|
|
419
|
+
indexPath,
|
|
420
|
+
sessions: count(db, 'SELECT count(*) AS total FROM session'),
|
|
421
|
+
liveSessions: count(db, `SELECT count(*) AS total FROM session WHERE source_path LIKE '${LIVE_SOURCE_LIKE}'`),
|
|
422
|
+
entries: count(db, 'SELECT count(*) AS total FROM entry'),
|
|
423
|
+
workspaces: count(db, 'SELECT count(DISTINCT workspace) AS total FROM session'),
|
|
424
|
+
// Epoch milliseconds fit a double, so a valid `indexed_at` never loses precision here; a missing
|
|
425
|
+
// or non-numeric stamp reads as "never run" rather than as 0 (i.e. 1970).
|
|
426
|
+
indexedAt: Number.isFinite(parsed) ? parsed : undefined,
|
|
427
|
+
bytes,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
//# sourceMappingURL=queries.js.map
|
package/lib/rows.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-boundary mapping from SQLite rows to seam records. Every value crossing this module comes
|
|
3
|
+
* from a file an independently versioned indexer wrote, so each field is checked before it becomes
|
|
4
|
+
* a typed record; a row that violates the format fails loud as `SESSIONGRAPH_CORRUPT` rather than
|
|
5
|
+
* reaching a consumer as a plausible-looking wrong value.
|
|
6
|
+
*
|
|
7
|
+
* Optional columns (`title`, `turn`, `parent_session`, `origin`, `agent_preset`) become `undefined`,
|
|
8
|
+
* never `null`: the seam's contract is a TypeScript `string | number | undefined`, and a leaked
|
|
9
|
+
* `null` would render as the literal text "null" one layer up.
|
|
10
|
+
* @module dsh-plugin-sessiongraph-sqlite/rows
|
|
11
|
+
*/
|
|
12
|
+
import type { SessiongraphEntry, SessiongraphHit, SessiongraphSessionSummary, SessiongraphWorkspaceStat } from 'dsh-plugin-sessiongraph-service';
|
|
13
|
+
/** One row as `node:sqlite` returns it, before validation. */
|
|
14
|
+
export type IndexRow = Record<string, unknown>;
|
|
15
|
+
/**
|
|
16
|
+
* Build a {@link SessiongraphSessionSummary} from a `session` row.
|
|
17
|
+
* @param row - the raw row, selected with every column the summary reads.
|
|
18
|
+
* @returns the validated session record.
|
|
19
|
+
*/
|
|
20
|
+
export declare function toSessionSummary(row: IndexRow): SessiongraphSessionSummary;
|
|
21
|
+
/**
|
|
22
|
+
* Build a {@link SessiongraphEntry} from an `entry` row.
|
|
23
|
+
* @param row - the raw row selected as `seq, time, kind, turn, text, clipped`.
|
|
24
|
+
* @returns the validated entry record.
|
|
25
|
+
*/
|
|
26
|
+
export declare function toEntry(row: IndexRow): SessiongraphEntry;
|
|
27
|
+
/**
|
|
28
|
+
* Build the metadata half of a {@link SessiongraphHit}; the caller adds the rendered `snippet` and
|
|
29
|
+
* the entry's full `length`, because both are a function of the query and the plugin's snippet cap
|
|
30
|
+
* rather than of the stored row.
|
|
31
|
+
* @param row - the raw row selected as `session_id, title, workspace, time, kind, seq, turn`.
|
|
32
|
+
* @returns the validated hit fields.
|
|
33
|
+
*/
|
|
34
|
+
export declare function toHitSubject(row: IndexRow): Omit<SessiongraphHit, 'snippet' | 'length'>;
|
|
35
|
+
/**
|
|
36
|
+
* Build a {@link SessiongraphWorkspaceStat} from a grouped `session` row.
|
|
37
|
+
* @param row - the raw row selected as `workspace, sessions, entries, first_at, last_at`.
|
|
38
|
+
* @returns the validated workspace record.
|
|
39
|
+
*/
|
|
40
|
+
export declare function toWorkspaceStat(row: IndexRow): SessiongraphWorkspaceStat;
|
|
41
|
+
//# sourceMappingURL=rows.d.ts.map
|
package/lib/rows.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable-boundary mapping from SQLite rows to seam records. Every value crossing this module comes
|
|
3
|
+
* from a file an independently versioned indexer wrote, so each field is checked before it becomes
|
|
4
|
+
* a typed record; a row that violates the format fails loud as `SESSIONGRAPH_CORRUPT` rather than
|
|
5
|
+
* reaching a consumer as a plausible-looking wrong value.
|
|
6
|
+
*
|
|
7
|
+
* Optional columns (`title`, `turn`, `parent_session`, `origin`, `agent_preset`) become `undefined`,
|
|
8
|
+
* never `null`: the seam's contract is a TypeScript `string | number | undefined`, and a leaked
|
|
9
|
+
* `null` would render as the literal text "null" one layer up.
|
|
10
|
+
* @module dsh-plugin-sessiongraph-sqlite/rows
|
|
11
|
+
*/
|
|
12
|
+
import { SESSIONGRAPH_ENTRY_KINDS, SessiongraphError, SessiongraphSessionId, } from 'dsh-plugin-sessiongraph-service';
|
|
13
|
+
/**
|
|
14
|
+
* Read a required text column.
|
|
15
|
+
* @param row - the raw row.
|
|
16
|
+
* @param column - the column name.
|
|
17
|
+
* @param what - the record being built, for the failure message.
|
|
18
|
+
* @returns the column's string value.
|
|
19
|
+
*/
|
|
20
|
+
function text(row, column, what) {
|
|
21
|
+
const value = row[column];
|
|
22
|
+
if (typeof value !== 'string') {
|
|
23
|
+
throw new SessiongraphError(`${what} has a non-text "${column}"`, 'SESSIONGRAPH_CORRUPT');
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read a required integer column.
|
|
29
|
+
* @param row - the raw row.
|
|
30
|
+
* @param column - the column name.
|
|
31
|
+
* @param what - the record being built, for the failure message.
|
|
32
|
+
* @returns the column's numeric value.
|
|
33
|
+
*/
|
|
34
|
+
function integer(row, column, what) {
|
|
35
|
+
const value = row[column];
|
|
36
|
+
if (typeof value === 'number')
|
|
37
|
+
return value;
|
|
38
|
+
// A stored INTEGER outside the safe range never reaches here: `node:sqlite` raises a RangeError
|
|
39
|
+
// while reading the row, because this store opens connections without bigint reads.
|
|
40
|
+
throw new SessiongraphError(`${what} has a non-integer "${column}"`, 'SESSIONGRAPH_CORRUPT');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Read an optional text column, treating SQL NULL and the empty string alike as absent.
|
|
44
|
+
* @param row - the raw row.
|
|
45
|
+
* @param column - the column name.
|
|
46
|
+
* @param what - the record being built, for the failure message.
|
|
47
|
+
* @returns the column's string value, or undefined when absent.
|
|
48
|
+
*/
|
|
49
|
+
function optionalText(row, column, what) {
|
|
50
|
+
const value = row[column];
|
|
51
|
+
if (value === null || value === undefined || value === '')
|
|
52
|
+
return undefined;
|
|
53
|
+
if (typeof value !== 'string') {
|
|
54
|
+
throw new SessiongraphError(`${what} has a non-text "${column}"`, 'SESSIONGRAPH_CORRUPT');
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Read an optional integer column.
|
|
60
|
+
* @param row - the raw row.
|
|
61
|
+
* @param column - the column name.
|
|
62
|
+
* @param what - the record being built, for the failure message.
|
|
63
|
+
* @returns the column's numeric value, or undefined when NULL.
|
|
64
|
+
*/
|
|
65
|
+
function optionalInteger(row, column, what) {
|
|
66
|
+
const value = row[column];
|
|
67
|
+
if (value === null || value === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
return integer(row, column, what);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Read a boolean stored as INTEGER 0/1.
|
|
73
|
+
* @param row - the raw row.
|
|
74
|
+
* @param column - the column name.
|
|
75
|
+
* @param what - the record being built, for the failure message.
|
|
76
|
+
* @returns whether the flag is set.
|
|
77
|
+
*/
|
|
78
|
+
function flag(row, column, what) {
|
|
79
|
+
return integer(row, column, what) !== 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Read an entry kind, refusing a value this schema version does not define.
|
|
83
|
+
* @param row - the raw row.
|
|
84
|
+
* @param what - the record being built, for the failure message.
|
|
85
|
+
* @returns the validated kind.
|
|
86
|
+
*/
|
|
87
|
+
function entryKind(row, what) {
|
|
88
|
+
const value = text(row, 'kind', what);
|
|
89
|
+
if (!SESSIONGRAPH_ENTRY_KINDS.includes(value)) {
|
|
90
|
+
throw new SessiongraphError(`${what} has an unknown entry kind "${value}"`, 'SESSIONGRAPH_CORRUPT');
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build a {@link SessiongraphSessionSummary} from a `session` row.
|
|
96
|
+
* @param row - the raw row, selected with every column the summary reads.
|
|
97
|
+
* @returns the validated session record.
|
|
98
|
+
*/
|
|
99
|
+
export function toSessionSummary(row) {
|
|
100
|
+
const what = 'an indexed session';
|
|
101
|
+
const id = text(row, 'id', what);
|
|
102
|
+
const named = `indexed session "${id}"`;
|
|
103
|
+
const title = optionalText(row, 'title', named);
|
|
104
|
+
const parentSession = optionalText(row, 'parent_session', named);
|
|
105
|
+
const origin = optionalText(row, 'origin', named);
|
|
106
|
+
const agentPreset = optionalText(row, 'agent_preset', named);
|
|
107
|
+
return {
|
|
108
|
+
sessionId: SessiongraphSessionId(id),
|
|
109
|
+
title,
|
|
110
|
+
workspace: text(row, 'workspace', named),
|
|
111
|
+
createdAt: integer(row, 'created_at', named),
|
|
112
|
+
lastAt: integer(row, 'last_at', named),
|
|
113
|
+
turns: integer(row, 'turns', named),
|
|
114
|
+
entries: integer(row, 'entries', named),
|
|
115
|
+
parentSession,
|
|
116
|
+
origin,
|
|
117
|
+
agentPreset,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Build a {@link SessiongraphEntry} from an `entry` row.
|
|
122
|
+
* @param row - the raw row selected as `seq, time, kind, turn, text, clipped`.
|
|
123
|
+
* @returns the validated entry record.
|
|
124
|
+
*/
|
|
125
|
+
export function toEntry(row) {
|
|
126
|
+
const what = 'an indexed entry';
|
|
127
|
+
return {
|
|
128
|
+
seq: integer(row, 'seq', what),
|
|
129
|
+
time: integer(row, 'time', what),
|
|
130
|
+
kind: entryKind(row, what),
|
|
131
|
+
turn: optionalInteger(row, 'turn', what),
|
|
132
|
+
text: text(row, 'text', what),
|
|
133
|
+
clipped: flag(row, 'clipped', what),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the metadata half of a {@link SessiongraphHit}; the caller adds the rendered `snippet` and
|
|
138
|
+
* the entry's full `length`, because both are a function of the query and the plugin's snippet cap
|
|
139
|
+
* rather than of the stored row.
|
|
140
|
+
* @param row - the raw row selected as `session_id, title, workspace, time, kind, seq, turn`.
|
|
141
|
+
* @returns the validated hit fields.
|
|
142
|
+
*/
|
|
143
|
+
export function toHitSubject(row) {
|
|
144
|
+
const what = 'a search hit';
|
|
145
|
+
return {
|
|
146
|
+
sessionId: SessiongraphSessionId(text(row, 'session_id', what)),
|
|
147
|
+
title: optionalText(row, 'title', what),
|
|
148
|
+
workspace: text(row, 'workspace', what),
|
|
149
|
+
time: integer(row, 'time', what),
|
|
150
|
+
kind: entryKind(row, what),
|
|
151
|
+
seq: integer(row, 'seq', what),
|
|
152
|
+
turn: optionalInteger(row, 'turn', what),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build a {@link SessiongraphWorkspaceStat} from a grouped `session` row.
|
|
157
|
+
* @param row - the raw row selected as `workspace, sessions, entries, first_at, last_at`.
|
|
158
|
+
* @returns the validated workspace record.
|
|
159
|
+
*/
|
|
160
|
+
export function toWorkspaceStat(row) {
|
|
161
|
+
const what = 'an indexed workspace';
|
|
162
|
+
return {
|
|
163
|
+
workspace: text(row, 'workspace', what),
|
|
164
|
+
sessions: integer(row, 'sessions', what),
|
|
165
|
+
entries: integer(row, 'entries', what),
|
|
166
|
+
firstAt: integer(row, 'first_at', what),
|
|
167
|
+
lastAt: integer(row, 'last_at', what),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
//# sourceMappingURL=rows.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-sessiongraph-sqlite",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Read-only SQLite store for the DeepSeek Harness session-graph seam — answers search/session/timeline/workspaces/status from the on-disk index at <indexRoot>/index.db without ever writing to it",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"cordis-plugin",
|
|
11
|
+
"session",
|
|
12
|
+
"session-history",
|
|
13
|
+
"search",
|
|
14
|
+
"fts",
|
|
15
|
+
"bigram",
|
|
16
|
+
"sqlite",
|
|
17
|
+
"ai-agent"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "CC ZHAO",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/CC19990113/dsh-plugin-sessiongraph.git",
|
|
24
|
+
"directory": "packages/sqlite"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://github.com/CC19990113/dsh-plugin-sessiongraph#readme",
|
|
27
|
+
"bugs": "https://github.com/CC19990113/dsh-plugin-sessiongraph/issues",
|
|
28
|
+
"type": "module",
|
|
29
|
+
"main": "lib/index.js",
|
|
30
|
+
"types": "lib/index.d.ts",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": {
|
|
33
|
+
"types": "./lib/index.d.ts",
|
|
34
|
+
"default": "./lib/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./invariant": {
|
|
37
|
+
"types": "./lib/invariant.d.ts",
|
|
38
|
+
"default": "./lib/invariant.js"
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"lib/**/*.js",
|
|
44
|
+
"lib/**/*.d.ts"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": false
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@deepseek-ai/schemastery": "^3.18.1",
|
|
52
|
+
"dsh-plugin-sessiongraph-service": "^0.1.1"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": ">=4.0.1",
|
|
56
|
+
"@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6",
|
|
57
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
61
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
|
|
62
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6"
|
|
63
|
+
}
|
|
6
64
|
}
|
package/README.md
DELETED