dsh-plugin-sessiongraph-sessions 0.0.0-stage → 0.1.0
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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `dsh-plugin-sessiongraph-sessions`.
|
|
3
|
+
* @module dsh-plugin-sessiongraph-sessions/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "sessiongraph-sessions-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-sessions`.
|
|
3
|
+
* @module dsh-plugin-sessiongraph-sessions/invariant
|
|
4
|
+
*/
|
|
5
|
+
const PACKAGE_NAME = 'dsh-plugin-sessiongraph-sessions';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export const name = 'sessiongraph-sessions-invariant';
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export const inject = ['invariants'];
|
|
10
|
+
/**
|
|
11
|
+
* No runtime invariant: an indexer is a write path with no enumerable owned relation — it publishes
|
|
12
|
+
* no service, holds no cross-plugin registry, and its only output is a file the store verifies
|
|
13
|
+
* through `meta.schema_version`.
|
|
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/live.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index the sessions the host still holds in memory.
|
|
3
|
+
*
|
|
4
|
+
* A session log is written when the session ends (or is checkpointed), so the conversation running
|
|
5
|
+
* right now is invisible to a file scan: asking "where did I say X" mid-session would miss the very
|
|
6
|
+
* words just typed. The host's `ctx.sessionQuery` can list live sessions and replay their event log,
|
|
7
|
+
* which is the one thing a file scanner cannot see.
|
|
8
|
+
*
|
|
9
|
+
* This is a **supplement**, never a second path over the same data: file scanning, multi-frame
|
|
10
|
+
* decompression and mtime-based incrementality stay the primary source, and this module only adds
|
|
11
|
+
* the handful of sessions that are still moving. The host interface is declared structurally rather
|
|
12
|
+
* than imported from a `@deepseek-ai/dsh-*` package — a host utility that exists in one DSH line and
|
|
13
|
+
* not another turns into a load-time failure that removes the whole plugin.
|
|
14
|
+
* @module dsh-plugin-sessiongraph-sessions/live
|
|
15
|
+
*/
|
|
16
|
+
import type { SessionParseLimits } from './parse.ts';
|
|
17
|
+
import type { IndexedSession } from './write.ts';
|
|
18
|
+
/**
|
|
19
|
+
* Prefix of the synthetic source path a live session is recorded under. It is not a filesystem
|
|
20
|
+
* path: the row is rebuilt from the in-memory log on every run, and the marker is what lets the
|
|
21
|
+
* file scanner tell those rows apart from ones a vanished log should remove.
|
|
22
|
+
*/
|
|
23
|
+
export declare const LIVE_SOURCE_PREFIX = "live://";
|
|
24
|
+
/**
|
|
25
|
+
* The slice of the host's `sessionQuery` service this indexer uses.
|
|
26
|
+
*
|
|
27
|
+
* `listSessions` answers metadata only; `readSession` replays one session's event log, returning the
|
|
28
|
+
* header, the count of events inherited from a parent context, and the events themselves — the same
|
|
29
|
+
* shape the on-disk JSONL carries after its header line.
|
|
30
|
+
*/
|
|
31
|
+
export interface SessionQueryService {
|
|
32
|
+
/**
|
|
33
|
+
* List every session the host knows about, newest first, with a `live` flag on the in-memory ones.
|
|
34
|
+
* @returns one entry per session.
|
|
35
|
+
*/
|
|
36
|
+
listSessions(): Promise<readonly unknown[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Read one session's header and full event log.
|
|
39
|
+
* @param sessionId - the session to replay.
|
|
40
|
+
* @returns the session record, as the host assembles it.
|
|
41
|
+
*/
|
|
42
|
+
readSession(sessionId: string): Promise<unknown>;
|
|
43
|
+
}
|
|
44
|
+
/** Bounds and filters the live pass shares with the file pass. */
|
|
45
|
+
export interface LiveIndexOptions {
|
|
46
|
+
/** Most live sessions to rebuild in one run; the rest are reported as skipped. */
|
|
47
|
+
readonly maxLiveSessions: number;
|
|
48
|
+
/** Entry-text bounds, identical to the file path's. */
|
|
49
|
+
readonly limits: SessionParseLimits;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a session is in the run's scope. The same predicate the file path applies, so a scoped
|
|
52
|
+
* index covers a live session and its later file under exactly the same rules.
|
|
53
|
+
*/
|
|
54
|
+
readonly inScope: (workspace: string, sessionId: string) => boolean;
|
|
55
|
+
}
|
|
56
|
+
/** What the live pass collected. */
|
|
57
|
+
export interface LiveIndexResult {
|
|
58
|
+
/** Sessions to (re)write, already parsed. */
|
|
59
|
+
readonly sessions: IndexedSession[];
|
|
60
|
+
/** Live sessions left out because of {@link LiveIndexOptions.maxLiveSessions}. */
|
|
61
|
+
readonly skipped: number;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Collect and parse the live sessions in scope.
|
|
65
|
+
*
|
|
66
|
+
* A live session has no stable version — its log grows with every step — so it is replayed in full
|
|
67
|
+
* on every run rather than compared against a recorded mtime. That is cheap only because there are
|
|
68
|
+
* normally one or two of them, which is why the cap exists: a host that reports dozens of live
|
|
69
|
+
* sessions must not turn one index run into dozens of log replays.
|
|
70
|
+
* @param query - the host's session-query service.
|
|
71
|
+
* @param options - the cap, entry bounds, and scope predicate.
|
|
72
|
+
* @returns the parsed sessions to write, and how many the cap left out.
|
|
73
|
+
*/
|
|
74
|
+
export declare function collectLiveSessions(query: SessionQueryService, options: LiveIndexOptions): Promise<LiveIndexResult>;
|
|
75
|
+
/**
|
|
76
|
+
* The synthetic source path a live session is recorded under.
|
|
77
|
+
* @param sessionId - the session id.
|
|
78
|
+
* @returns the marker path.
|
|
79
|
+
*/
|
|
80
|
+
export declare function liveSourcePath(sessionId: string): string;
|
|
81
|
+
//# sourceMappingURL=live.d.ts.map
|
package/lib/live.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index the sessions the host still holds in memory.
|
|
3
|
+
*
|
|
4
|
+
* A session log is written when the session ends (or is checkpointed), so the conversation running
|
|
5
|
+
* right now is invisible to a file scan: asking "where did I say X" mid-session would miss the very
|
|
6
|
+
* words just typed. The host's `ctx.sessionQuery` can list live sessions and replay their event log,
|
|
7
|
+
* which is the one thing a file scanner cannot see.
|
|
8
|
+
*
|
|
9
|
+
* This is a **supplement**, never a second path over the same data: file scanning, multi-frame
|
|
10
|
+
* decompression and mtime-based incrementality stay the primary source, and this module only adds
|
|
11
|
+
* the handful of sessions that are still moving. The host interface is declared structurally rather
|
|
12
|
+
* than imported from a `@deepseek-ai/dsh-*` package — a host utility that exists in one DSH line and
|
|
13
|
+
* not another turns into a load-time failure that removes the whole plugin.
|
|
14
|
+
* @module dsh-plugin-sessiongraph-sessions/live
|
|
15
|
+
*/
|
|
16
|
+
import { parseSessionEvents } from "./parse.js";
|
|
17
|
+
/**
|
|
18
|
+
* Prefix of the synthetic source path a live session is recorded under. It is not a filesystem
|
|
19
|
+
* path: the row is rebuilt from the in-memory log on every run, and the marker is what lets the
|
|
20
|
+
* file scanner tell those rows apart from ones a vanished log should remove.
|
|
21
|
+
*/
|
|
22
|
+
export const LIVE_SOURCE_PREFIX = 'live://';
|
|
23
|
+
/**
|
|
24
|
+
* Collect and parse the live sessions in scope.
|
|
25
|
+
*
|
|
26
|
+
* A live session has no stable version — its log grows with every step — so it is replayed in full
|
|
27
|
+
* on every run rather than compared against a recorded mtime. That is cheap only because there are
|
|
28
|
+
* normally one or two of them, which is why the cap exists: a host that reports dozens of live
|
|
29
|
+
* sessions must not turn one index run into dozens of log replays.
|
|
30
|
+
* @param query - the host's session-query service.
|
|
31
|
+
* @param options - the cap, entry bounds, and scope predicate.
|
|
32
|
+
* @returns the parsed sessions to write, and how many the cap left out.
|
|
33
|
+
*/
|
|
34
|
+
export async function collectLiveSessions(query, options) {
|
|
35
|
+
const listed = await query.listSessions();
|
|
36
|
+
const candidates = [];
|
|
37
|
+
for (const entry of listed) {
|
|
38
|
+
const record = asRecord(entry);
|
|
39
|
+
if (record === undefined || record.live !== true)
|
|
40
|
+
continue;
|
|
41
|
+
const header = asRecord(record.header);
|
|
42
|
+
const id = stringField(header, 'id');
|
|
43
|
+
if (id === undefined || id === '')
|
|
44
|
+
continue;
|
|
45
|
+
const cwd = stringField(header, 'cwd') ?? '';
|
|
46
|
+
if (!options.inScope(cwd, id))
|
|
47
|
+
continue;
|
|
48
|
+
candidates.push({ id, cwd });
|
|
49
|
+
}
|
|
50
|
+
// `listSessions` is documented newest-first; taking from the front keeps the most recent
|
|
51
|
+
// conversations searchable when a host reports more live sessions than the cap allows.
|
|
52
|
+
const chosen = candidates.slice(0, options.maxLiveSessions);
|
|
53
|
+
const skipped = candidates.length - chosen.length;
|
|
54
|
+
const sessions = [];
|
|
55
|
+
for (const candidate of chosen) {
|
|
56
|
+
const record = asRecord(await query.readSession(candidate.id));
|
|
57
|
+
const events = Array.isArray(record?.events) ? record.events : [];
|
|
58
|
+
const session = parseSessionEvents(record?.session, events, candidate.id, options.limits);
|
|
59
|
+
sessions.push({
|
|
60
|
+
session,
|
|
61
|
+
sourcePath: liveSourcePath(candidate.id),
|
|
62
|
+
// No version to compare: the live row is rebuilt every run, never skipped.
|
|
63
|
+
mtime: 0,
|
|
64
|
+
size: 0,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { sessions, skipped };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The synthetic source path a live session is recorded under.
|
|
71
|
+
* @param sessionId - the session id.
|
|
72
|
+
* @returns the marker path.
|
|
73
|
+
*/
|
|
74
|
+
export function liveSourcePath(sessionId) {
|
|
75
|
+
return `${LIVE_SOURCE_PREFIX}${sessionId}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Narrow a value to a plain object.
|
|
79
|
+
* @param value - candidate.
|
|
80
|
+
* @returns the object, or undefined.
|
|
81
|
+
*/
|
|
82
|
+
function asRecord(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
? value
|
|
85
|
+
: undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Read a string field from a record.
|
|
89
|
+
* @param record - the record, possibly absent.
|
|
90
|
+
* @param key - field name.
|
|
91
|
+
* @returns the string, or undefined when the field is absent or not a string.
|
|
92
|
+
*/
|
|
93
|
+
function stringField(record, key) {
|
|
94
|
+
const value = record?.[key];
|
|
95
|
+
return typeof value === 'string' ? value : undefined;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=live.js.map
|
package/lib/parse.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read one harness session log into the normalized shape the indexer writes.
|
|
3
|
+
*
|
|
4
|
+
* The log is a zstd-compressed JSONL file whose first line is a flat session header and whose later
|
|
5
|
+
* lines are events. It is NOT one zstd stream: the harness appends one complete zstd frame per write,
|
|
6
|
+
* so a real log is thousands of concatenated frames. `zstdDecompressSync` stops at the first frame's
|
|
7
|
+
* end, which is why {@link decompressSessionLog} walks the frames itself.
|
|
8
|
+
*
|
|
9
|
+
* What is indexed is the readable conversation, not the transcript feed: a human's messages, the
|
|
10
|
+
* model's visible text (never its reasoning), tool invocations (name and arguments, never results),
|
|
11
|
+
* and the session's final title. Injected context — runtime/skill/time blocks, team and agent
|
|
12
|
+
* messages, plugin notifications — arrives as `user/message` events too, so the `source.kind` check
|
|
13
|
+
* is what keeps them out of the index; on a real machine those injections outnumber real human
|
|
14
|
+
* messages by more than an order of magnitude.
|
|
15
|
+
*
|
|
16
|
+
* Parsing is tolerant per line: a truncated or corrupt line is skipped rather than failing the whole
|
|
17
|
+
* session, because an interrupted append must cost one event, not the conversation.
|
|
18
|
+
* @module dsh-plugin-sessiongraph-sessions/parse
|
|
19
|
+
*/
|
|
20
|
+
import type { SessiongraphEntryKind } from 'dsh-plugin-sessiongraph-service';
|
|
21
|
+
/** Bounds applied while normalizing one session's entries. */
|
|
22
|
+
export interface SessionParseLimits {
|
|
23
|
+
/** Longest message text stored whole; longer text is clipped. */
|
|
24
|
+
readonly maxEntryChars: number;
|
|
25
|
+
/** Longest tool invocation text (name plus arguments) stored whole. */
|
|
26
|
+
readonly toolCallChars: number;
|
|
27
|
+
}
|
|
28
|
+
/** One searchable entry extracted from a session log. */
|
|
29
|
+
export interface ParsedEntry {
|
|
30
|
+
/** Event sequence number inside the session. */
|
|
31
|
+
readonly seq: number;
|
|
32
|
+
/** Event timestamp, epoch milliseconds. */
|
|
33
|
+
readonly time: number;
|
|
34
|
+
/** Turn the entry belongs to, when the event records one. */
|
|
35
|
+
readonly turn: number | undefined;
|
|
36
|
+
/** What the entry is. */
|
|
37
|
+
readonly kind: SessiongraphEntryKind;
|
|
38
|
+
/** Entry text, already bounded by the parse limits. */
|
|
39
|
+
readonly text: string;
|
|
40
|
+
/** True when the stored text is a clipped prefix. */
|
|
41
|
+
readonly clipped: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** One session log, normalized for the index. */
|
|
44
|
+
export interface ParsedSession {
|
|
45
|
+
/** Session id exactly as the header records it (`session-…` or bare uuid, never rewritten). */
|
|
46
|
+
readonly id: string;
|
|
47
|
+
/** Working directory the session ran in. */
|
|
48
|
+
readonly cwd: string;
|
|
49
|
+
/** Epoch milliseconds of the session's first event. */
|
|
50
|
+
readonly createdAt: number;
|
|
51
|
+
/** Epoch milliseconds of the session's last event. */
|
|
52
|
+
readonly lastAt: number;
|
|
53
|
+
/** Final session title, when the log records one. */
|
|
54
|
+
readonly title: string | undefined;
|
|
55
|
+
/** Parent session id when this session was delegated from another. */
|
|
56
|
+
readonly parentSession: string | undefined;
|
|
57
|
+
/** Session origin label (`user`, `subagent`, …), when recorded. */
|
|
58
|
+
readonly origin: string | undefined;
|
|
59
|
+
/** Agent preset the session ran under, when recorded. */
|
|
60
|
+
readonly agentPreset: string | undefined;
|
|
61
|
+
/** Number of turns the session contains. */
|
|
62
|
+
readonly turns: number;
|
|
63
|
+
/** Searchable entries in chronological order. */
|
|
64
|
+
readonly entries: ParsedEntry[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Decompress a session log, one zstd frame at a time.
|
|
68
|
+
*
|
|
69
|
+
* Node's `zstdDecompressSync` decodes the first frame of its input and stops, so a log written by
|
|
70
|
+
* repeated appends must be split into frames first. The frame structure is parsed exactly (RFC 8878
|
|
71
|
+
* header and block headers), which is why a magic-byte sequence inside a compressed block cannot
|
|
72
|
+
* split a frame incorrectly; the magic scan remains as a fallback for a frame layout this parser
|
|
73
|
+
* cannot describe.
|
|
74
|
+
* @param buffer - the raw log file contents.
|
|
75
|
+
* @returns the concatenated decompressed bytes of every frame.
|
|
76
|
+
*/
|
|
77
|
+
export declare function decompressSessionLog(buffer: Buffer): Buffer;
|
|
78
|
+
/**
|
|
79
|
+
* Parse one session log.
|
|
80
|
+
* @param buffer - the raw log file contents.
|
|
81
|
+
* @param fallbackId - id to use when the header is missing or has none (the session directory name).
|
|
82
|
+
* @param limits - bounds applied to stored entry text.
|
|
83
|
+
* @returns the normalized session.
|
|
84
|
+
*/
|
|
85
|
+
export declare function parseSessionLog(buffer: Buffer, fallbackId: string, limits: SessionParseLimits): ParsedSession;
|
|
86
|
+
/**
|
|
87
|
+
* Parse an already-split JSONL log, keeping the harness's first-line header convention.
|
|
88
|
+
* @param lines - the decompressed log split on newline characters.
|
|
89
|
+
* @param fallbackId - id to use when the header is missing or has none.
|
|
90
|
+
* @param limits - bounds applied to stored entry text.
|
|
91
|
+
* @returns the normalized session.
|
|
92
|
+
*/
|
|
93
|
+
export declare function parseSessionLines(lines: readonly string[], fallbackId: string, limits: SessionParseLimits): ParsedSession;
|
|
94
|
+
/**
|
|
95
|
+
* Parse a session the host already holds in memory.
|
|
96
|
+
*
|
|
97
|
+
* The live view carries the header and the raw events as two values, which is the same information a
|
|
98
|
+
* log's first line and remaining lines carry. Both paths normalize through {@link normalizeSession},
|
|
99
|
+
* so a live session and its later on-disk copy cannot disagree about what is searchable.
|
|
100
|
+
* @param header - the session header object, as the host reports it.
|
|
101
|
+
* @param events - raw event objects, shaped like the JSONL lines that follow the header.
|
|
102
|
+
* @param fallbackId - id to use when the header has none.
|
|
103
|
+
* @param limits - bounds applied to stored entry text.
|
|
104
|
+
* @returns the normalized session.
|
|
105
|
+
*/
|
|
106
|
+
export declare function parseSessionEvents(header: unknown, events: readonly unknown[], fallbackId: string, limits: SessionParseLimits): ParsedSession;
|
|
107
|
+
//# sourceMappingURL=parse.d.ts.map
|