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 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
@@ -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
@@ -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
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.