dsh-plugin-sessiongraph-tool 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/command.d.ts +179 -0
- package/lib/command.js +398 -0
- package/lib/index.d.ts +102 -0
- package/lib/index.js +582 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +22 -0
- package/lib/refresh.d.ts +103 -0
- package/lib/refresh.js +171 -0
- package/lib/render.d.ts +85 -0
- package/lib/render.js +247 -0
- package/lib/schema.d.ts +539 -0
- package/lib/schema.js +267 -0
- package/lib/status-hint.d.ts +42 -0
- package/lib/status-hint.js +100 -0
- package/package.json +69 -3
- package/README.md +0 -3
package/lib/refresh.d.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy incremental refresh: a query should not answer from an index that is hours old when the
|
|
3
|
+
* machine has been writing session logs all along.
|
|
4
|
+
*
|
|
5
|
+
* The indexer refreshes in the background when a *new session* is created, which leaves one gap: the
|
|
6
|
+
* session currently being written has no successor yet, so the model asking "what did we just say"
|
|
7
|
+
* would search an index that predates the current conversation. This module closes that gap on the
|
|
8
|
+
* query path — but only when the sidecar says the index is older than `freshnessThresholdMs`, and
|
|
9
|
+
* never at the price of the query itself: a refresh that fails or is handed to a background job is
|
|
10
|
+
* logged/skipped and the answer is served from the index as it stands.
|
|
11
|
+
*
|
|
12
|
+
* Two rules shape the implementation:
|
|
13
|
+
*
|
|
14
|
+
* - The decision reads `<indexRoot>/status.json`, the same synchronous sidecar prompt assembly reads.
|
|
15
|
+
* Opening SQLite to ask "how old are you" would put a database on every query's critical path.
|
|
16
|
+
* - One incremental run per index root at a time. A model that fires two queries in parallel joins
|
|
17
|
+
* the in-flight run instead of starting a second one; a burst must not turn into a rebuild storm.
|
|
18
|
+
* @module dsh-plugin-sessiongraph-tool/refresh
|
|
19
|
+
*/
|
|
20
|
+
import type { SessiongraphIndexOptions } from 'dsh-plugin-sessiongraph-service';
|
|
21
|
+
/**
|
|
22
|
+
* The options a lazy refresh sends, and the one it deliberately does not.
|
|
23
|
+
*
|
|
24
|
+
* A job-capable indexer reads the owning session id from a runtime extension of the seam's options
|
|
25
|
+
* (`owner`); it hands the run to the host's background-job registry only when that extension is
|
|
26
|
+
* present. Omitting it is therefore how this call asks for a synchronous report — the whole point,
|
|
27
|
+
* because the query must answer with the refreshed index, not with a promise about one. A run that
|
|
28
|
+
* some indexer still delegates is detected by its `jobId` (see {@link isCompletedReport}) and skipped:
|
|
29
|
+
* the query answers from the previous index, and the job makes the next call fresh.
|
|
30
|
+
*/
|
|
31
|
+
export type SynchronousIndexOptions = SessiongraphIndexOptions;
|
|
32
|
+
/** What one `ensureFresh` call decided and did, for the renderer's staleness note. */
|
|
33
|
+
export interface RefreshOutcome {
|
|
34
|
+
/** True when this call actually ran an incremental index (and got a report back). */
|
|
35
|
+
readonly refreshed: boolean;
|
|
36
|
+
/** True when the answer is served from an index that may predate the conversation. */
|
|
37
|
+
readonly stale: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** Dependencies a {@link LazyIndexRefresher} needs; all injected so the decision stays testable. */
|
|
40
|
+
export interface LazyRefreshDeps {
|
|
41
|
+
/** Absolute directory holding the index and its sidecar. */
|
|
42
|
+
readonly indexRoot: string;
|
|
43
|
+
/**
|
|
44
|
+
* Age at which the index counts as stale, milliseconds. `0` disables lazy refresh entirely — the
|
|
45
|
+
* query then trusts the indexer's own schedule and never spends a query budget on indexing.
|
|
46
|
+
*/
|
|
47
|
+
readonly freshnessThresholdMs: number;
|
|
48
|
+
/**
|
|
49
|
+
* Run one incremental index. Resolves with the seam's report, or with whatever a job-capable
|
|
50
|
+
* indexer returned instead; rejecting is expected and handled.
|
|
51
|
+
* @param options - an incremental scope with no owning-session extension, so the run stays
|
|
52
|
+
* synchronous.
|
|
53
|
+
* @param signal - aborts the run.
|
|
54
|
+
*/
|
|
55
|
+
readonly index: (options: SynchronousIndexOptions, signal?: AbortSignal) => Promise<unknown>;
|
|
56
|
+
/** Clock, injected so a test can place the sidecar on either side of the threshold. */
|
|
57
|
+
readonly now?: () => number;
|
|
58
|
+
/** Sink for the single explanatory line a failed refresh logs (default `console.error`). */
|
|
59
|
+
readonly logError?: (message: string) => void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Refresh one index root lazily, at most once at a time.
|
|
63
|
+
*
|
|
64
|
+
* The instance is process-local state (one per plugin registration), which is exactly the scope the
|
|
65
|
+
* requirement names: a second query in the same process joins the run already in flight, while a
|
|
66
|
+
* second DSH process is a separate writer the indexer's own atomic rename already handles.
|
|
67
|
+
*/
|
|
68
|
+
export declare class LazyIndexRefresher {
|
|
69
|
+
private readonly deps;
|
|
70
|
+
private inFlight;
|
|
71
|
+
private lastOutcome;
|
|
72
|
+
/**
|
|
73
|
+
* @param deps - the root, threshold, index runner, and optional clock/logger.
|
|
74
|
+
*/
|
|
75
|
+
constructor(deps: LazyRefreshDeps);
|
|
76
|
+
/**
|
|
77
|
+
* Make sure the index is fresh enough for one query, running an incremental index when needed.
|
|
78
|
+
*
|
|
79
|
+
* Never rejects: a refresh is an optimisation, and a failed one must leave the query free to
|
|
80
|
+
* answer from the existing index. The returned outcome says whether this call refreshed and
|
|
81
|
+
* whether the answer may be stale, which the renderer turns into one line of text.
|
|
82
|
+
* @param signal - the query's cancellation signal, forwarded to the index run.
|
|
83
|
+
* @returns what this call did.
|
|
84
|
+
*/
|
|
85
|
+
ensureFresh(signal?: AbortSignal): Promise<RefreshOutcome>;
|
|
86
|
+
/**
|
|
87
|
+
* Whether the sidecar places the index past the threshold.
|
|
88
|
+
*
|
|
89
|
+
* A missing sidecar beside an existing index means a run that predates the summary (or one whose
|
|
90
|
+
* summary was lost), and the only way to learn its age is to refresh it — an incremental run is
|
|
91
|
+
* cheap and self-heals the sidecar. A missing index file is different: building from nothing can
|
|
92
|
+
* take minutes, which the dedicated `sessiongraph_index` tool owns on its own budget, so a query
|
|
93
|
+
* leaves it alone and fails with the "no index" guidance instead.
|
|
94
|
+
* @returns true when an incremental run should happen before the query.
|
|
95
|
+
*/
|
|
96
|
+
private shouldRefresh;
|
|
97
|
+
/**
|
|
98
|
+
* Run one incremental index, swallowing every failure into the outcome.
|
|
99
|
+
* @param signal - the query's cancellation signal.
|
|
100
|
+
*/
|
|
101
|
+
private refreshOnce;
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=refresh.d.ts.map
|
package/lib/refresh.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy incremental refresh: a query should not answer from an index that is hours old when the
|
|
3
|
+
* machine has been writing session logs all along.
|
|
4
|
+
*
|
|
5
|
+
* The indexer refreshes in the background when a *new session* is created, which leaves one gap: the
|
|
6
|
+
* session currently being written has no successor yet, so the model asking "what did we just say"
|
|
7
|
+
* would search an index that predates the current conversation. This module closes that gap on the
|
|
8
|
+
* query path — but only when the sidecar says the index is older than `freshnessThresholdMs`, and
|
|
9
|
+
* never at the price of the query itself: a refresh that fails or is handed to a background job is
|
|
10
|
+
* logged/skipped and the answer is served from the index as it stands.
|
|
11
|
+
*
|
|
12
|
+
* Two rules shape the implementation:
|
|
13
|
+
*
|
|
14
|
+
* - The decision reads `<indexRoot>/status.json`, the same synchronous sidecar prompt assembly reads.
|
|
15
|
+
* Opening SQLite to ask "how old are you" would put a database on every query's critical path.
|
|
16
|
+
* - One incremental run per index root at a time. A model that fires two queries in parallel joins
|
|
17
|
+
* the in-flight run instead of starting a second one; a burst must not turn into a rebuild storm.
|
|
18
|
+
* @module dsh-plugin-sessiongraph-tool/refresh
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { INDEX_RELATIVE_PATH } from 'dsh-plugin-sessiongraph-service';
|
|
23
|
+
import { readIndexStatus } from "./status-hint.js";
|
|
24
|
+
/** The outcome of a call that decided to leave the index alone. */
|
|
25
|
+
const IDLE = { refreshed: false, stale: false };
|
|
26
|
+
/**
|
|
27
|
+
* Refresh one index root lazily, at most once at a time.
|
|
28
|
+
*
|
|
29
|
+
* The instance is process-local state (one per plugin registration), which is exactly the scope the
|
|
30
|
+
* requirement names: a second query in the same process joins the run already in flight, while a
|
|
31
|
+
* second DSH process is a separate writer the indexer's own atomic rename already handles.
|
|
32
|
+
*/
|
|
33
|
+
export class LazyIndexRefresher {
|
|
34
|
+
deps;
|
|
35
|
+
inFlight;
|
|
36
|
+
lastOutcome = IDLE;
|
|
37
|
+
/**
|
|
38
|
+
* @param deps - the root, threshold, index runner, and optional clock/logger.
|
|
39
|
+
*/
|
|
40
|
+
constructor(deps) {
|
|
41
|
+
this.deps = deps;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Make sure the index is fresh enough for one query, running an incremental index when needed.
|
|
45
|
+
*
|
|
46
|
+
* Never rejects: a refresh is an optimisation, and a failed one must leave the query free to
|
|
47
|
+
* answer from the existing index. The returned outcome says whether this call refreshed and
|
|
48
|
+
* whether the answer may be stale, which the renderer turns into one line of text.
|
|
49
|
+
* @param signal - the query's cancellation signal, forwarded to the index run.
|
|
50
|
+
* @returns what this call did.
|
|
51
|
+
*/
|
|
52
|
+
async ensureFresh(signal) {
|
|
53
|
+
if (this.deps.freshnessThresholdMs <= 0) {
|
|
54
|
+
this.lastOutcome = IDLE;
|
|
55
|
+
return IDLE;
|
|
56
|
+
}
|
|
57
|
+
// A parallel call joins the run already in flight instead of starting a second one. It waits,
|
|
58
|
+
// because the index it would otherwise read is the one being replaced right now.
|
|
59
|
+
if (this.inFlight !== undefined) {
|
|
60
|
+
await this.inFlight;
|
|
61
|
+
return this.lastOutcome;
|
|
62
|
+
}
|
|
63
|
+
if (!this.shouldRefresh()) {
|
|
64
|
+
this.lastOutcome = IDLE;
|
|
65
|
+
return IDLE;
|
|
66
|
+
}
|
|
67
|
+
const run = this.refreshOnce(signal);
|
|
68
|
+
this.inFlight = run;
|
|
69
|
+
try {
|
|
70
|
+
await run;
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
this.inFlight = undefined;
|
|
74
|
+
}
|
|
75
|
+
return this.lastOutcome;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether the sidecar places the index past the threshold.
|
|
79
|
+
*
|
|
80
|
+
* A missing sidecar beside an existing index means a run that predates the summary (or one whose
|
|
81
|
+
* summary was lost), and the only way to learn its age is to refresh it — an incremental run is
|
|
82
|
+
* cheap and self-heals the sidecar. A missing index file is different: building from nothing can
|
|
83
|
+
* take minutes, which the dedicated `sessiongraph_index` tool owns on its own budget, so a query
|
|
84
|
+
* leaves it alone and fails with the "no index" guidance instead.
|
|
85
|
+
* @returns true when an incremental run should happen before the query.
|
|
86
|
+
*/
|
|
87
|
+
shouldRefresh() {
|
|
88
|
+
const { indexRoot, freshnessThresholdMs } = this.deps;
|
|
89
|
+
// Without an index file there is nothing to refresh in place. Building one from nothing can take
|
|
90
|
+
// minutes, and that decision belongs to `sessiongraph_index` on its own budget — a query leaves
|
|
91
|
+
// it alone and fails with the "no index" guidance instead.
|
|
92
|
+
if (!existsSync(join(indexRoot, INDEX_RELATIVE_PATH)))
|
|
93
|
+
return false;
|
|
94
|
+
const status = readIndexStatus(indexRoot);
|
|
95
|
+
// An index with no sidecar is a run that predates the summary (or lost it). Its age cannot be
|
|
96
|
+
// read, and the incremental run that answers the question is cheap and writes the sidecar back.
|
|
97
|
+
if (status === undefined)
|
|
98
|
+
return true;
|
|
99
|
+
const now = this.deps.now?.() ?? Date.now();
|
|
100
|
+
return now - status.indexedAt >= freshnessThresholdMs;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run one incremental index, swallowing every failure into the outcome.
|
|
104
|
+
* @param signal - the query's cancellation signal.
|
|
105
|
+
*/
|
|
106
|
+
async refreshOnce(signal) {
|
|
107
|
+
const { indexRoot } = this.deps;
|
|
108
|
+
try {
|
|
109
|
+
// No `owner` extension on purpose: a job-capable indexer only delegates a run whose caller
|
|
110
|
+
// named an owner, so this is the explicit request for a synchronous report.
|
|
111
|
+
const result = await this.deps.index({ force: false }, signal);
|
|
112
|
+
if (!isCompletedReport(result)) {
|
|
113
|
+
// The indexer still handed the run to a background job: waiting would outlive this query,
|
|
114
|
+
// and starting another would only duplicate it. Answer from the index as it stands; the job
|
|
115
|
+
// will make the next call fresh.
|
|
116
|
+
this.lastOutcome = { refreshed: false, stale: true };
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.lastOutcome = { refreshed: true, stale: false };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
this.lastOutcome = { refreshed: false, stale: true };
|
|
123
|
+
// A cancelled query is not a failed refresh: the caller is gone, so a log line would only
|
|
124
|
+
// report an abort as a defect.
|
|
125
|
+
if (signal?.aborted === true)
|
|
126
|
+
return;
|
|
127
|
+
// One line, once per failed attempt: the model still gets its answer, and a human reading the
|
|
128
|
+
// log learns why the result may be behind. The default sink is `console.error`; a test (or a
|
|
129
|
+
// host that routes tool diagnostics elsewhere) can replace it.
|
|
130
|
+
const log = this.deps.logError ?? logToConsole;
|
|
131
|
+
log(`sessiongraph: incremental refresh of ${indexRoot} failed; answering from the existing index: ${describe(error)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Whether a seam answer is a finished index report rather than a report about a delegated run.
|
|
137
|
+
*
|
|
138
|
+
* A job-capable indexer answers a delegated run with a full report shape plus a `jobId` (the seam's
|
|
139
|
+
* return type cannot express the distinction), so the discriminator is the presence of that id — not
|
|
140
|
+
* the report's other fields, which describe the index as it stood when the job started. Detected
|
|
141
|
+
* structurally on purpose: this package depends on the seam, never on a particular indexer.
|
|
142
|
+
* @param value - whatever `ctx.sessiongraph.index` resolved with.
|
|
143
|
+
* @returns true when this call itself finished the work.
|
|
144
|
+
*/
|
|
145
|
+
function isCompletedReport(value) {
|
|
146
|
+
if (typeof value !== 'object' || value === null)
|
|
147
|
+
return false;
|
|
148
|
+
const record = value;
|
|
149
|
+
if (typeof record['jobId'] === 'string')
|
|
150
|
+
return false;
|
|
151
|
+
return typeof record['indexPath'] === 'string'
|
|
152
|
+
&& typeof record['added'] === 'number'
|
|
153
|
+
&& typeof record['sessions'] === 'number';
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* One line describing a thrown value, without a stack.
|
|
157
|
+
* @param error - whatever the indexer threw.
|
|
158
|
+
* @returns the message when there is one, else the string form.
|
|
159
|
+
*/
|
|
160
|
+
function describe(error) {
|
|
161
|
+
return error instanceof Error ? error.message : String(error);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The default sink for a failed refresh: one line on stderr, never a stack. A refresh failure is a
|
|
165
|
+
* degraded state, not a query failure, so it must not look like a crash.
|
|
166
|
+
* @param message - the already-composed diagnostic line.
|
|
167
|
+
*/
|
|
168
|
+
function logToConsole(message) {
|
|
169
|
+
console.error(message);
|
|
170
|
+
}
|
|
171
|
+
//# sourceMappingURL=refresh.js.map
|
package/lib/render.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing text for each operation's canonical value.
|
|
3
|
+
*
|
|
4
|
+
* Every line is meant to be acted on directly: a hit names the full session id, so the next call can
|
|
5
|
+
* be `session` without a search round-trip, and every timestamp is rendered in the machine's local
|
|
6
|
+
* zone (`2026-11-03 10:37`) rather than as a bare epoch, which the model would have to convert before
|
|
7
|
+
* it could reason about "last week". A truncated answer always says so — a capped list that reads as
|
|
8
|
+
* complete is worse than a short one, because the model concludes it has seen everything.
|
|
9
|
+
* @module dsh-plugin-sessiongraph-tool/render
|
|
10
|
+
*/
|
|
11
|
+
import type { SessiongraphToolValue } from './schema.ts';
|
|
12
|
+
/**
|
|
13
|
+
* The arguments rendering may look at: the few model-supplied facts the canonical value does not echo
|
|
14
|
+
* back (the query text, the workspace filter).
|
|
15
|
+
*
|
|
16
|
+
* Deliberately structural rather than `SessiongraphToolArgs`: the index tool shares this renderer and
|
|
17
|
+
* its arguments carry no `operation` at all. Both tools' argument objects satisfy this shape.
|
|
18
|
+
*/
|
|
19
|
+
export interface RenderArgs {
|
|
20
|
+
/** The search text, when the call was a search. */
|
|
21
|
+
readonly query?: string;
|
|
22
|
+
/** The workspace filter, when the model named one. */
|
|
23
|
+
readonly workspace?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Caps applied while rendering; the tool owns them so a deployment can retune answer size. */
|
|
26
|
+
export interface RenderOptions {
|
|
27
|
+
/** Longest snippet (or session entry) shown per item before it is clipped with `…`. */
|
|
28
|
+
maxSnippetChars: number;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Render one epoch-millisecond timestamp in the machine's local zone, minute precision.
|
|
32
|
+
* @param epochMs - epoch milliseconds.
|
|
33
|
+
* @returns `YYYY-MM-DD HH:mm` in local time.
|
|
34
|
+
*/
|
|
35
|
+
export declare function formatTime(epochMs: number): string;
|
|
36
|
+
/**
|
|
37
|
+
* Render a byte count in binary units, at most one decimal.
|
|
38
|
+
* @param bytes - the byte count.
|
|
39
|
+
* @returns a short human-readable size.
|
|
40
|
+
*/
|
|
41
|
+
export declare function formatBytes(bytes: number): string;
|
|
42
|
+
/**
|
|
43
|
+
* Render a duration in the unit a reader thinks in: seconds under a minute, minutes above it.
|
|
44
|
+
* @param ms - duration in milliseconds.
|
|
45
|
+
* @returns a short human-readable duration.
|
|
46
|
+
*/
|
|
47
|
+
export declare function formatDuration(ms: number): string;
|
|
48
|
+
/**
|
|
49
|
+
* Report how many of the indexed sessions are still running.
|
|
50
|
+
*
|
|
51
|
+
* Always rendered, even at zero: "no live sessions" and "this store cannot tell" are different
|
|
52
|
+
* states, and a reader who wanted to know whether the conversation they are in right now is
|
|
53
|
+
* searchable must be able to tell them apart. The caller supplies `0` for a store that omits the
|
|
54
|
+
* count, which is the honest reading of a store that predates live indexing.
|
|
55
|
+
* @param live - live sessions the index holds.
|
|
56
|
+
* @returns the one status line.
|
|
57
|
+
*/
|
|
58
|
+
export declare function formatLiveSessions(live: number): string;
|
|
59
|
+
/**
|
|
60
|
+
* One line a finished index run adds when it replayed sessions that are still running.
|
|
61
|
+
*
|
|
62
|
+
* Omitted at zero on purpose: an empty run is the normal case, and a line that almost always says
|
|
63
|
+
* "replayed 0 live sessions" trains a reader to skip the block it sits in.
|
|
64
|
+
* @param live - live sessions replayed by the run.
|
|
65
|
+
* @returns the sentence, or an empty string when there were none.
|
|
66
|
+
*/
|
|
67
|
+
export declare function formatLiveReplay(live: number): string;
|
|
68
|
+
/**
|
|
69
|
+
* Clip one blob of text to a character cap, marking the cut.
|
|
70
|
+
* @param text - the text to bound.
|
|
71
|
+
* @param maxChars - characters allowed, ellipsis included.
|
|
72
|
+
* @returns the text, or its clipped prefix with `…`.
|
|
73
|
+
*/
|
|
74
|
+
export declare function clipText(text: string, maxChars: number): string;
|
|
75
|
+
/** The one line an answer carries when the index could not be brought up to date before querying. */
|
|
76
|
+
export declare const STALE_NOTE: string;
|
|
77
|
+
/**
|
|
78
|
+
* Render one result as the text the model reads.
|
|
79
|
+
* @param args - the validated arguments, used for the few facts the value does not echo back.
|
|
80
|
+
* @param value - the canonical value the operation returned.
|
|
81
|
+
* @param options - rendering caps.
|
|
82
|
+
* @returns the rendered text.
|
|
83
|
+
*/
|
|
84
|
+
export declare function renderSessiongraph(args: RenderArgs, value: SessiongraphToolValue, options: RenderOptions): string;
|
|
85
|
+
//# sourceMappingURL=render.d.ts.map
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing text for each operation's canonical value.
|
|
3
|
+
*
|
|
4
|
+
* Every line is meant to be acted on directly: a hit names the full session id, so the next call can
|
|
5
|
+
* be `session` without a search round-trip, and every timestamp is rendered in the machine's local
|
|
6
|
+
* zone (`2026-11-03 10:37`) rather than as a bare epoch, which the model would have to convert before
|
|
7
|
+
* it could reason about "last week". A truncated answer always says so — a capped list that reads as
|
|
8
|
+
* complete is worse than a short one, because the model concludes it has seen everything.
|
|
9
|
+
* @module dsh-plugin-sessiongraph-tool/render
|
|
10
|
+
*/
|
|
11
|
+
import { assertNever } from 'dsh-plugin-sessiongraph-service';
|
|
12
|
+
/**
|
|
13
|
+
* Render one epoch-millisecond timestamp in the machine's local zone, minute precision.
|
|
14
|
+
* @param epochMs - epoch milliseconds.
|
|
15
|
+
* @returns `YYYY-MM-DD HH:mm` in local time.
|
|
16
|
+
*/
|
|
17
|
+
export function formatTime(epochMs) {
|
|
18
|
+
const date = new Date(epochMs);
|
|
19
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
|
20
|
+
+ `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Two-digit zero pad.
|
|
24
|
+
* @param value - the number to pad.
|
|
25
|
+
* @returns the padded decimal string.
|
|
26
|
+
*/
|
|
27
|
+
function pad(value) {
|
|
28
|
+
return String(value).padStart(2, '0');
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Render a byte count in binary units, at most one decimal.
|
|
32
|
+
* @param bytes - the byte count.
|
|
33
|
+
* @returns a short human-readable size.
|
|
34
|
+
*/
|
|
35
|
+
export function formatBytes(bytes) {
|
|
36
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
37
|
+
return '0 B';
|
|
38
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
39
|
+
const exponent = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
40
|
+
const unit = units[exponent] ?? 'B';
|
|
41
|
+
const value = bytes / 1024 ** exponent;
|
|
42
|
+
const rounded = exponent === 0 || value >= 10 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
43
|
+
return `${rounded} ${unit}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Render a duration in the unit a reader thinks in: seconds under a minute, minutes above it.
|
|
47
|
+
* @param ms - duration in milliseconds.
|
|
48
|
+
* @returns a short human-readable duration.
|
|
49
|
+
*/
|
|
50
|
+
export function formatDuration(ms) {
|
|
51
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
52
|
+
return '0 秒';
|
|
53
|
+
if (ms < 60_000)
|
|
54
|
+
return `${Math.round(ms / 100) / 10} 秒`;
|
|
55
|
+
const minutes = Math.floor(ms / 60_000);
|
|
56
|
+
const seconds = Math.round((ms % 60_000) / 1000);
|
|
57
|
+
return `${minutes} 分 ${seconds} 秒`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Report how many of the indexed sessions are still running.
|
|
61
|
+
*
|
|
62
|
+
* Always rendered, even at zero: "no live sessions" and "this store cannot tell" are different
|
|
63
|
+
* states, and a reader who wanted to know whether the conversation they are in right now is
|
|
64
|
+
* searchable must be able to tell them apart. The caller supplies `0` for a store that omits the
|
|
65
|
+
* count, which is the honest reading of a store that predates live indexing.
|
|
66
|
+
* @param live - live sessions the index holds.
|
|
67
|
+
* @returns the one status line.
|
|
68
|
+
*/
|
|
69
|
+
export function formatLiveSessions(live) {
|
|
70
|
+
return live === 0
|
|
71
|
+
? '实时会话:0(当前没有正在跑的会话)'
|
|
72
|
+
: `实时会话:${live} 个(当前还在跑、未落盘)`;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* One line a finished index run adds when it replayed sessions that are still running.
|
|
76
|
+
*
|
|
77
|
+
* Omitted at zero on purpose: an empty run is the normal case, and a line that almost always says
|
|
78
|
+
* "replayed 0 live sessions" trains a reader to skip the block it sits in.
|
|
79
|
+
* @param live - live sessions replayed by the run.
|
|
80
|
+
* @returns the sentence, or an empty string when there were none.
|
|
81
|
+
*/
|
|
82
|
+
export function formatLiveReplay(live) {
|
|
83
|
+
return live === 0 ? '' : `本次重放 ${live} 个实时会话。`;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Clip one blob of text to a character cap, marking the cut.
|
|
87
|
+
* @param text - the text to bound.
|
|
88
|
+
* @param maxChars - characters allowed, ellipsis included.
|
|
89
|
+
* @returns the text, or its clipped prefix with `…`.
|
|
90
|
+
*/
|
|
91
|
+
export function clipText(text, maxChars) {
|
|
92
|
+
if (text.length <= maxChars)
|
|
93
|
+
return text;
|
|
94
|
+
return `${text.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
95
|
+
}
|
|
96
|
+
/** The `N shown of M` suffix a truncatable list carries. */
|
|
97
|
+
function counted(shown, total, truncated) {
|
|
98
|
+
return truncated ? `(共 ${total} 条,显示前 ${shown} 条;用 offset 翻页)` : `(共 ${total} 条)`;
|
|
99
|
+
}
|
|
100
|
+
/** A session title for a line, with an explicit placeholder when the log never recorded one. */
|
|
101
|
+
function titled(title) {
|
|
102
|
+
return title === undefined || title.trim() === '' ? '(无标题)' : `《${title}》`;
|
|
103
|
+
}
|
|
104
|
+
/** One hit as the three lines a reader needs: when/who, where, and the matched text. */
|
|
105
|
+
function hitLines(hit, maxChars) {
|
|
106
|
+
const turn = hit.turn === undefined ? '' : ` · 第 ${hit.turn} 轮`;
|
|
107
|
+
return [
|
|
108
|
+
`${formatTime(hit.time)} · [${hit.kind}] · ${titled(hit.title)}`,
|
|
109
|
+
` ${hit.workspace} · session ${hit.session_id} #${hit.seq}${turn}`,
|
|
110
|
+
` ${clipText(hit.snippet, maxChars)}`,
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
/** The one line an answer carries when the index could not be brought up to date before querying. */
|
|
114
|
+
export const STALE_NOTE = '(注意:本次增量刷新没有完成,答案可能不是最新——索引里可能还没有刚才的对话。'
|
|
115
|
+
+ '确实需要最新结果时可以调用 sessiongraph_index 强制刷新。)';
|
|
116
|
+
/** Whether a value was served from an index the query path could not refresh first. */
|
|
117
|
+
function isStale(value) {
|
|
118
|
+
return 'stale' in value && value.stale === true;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Render one result as the text the model reads.
|
|
122
|
+
* @param args - the validated arguments, used for the few facts the value does not echo back.
|
|
123
|
+
* @param value - the canonical value the operation returned.
|
|
124
|
+
* @param options - rendering caps.
|
|
125
|
+
* @returns the rendered text.
|
|
126
|
+
*/
|
|
127
|
+
export function renderSessiongraph(args, value, options) {
|
|
128
|
+
const text = renderValue(args, value, options);
|
|
129
|
+
return isStale(value) ? `${text}\n${STALE_NOTE}` : text;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The per-operation rendering, without the cross-cutting staleness note.
|
|
133
|
+
* @param args - the validated arguments.
|
|
134
|
+
* @param value - the canonical value the operation returned.
|
|
135
|
+
* @param options - rendering caps.
|
|
136
|
+
* @returns the rendered text.
|
|
137
|
+
*/
|
|
138
|
+
function renderValue(args, value, options) {
|
|
139
|
+
const maxChars = options.maxSnippetChars;
|
|
140
|
+
switch (value.operation) {
|
|
141
|
+
case 'search': {
|
|
142
|
+
if (value.hits.length === 0) {
|
|
143
|
+
const where = args.workspace === undefined ? '' : `(工作区 ${args.workspace})`;
|
|
144
|
+
return `会话历史里没有命中「${args.query ?? ''}」${where}。换个词,或省略 workspace 扩大范围。`;
|
|
145
|
+
}
|
|
146
|
+
const header = `命中 ${value.hits.length} 条${counted(value.hits.length, value.total, value.truncated)}`
|
|
147
|
+
+ `${args.query === undefined ? '' : `,查询「${args.query}」`}:`;
|
|
148
|
+
const lines = [header];
|
|
149
|
+
value.hits.forEach((hit, index) => {
|
|
150
|
+
lines.push(`${index + 1}. ${hitLines(hit, maxChars).join('\n')}`);
|
|
151
|
+
});
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
154
|
+
case 'session': {
|
|
155
|
+
const session = value.session;
|
|
156
|
+
const lines = [
|
|
157
|
+
`会话 ${session.session_id} · ${titled(session.title)}`,
|
|
158
|
+
`${session.workspace} · ${formatTime(session.created_at)} → ${formatTime(session.last_at)}`
|
|
159
|
+
+ ` · ${session.turns} 轮 · ${session.entries} 条`,
|
|
160
|
+
];
|
|
161
|
+
const meta = [];
|
|
162
|
+
if (session.parent_session !== undefined)
|
|
163
|
+
meta.push(`父会话 ${session.parent_session}`);
|
|
164
|
+
if (session.origin !== undefined)
|
|
165
|
+
meta.push(`来源 ${session.origin}`);
|
|
166
|
+
if (session.agent_preset !== undefined)
|
|
167
|
+
meta.push(`预设 ${session.agent_preset}`);
|
|
168
|
+
if (meta.length > 0)
|
|
169
|
+
lines.push(meta.join(' · '));
|
|
170
|
+
if (value.children.length > 0) {
|
|
171
|
+
lines.push(`子会话 ${value.children.length} 个:${value.children.join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
if (value.entries.length === 0) {
|
|
174
|
+
lines.push('索引里没有这个会话的可读条目。');
|
|
175
|
+
return lines.join('\n');
|
|
176
|
+
}
|
|
177
|
+
const clipped = value.truncated ? '(已截断;用 offset 继续翻页)' : '';
|
|
178
|
+
lines.push(`条目 ${value.entries.length} 条${clipped}:`);
|
|
179
|
+
for (const entry of value.entries) {
|
|
180
|
+
const turn = entry.turn === undefined ? '' : ` 第 ${entry.turn} 轮`;
|
|
181
|
+
const cut = entry.clipped ? ' [索引里只存了前缀]' : '';
|
|
182
|
+
lines.push(`${formatTime(entry.time)} #${entry.seq} [${entry.kind}]${turn}${cut}:`);
|
|
183
|
+
lines.push(` ${clipText(entry.text, maxChars)}`);
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
case 'timeline': {
|
|
188
|
+
if (value.sessions.length === 0) {
|
|
189
|
+
return args.workspace === undefined ? '索引里还没有会话。' : `工作区 ${args.workspace} 还没有被索引的会话。`;
|
|
190
|
+
}
|
|
191
|
+
const header = `最近 ${value.sessions.length} 个会话${value.truncated ? '(还有更旧的;用 limit 扩大)' : ''},最新在前:`;
|
|
192
|
+
const lines = [header];
|
|
193
|
+
for (const session of value.sessions) {
|
|
194
|
+
lines.push(`${formatTime(session.last_at)} · session ${session.session_id} · ${titled(session.title)}`
|
|
195
|
+
+ ` · ${session.workspace} · ${session.turns} 轮 / ${session.entries} 条`);
|
|
196
|
+
}
|
|
197
|
+
return lines.join('\n');
|
|
198
|
+
}
|
|
199
|
+
case 'workspaces': {
|
|
200
|
+
if (value.workspaces.length === 0)
|
|
201
|
+
return '索引里还没有工作区。';
|
|
202
|
+
const header = `工作区 ${value.workspaces.length} 个${value.total > value.workspaces.length ? `(共 ${value.total} 个)` : ''},最近活跃在前:`;
|
|
203
|
+
const lines = [header];
|
|
204
|
+
for (const workspace of value.workspaces) {
|
|
205
|
+
lines.push(`${workspace.workspace} · ${workspace.sessions} 会话 · ${workspace.entries} 条`
|
|
206
|
+
+ ` · ${formatTime(workspace.first_at)} → ${formatTime(workspace.last_at)}`);
|
|
207
|
+
}
|
|
208
|
+
return lines.join('\n');
|
|
209
|
+
}
|
|
210
|
+
case 'status': {
|
|
211
|
+
if (!value.indexed) {
|
|
212
|
+
return `还没有索引(${value.index_path})。请先调用 sessiongraph_index 建一次索引;建好之前除 status 外的查询都会失败。`;
|
|
213
|
+
}
|
|
214
|
+
const indexedAt = value.indexed_at === undefined ? '未知' : formatTime(value.indexed_at);
|
|
215
|
+
return `索引就绪(${value.index_path}):${value.sessions} 个会话、${value.entries} 条可检索条目、`
|
|
216
|
+
+ `${value.workspaces} 个工作区、${formatBytes(value.bytes)}。最近一次建索引 ${indexedAt}。`
|
|
217
|
+
+ `\n${formatLiveSessions(value.live_sessions)}`;
|
|
218
|
+
}
|
|
219
|
+
case 'index': {
|
|
220
|
+
// A delegated run has no report yet. Saying so, and naming the job that will carry it, is the
|
|
221
|
+
// whole answer: the model must not read "started" as "indexed" and query an index that is
|
|
222
|
+
// still being replaced.
|
|
223
|
+
if ('status' in value) {
|
|
224
|
+
return `已在后台建索引,jobId=${value.job_id}。可以稍后查 sessiongraph status 看规模;`
|
|
225
|
+
+ `job_output ${value.job_id} 能取这次运行的报告。`;
|
|
226
|
+
}
|
|
227
|
+
const failed = value.failed.length === 0
|
|
228
|
+
? '没有会话解析失败。'
|
|
229
|
+
: `解析失败 ${value.failed.length} 个:`
|
|
230
|
+
+ value.failed.map(entry => `\n ${entry.path} — ${entry.reason}`).join('');
|
|
231
|
+
const replayed = formatLiveReplay(value.live_sessions);
|
|
232
|
+
return [
|
|
233
|
+
`建索引完成,用时 ${formatDuration(value.elapsed_ms)}:`
|
|
234
|
+
+ `新增 ${value.added}、更新 ${value.updated}、跳过 ${value.skipped}、移除 ${value.removed}`
|
|
235
|
+
+ `(共发现 ${value.discovered} 个会话)。`,
|
|
236
|
+
`${value.index_path}:${value.sessions} 个会话、${value.entries} 条可检索条目、`
|
|
237
|
+
+ `${value.workspaces} 个工作区、${formatBytes(value.bytes)}。`,
|
|
238
|
+
...(replayed === '' ? [] : [replayed]),
|
|
239
|
+
failed,
|
|
240
|
+
].join('\n');
|
|
241
|
+
}
|
|
242
|
+
/* v8 ignore next -- exhaustive over the output schema's closed union; unreachable. */
|
|
243
|
+
default:
|
|
244
|
+
return assertNever(value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
//# sourceMappingURL=render.js.map
|