@itookit/dsht 0.3.4 → 0.3.8

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.
Files changed (42) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -3
  3. package/README.zh.md +4 -3
  4. package/dist/cli/dsht.js +5 -2
  5. package/dist/controller/controller.d.ts +113 -1
  6. package/dist/controller/controller.js +145 -2
  7. package/dist/cost/controller.d.ts +5 -0
  8. package/dist/cost/controller.js +2 -1
  9. package/dist/cost/scanner.d.ts +4 -2
  10. package/dist/cost/scanner.js +6 -3
  11. package/dist/session/controller.d.ts +150 -1
  12. package/dist/session/controller.js +388 -29
  13. package/dist/session/history.d.ts +21 -1
  14. package/dist/session/history.js +15 -0
  15. package/dist/session/index.d.ts +2 -0
  16. package/dist/session/index.js +1 -0
  17. package/dist/session/info.d.ts +262 -0
  18. package/dist/session/info.js +326 -0
  19. package/dist/session/transcript.d.ts +37 -1
  20. package/dist/session/transcript.js +73 -0
  21. package/dist/shell/controller.d.ts +67 -0
  22. package/dist/shell/controller.js +126 -0
  23. package/dist/shell/index.d.ts +5 -0
  24. package/dist/shell/index.js +3 -0
  25. package/dist/shell/runner.d.ts +28 -0
  26. package/dist/shell/runner.js +108 -0
  27. package/dist/state.d.ts +3 -2
  28. package/dist/state.js +2 -2
  29. package/dist/ui/app.js +206 -177
  30. package/dist/ui/chat/history-view.js +1 -1
  31. package/dist/ui/chat/shell-view.d.ts +34 -0
  32. package/dist/ui/chat/shell-view.js +111 -0
  33. package/dist/ui/chat/status.js +4 -4
  34. package/dist/ui/commands/parse.d.ts +5 -0
  35. package/dist/ui/commands/parse.js +9 -0
  36. package/dist/ui/dialogs/index.d.ts +3 -6
  37. package/dist/ui/theme/index.d.ts +5 -0
  38. package/dist/ui/theme/index.js +2 -1
  39. package/dsht-m.png +0 -0
  40. package/package.json +1 -1
  41. package/dist/ui/input/history.d.ts +0 -19
  42. package/dist/ui/input/history.js +0 -43
@@ -59,6 +59,40 @@ export function contentText(content, tools, width = 100, reasoning = 'full') {
59
59
  }
60
60
  /** Event types that contribute a displayed message; other retained events only affect live state. */
61
61
  const DISPLAY_EVENTS = new Set(['user/message', 'assistant/message', 'tool/result']);
62
+ /** One durable `user/message` event as a recallable prompt.
63
+ *
64
+ * Injected context and every other record type return undefined. This is the single place the rule
65
+ * lives, so a prompt parsed straight from a wire record (the cost scan's pages) is identical to one
66
+ * the transcript folded into its own window.
67
+ * @param seq - Durable sequence of the record.
68
+ * @param event - Decoded wire event, when the record carried one.
69
+ * @returns The prompt text, or undefined when the record is not a user prompt.
70
+ */
71
+ export function eventPrompt(seq, event) {
72
+ if (!event || event.type !== 'user/message' || event.surfaceOp !== 'append')
73
+ return undefined;
74
+ const data = object(event.data);
75
+ if (data.source && object(data.source).kind !== 'user')
76
+ return undefined;
77
+ return { seq, text: contentText(data.content) };
78
+ }
79
+ /** User prompts in one raw history page, oldest first.
80
+ * @param records - One HTTP history page's records.
81
+ * @returns Prompts with their durable sequences, in page order.
82
+ */
83
+ export function recordPrompts(records) {
84
+ const prompts = [];
85
+ for (const raw of array(records)) {
86
+ const event = object(object(raw).event);
87
+ const seq = event.seq;
88
+ if (typeof seq !== 'number' || !Number.isSafeInteger(seq))
89
+ continue;
90
+ const prompt = eventPrompt(seq, event);
91
+ if (prompt)
92
+ prompts.push(prompt);
93
+ }
94
+ return prompts;
95
+ }
62
96
  /** Opening snapshots replace all state; durable events are deduplicated by sequence. */
