@deepseek-harness-tui/dsh-tui 0.7.1 → 0.7.2
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/README.md +6 -4
- package/bin/dsh-tui.js +37 -1
- package/cordis.patch.yml +10 -0
- package/lib/types/commands.js +1 -1
- package/lib/types/components/LoadedContextPanel.d.ts.map +1 -1
- package/lib/types/components/LoadedContextPanel.js +1 -1
- package/lib/types/components/design-system/ThemedText.d.ts +2 -2
- package/lib/types/components/design-system/ThemedText.d.ts.map +1 -1
- package/lib/types/components/design-system/ThemedText.js +1 -3
- package/lib/types/components/sessions/SessionListRow.d.ts +26 -0
- package/lib/types/components/sessions/SessionListRow.d.ts.map +1 -0
- package/lib/types/components/sessions/SessionListRow.js +35 -0
- package/lib/types/components/sessions/SessionPreview.d.ts +25 -0
- package/lib/types/components/sessions/SessionPreview.d.ts.map +1 -0
- package/lib/types/components/sessions/SessionPreview.js +60 -0
- package/lib/types/dsh-adapter/channel.d.ts +9 -4
- package/lib/types/dsh-adapter/channel.d.ts.map +1 -1
- package/lib/types/dsh-adapter/channel.js +31 -73
- package/lib/types/dsh-adapter/compat/index.d.ts +1 -1
- package/lib/types/dsh-adapter/compat/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/index.js +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts +8 -0
- package/lib/types/dsh-adapter/compat/sessionLog.d.ts.map +1 -1
- package/lib/types/dsh-adapter/compat/sessionLog.js +1 -1
- package/lib/types/dsh-adapter/index.d.ts.map +1 -1
- package/lib/types/dsh-adapter/index.js +10 -1
- package/lib/types/dsh-adapter/packaged-presets.d.ts +22 -0
- package/lib/types/dsh-adapter/packaged-presets.d.ts.map +1 -0
- package/lib/types/dsh-adapter/packaged-presets.js +90 -0
- package/lib/types/dsh-adapter/plugin.d.ts.map +1 -1
- package/lib/types/dsh-adapter/plugin.js +46 -4
- package/lib/types/dsh-adapter/presets.d.ts +16 -0
- package/lib/types/dsh-adapter/presets.d.ts.map +1 -1
- package/lib/types/dsh-adapter/presets.js +23 -0
- package/lib/types/dsh-adapter/sessions/digest.d.ts +29 -0
- package/lib/types/dsh-adapter/sessions/digest.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/digest.js +224 -0
- package/lib/types/dsh-adapter/sessions/frames.d.ts +100 -0
- package/lib/types/dsh-adapter/sessions/frames.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/frames.js +246 -0
- package/lib/types/dsh-adapter/sessions/header.d.ts +50 -0
- package/lib/types/dsh-adapter/sessions/header.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/header.js +62 -0
- package/lib/types/dsh-adapter/sessions/index.d.ts +17 -0
- package/lib/types/dsh-adapter/sessions/index.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/index.js +15 -0
- package/lib/types/dsh-adapter/sessions/list.d.ts +41 -0
- package/lib/types/dsh-adapter/sessions/list.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/list.js +213 -0
- package/lib/types/dsh-adapter/sessions/store.d.ts +48 -0
- package/lib/types/dsh-adapter/sessions/store.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/store.js +164 -0
- package/lib/types/dsh-adapter/sessions/types.d.ts +142 -0
- package/lib/types/dsh-adapter/sessions/types.d.ts.map +1 -0
- package/lib/types/dsh-adapter/sessions/types.js +14 -0
- package/lib/types/i18n.d.ts +118 -14
- package/lib/types/i18n.d.ts.map +1 -1
- package/lib/types/i18n.js +36 -6
- package/lib/types/screens/Chat.d.ts +3 -2
- package/lib/types/screens/Chat.d.ts.map +1 -1
- package/lib/types/screens/Chat.js +28 -144
- package/lib/types/screens/SessionBrowser.d.ts +37 -0
- package/lib/types/screens/SessionBrowser.d.ts.map +1 -0
- package/lib/types/screens/SessionBrowser.js +437 -0
- package/lib/types/sessionHistory.d.ts +0 -8
- package/lib/types/sessionHistory.d.ts.map +1 -1
- package/lib/types/sessions/format.d.ts +117 -0
- package/lib/types/sessions/format.d.ts.map +1 -0
- package/lib/types/sessions/format.js +244 -0
- package/lib/types/sessions/view.d.ts +125 -0
- package/lib/types/sessions/view.d.ts.map +1 -0
- package/lib/types/sessions/view.js +222 -0
- package/package.json +6 -2
- package/presets/liangshen/.dsh-tui-managed.json +5 -0
- package/presets/liangshen/agent.cordis.yml +389 -0
- package/presets/liangshen/compaction-epoch.mjs +81 -0
- package/presets/liangshen/custom-bash.mjs +126 -0
- package/presets/liangshen/instruction-hint.mjs +181 -0
- package/presets/liangshen/preset.yml +3 -0
- package/presets/liangshen/skill-search.mjs +142 -0
- package/presets/liangshen/tool-bootstrap.mjs +300 -0
- package/lib/types/components/ResumePicker.d.ts +0 -26
- package/lib/types/components/ResumePicker.d.ts.map +0 -1
- package/lib/types/components/ResumePicker.js +0 -50
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** A finite number, or undefined for anything else (NaN and ±Infinity included). */
|
|
2
|
+
function finiteNumber(value) {
|
|
3
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
4
|
+
}
|
|
5
|
+
/** A non-empty string, or undefined. */
|
|
6
|
+
function text(value) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Narrow one listed header.
|
|
11
|
+
*
|
|
12
|
+
* @param value - A header as the persistence service returned it.
|
|
13
|
+
* @returns The fields this feature uses, or undefined when the value carries
|
|
14
|
+
* no usable session id — the one field nothing can substitute for.
|
|
15
|
+
*/
|
|
16
|
+
export function readHeader(value) {
|
|
17
|
+
if (value === null || typeof value !== 'object')
|
|
18
|
+
return undefined;
|
|
19
|
+
const record = value;
|
|
20
|
+
const id = text(record['id']);
|
|
21
|
+
if (id === undefined)
|
|
22
|
+
return undefined;
|
|
23
|
+
return {
|
|
24
|
+
id,
|
|
25
|
+
cwd: text(record['cwd']),
|
|
26
|
+
createdAt: finiteNumber(record['createdAt']),
|
|
27
|
+
parentSession: text(record['parentSession']),
|
|
28
|
+
origin: text(record['origin']),
|
|
29
|
+
delegationDepth: finiteNumber(record['delegationDepth']),
|
|
30
|
+
seedLength: finiteNumber(record['seedLength']),
|
|
31
|
+
agentPreset: text(record['agentPreset']),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Decide what a session is, from its header alone.
|
|
36
|
+
*
|
|
37
|
+
* Precedence is `origin` first, lineage second, and that order is the whole
|
|
38
|
+
* correctness argument: a `/rewind` fork records `parentSession` exactly like
|
|
39
|
+
* a delegated run does, and only `origin` separates them. Upstream documents
|
|
40
|
+
* `origin` as "coarse product classification for a session created as a
|
|
41
|
+
* subagent child … presentation metadata", which is precisely this decision.
|
|
42
|
+
*
|
|
43
|
+
* `delegationDepth` is optional upstream, so a sub-agent whose header omits it
|
|
44
|
+
* is reported at depth 1: it is a delegated child by `origin`, and 1 is the
|
|
45
|
+
* shallowest depth that can be.
|
|
46
|
+
*
|
|
47
|
+
* @param header - A narrowed header.
|
|
48
|
+
* @returns The session's kind.
|
|
49
|
+
*/
|
|
50
|
+
export function classify(header) {
|
|
51
|
+
if (header.origin === 'subagent') {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'subagent',
|
|
54
|
+
parent: header.parentSession,
|
|
55
|
+
depth: header.delegationDepth ?? 1,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (header.parentSession !== undefined) {
|
|
59
|
+
return { kind: 'fork', parent: header.parentSession };
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'root' };
|
|
62
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted-session metadata — the adapter-side barrel.
|
|
3
|
+
*
|
|
4
|
+
* This directory is the only part of the session browser allowed to know how
|
|
5
|
+
* sessions are stored: the persistence service's shape, the log's frame
|
|
6
|
+
* container, the header's lineage fields. The browser screen and its
|
|
7
|
+
* components are pure UI over the types re-exported here and never touch a
|
|
8
|
+
* session log directly, which is what `verify-adapter-boundary` enforces.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-harness-tui/dsh-tui/sessions
|
|
11
|
+
*/
|
|
12
|
+
export { classify, readHeader, type RawSessionHeader } from './header.js';
|
|
13
|
+
export { digestSession, previewSession } from './digest.js';
|
|
14
|
+
export { listSummaries, locateSession, type SessionSource } from './list.js';
|
|
15
|
+
export { noteBranch, readIndex, writeIndex, type IndexEntry, type SessionIndex } from './store.js';
|
|
16
|
+
export type { PreviewEntry, SessionDigest, SessionKind, SessionSummary, SessionTitle, TitleSource, } from './types.js';
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,KAAK,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAEzE,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAE3D,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,KAAK,aAAa,EAAE,MAAM,WAAW,CAAA;AAE5E,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAA;AAElG,YAAY,EACV,YAAY,EACZ,aAAa,EACb,WAAW,EACX,cAAc,EACd,YAAY,EACZ,WAAW,GACZ,MAAM,YAAY,CAAA"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted-session metadata — the adapter-side barrel.
|
|
3
|
+
*
|
|
4
|
+
* This directory is the only part of the session browser allowed to know how
|
|
5
|
+
* sessions are stored: the persistence service's shape, the log's frame
|
|
6
|
+
* container, the header's lineage fields. The browser screen and its
|
|
7
|
+
* components are pure UI over the types re-exported here and never touch a
|
|
8
|
+
* session log directly, which is what `verify-adapter-boundary` enforces.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-harness-tui/dsh-tui/sessions
|
|
11
|
+
*/
|
|
12
|
+
export { classify, readHeader } from './header.js';
|
|
13
|
+
export { digestSession, previewSession } from './digest.js';
|
|
14
|
+
export { listSummaries, locateSession } from './list.js';
|
|
15
|
+
export { noteBranch, readIndex, writeIndex } from './store.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SessionSummary } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The slice of `ctx.sessionPersistence` this module uses.
|
|
4
|
+
*
|
|
5
|
+
* Structural and fully optional: the service is resolved from a running
|
|
6
|
+
* context whose packages may be a version apart from ours, and a listing that
|
|
7
|
+
* degrades is worth more than one that throws.
|
|
8
|
+
*/
|
|
9
|
+
export interface SessionSource {
|
|
10
|
+
/** Headers plus per-log change tokens — the contract built for this. */
|
|
11
|
+
listSnapshots?: (signal?: AbortSignal) => Promise<readonly unknown[]>;
|
|
12
|
+
/** Headers alone, for a backend or version without snapshots. */
|
|
13
|
+
list?: (signal?: AbortSignal) => Promise<readonly unknown[]>;
|
|
14
|
+
/** Absolute artifact path for one header; absent for storeless backends. */
|
|
15
|
+
locate?: (meta: unknown) => unknown;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read every stored session into a complete summary.
|
|
19
|
+
*
|
|
20
|
+
* @param source - The persistence service.
|
|
21
|
+
* @param signal - Optional cancellation for the backend's own listing work.
|
|
22
|
+
* @returns One summary per stored session, most recently active first. No
|
|
23
|
+
* filtering of any kind is applied — sub-agent runs and sessions with no
|
|
24
|
+
* conversation are present and labelled as such.
|
|
25
|
+
*/
|
|
26
|
+
export declare function listSummaries(source: SessionSource, signal?: AbortSignal): Promise<readonly SessionSummary[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve one session's artifact path.
|
|
29
|
+
*
|
|
30
|
+
* Listing headers is a first-line-only read per log — about 2 ms across a
|
|
31
|
+
* fifty-session history — so the preview pane resolves its target this way
|
|
32
|
+
* rather than making every summary carry a filesystem path it has no business
|
|
33
|
+
* knowing about.
|
|
34
|
+
*
|
|
35
|
+
* @param source - The persistence service.
|
|
36
|
+
* @param sessionId - Session to locate.
|
|
37
|
+
* @returns The absolute artifact path, or undefined when the backend owns no
|
|
38
|
+
* per-session file or the session is gone.
|
|
39
|
+
*/
|
|
40
|
+
export declare function locateSession(source: SessionSource, sessionId: string, signal?: AbortSignal): Promise<string | undefined>;
|
|
41
|
+
//# sourceMappingURL=list.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/list.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAGhD;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,wEAAwE;IACxE,aAAa,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAAA;IACrE,iEAAiE;IACjE,IAAI,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,SAAS,OAAO,EAAE,CAAC,CAAA;IAC5D,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAA;CACpC;AA0ED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,aAAa,EACrB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,SAAS,cAAc,EAAE,CAAC,CA8FpC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,aAAa,EACrB,SAAS,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAS7B"}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Building the session list.
|
|
3
|
+
*
|
|
4
|
+
* One resolution path produces one complete, honestly-classified record per
|
|
5
|
+
* stored session — every kind, empties included — and callers decide what to
|
|
6
|
+
* show. That split is deliberate: the old picker filtered while it resolved,
|
|
7
|
+
* so "hide sub-agent runs" and "resolve a title" were the same pass and
|
|
8
|
+
* neither could change without disturbing the other. Here the browser can
|
|
9
|
+
* toggle sub-agent runs into view, or offer to clean up boot artifacts,
|
|
10
|
+
* without re-deriving anything.
|
|
11
|
+
*
|
|
12
|
+
* Cost: one `stat` per session always, plus one bounded log read per session
|
|
13
|
+
* whose revision moved since the last listing. On a warm index that is zero
|
|
14
|
+
* log reads. The path this replaces decompressed every frame of the twenty
|
|
15
|
+
* most recent logs on every open — 3.9 s over a 31 MB history.
|
|
16
|
+
*
|
|
17
|
+
* @module @deepseek-harness-tui/dsh-tui/sessions/list
|
|
18
|
+
*/
|
|
19
|
+
import { basename } from 'node:path';
|
|
20
|
+
import { digestSession } from './digest.js';
|
|
21
|
+
import { fileFacts } from './frames.js';
|
|
22
|
+
import { classify, readHeader } from './header.js';
|
|
23
|
+
import { findSessionLogFile } from '../compat/sessionLog.js';
|
|
24
|
+
import { readIndex, writeIndex } from './store.js';
|
|
25
|
+
import { readLastUsed } from '../../sessionHistory.js';
|
|
26
|
+
/** Pull `{ header, revision }` out of one `listSnapshots()` element. */
|
|
27
|
+
function readSnapshot(value) {
|
|
28
|
+
if (value === null || typeof value !== 'object')
|
|
29
|
+
return undefined;
|
|
30
|
+
const record = value;
|
|
31
|
+
const raw = record['header'];
|
|
32
|
+
const header = readHeader(raw);
|
|
33
|
+
if (header === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
const revision = record['revision'];
|
|
36
|
+
return { header, raw, revision: typeof revision === 'string' ? revision : undefined };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Enumerate stored sessions.
|
|
40
|
+
*
|
|
41
|
+
* Prefers `listSnapshots()` because its revision is the backend's own answer
|
|
42
|
+
* to "has this log changed", and falls back to `list()` when the resolved
|
|
43
|
+
* service predates it — in which case the change token is derived from the
|
|
44
|
+
* file's own size and mtime further down. Both are honest change tokens for an
|
|
45
|
+
* append-only log; only the authority differs.
|
|
46
|
+
*/
|
|
47
|
+
async function enumerate(source, signal) {
|
|
48
|
+
if (typeof source.listSnapshots === 'function') {
|
|
49
|
+
const snapshots = await source.listSnapshots(signal);
|
|
50
|
+
return snapshots.map(readSnapshot).filter((entry) => entry !== undefined);
|
|
51
|
+
}
|
|
52
|
+
if (typeof source.list === 'function') {
|
|
53
|
+
const headers = await source.list(signal);
|
|
54
|
+
return headers
|
|
55
|
+
.map((raw) => {
|
|
56
|
+
const header = readHeader(raw);
|
|
57
|
+
return header === undefined ? undefined : { header, raw, revision: undefined };
|
|
58
|
+
})
|
|
59
|
+
.filter((entry) => entry !== undefined);
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Absolute artifact path for one session.
|
|
65
|
+
*
|
|
66
|
+
* The backend's own `locate()` is authoritative and is asked first. The
|
|
67
|
+
* fallback scans the session roots for the id, which is what the compat layer
|
|
68
|
+
* has always done and is deliberately independent of the backend's
|
|
69
|
+
* workspace-key scheme — so a runtime whose persistence service predates
|
|
70
|
+
* `locate`, or whose key sanitization changes, still resolves.
|
|
71
|
+
*
|
|
72
|
+
* A backend that stores no per-session artifact (SQLite) answers neither, and
|
|
73
|
+
* its sessions are summarized from their headers alone.
|
|
74
|
+
*/
|
|
75
|
+
function locate(source, raw, sessionId) {
|
|
76
|
+
if (typeof source.locate === 'function') {
|
|
77
|
+
let location;
|
|
78
|
+
try {
|
|
79
|
+
location = source.locate(raw);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
location = undefined;
|
|
83
|
+
}
|
|
84
|
+
if (location !== null && typeof location === 'object') {
|
|
85
|
+
const path = location['path'];
|
|
86
|
+
if (typeof path === 'string' && path.length > 0)
|
|
87
|
+
return path;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return findSessionLogFile(sessionId);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Read every stored session into a complete summary.
|
|
94
|
+
*
|
|
95
|
+
* @param source - The persistence service.
|
|
96
|
+
* @param signal - Optional cancellation for the backend's own listing work.
|
|
97
|
+
* @returns One summary per stored session, most recently active first. No
|
|
98
|
+
* filtering of any kind is applied — sub-agent runs and sessions with no
|
|
99
|
+
* conversation are present and labelled as such.
|
|
100
|
+
*/
|
|
101
|
+
export async function listSummaries(source, signal) {
|
|
102
|
+
let listed;
|
|
103
|
+
try {
|
|
104
|
+
listed = await enumerate(source, signal);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
// Children are counted from the same listing rather than by walking logs:
|
|
110
|
+
// lineage lives in the header, so a parent's sub-agent count is free.
|
|
111
|
+
const children = new Map();
|
|
112
|
+
for (const entry of listed) {
|
|
113
|
+
if (entry.header.origin !== 'subagent')
|
|
114
|
+
continue;
|
|
115
|
+
const parent = entry.header.parentSession;
|
|
116
|
+
if (parent === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
children.set(parent, (children.get(parent) ?? 0) + 1);
|
|
119
|
+
}
|
|
120
|
+
const index = readIndex();
|
|
121
|
+
const next = new Map();
|
|
122
|
+
const lastUsed = readLastUsed();
|
|
123
|
+
let changed = false;
|
|
124
|
+
const summaries = [];
|
|
125
|
+
for (const { header, raw, revision } of listed) {
|
|
126
|
+
const cached = index.get(header.id);
|
|
127
|
+
const path = locate(source, raw, header.id);
|
|
128
|
+
const facts = path === undefined ? undefined : fileFacts(path);
|
|
129
|
+
// Falls back to the file's own identity when the backend offered no token.
|
|
130
|
+
const token = revision ?? (facts === undefined ? undefined : `${facts.bytes}:${facts.modifiedAt}`);
|
|
131
|
+
let derived;
|
|
132
|
+
if (cached?.derived !== undefined && token !== undefined && cached.derived.revision === token) {
|
|
133
|
+
derived = cached.derived;
|
|
134
|
+
}
|
|
135
|
+
else if (path !== undefined && token !== undefined) {
|
|
136
|
+
const digest = digestSession(path, header.cwd ?? '');
|
|
137
|
+
derived = {
|
|
138
|
+
revision: token,
|
|
139
|
+
title: digest.title?.text ?? '',
|
|
140
|
+
titleSource: digest.title?.source ?? 'fallback',
|
|
141
|
+
hasPrompt: digest.hasPrompt,
|
|
142
|
+
model: digest.model,
|
|
143
|
+
label: digest.label,
|
|
144
|
+
};
|
|
145
|
+
changed = true;
|
|
146
|
+
}
|
|
147
|
+
// Carry every entry that holds anything worth keeping — including a pure
|
|
148
|
+
// cache hit, which must survive into the next index or the following
|
|
149
|
+
// listing would re-derive everything it just reused.
|
|
150
|
+
if (derived !== undefined || cached?.branch !== undefined) {
|
|
151
|
+
next.set(header.id, { derived, branch: cached?.branch });
|
|
152
|
+
}
|
|
153
|
+
const createdAt = header.createdAt ?? facts?.modifiedAt ?? 0;
|
|
154
|
+
summaries.push({
|
|
155
|
+
id: header.id,
|
|
156
|
+
kind: classify(header),
|
|
157
|
+
title: {
|
|
158
|
+
text: derived?.title !== undefined && derived.title.length > 0
|
|
159
|
+
? derived.title
|
|
160
|
+
: basename(header.cwd ?? '') || header.id.slice(0, 8),
|
|
161
|
+
source: derived?.titleSource ?? 'fallback',
|
|
162
|
+
},
|
|
163
|
+
cwd: header.cwd ?? '',
|
|
164
|
+
createdAt,
|
|
165
|
+
updatedAt: Math.max(facts?.modifiedAt ?? 0, lastUsed[header.id] ?? 0, createdAt),
|
|
166
|
+
bytes: facts?.bytes,
|
|
167
|
+
// Without a readable artifact nothing can be proven empty, and hiding a
|
|
168
|
+
// real session is the worse error — so an unreadable log is listed.
|
|
169
|
+
hasPrompt: derived?.hasPrompt ?? true,
|
|
170
|
+
agentPreset: header.agentPreset,
|
|
171
|
+
model: derived?.model,
|
|
172
|
+
label: derived?.label,
|
|
173
|
+
branch: cached?.branch,
|
|
174
|
+
childCount: children.get(header.id) ?? 0,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
// Entries for sessions the backend no longer lists are dropped here; that is
|
|
178
|
+
// the whole of the cache's garbage collection, and it runs on every listing.
|
|
179
|
+
if (changed || next.size !== index.size)
|
|
180
|
+
writeIndex(next);
|
|
181
|
+
// A total order, not just a sort key. `updatedAt` is dominated by the log's
|
|
182
|
+
// mtime, and sessions written inside the same millisecond tie on it — which
|
|
183
|
+
// would leave their relative order down to whatever the backend happened to
|
|
184
|
+
// enumerate first, so the same history could list differently twice in a
|
|
185
|
+
// row. Creation time breaks the tie, and the id breaks that.
|
|
186
|
+
return summaries.sort((left, right) => right.updatedAt - left.updatedAt ||
|
|
187
|
+
right.createdAt - left.createdAt ||
|
|
188
|
+
(left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Resolve one session's artifact path.
|
|
192
|
+
*
|
|
193
|
+
* Listing headers is a first-line-only read per log — about 2 ms across a
|
|
194
|
+
* fifty-session history — so the preview pane resolves its target this way
|
|
195
|
+
* rather than making every summary carry a filesystem path it has no business
|
|
196
|
+
* knowing about.
|
|
197
|
+
*
|
|
198
|
+
* @param source - The persistence service.
|
|
199
|
+
* @param sessionId - Session to locate.
|
|
200
|
+
* @returns The absolute artifact path, or undefined when the backend owns no
|
|
201
|
+
* per-session file or the session is gone.
|
|
202
|
+
*/
|
|
203
|
+
export async function locateSession(source, sessionId, signal) {
|
|
204
|
+
let listed;
|
|
205
|
+
try {
|
|
206
|
+
listed = await enumerate(source, signal);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
const match = listed.find(entry => entry.header.id === sessionId);
|
|
212
|
+
return match === undefined ? undefined : locate(source, match.raw, sessionId);
|
|
213
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { TitleSource } from './types.js';
|
|
2
|
+
/** Facts derived from a log at one revision. */
|
|
3
|
+
export interface DerivedEntry {
|
|
4
|
+
readonly revision: string;
|
|
5
|
+
readonly title: string;
|
|
6
|
+
readonly titleSource: TitleSource;
|
|
7
|
+
readonly hasPrompt: boolean;
|
|
8
|
+
readonly model: string | undefined;
|
|
9
|
+
readonly label: string | undefined;
|
|
10
|
+
}
|
|
11
|
+
/** One session's cached record: derived facts plus this install's own notes. */
|
|
12
|
+
export interface IndexEntry {
|
|
13
|
+
readonly derived: DerivedEntry | undefined;
|
|
14
|
+
/** Git branch this install was on when it last used the session. */
|
|
15
|
+
readonly branch: string | undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The whole cache, session id → entry. */
|
|
18
|
+
export type SessionIndex = Map<string, IndexEntry>;
|
|
19
|
+
/**
|
|
20
|
+
* Load the cache.
|
|
21
|
+
* @returns The parsed index; an unreadable, malformed, or stale-schema file
|
|
22
|
+
* yields an empty one, which costs a rebuild and never an error.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readIndex(): SessionIndex;
|
|
25
|
+
/**
|
|
26
|
+
* Persist the cache, atomically.
|
|
27
|
+
*
|
|
28
|
+
* The write goes to a per-process temporary name and is renamed into place, so
|
|
29
|
+
* a reader never observes a half-written file and a crash never leaves one.
|
|
30
|
+
* A concurrent writer may win the rename; the loser's derivations are simply
|
|
31
|
+
* recomputed next time.
|
|
32
|
+
*
|
|
33
|
+
* @param index - The index to store. Entries are written in insertion order.
|
|
34
|
+
*/
|
|
35
|
+
export declare function writeIndex(index: SessionIndex): void;
|
|
36
|
+
/**
|
|
37
|
+
* Record the git branch this install is on for a session.
|
|
38
|
+
*
|
|
39
|
+
* Kept here rather than in the log because it is not a fact about the session,
|
|
40
|
+
* it is a fact about how this machine used it — the same reason it is reported
|
|
41
|
+
* as "branch when last used" and omitted entirely for sessions that predate
|
|
42
|
+
* the note. Inventing a branch for them would be worse than showing none.
|
|
43
|
+
*
|
|
44
|
+
* @param sessionId - Session being used.
|
|
45
|
+
* @param branch - Current branch, or undefined to leave any note untouched.
|
|
46
|
+
*/
|
|
47
|
+
export declare function noteBranch(sessionId: string, branch: string | undefined): void;
|
|
48
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../../src/dsh-adapter/sessions/store.ts"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAW7C,gDAAgD;AAChD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;IACjC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAA;CACnC;AAED,gFAAgF;AAChF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,SAAS,CAAA;IAC1C,oEAAoE;IACpE,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAA;CACpC;AAED,2CAA2C;AAC3C,MAAM,MAAM,YAAY,GAAG,GAAG,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;AAoClD;;;;GAIG;AACH,wBAAgB,SAAS,IAAI,YAAY,CAkBxC;AAED;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,CAoBpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAO9E"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session index — a local cache keyed by the backend's own change token.
|
|
3
|
+
*
|
|
4
|
+
* Deriving a session's title costs a bounded read; doing it for every session
|
|
5
|
+
* on every picker open costs that read times the history. The persistence
|
|
6
|
+
* service already hands out the exact token needed to avoid it:
|
|
7
|
+
* `listSnapshots()` returns, per session, an opaque revision that changes
|
|
8
|
+
* whenever the stored log changes. An entry whose revision still matches is
|
|
9
|
+
* reused verbatim; anything else is re-derived. Steady state is therefore zero
|
|
10
|
+
* log reads, and a session edited by another client (the store is shared with
|
|
11
|
+
* dsh web) invalidates itself without any coordination.
|
|
12
|
+
*
|
|
13
|
+
* The revision is treated as opaque, as its contract requires. It happens to
|
|
14
|
+
* be stat-derived today, but parsing it to shortcut a `stat` would couple this
|
|
15
|
+
* cache to one backend's private format and break the moment a store without
|
|
16
|
+
* per-session files is used.
|
|
17
|
+
*
|
|
18
|
+
* Two halves live in one entry because they have different lifetimes:
|
|
19
|
+
* `derived` facts come from the log and die with the revision, while `branch`
|
|
20
|
+
* is a local note about how this install used the session and no log change
|
|
21
|
+
* can invalidate it. Keeping them in one record with one governing revision
|
|
22
|
+
* field makes that boundary explicit instead of implied.
|
|
23
|
+
*
|
|
24
|
+
* Every operation is best-effort. The index is a cache: a corrupt file, a
|
|
25
|
+
* losing concurrent write, or a read-only home directory costs a re-derivation
|
|
26
|
+
* and nothing else, so nothing here throws.
|
|
27
|
+
*
|
|
28
|
+
* @module @deepseek-harness-tui/dsh-tui/sessions/store
|
|
29
|
+
*/
|
|
30
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { DATA_DIR } from '../../utils/paths.js';
|
|
33
|
+
/**
|
|
34
|
+
* Bumped when an entry's shape changes. A mismatch drops the whole file
|
|
35
|
+
* rather than migrating it — re-deriving is cheap and bounded, whereas a
|
|
36
|
+
* migration path is code that runs once and is never exercised again.
|
|
37
|
+
*/
|
|
38
|
+
const SCHEMA_VERSION = 1;
|
|
39
|
+
const INDEX_FILE = join(DATA_DIR, 'session-index.json');
|
|
40
|
+
/** A title source as written to disk, or undefined for anything unexpected. */
|
|
41
|
+
function readTitleSource(value) {
|
|
42
|
+
return value === 'renamed' || value === 'auto' || value === 'prompt' || value === 'fallback'
|
|
43
|
+
? value
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Narrow one persisted entry; an unrecognizable record is simply absent. */
|
|
47
|
+
function readEntry(value) {
|
|
48
|
+
if (value === null || typeof value !== 'object')
|
|
49
|
+
return undefined;
|
|
50
|
+
const record = value;
|
|
51
|
+
const branch = typeof record['branch'] === 'string' ? record['branch'] : undefined;
|
|
52
|
+
const raw = record['derived'];
|
|
53
|
+
if (raw === null || typeof raw !== 'object')
|
|
54
|
+
return { derived: undefined, branch };
|
|
55
|
+
const derived = raw;
|
|
56
|
+
const revision = derived['revision'];
|
|
57
|
+
const title = derived['title'];
|
|
58
|
+
const titleSource = readTitleSource(derived['titleSource']);
|
|
59
|
+
if (typeof revision !== 'string' || typeof title !== 'string' || titleSource === undefined) {
|
|
60
|
+
return { derived: undefined, branch };
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
branch,
|
|
64
|
+
derived: {
|
|
65
|
+
revision,
|
|
66
|
+
title,
|
|
67
|
+
titleSource,
|
|
68
|
+
hasPrompt: derived['hasPrompt'] === true,
|
|
69
|
+
model: typeof derived['model'] === 'string' ? derived['model'] : undefined,
|
|
70
|
+
label: typeof derived['label'] === 'string' ? derived['label'] : undefined,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Load the cache.
|
|
76
|
+
* @returns The parsed index; an unreadable, malformed, or stale-schema file
|
|
77
|
+
* yields an empty one, which costs a rebuild and never an error.
|
|
78
|
+
*/
|
|
79
|
+
export function readIndex() {
|
|
80
|
+
const index = new Map();
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return index;
|
|
87
|
+
}
|
|
88
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
89
|
+
return index;
|
|
90
|
+
const file = parsed;
|
|
91
|
+
if (file['version'] !== SCHEMA_VERSION)
|
|
92
|
+
return index;
|
|
93
|
+
const entries = file['entries'];
|
|
94
|
+
if (entries === null || typeof entries !== 'object')
|
|
95
|
+
return index;
|
|
96
|
+
for (const [id, value] of Object.entries(entries)) {
|
|
97
|
+
const entry = readEntry(value);
|
|
98
|
+
if (entry !== undefined)
|
|
99
|
+
index.set(id, entry);
|
|
100
|
+
}
|
|
101
|
+
return index;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Persist the cache, atomically.
|
|
105
|
+
*
|
|
106
|
+
* The write goes to a per-process temporary name and is renamed into place, so
|
|
107
|
+
* a reader never observes a half-written file and a crash never leaves one.
|
|
108
|
+
* A concurrent writer may win the rename; the loser's derivations are simply
|
|
109
|
+
* recomputed next time.
|
|
110
|
+
*
|
|
111
|
+
* @param index - The index to store. Entries are written in insertion order.
|
|
112
|
+
*/
|
|
113
|
+
export function writeIndex(index) {
|
|
114
|
+
const entries = {};
|
|
115
|
+
for (const [id, entry] of index) {
|
|
116
|
+
entries[id] = {
|
|
117
|
+
...(entry.derived === undefined ? {} : { derived: entry.derived }),
|
|
118
|
+
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const temporary = `${INDEX_FILE}.${process.pid}.tmp`;
|
|
122
|
+
try {
|
|
123
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
124
|
+
writeFileSync(temporary, JSON.stringify({ version: SCHEMA_VERSION, entries }));
|
|
125
|
+
renameSync(temporary, INDEX_FILE);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
try {
|
|
129
|
+
rmSync(temporary, { force: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Nothing left to do; the cache stays as it was.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Record the git branch this install is on for a session.
|
|
138
|
+
*
|
|
139
|
+
* Kept here rather than in the log because it is not a fact about the session,
|
|
140
|
+
* it is a fact about how this machine used it — the same reason it is reported
|
|
141
|
+
* as "branch when last used" and omitted entirely for sessions that predate
|
|
142
|
+
* the note. Inventing a branch for them would be worse than showing none.
|
|
143
|
+
*
|
|
144
|
+
* @param sessionId - Session being used.
|
|
145
|
+
* @param branch - Current branch, or undefined to leave any note untouched.
|
|
146
|
+
*/
|
|
147
|
+
export function noteBranch(sessionId, branch) {
|
|
148
|
+
if (branch === undefined || branch.length === 0)
|
|
149
|
+
return;
|
|
150
|
+
const index = readIndex();
|
|
151
|
+
const existing = index.get(sessionId);
|
|
152
|
+
if (existing?.branch === branch)
|
|
153
|
+
return;
|
|
154
|
+
index.set(sessionId, { derived: existing?.derived, branch });
|
|
155
|
+
writeIndex(index);
|
|
156
|
+
}
|
|
157
|
+
/*
|
|
158
|
+
* Deliberately absent: a "forget this entry" call for the picker's delete, and
|
|
159
|
+
* a "patch this title" call for its rename. Listing prunes entries whose
|
|
160
|
+
* session no longer exists, and a renamed log has a new revision whose
|
|
161
|
+
* re-derivation reads the very title event the rename just appended — both
|
|
162
|
+
* paths already converge on the right answer, so a second mechanism to reach
|
|
163
|
+
* it could only disagree with the first.
|
|
164
|
+
*/
|