dsh-plugin-sessiongraph-sessions 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/index.d.ts +186 -0
- package/lib/index.js +706 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/live.d.ts +81 -0
- package/lib/live.js +97 -0
- package/lib/parse.d.ts +107 -0
- package/lib/parse.js +445 -0
- package/lib/scan.d.ts +33 -0
- package/lib/scan.js +121 -0
- package/lib/write.d.ts +95 -0
- package/lib/write.js +309 -0
- package/package.json +62 -3
- package/README.md +0 -3
package/lib/write.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write the session index with `node:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* Two modes, deliberately different on disk:
|
|
5
|
+
*
|
|
6
|
+
* - **Rebuild** (`force`) writes a complete index to `index.db.tmp` and renames it over `index.db`
|
|
7
|
+
* only after the transaction commits, so a reader never observes a half-built index and a run that
|
|
8
|
+
* dies leaves the previous one untouched. When the run is also scoped (a workspace or single
|
|
9
|
+
* session was requested), the previous index is attached and copied in first, because a scoped
|
|
10
|
+
* rebuild must not delete the sessions it was not asked about.
|
|
11
|
+
* - **Incremental** opens the live index and rewrites only the sessions whose source changed, inside
|
|
12
|
+
* one transaction.
|
|
13
|
+
*
|
|
14
|
+
* `entry_fts.rowid` is aligned with `entry.id`: every written entry row is followed by exactly one
|
|
15
|
+
* FTS row carrying the tokenizer's output. Deletion therefore goes FTS-first — the entry rows are
|
|
16
|
+
* what names the rowids, so dropping them first would strand the FTS rows.
|
|
17
|
+
* @module dsh-plugin-sessiongraph-sessions/write
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
22
|
+
import { ENTRY_FTS_TABLE, ENTRY_TABLE, META_DISCOVERED, META_FAILED, META_INDEXED_AT, META_SCHEMA_VERSION, SCHEMA_STATEMENTS, SCHEMA_VERSION, SESSION_TABLE, tokenize, } from 'dsh-plugin-sessiongraph-service';
|
|
23
|
+
/**
|
|
24
|
+
* Sidecar summary an indexer leaves beside `index.db`.
|
|
25
|
+
*
|
|
26
|
+
* Prompt assembly must never open SQLite, so the same post-run totals the report carries are also
|
|
27
|
+
* persisted as a small JSON file the tool can read synchronously. It is a derived copy: the `meta`
|
|
28
|
+
* table stays authoritative for the store, and a missing sidecar only degrades the prompt line.
|
|
29
|
+
*/
|
|
30
|
+
export const STATUS_RELATIVE_PATH = 'status.json';
|
|
31
|
+
/**
|
|
32
|
+
* Read the state a previous index recorded, when it is usable.
|
|
33
|
+
*
|
|
34
|
+
* An index written by another schema version is refused rather than reused: silently mixing two
|
|
35
|
+
* shapes would hand the store rows it cannot interpret.
|
|
36
|
+
* @param indexPath - absolute path of `index.db`.
|
|
37
|
+
* @returns rows keyed by source path, or undefined when there is no usable index.
|
|
38
|
+
*/
|
|
39
|
+
export function readIndexState(indexPath) {
|
|
40
|
+
if (!existsSync(indexPath))
|
|
41
|
+
return undefined;
|
|
42
|
+
let database;
|
|
43
|
+
try {
|
|
44
|
+
database = new DatabaseSync(indexPath, { readOnly: true });
|
|
45
|
+
const version = database.prepare(`SELECT value FROM meta WHERE key = ?`).get(META_SCHEMA_VERSION);
|
|
46
|
+
if (version === undefined || String(version.value) !== String(SCHEMA_VERSION))
|
|
47
|
+
return undefined;
|
|
48
|
+
const rows = database
|
|
49
|
+
.prepare(`SELECT id, workspace, source_path, source_mtime, source_size FROM ${SESSION_TABLE}`)
|
|
50
|
+
.all();
|
|
51
|
+
const existing = new Map();
|
|
52
|
+
for (const row of rows) {
|
|
53
|
+
const id = stringColumn(row, 'id');
|
|
54
|
+
const sourcePath = stringColumn(row, 'source_path');
|
|
55
|
+
if (id === undefined || sourcePath === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
existing.set(sourcePath, {
|
|
58
|
+
id,
|
|
59
|
+
workspace: stringColumn(row, 'workspace') ?? '',
|
|
60
|
+
sourcePath,
|
|
61
|
+
mtime: numberColumn(row, 'source_mtime') ?? 0,
|
|
62
|
+
size: numberColumn(row, 'source_size') ?? 0,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return existing;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
database?.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Write one index update.
|
|
76
|
+
* @param plan - sessions to write, sessions to drop, and how to place the result.
|
|
77
|
+
* @returns what the index holds after the write.
|
|
78
|
+
*/
|
|
79
|
+
export function writeIndex(plan) {
|
|
80
|
+
mkdirSync(dirname(plan.indexPath), { recursive: true });
|
|
81
|
+
if (plan.force) {
|
|
82
|
+
const temporaryPath = `${plan.indexPath}.tmp`;
|
|
83
|
+
removeDatabaseFiles(temporaryPath);
|
|
84
|
+
const database = new DatabaseSync(temporaryPath);
|
|
85
|
+
try {
|
|
86
|
+
createSchema(database);
|
|
87
|
+
writeAll(database, plan);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
database.close();
|
|
91
|
+
removeDatabaseFiles(temporaryPath);
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
database.close();
|
|
95
|
+
renameSync(temporaryPath, plan.indexPath);
|
|
96
|
+
const summary = summarize(plan.indexPath);
|
|
97
|
+
writeStatusFile(plan.indexPath, plan.indexedAt, summary);
|
|
98
|
+
return summary;
|
|
99
|
+
}
|
|
100
|
+
const database = new DatabaseSync(plan.indexPath);
|
|
101
|
+
try {
|
|
102
|
+
createSchema(database);
|
|
103
|
+
writeAll(database, plan);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
database.close();
|
|
107
|
+
}
|
|
108
|
+
const summary = summarize(plan.indexPath);
|
|
109
|
+
writeStatusFile(plan.indexPath, plan.indexedAt, summary);
|
|
110
|
+
return summary;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Create every table and index the shared schema declares.
|
|
114
|
+
* @param database - open index database.
|
|
115
|
+
*/
|
|
116
|
+
function createSchema(database) {
|
|
117
|
+
for (const statement of SCHEMA_STATEMENTS)
|
|
118
|
+
database.exec(statement);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Write the sidecar summary the prompt-assembly path reads.
|
|
122
|
+
*
|
|
123
|
+
* Written only after the index transaction committed and the file is in place, through a temporary
|
|
124
|
+
* file and a rename: a reader polling the sidecar sees either the previous run's complete JSON or
|
|
125
|
+
* this run's, never a half-written one.
|
|
126
|
+
* @param indexPath - absolute path of the written index.
|
|
127
|
+
* @param indexedAt - epoch milliseconds of this run.
|
|
128
|
+
* @param summary - totals the index now holds.
|
|
129
|
+
*/
|
|
130
|
+
function writeStatusFile(indexPath, indexedAt, summary) {
|
|
131
|
+
const statusPath = join(dirname(indexPath), STATUS_RELATIVE_PATH);
|
|
132
|
+
const temporaryPath = `${statusPath}.tmp`;
|
|
133
|
+
const document = {
|
|
134
|
+
schemaVersion: SCHEMA_VERSION,
|
|
135
|
+
indexedAt,
|
|
136
|
+
sessions: summary.sessions,
|
|
137
|
+
entries: summary.entries,
|
|
138
|
+
workspaces: summary.workspaces,
|
|
139
|
+
bytes: summary.bytes,
|
|
140
|
+
};
|
|
141
|
+
writeFileSync(temporaryPath, `${JSON.stringify(document)}\n`);
|
|
142
|
+
renameSync(temporaryPath, statusPath);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Apply the plan inside one transaction.
|
|
146
|
+
* @param database - open index database.
|
|
147
|
+
* @param plan - what to write.
|
|
148
|
+
*/
|
|
149
|
+
function writeAll(database, plan) {
|
|
150
|
+
// Only a rebuild seeds itself from the previous index. An incremental run writes into the live
|
|
151
|
+
// database, which is the same file a preserved copy would be attached from, so there is nothing to
|
|
152
|
+
// copy and attaching it would lock the connection against its own transaction.
|
|
153
|
+
const preserve = plan.force && plan.preserveExisting && existsSync(plan.indexPath);
|
|
154
|
+
if (preserve)
|
|
155
|
+
database.exec(`ATTACH DATABASE '${escapeSqlLiteral(plan.indexPath)}' AS existing`);
|
|
156
|
+
try {
|
|
157
|
+
database.exec('BEGIN IMMEDIATE');
|
|
158
|
+
try {
|
|
159
|
+
if (preserve)
|
|
160
|
+
copyExisting(database);
|
|
161
|
+
const removeFts = database.prepare(`DELETE FROM ${ENTRY_FTS_TABLE} WHERE rowid IN (SELECT id FROM ${ENTRY_TABLE} WHERE session_id = ?)`);
|
|
162
|
+
const removeEntries = database.prepare(`DELETE FROM ${ENTRY_TABLE} WHERE session_id = ?`);
|
|
163
|
+
const removeSession = database.prepare(`DELETE FROM ${SESSION_TABLE} WHERE id = ?`);
|
|
164
|
+
for (const id of plan.removed) {
|
|
165
|
+
removeFts.run(id);
|
|
166
|
+
removeEntries.run(id);
|
|
167
|
+
removeSession.run(id);
|
|
168
|
+
}
|
|
169
|
+
const removeFtsForSource = database.prepare(`DELETE FROM ${ENTRY_FTS_TABLE} WHERE rowid IN (SELECT id FROM ${ENTRY_TABLE}
|
|
170
|
+
WHERE session_id IN (SELECT id FROM ${SESSION_TABLE} WHERE id = ? OR source_path = ?))`);
|
|
171
|
+
const removeEntriesForSource = database.prepare(`DELETE FROM ${ENTRY_TABLE}
|
|
172
|
+
WHERE session_id IN (SELECT id FROM ${SESSION_TABLE} WHERE id = ? OR source_path = ?)`);
|
|
173
|
+
const removeSessionForSource = database.prepare(`DELETE FROM ${SESSION_TABLE} WHERE id = ? OR source_path = ?`);
|
|
174
|
+
const insertSession = database.prepare(`INSERT INTO ${SESSION_TABLE}
|
|
175
|
+
(id, workspace, title, created_at, last_at, parent_session, origin, agent_preset, turns, entries,
|
|
176
|
+
source_path, source_mtime, source_size)
|
|
177
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
178
|
+
const insertEntry = database.prepare(`INSERT INTO ${ENTRY_TABLE} (session_id, seq, time, turn, kind, text, clipped) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
|
179
|
+
const insertFts = database.prepare(`INSERT INTO ${ENTRY_FTS_TABLE} (rowid, tokens) VALUES (?, ?)`);
|
|
180
|
+
for (const item of plan.sessions) {
|
|
181
|
+
const { session, sourcePath, mtime, size } = item;
|
|
182
|
+
// Keyed by id AND source path: a session whose id changed between runs must not leave its
|
|
183
|
+
// old row behind, and the source path is the stable half of the pair.
|
|
184
|
+
removeFtsForSource.run(session.id, sourcePath);
|
|
185
|
+
removeEntriesForSource.run(session.id, sourcePath);
|
|
186
|
+
removeSessionForSource.run(session.id, sourcePath);
|
|
187
|
+
insertSession.run(session.id, session.cwd, session.title ?? null, session.createdAt, session.lastAt, session.parentSession ?? null, session.origin ?? null, session.agentPreset ?? null, session.turns, session.entries.length, sourcePath, mtime, size);
|
|
188
|
+
for (const entry of session.entries) {
|
|
189
|
+
const result = insertEntry.run(session.id, entry.seq, entry.time, entry.turn ?? null, entry.kind, entry.text, entry.clipped ? 1 : 0);
|
|
190
|
+
insertFts.run(Number(result.lastInsertRowid), tokenize(entry.text).join(' '));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const upsertMeta = database.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
|
194
|
+
upsertMeta.run(META_SCHEMA_VERSION, String(SCHEMA_VERSION));
|
|
195
|
+
upsertMeta.run(META_INDEXED_AT, String(plan.indexedAt));
|
|
196
|
+
upsertMeta.run(META_DISCOVERED, String(plan.discovered));
|
|
197
|
+
upsertMeta.run(META_FAILED, String(plan.failed));
|
|
198
|
+
database.exec('COMMIT');
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
rollback(database);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
if (preserve) {
|
|
207
|
+
try {
|
|
208
|
+
database.exec('DETACH DATABASE existing');
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// The connection is about to close; a failed detach cannot corrupt the committed index.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Copy the previous index's rows into a scoped rebuild.
|
|
218
|
+
* @param database - the temporary database, with the previous index attached as `existing`.
|
|
219
|
+
*/
|
|
220
|
+
function copyExisting(database) {
|
|
221
|
+
database.exec(`INSERT INTO ${SESSION_TABLE} SELECT * FROM existing.${SESSION_TABLE}`);
|
|
222
|
+
database.exec(`INSERT INTO ${ENTRY_TABLE} SELECT * FROM existing.${ENTRY_TABLE}`);
|
|
223
|
+
database.exec(`INSERT INTO ${ENTRY_FTS_TABLE} (rowid, tokens) SELECT rowid, tokens FROM existing.${ENTRY_FTS_TABLE}`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Count what the index now holds and measure it.
|
|
227
|
+
*
|
|
228
|
+
* `entries` is the index's total, not this run's writes: it is reported beside `sessions`,
|
|
229
|
+
* `workspaces` and `bytes`, which are all post-run totals, and a caller reading the report is
|
|
230
|
+
* asking what the index now contains. What this run changed is already in
|
|
231
|
+
* `added`/`updated`/`skipped`/`removed`.
|
|
232
|
+
* @param indexPath - absolute path of the written index.
|
|
233
|
+
* @returns the totals the report carries.
|
|
234
|
+
*/
|
|
235
|
+
function summarize(indexPath) {
|
|
236
|
+
const database = new DatabaseSync(indexPath, { readOnly: true });
|
|
237
|
+
try {
|
|
238
|
+
const sessions = count(database, `SELECT COUNT(*) AS n FROM ${SESSION_TABLE}`);
|
|
239
|
+
const entries = count(database, `SELECT COUNT(*) AS n FROM ${ENTRY_TABLE}`);
|
|
240
|
+
const workspaces = count(database, `SELECT COUNT(DISTINCT workspace) AS n FROM ${SESSION_TABLE}`);
|
|
241
|
+
return { entries, sessions, workspaces, bytes: statSync(indexPath).size };
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
database.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Run a counting query.
|
|
249
|
+
* @param database - open index database.
|
|
250
|
+
* @param sql - query returning one `n` column.
|
|
251
|
+
* @returns the count.
|
|
252
|
+
*/
|
|
253
|
+
function count(database, sql) {
|
|
254
|
+
const row = database.prepare(sql).get();
|
|
255
|
+
return row === undefined ? 0 : (numberColumn(row, 'n') ?? 0);
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Roll a transaction back without masking the error that caused it.
|
|
259
|
+
* @param database - open index database.
|
|
260
|
+
*/
|
|
261
|
+
function rollback(database) {
|
|
262
|
+
try {
|
|
263
|
+
database.exec('ROLLBACK');
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// The transaction was already resolved; the caller rethrows the original failure.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Delete an index file and any journal/WAL siblings a previous run left behind.
|
|
271
|
+
* @param path - index file path.
|
|
272
|
+
*/
|
|
273
|
+
function removeDatabaseFiles(path) {
|
|
274
|
+
for (const suffix of ['', '-journal', '-wal', '-shm'])
|
|
275
|
+
rmSync(`${path}${suffix}`, { force: true });
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Quote a value for inlining into a statement SQLite will not bind a parameter to (`ATTACH`).
|
|
279
|
+
* @param value - raw string.
|
|
280
|
+
* @returns the single-quoted literal.
|
|
281
|
+
*/
|
|
282
|
+
function escapeSqlLiteral(value) {
|
|
283
|
+
return value.replaceAll("'", "''");
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Read a string column from a prototype-less SQLite row.
|
|
287
|
+
* @param row - result row.
|
|
288
|
+
* @param key - column name.
|
|
289
|
+
* @returns the string, or undefined.
|
|
290
|
+
*/
|
|
291
|
+
function stringColumn(row, key) {
|
|
292
|
+
const value = row[key];
|
|
293
|
+
return typeof value === 'string' ? value : undefined;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Read a numeric column from a prototype-less SQLite row.
|
|
297
|
+
* @param row - result row.
|
|
298
|
+
* @param key - column name.
|
|
299
|
+
* @returns the number, or undefined.
|
|
300
|
+
*/
|
|
301
|
+
function numberColumn(row, key) {
|
|
302
|
+
const value = row[key];
|
|
303
|
+
if (typeof value === 'number')
|
|
304
|
+
return value;
|
|
305
|
+
if (typeof value === 'bigint')
|
|
306
|
+
return Number(value);
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
//# sourceMappingURL=write.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-sessiongraph-sessions",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Session-log indexer for DeepSeek Harness — scans ~/.dsh/sessions, decompresses the multi-frame zstd JSONL logs and writes the sessiongraph SQLite index the store reads",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"deepseek-harness",
|
|
9
|
+
"cordis",
|
|
10
|
+
"cordis-plugin",
|
|
11
|
+
"sessiongraph",
|
|
12
|
+
"session",
|
|
13
|
+
"session-history",
|
|
14
|
+
"indexer",
|
|
15
|
+
"zstd",
|
|
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/sessions"
|
|
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
|
+
"dsh-plugin-sessiongraph-native": "^0.1.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": ">=4.0.1",
|
|
57
|
+
"@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6",
|
|
58
|
+
"@deepseek-ai/dsh-llm": ">=0.1.0-rc.6"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
62
|
+
"@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
|
|
63
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.6"
|
|
64
|
+
}
|
|
6
65
|
}
|
package/README.md
DELETED