63
97
  export class Transcript {
64
98
  sizes = new Map();
@@ -397,6 +431,45 @@ export class Transcript {
397
431
  }
398
432
  /** Latest loaded user prompt summary, sharing the durable thought index. */
399
433
  get latestPrompt() { void this.thoughts; return this.thoughtIndex.prompt; }
434
+ /** User prompts newer than `afterSeq`, oldest first, with the newest sequence scanned.
435
+ *
436
+ * Recall folds its tail this way instead of projecting messages, so a stream frame never rebuilds
437
+ * the row layout at a second width just to notice a new prompt. The watermark is returned even when
438
+ * the scanned records contributed no prompt, because an assistant-only turn must not make the next
439
+ * frame rescan it.
440
+ * @param afterSeq - Newest sequence already folded into the caller's index.
441
+ * @returns Prompts with an increasing sequence, and the watermark the caller should adopt.
442
+ */
443
+ promptsSince(afterSeq) {
444
+ const keys = this.sortedKeys();
445
+ let start = keys.length;
446
+ while (start > 0 && keys[start - 1] > afterSeq)
447
+ start--;
448
+ const prompts = [];
449
+ for (let index = start; index < keys.length; index++) {
450
+ const prompt = this.userPrompt(keys[index]);
451
+ if (prompt)
452
+ prompts.push(prompt);
453
+ }
454
+ return { prompts, through: Math.max(afterSeq, keys.at(-1) ?? afterSeq) };
455
+ }
456
+ /** User prompts strictly older than `beforeSeq`, oldest first, from the retained window.
457
+ * @param beforeSeq - Sequence the caller's index has already reached.
458
+ * @returns Loaded prompts that can refill an evicted prefix without a page request.
459
+ */
460
+ promptsBefore(beforeSeq) {
461
+ const prompts = [];
462
+ for (const seq of this.sortedKeys()) {
463
+ if (seq >= beforeSeq)
464
+ break;
465
+ const prompt = this.userPrompt(seq);
466
+ if (prompt)
467
+ prompts.push(prompt);
468
+ }
469
+ return prompts;
470
+ }
471
+ /** One `user/message` record as a prompt; injected context and other records return undefined. */
472
+ userPrompt(seq) { return eventPrompt(seq, this.events.get(seq)); }
400
473
  /** Number of semantic records and unfinished legacy chunks held by the client. */
401
474
  get retainedRecordCount() { return this.events.size; }
402
475
  /** Earliest loaded record, used with the opening cursor for backward paging. */
@@ -0,0 +1,67 @@
1
+ /** One local command and the output retained for it. */
2
+ export interface ShellBlock {
3
+ id: number;
4
+ command: string;
5
+ /** Retained output lines, oldest first. */
6
+ lines: string[];
7
+ /** Lines dropped from the front because the block exceeded its budget. */
8
+ dropped: number;
9
+ /** Durable sequence that was newest when the command started; the block is shown after it. */
10
+ anchor: number;
11
+ status: 'running' | 'exited';
12
+ /** Exit code, once the command ended; null when a signal ended it. */
13
+ code?: number | null;
14
+ signal?: string | null;
15
+ startedAt: number;
16
+ endedAt?: number;
17
+ }
18
+ /** What one shell controller needs from its owner. */
19
+ export interface ShellHost {
20
+ /** Repaint after output or a status change. */
21
+ publish(): void;
22
+ /** Client working directory the command runs in. */
23
+ cwd(): string;
24
+ /** Environment for the child, already stripped of client credentials. */
25
+ env(): NodeJS.ProcessEnv;
26
+ /** Newest durable sequence of the selected session, so a block stays where it happened. */
27
+ anchor(): number;
28
+ }
29
+ /** Owns the `!` commands of this client process: one at a time, bounded, killable. */
30
+ export declare class ShellController {
31
+ private readonly host;
32
+ readonly enabled: boolean;
33
+ private readonly blocks;
34
+ private readonly bytes;
35
+ private nextId;
36
+ private task;
37
+ private abort;
38
+ private lastPublish;
39
+ constructor(host: ShellHost, /** Whether `!` is allowed at all. */ enabled?: boolean);
40
+ /** Runs in creation order, oldest first; the newest is what the transcript shows at its end. */
41
+ get runs(): readonly ShellBlock[];
42
+ /** Whether a command is still running. */
43
+ get running(): boolean;
44
+ /** Start one command.
45
+ *
46
+ * One command runs at a time: a second `!` while the first is live is refused rather than queued,
47
+ * because the transcript shows a single result block and the reader can stop the first with Ctrl+C.
48
+ * @param command - Command line typed after `!`.
49
+ * @returns The block created for it.
50
+ */
51
+ start(command: string): ShellBlock;
52
+ /** Stop the running command: its process group gets SIGTERM, then SIGKILL after a grace period.
53
+ * @returns Whether a run was stopped.
54
+ */
55
+ cancel(): boolean;
56
+ /** One run's retained output, with dropped lines made explicit.
57
+ * @param id - Run to read; defaults to the newest.
58
+ * @returns The text, or undefined when the run is unknown.
59
+ */
60
+ output(id?: number): string | undefined;
61
+ /** Stop the running command and wait for it, so no child outlives this client. */
62
+ stop(): Promise<void>;
63
+ /** Append one output line, trimming the block to its budgets from the front. */
64
+ private append;
65
+ /** Repaint, at most once per interval unless the change is a status change. */
66
+ private publish;
67
+ }
@@ -0,0 +1,126 @@
1
+ /** Local shell runs the reader started with `!`, kept as bounded blocks for inline display.
2
+ *
3
+ * A block is the command line plus its retained output. Output is capped by both lines and bytes, so
4
+ * a runaway command can only fill its block, and only the newest blocks are kept. Nothing here is
5
+ * durable: no host record, no session state, no file.
6
+ */
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { runShell } from "./runner.js";
9
+ /** Output lines one block keeps before it drops the oldest. */
10
+ const BLOCK_LINES = 200;
11
+ /** Output bytes one block keeps before it drops the oldest. */
12
+ const BLOCK_BYTES = 64 * 1024;
13
+ /** Runs kept for display; the newest always survives. */
14
+ const BLOCK_LIMIT = 20;
15
+ /** Fastest repaint cadence while output streams; status changes always publish. */
16
+ const PUBLISH_INTERVAL_MS = 80;
17
+ /** Owns the `!` commands of this client process: one at a time, bounded, killable. */
18
+ export class ShellController {
19
+ host;
20
+ enabled;
21
+ blocks = [];
22
+ bytes = new WeakMap();
23
+ nextId = 1;
24
+ task;
25
+ abort;
26
+ lastPublish = 0;
27
+ constructor(host, /** Whether `!` is allowed at all. */ enabled = true) {
28
+ this.host = host;
29
+ this.enabled = enabled;
30
+ }
31
+ /** Runs in creation order, oldest first; the newest is what the transcript shows at its end. */
32
+ get runs() { return this.blocks; }
33
+ /** Whether a command is still running. */
34
+ get running() { return this.blocks.some(block => block.status === 'running'); }
35
+ /** Start one command.
36
+ *
37
+ * One command runs at a time: a second `!` while the first is live is refused rather than queued,
38
+ * because the transcript shows a single result block and the reader can stop the first with Ctrl+C.
39
+ * @param command - Command line typed after `!`.
40
+ * @returns The block created for it.
41
+ */
42
+ start(command) {
43
+ if (!this.enabled)
44
+ throw new Error('Shell commands are disabled (--no-shell or DSHT_NO_SHELL=1)');
45
+ if (this.task)
46
+ throw new Error('A shell command is already running; Ctrl+C stops it');
47
+ const block = { id: this.nextId++, command, lines: [], dropped: 0, status: 'running',
48
+ startedAt: Date.now(), anchor: this.host.anchor() };
49
+ this.blocks.push(block);
50
+ while (this.blocks.length > BLOCK_LIMIT)
51
+ this.blocks.shift();
52
+ this.bytes.set(block, 0);
53
+ const abort = new AbortController();
54
+ this.abort = abort;
55
+ this.publish(true);
56
+ const task = runShell(command, {
57
+ cwd: this.host.cwd(), env: this.host.env(), signal: abort.signal,
58
+ onLine: (line, stream) => this.append(block, line),
59
+ }).then(exit => {
60
+ block.status = 'exited';
61
+ block.code = exit.code;
62
+ block.signal = exit.signal;
63
+ block.endedAt = Date.now();
64
+ }, error => {
65
+ block.status = 'exited';
66
+ block.code = null;
67
+ this.append(block, `! ${error instanceof Error ? error.message : String(error)}`);
68
+ }).finally(() => {
69
+ if (this.abort === abort)
70
+ this.abort = undefined;
71
+ if (this.task === task)
72
+ this.task = undefined;
73
+ this.publish(true);
74
+ });
75
+ this.task = task;
76
+ return block;
77
+ }
78
+ /** Stop the running command: its process group gets SIGTERM, then SIGKILL after a grace period.
79
+ * @returns Whether a run was stopped.
80
+ */
81
+ cancel() {
82
+ if (!this.abort)
83
+ return false;
84
+ this.abort.abort();
85
+ return true;
86
+ }
87
+ /** One run's retained output, with dropped lines made explicit.
88
+ * @param id - Run to read; defaults to the newest.
89
+ * @returns The text, or undefined when the run is unknown.
90
+ */
91
+ output(id) {
92
+ const block = id === undefined ? this.blocks.at(-1) : this.blocks.find(item => item.id === id);
93
+ if (!block)
94
+ return undefined;
95
+ const head = block.dropped > 0 ? [`… ${block.dropped} earlier lines dropped …`] : [];
96
+ return [...head, ...block.lines].join('\n');
97
+ }
98
+ /** Stop the running command and wait for it, so no child outlives this client. */
99
+ async stop() {
100
+ this.cancel();
101
+ await this.task;
102
+ while (this.running)
103
+ await delay(20);
104
+ }
105
+ /** Append one output line, trimming the block to its budgets from the front. */
106
+ append(block, line) {
107
+ block.lines.push(line);
108
+ let bytes = (this.bytes.get(block) ?? 0) + line.length * 2;
109
+ while (block.lines.length > BLOCK_LINES || bytes > BLOCK_BYTES) {
110
+ if (block.lines.length === 1)
111
+ break;
112
+ bytes -= block.lines.shift().length * 2;
113
+ block.dropped++;
114
+ }
115
+ this.bytes.set(block, bytes);
116
+ this.publish(false);
117
+ }
118
+ /** Repaint, at most once per interval unless the change is a status change. */
119
+ publish(force) {
120
+ const now = Date.now();
121
+ if (!force && now - this.lastPublish < PUBLISH_INTERVAL_MS)
122
+ return;
123
+ this.lastPublish = now;
124
+ this.host.publish();
125
+ }
126
+ }
@@ -0,0 +1,5 @@
1
+ /** Shell domain: the local `!` command runner and the bounded blocks it produces. */
2
+ export { ShellController } from './controller.ts';
3
+ export type { ShellBlock, ShellHost } from './controller.ts';
4
+ export { runShell } from './runner.ts';
5
+ export type { ShellExit, ShellRunOptions, ShellStream } from './runner.ts';
@@ -0,0 +1,3 @@
1
+ /** Shell domain: the local `!` command runner and the bounded blocks it produces. */
2
+ export { ShellController } from "./controller.js";
3
+ export { runShell } from "./runner.js";
@@ -0,0 +1,28 @@
1
+ /** Which pipe one line arrived on. */
2
+ export type ShellStream = 'stdout' | 'stderr';
3
+ /** How one command ended. */
4
+ export interface ShellExit {
5
+ code: number | null;
6
+ signal: string | null;
7
+ }
8
+ /** One command's execution contract. */
9
+ export interface ShellRunOptions {
10
+ /** Working directory; the client's own directory, not the host's. */
11
+ cwd: string;
12
+ /** Environment for the child; the caller strips client credentials first. */
13
+ env: NodeJS.ProcessEnv;
14
+ /** Cancels the run; the child's process group is terminated. */
15
+ signal: AbortSignal;
16
+ /** Receives every complete line, in arrival order across both pipes. */
17
+ onLine(line: string, stream: ShellStream): void;
18
+ }
19
+ /** Run one command through the operator's shell and stream its lines.
20
+ *
21
+ * Both pipes are merged into one line stream in arrival order. A line longer than the assemble
22
+ * budget is emitted once, truncated, and the remainder is discarded until the next newline, so a
23
+ * command that never emits one cannot grow the client's memory.
24
+ * @param command - Command line, exactly as typed after `!`.
25
+ * @param options - Directory, environment, cancellation and the line sink.
26
+ * @returns How the command ended.
27
+ */
28
+ export declare function runShell(command: string, options: ShellRunOptions): Promise<ShellExit>;
@@ -0,0 +1,108 @@
1
+ /** Running one local shell command for the reader, and nothing else.
2
+ *
3
+ * `!cmd` executes on the machine this client runs on — the operator's laptop or a jump host — never
4
+ * on the host the agent works in. The host's shell belongs to the model's own tools; this module is
5
+ * a separate, local facility, and it is the only place in `src/` allowed to spawn a process.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ /** Longest single output line kept while it is still being assembled. */
9
+ const MAX_LINE_CHARS = 8 * 1024;
10
+ /** How long a cancelled command may ignore SIGTERM before it is killed. */
11
+ const KILL_GRACE_MS = 2_000;
12
+ /** The interactive shell to run under, falling back to `sh` when `$SHELL` is unusable. */
13
+ function shellPath() {
14
+ const chosen = process.env.SHELL;
15
+ return chosen !== undefined && chosen !== '' ? chosen : '/bin/sh';
16
+ }
17
+ /** Terminate one child's whole process group, so pipelines and background children die with it.
18
+ *
19
+ * The child is spawned detached, which gives it its own group; signalling the group is what makes
20
+ * cancellation behave like Ctrl+C in a terminal instead of leaving orphans holding the pipes.
21
+ * @param child - The spawned shell.
22
+ * @param signal - Signal to send the group.
23
+ */
24
+ function signalGroup(child, signal) {
25
+ const pid = child.pid;
26
+ if (pid === undefined)
27
+ return;
28
+ try {
29
+ process.kill(-pid, signal);
30
+ }
31
+ catch {
32
+ try {
33
+ child.kill(signal);
34
+ }
35
+ catch { /* already gone */ }
36
+ }
37
+ }
38
+ /** Run one command through the operator's shell and stream its lines.
39
+ *
40
+ * Both pipes are merged into one line stream in arrival order. A line longer than the assemble
41
+ * budget is emitted once, truncated, and the remainder is discarded until the next newline, so a
42
+ * command that never emits one cannot grow the client's memory.
43
+ * @param command - Command line, exactly as typed after `!`.
44
+ * @param options - Directory, environment, cancellation and the line sink.
45
+ * @returns How the command ended.
46
+ */
47
+ export function runShell(command, options) {
48
+ return new Promise(resolve => {
49
+ const child = spawn(shellPath(), ['-c', command], {
50
+ cwd: options.cwd, env: options.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
51
+ });
52
+ const carry = { stdout: '', stderr: '' };
53
+ const discarding = { stdout: false, stderr: false };
54
+ let settled = false;
55
+ let graceTimer;
56
+ /** Emit one assembled line, keeping the partial remainder for the next chunk. */
57
+ const feed = (stream, chunk) => {
58
+ carry[stream] += chunk;
59
+ for (;;) {
60
+ const newline = carry[stream].indexOf('\n');
61
+ if (newline < 0)
62
+ break;
63
+ const line = carry[stream].slice(0, newline);
64
+ carry[stream] = carry[stream].slice(newline + 1);
65
+ if (discarding[stream])
66
+ discarding[stream] = false;
67
+ else
68
+ options.onLine(line.replace(/\r$/, ''), stream);
69
+ }
70
+ if (carry[stream].length > MAX_LINE_CHARS) {
71
+ if (!discarding[stream]) {
72
+ options.onLine(`${carry[stream].slice(0, MAX_LINE_CHARS)}…`, stream);
73
+ discarding[stream] = true;
74
+ }
75
+ carry[stream] = '';
76
+ }
77
+ };
78
+ const flush = (stream) => {
79
+ if (carry[stream] !== '' && !discarding[stream])
80
+ options.onLine(carry[stream].replace(/\r$/, ''), stream);
81
+ carry[stream] = '';
82
+ };
83
+ const finish = (exit) => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ clearTimeout(graceTimer);
88
+ options.signal.removeEventListener('abort', onAbort);
89
+ flush('stdout');
90
+ flush('stderr');
91
+ resolve(exit);
92
+ };
93
+ const onAbort = () => {
94
+ signalGroup(child, 'SIGTERM');
95
+ graceTimer = setTimeout(() => { signalGroup(child, 'SIGKILL'); }, KILL_GRACE_MS);
96
+ };
97
+ child.stdout?.setEncoding('utf8');
98
+ child.stderr?.setEncoding('utf8');
99
+ child.stdout?.on('data', (chunk) => feed('stdout', chunk));
100
+ child.stderr?.on('data', (chunk) => feed('stderr', chunk));
101
+ child.on('error', error => { options.onLine(`! ${error.message}`, 'stderr'); finish({ code: null, signal: null }); });
102
+ child.on('close', (code, signal) => finish({ code, signal }));
103
+ if (options.signal.aborted)
104
+ onAbort();
105
+ else
106
+ options.signal.addEventListener('abort', onAbort, { once: true });
107
+ });
108
+ }
package/dist/state.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Application state shared by the controller facade and every domain controller. */
2
- import { Transcript } from './session/transcript.ts';
2
+ import { SessionInfo } from './session/info.ts';
3
3
  import type { HistorySearch, RemovalTarget } from './session/types.ts';
4
4
  import type { ObjectValue } from './transport/wire.ts';
5
5
  /** State shared by the picker and conversation view. */
@@ -21,7 +21,8 @@ export interface State {
21
21
  presetError?: string;
22
22
  presets?: ObjectValue[];
23
23
  defaultModel?: ObjectValue;
24
- transcript: Transcript;
24
+ /** Record, prompt index, composer, view and interaction state of the selected session. */
25
+ session: SessionInfo;
25
26
  }
26
27
  /** The state contract every domain controller writes through. */
27
28
  export interface ControllerStore {
package/dist/state.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Application state shared by the controller facade and every domain controller. */
2
- import { Transcript } from "./session/transcript.js";
2
+ import { SessionInfo } from "./session/info.js";
3
3
  /** Build the initial state before any connection exists.
4
4
  * @returns A fresh state whose transcript is empty and disconnected.
5
5
  */
6
6
  export function initialState() {
7
7
  return { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
8
- error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
8
+ error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], session: new SessionInfo() };
9
9
  }