@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -3
- package/README.zh.md +4 -3
- package/dist/cli/dsht.js +5 -2
- package/dist/controller/controller.d.ts +113 -1
- package/dist/controller/controller.js +145 -2
- package/dist/cost/controller.d.ts +5 -0
- package/dist/cost/controller.js +2 -1
- package/dist/cost/scanner.d.ts +4 -2
- package/dist/cost/scanner.js +6 -3
- package/dist/session/controller.d.ts +150 -1
- package/dist/session/controller.js +388 -29
- package/dist/session/history.d.ts +21 -1
- package/dist/session/history.js +15 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/info.d.ts +262 -0
- package/dist/session/info.js +326 -0
- package/dist/session/transcript.d.ts +37 -1
- package/dist/session/transcript.js +73 -0
- package/dist/shell/controller.d.ts +67 -0
- package/dist/shell/controller.js +126 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +28 -0
- package/dist/shell/runner.js +108 -0
- package/dist/state.d.ts +3 -2
- package/dist/state.js +2 -2
- package/dist/ui/app.js +206 -177
- package/dist/ui/chat/history-view.js +1 -1
- package/dist/ui/chat/shell-view.d.ts +34 -0
- package/dist/ui/chat/shell-view.js +111 -0
- package/dist/ui/chat/status.js +4 -4
- package/dist/ui/commands/parse.d.ts +5 -0
- package/dist/ui/commands/parse.js +9 -0
- package/dist/ui/dialogs/index.d.ts +3 -6
- package/dist/ui/theme/index.d.ts +5 -0
- package/dist/ui/theme/index.js +2 -1
- package/dsht-m.png +0 -0
- package/package.json +1 -1
- package/dist/ui/input/history.d.ts +0 -19
- package/dist/ui/input/history.js +0 -43
package/README.i18n.yaml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Git blob hashes of the reviewed bilingual pair.
|
|
2
|
-
README.md:
|
|
3
|
-
README.zh.md:
|
|
2
|
+
README.md: ce72e2f65a0834f3c58925ab907a5ad39653eca1
|
|
3
|
+
README.zh.md: 432aea7bb61f908845055aa06a1779156ad33258
|
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ npm start
|
|
|
166
166
|
|
|
167
167
|
Both paths read the same `DSH_URL` and `DSH_TOKEN` variables.
|
|
168
168
|
|
|
169
|
-
Select a workspace with ↑/↓ and Enter, then select a session or **New session**. **All sessions** also exposes sessions outside registered workspaces. **Add workspace (this directory)** registers the directory `dsht` itself runs in, and appears only while the host does not already have it; **Add workspace (host directory)** takes an existing absolute directory on the host, which may differ from your local filesystem, and Esc leaves that prompt for the picker again. Creating a session requires a selected workspace.
|
|
169
|
+
Select a workspace with ↑/↓ and Enter, then select a session or **New session**. **All sessions** also exposes sessions outside registered workspaces. **Add workspace (this directory)** registers the directory `dsht` itself runs in, and appears only while the host does not already have it; **Add workspace (host directory)** takes an existing absolute directory on the host, which may differ from your local filesystem, and Esc leaves that prompt for the picker again. Starting inside a registered workspace directory selects that workspace instead of showing the picker, with `←` in the session list switching to another; a session id passed on the command line still opens directly. Creating a session requires a selected workspace.
|
|
170
170
|
|
|
171
171
|
On first login, authentication exchanges the token at `GET /` and saves the cookie per HTTP origin. Later starts, including list commands, reuse that cookie without a token. The store uses `$XDG_STATE_HOME/dsht/auth`, or `~/.local/state/dsht/auth` when unset; `--auth-dir` or `DSHT_AUTH_DIR` overrides it. POSIX directories use 0700 and cookie files use 0600; Windows uses the account directory's inherited access controls. Launch tokens are never saved.
|
|
172
172
|
|
|
@@ -297,7 +297,8 @@ In `/ws` and `/resume` pickers, every row reports one of three user-visible stat
|
|
|
297
297
|
|
|
298
298
|
`/search` matches literal text case-insensitively in conversation messages, including older pages; tool-only rows are excluded. Search scans up to 80 messages per request, discards each temporary page, and retains at most 200 short matches, including folded reasoning. A truncated result asks you to refine the query. Opening a match loads a separate page around its sequence; `/latest` releases that window. Esc or Ctrl+C cancels the search. A rare or absent term still requires scanning the full history over HTTP; there is no server-side full-text index for this command. `/history` lists your own prompts from the loaded pages. Record sequences are the numbers shown by these pickers. `/ssearch` and `/wsearch` call `session/search`, which searches current user/assistant message content and returns at most 20 sessions, snippets, and a truncation flag; it exposes neither a result cursor nor matching record sequences. Workspace filtering happens after that global limit, so a truncated workspace result can omit matches. The UI warns when results are incomplete; refine the query. Selecting a session loads its history and offers matching messages for the jump. These operations use HTTP and never scan the host configuration directory.
|
|
299
299
|
|
|
300
|
-
↑/↓ or Ctrl+P/N recalls previously submitted prompts and slash commands without sending them; Enter submits the recalled text. A reading panel that fits the screen leaves the arrows with this history, a panel that has to scroll takes them for itself, and Ctrl+P/N reach the history from any panel. Moving past the newest entry restores the unsent draft. Editing recalled text starts a new draft. Recall keeps up to
|
|
300
|
+
↑/↓ or Ctrl+P/N recalls previously submitted prompts and slash commands without sending them; Enter submits the recalled text. A reading panel that fits the screen leaves the arrows with this history, a panel that has to scroll takes them for itself, and Ctrl+P/N reach the history from any panel. Moving past the newest entry restores the unsent draft. Editing recalled text starts a new draft, and switching sessions clears an unsent one. Recall keeps the selected session's user prompts — up to 2,000 entries and approximately 512 KiB of text — and never writes a separate history file; switching sessions releases them. It folds those prompts from the session records as they arrive, so it covers prompts from before this client connected rather than only the window that happened to load. Opening a session also walks the earlier history in the background, keeping only prompts rather than loading those pages into the conversation, so the whole session's prompt list is available from the start. Reaching its oldest retained prompt first refills from the already-loaded conversation at no request cost, and only then fetches the page before the window, inside one bounded loop that skips tool-only pages so the key cannot stall; the fetched page also stays in the conversation above the composer. Eviction therefore bounds memory without deciding reachability: an evicted prompt is either still loaded or still on the host. Consecutive duplicates are merged, oversized entries are skipped, and question or approval answers are excluded. Question options and completion menus keep arrow navigation; workspace/session lists use arrows when the composer is empty, with Ctrl+P/N available for recall.
|
|
301
|
+
`!command` runs that command on the machine this client is on — not on the host the agent works in — and prints the command and its output inline in the conversation, where it stays in place and scrolls away with the history; nothing is sent to the model and nothing is written to disk. Output is capped at 200 lines and 64 KiB per command, only the last 20 commands are kept, and the number of dropped lines is stated in the block. One command runs at a time; Esc, or Ctrl+C on an empty prompt, stops it by killing its whole process group, so pipelines and background children die with it; a command that needs a terminal of its own, such as `vim`, cannot work. `--no-shell` or `DSHT_NO_SHELL=1` disables the prefix.
|
|
301
302
|
|
|
302
303
|
Within each User group, only the first assistant prose or reasoning message shows an Assistant heading. Later messages and live output reuse that heading across tool results and Context messages. A newly loaded history window starts its own visible group; message sequences, tool status, search, and reasoning expansion remain independent.
|
|
303
304
|
|
|
@@ -426,7 +427,7 @@ This repository publishes one public package, `@itookit/dsht`, from the `mushuan
|
|
|
426
427
|
|
|
427
428
|
| Field | Value |
|
|
428
429
|
| --- | --- |
|
|
429
|
-
| Name and version | `@itookit/dsht` `0.3.
|
|
430
|
+
| Name and version | `@itookit/dsht` `0.3.8` |
|
|
430
431
|
| Executable | `dsht`, or `npx @itookit/dsht` without installing |
|
|
431
432
|
| Library entries | `@itookit/dsht` and `@itookit/dsht/auth` |
|
|
432
433
|
| Author | lizlok@gmail.com |
|
package/README.zh.md
CHANGED
|
@@ -166,7 +166,7 @@ npm start
|
|
|
166
166
|
|
|
167
167
|
两种方式读取相同的 `DSH_URL` 和 `DSH_TOKEN` 变量。
|
|
168
168
|
|
|
169
|
-
使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录,只在服务端尚未注册它时出现;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc
|
|
169
|
+
使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录,只在服务端尚未注册它时出现;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc 可以退回选择器。在已注册的工作区目录里启动时,直接选中该工作区而不再显示选择器(会话列表里的 `←` 仍可切到别的工作区);命令行给出会话 ID 时依旧直接打开。新建会话前必须选择工作区。
|
|
170
170
|
|
|
171
171
|
首次登录通过 `GET /` 兑换 token,并按 HTTP origin 保存 cookie。后续启动和列表命令自动复用 cookie,无需再次提供 token。默认目录为 `$XDG_STATE_HOME/dsht/auth`,未设置时使用 `~/.local/state/dsht/auth`;可通过 `--auth-dir` 或 `DSHT_AUTH_DIR` 覆盖。POSIX 下目录权限为 0700、cookie 文件为 0600;Windows 使用账户目录继承的访问控制。启动 token 永不保存。
|
|
172
172
|
|
|
@@ -297,7 +297,8 @@ Enter 提交消息:代理处于 Working 时自动作为下一个 step 的转
|
|
|
297
297
|
|
|
298
298
|
`/search` 对对话消息进行不区分大小写的字面文本匹配,包含旧页,排除纯工具行。搜索每次请求最多 80 条消息,扫描后释放临时页,只保留最多 200 条简短命中摘要,包含折叠的思考;结果截断时提示缩小查询范围。选择命中项只加载其序号附近的独立页面,`/latest` 释放该窗口。Esc 或 Ctrl+C 可取消搜索。稀有词或无匹配查询仍需通过 HTTP 扫描全部历史,此命令尚无服务端全文索引。`/history` 只列出已加载页面中自己的提示词,选择器显示的数字就是记录序号。`/ssearch` 与 `/wsearch` 调用 `session/search`,服务端搜索当前用户/助手消息内容,最多返回 20 个会话、摘要和截断标记,没有结果分页游标或命中记录序号。工作区筛选在全局数量限制之后进行,因此截断时可能漏掉工作区内的匹配会话;界面会提示结果不完整,可缩小查询范围。选择会话后加载其历史,再选择匹配消息跳转。所有操作均通过 HTTP 完成,不扫描服务端配置目录。
|
|
299
299
|
|
|
300
|
-
↑/↓ 或 Ctrl+P/N 回填之前提交的提示词和 slash 命令,按 Enter 才提交。一屏放得下的阅读面板会把方向键留给该历史,需要滚动的面板才接管方向键,而 Ctrl+P/N
|
|
300
|
+
↑/↓ 或 Ctrl+P/N 回填之前提交的提示词和 slash 命令,按 Enter 才提交。一屏放得下的阅读面板会把方向键留给该历史,需要滚动的面板才接管方向键,而 Ctrl+P/N 在任何面板打开时都能回填。向下越过最新记录时恢复未发送草稿;编辑回填内容后开始新的草稿,切换会话会清空未发送的草稿。回填按当前会话保留其 user prompt,最多 2,000 条、约 512 KiB 文本,且不写入独立历史文件;切换会话即释放。它在记录到达时增量折叠这些提示词,因此覆盖本客户端连接之前的提示词,而不只是碰巧加载的那个窗口。打开会话后它还会在后台把更早的历史翻一遍,只提取提示词、不把这些页读进上方对话,因此从会话开始就持有整个会话的提示词列表。走到索引里最旧一条时,先用已加载的对话补回被预算淘汰的提示词,这一步不发请求;只有窗口也用尽才取回窗口之前的一页,并在同一次有界循环里跳过整页没有 User 消息的页,因此按键不会被工具页卡住;取回的那页也会留在输入框上方的对话里。于是淘汰只约束内存、不决定可达性:被淘汰的提示词要么仍在窗口内,要么仍在宿主上。连续重复输入合并,超大输入跳过,提问和审批回答不记入历史。提问选项与补全菜单优先使用箭头;工作区/会话列表在输入框为空时使用箭头选择,可用 Ctrl+P/N 调出输入历史。
|
|
301
|
+
`!命令` 在这台运行客户端的机器上执行——不是 agent 所在的宿主——并把命令行与输出内联打印在对话里、留在它发生的位置随历史一起滚走;不会发给模型,也不写入磁盘。每条命令的输出上限为 200 行与 64 KiB,只保留最近 20 条命令,被丢弃的行数会在块内注明。同一时刻只运行一条命令;Esc(或输入框为空时 Ctrl+C)会终止其整个进程组来停止它,因此管道与后台子进程一起结束;需要独占终端的命令(如 `vim`)无法工作。`--no-shell` 或 `DSHT_NO_SHELL=1` 关闭该前缀。
|
|
301
302
|
|
|
302
303
|
每条 User 消息之后,只在第一段助手正文或思考前显示 Assistant 标题;后续消息及流式输出沿用分组,工具结果和 Context 消息不重置分组。当前加载的历史窗口从自身起点建立可见分组,消息序号、工具状态、搜索和思考展开仍各自保留。
|
|
303
304
|
|
|
@@ -426,7 +427,7 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
426
427
|
|
|
427
428
|
| 字段 | 值 |
|
|
428
429
|
| --- | --- |
|
|
429
|
-
| 名称与版本 | `@itookit/dsht` `0.3.
|
|
430
|
+
| 名称与版本 | `@itookit/dsht` `0.3.8` |
|
|
430
431
|
| 可执行命令 | `dsht`,不安装时用 `npx @itookit/dsht` |
|
|
431
432
|
| 库入口 | `@itookit/dsht` 和 `@itookit/dsht/auth` |
|
|
432
433
|
| 作者 | lizlok\@gmail.com |
|
package/dist/cli/dsht.js
CHANGED
|
@@ -26,6 +26,7 @@ With no command, choose a workspace and session interactively.
|
|
|
26
26
|
--history-mb <n> Soft history payload budget in MiB (default 16)
|
|
27
27
|
--memory-log <path> Append runtime memory samples; a failing log stops itself
|
|
28
28
|
--no-memory-log Disable the runtime memory log (default: enabled)
|
|
29
|
+
--no-shell Disable ! local commands (DSHT_NO_SHELL=1)
|
|
29
30
|
--json Print machine-readable list output
|
|
30
31
|
--help Show this help
|
|
31
32
|
|
|
@@ -33,6 +34,7 @@ The default host is http://127.0.0.1:3080.
|
|
|
33
34
|
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
34
35
|
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
35
36
|
/cost shows the session and today CNY estimates.
|
|
37
|
+
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
36
38
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
37
39
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
38
40
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
@@ -47,7 +49,7 @@ async function main() {
|
|
|
47
49
|
url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
|
|
48
50
|
'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
|
|
49
51
|
workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
|
|
50
|
-
'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' },
|
|
52
|
+
'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' }, 'no-shell': { type: 'boolean' },
|
|
51
53
|
} });
|
|
52
54
|
if (values.help) {
|
|
53
55
|
process.stdout.write(HELP);
|
|
@@ -93,7 +95,8 @@ async function main() {
|
|
|
93
95
|
await costs.load();
|
|
94
96
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
95
97
|
throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
|
|
96
|
-
const
|
|
98
|
+
const shellEnabled = !values['no-shell'] && process.env.DSHT_NO_SHELL !== '1';
|
|
99
|
+
const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits, memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']), undefined, shellEnabled);
|
|
97
100
|
const app = mount(controller);
|
|
98
101
|
const terminate = () => app.unmount();
|
|
99
102
|
process.once('SIGTERM', terminate);
|
|
@@ -11,7 +11,10 @@ import { CostController } from '../cost/controller.ts';
|
|
|
11
11
|
import { ConnectionController, type ConnectionListener } from './connection.ts';
|
|
12
12
|
import { MemoryLog } from './memory-log.ts';
|
|
13
13
|
import { type ControllerStore, type State } from '../state.ts';
|
|
14
|
+
import { ShellController } from '../shell/index.ts';
|
|
14
15
|
import type { HistorySearch, RemovalTarget } from '../session/types.ts';
|
|
16
|
+
import type { ComposerState, InteractionState, ModelState, OptionState, PanelState, ReferenceState, ViewState } from '../session/info.ts';
|
|
17
|
+
import type { Reasoning } from '../session/history.ts';
|
|
15
18
|
/** Application facade over the domain controllers; the UI owns only this object.
|
|
16
19
|
*
|
|
17
20
|
* State lives here, connection generations live in `connection`, the selected session and its
|
|
@@ -36,17 +39,23 @@ export declare class Controller implements ControllerStore, ConnectionListener {
|
|
|
36
39
|
readonly cost: CostController | undefined;
|
|
37
40
|
/** Bounded runtime memory samples; present only when a log path was supplied. */
|
|
38
41
|
readonly memoryLog: MemoryLog | undefined;
|
|
42
|
+
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
43
|
+
readonly shell: ShellController;
|
|
39
44
|
private readonly observers;
|
|
40
45
|
private selector;
|
|
41
46
|
constructor(base: string, token: string | undefined, initialSession?: string | undefined, makeClient?: () => Client, authenticate?: (client: Client) => Promise<void>, costs?: CostLedger | undefined, historyLimits?: HistoryLimits, memoryLogPath?: string | undefined,
|
|
42
47
|
/** Directory this client runs in, offered as a workspace when the host has not registered it. */
|
|
43
|
-
localDirectory?: string
|
|
48
|
+
localDirectory?: string,
|
|
49
|
+
/** Whether `!` may run local commands; the CLI disables it with `--no-shell`. */
|
|
50
|
+
shellEnabled?: boolean);
|
|
44
51
|
/** React-compatible state subscription. */
|
|
45
52
|
subscribe: (listener: () => void) => (() => void);
|
|
46
53
|
/** Snapshot identity changes only when the controller publishes. */
|
|
47
54
|
snapshot: () => State;
|
|
48
55
|
/** Current projection store; replaced at each connection generation. */
|
|
49
56
|
get telemetry(): Telemetry;
|
|
57
|
+
/** Record of the selected session; the one strong owner lives in `State.session`. */
|
|
58
|
+
get record(): Transcript;
|
|
50
59
|
/** @returns The current selector generation. */
|
|
51
60
|
selection(): number;
|
|
52
61
|
/** Advance the selector generation when the selected workspace or session changes. */
|
|
@@ -141,10 +150,113 @@ export declare class Controller implements ControllerStore, ConnectionListener {
|
|
|
141
150
|
* @returns True when the caller may exit.
|
|
142
151
|
*/
|
|
143
152
|
interrupt(force?: boolean): Promise<boolean>;
|
|
153
|
+
/** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
|
|
154
|
+
get sessionCostText(): string;
|
|
144
155
|
/** Keep history stable while the user reads, searches, or expands it.
|
|
145
156
|
* @param pinned - Whether the main transcript is being read away from its tail.
|
|
146
157
|
*/
|
|
147
158
|
pinHistory(pinned: boolean): void;
|
|
159
|
+
/** Recall one step through the selected session's prompt index; never touches the network.
|
|
160
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
161
|
+
* @param current - Composer content before recall began, restored at the newest position.
|
|
162
|
+
* @returns The recalled prompt, or the unsent draft.
|
|
163
|
+
*/
|
|
164
|
+
recall(direction: -1 | 1, current: string): string;
|
|
165
|
+
/** Remember a locally submitted command, which never becomes a durable session record.
|
|
166
|
+
* @param value - Submitted command text.
|
|
167
|
+
*/
|
|
168
|
+
recordRecall(value: string): void;
|
|
169
|
+
/** Leave recall navigation because the composer was edited or replaced. */
|
|
170
|
+
resetRecall(): void;
|
|
171
|
+
/** Whether recall is parked on the oldest prompt the session retains. */
|
|
172
|
+
get recallAtOldest(): boolean;
|
|
173
|
+
/** How many prompts the selected session retains for recall. */
|
|
174
|
+
get recallLength(): number;
|
|
175
|
+
/** Whether an older prompt is reachable, in the loaded window or on the host. */
|
|
176
|
+
get recallHasOlder(): boolean;
|
|
177
|
+
/** Recover older prompts from the loaded window before spending a page request.
|
|
178
|
+
* @returns Whether any older prompt was recovered.
|
|
179
|
+
*/
|
|
180
|
+
refillRecall(): boolean;
|
|
181
|
+
/** Composer draft, caret and parked draft of the selected session. */
|
|
182
|
+
get composer(): ComposerState;
|
|
183
|
+
/** Replace the composer text and caret.
|
|
184
|
+
* @param draft - New text.
|
|
185
|
+
* @param cursor - Caret column; defaults to the end of the text.
|
|
186
|
+
*/
|
|
187
|
+
setComposer(draft: string, cursor?: number): void;
|
|
188
|
+
/** Move the caret without changing the text.
|
|
189
|
+
* @param cursor - Caret column.
|
|
190
|
+
*/
|
|
191
|
+
setComposerCursor(cursor: number): void;
|
|
192
|
+
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
193
|
+
parkComposer(): void;
|
|
194
|
+
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
195
|
+
restoreComposer(): void;
|
|
196
|
+
/** How the selected session's record is being read right now. */
|
|
197
|
+
get view(): ViewState;
|
|
198
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
199
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
200
|
+
*/
|
|
201
|
+
setViewWindow(window?: Transcript): void;
|
|
202
|
+
/** Move the reader's position inside the displayed record.
|
|
203
|
+
* @param scroll - Rows scrolled back from the live end.
|
|
204
|
+
*/
|
|
205
|
+
setScroll(scroll: number): void;
|
|
206
|
+
/** Replace the set of expanded reasoning blocks.
|
|
207
|
+
* @param folds - Sequences to expand beyond the default fold.
|
|
208
|
+
*/
|
|
209
|
+
setFolds(folds: ReadonlySet<number>): void;
|
|
210
|
+
/** Set the fold mode of the live attempt's completed reasoning.
|
|
211
|
+
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
212
|
+
*/
|
|
213
|
+
setLiveReasoning(reasoning: Reasoning): void;
|
|
214
|
+
/** Local answer state for the selected session's pending waterfalls. */
|
|
215
|
+
get interaction(): InteractionState;
|
|
216
|
+
/** Replace the partly collected answers, keyed by waterfall event id.
|
|
217
|
+
* @param answers - Answers collected so far, by event id.
|
|
218
|
+
*/
|
|
219
|
+
setAnswers(answers: Record<string, ObjectValue[]>): void;
|
|
220
|
+
/** Replace the pending question's option keyboard state.
|
|
221
|
+
* @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
|
|
222
|
+
*/
|
|
223
|
+
setOption(option?: OptionState): void;
|
|
224
|
+
/** Replace the pending approval's selected row.
|
|
225
|
+
* @param approval - Selected approval row; undefined clears the highlight.
|
|
226
|
+
*/
|
|
227
|
+
setApproval(approval?: InteractionState['approval']): void;
|
|
228
|
+
/** Composer-adjacent `@` reference menu state. */
|
|
229
|
+
get reference(): ReferenceState;
|
|
230
|
+
/** Highlight one row of the open reference menu.
|
|
231
|
+
* @param index - Row index into the current matches.
|
|
232
|
+
*/
|
|
233
|
+
setReferenceIndex(index: number): void;
|
|
234
|
+
/** Remember the draft that dismissed the reference menu.
|
|
235
|
+
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
236
|
+
*/
|
|
237
|
+
setReferenceDismissed(draft?: string): void;
|
|
238
|
+
/** Panels the selected session has open. */
|
|
239
|
+
get panels(): PanelState;
|
|
240
|
+
/** Show or hide the reasoning panel.
|
|
241
|
+
* @param open - Whether `/think` is open.
|
|
242
|
+
*/
|
|
243
|
+
openThoughts(open: boolean): void;
|
|
244
|
+
/** Show or hide the pending-input panel.
|
|
245
|
+
* @param open - Whether `/queue` is open.
|
|
246
|
+
*/
|
|
247
|
+
openQueue(open: boolean): void;
|
|
248
|
+
/** Show the model dialog at one step, or close it.
|
|
249
|
+
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
250
|
+
*/
|
|
251
|
+
setModelPanel(model?: ModelState): void;
|
|
252
|
+
/** Show the history or content-search dialog, or close it.
|
|
253
|
+
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
254
|
+
*/
|
|
255
|
+
setHistoryPanel(history?: PanelState['history']): void;
|
|
256
|
+
/** Show the host session-search results, or close them.
|
|
257
|
+
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
258
|
+
*/
|
|
259
|
+
setSearchPanel(search?: PanelState['search']): void;
|
|
148
260
|
/** Refresh all HTTP-visible sessions without changing the selected conversation.
|
|
149
261
|
* @param signal - Optional cancellation for an explicit /cost refresh.
|
|
150
262
|
*/
|
|
@@ -8,10 +8,25 @@ import { markdownCacheStats } from "../session/markdown.js";
|
|
|
8
8
|
import { SessionController } from "../session/controller.js";
|
|
9
9
|
import { CatalogController } from "../catalog/controller.js";
|
|
10
10
|
import { CostController } from "../cost/controller.js";
|
|
11
|
+
import { costText } from "../cost/ledger.js";
|
|
11
12
|
import { ConnectionController } from "./connection.js";
|
|
12
13
|
import { MemoryLog } from "./memory-log.js";
|
|
13
14
|
import { clearReactMeasures, measureCount } from "./perf-measures.js";
|
|
14
15
|
import { initialState } from "../state.js";
|
|
16
|
+
import { ShellController } from "../shell/index.js";
|
|
17
|
+
/** Environment for a local `!` command: this client's variables without its credentials.
|
|
18
|
+
*
|
|
19
|
+
* `DSH_URL` is removed as well as the token, because the URL form the README documents can carry a
|
|
20
|
+
* token in its query string. Commands therefore run with the operator's environment, not this
|
|
21
|
+
* client's session.
|
|
22
|
+
* @returns A copy of the environment safe to hand to a child process.
|
|
23
|
+
*/
|
|
24
|
+
function shellEnv() {
|
|
25
|
+
const env = { ...process.env };
|
|
26
|
+
delete env.DSH_TOKEN;
|
|
27
|
+
delete env.DSH_URL;
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
15
30
|
/** Application facade over the domain controllers; the UI owns only this object.
|
|
16
31
|
*
|
|
17
32
|
* State lives here, connection generations live in `connection`, the selected session and its
|
|
@@ -35,11 +50,15 @@ export class Controller {
|
|
|
35
50
|
cost;
|
|
36
51
|
/** Bounded runtime memory samples; present only when a log path was supplied. */
|
|
37
52
|
memoryLog;
|
|
53
|
+
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
54
|
+
shell;
|
|
38
55
|
observers = new Set();
|
|
39
56
|
selector = 0;
|
|
40
57
|
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath,
|
|
41
58
|
/** Directory this client runs in, offered as a workspace when the host has not registered it. */
|
|
42
|
-
localDirectory = process.cwd()
|
|
59
|
+
localDirectory = process.cwd(),
|
|
60
|
+
/** Whether `!` may run local commands; the CLI disables it with `--no-shell`. */
|
|
61
|
+
shellEnabled = true) {
|
|
43
62
|
this.base = base;
|
|
44
63
|
this.initialSession = initialSession;
|
|
45
64
|
this.costs = costs;
|
|
@@ -49,6 +68,12 @@ export class Controller {
|
|
|
49
68
|
const options = { base, token, initialSession, makeClient, authenticate };
|
|
50
69
|
this.connection = new ConnectionController(this, options, this);
|
|
51
70
|
this.session = new SessionController(this, this.connection, this.connection, historyLimits);
|
|
71
|
+
this.shell = new ShellController({
|
|
72
|
+
publish: () => this.update({}),
|
|
73
|
+
cwd: () => this.localDirectory,
|
|
74
|
+
env: () => shellEnv(),
|
|
75
|
+
anchor: () => this.state.session.record.readThrough,
|
|
76
|
+
}, shellEnabled);
|
|
52
77
|
this.catalog = new CatalogController(this, this.connection);
|
|
53
78
|
if (costs)
|
|
54
79
|
this.cost = new CostController(costs, {
|
|
@@ -56,6 +81,10 @@ export class Controller {
|
|
|
56
81
|
online: () => this.state.online,
|
|
57
82
|
signal: () => this.connection.signal(),
|
|
58
83
|
publish: () => this.update({}),
|
|
84
|
+
// The scan already reads every session's whole history; handing its pages to the session
|
|
85
|
+
// domain lets the prompt cache pick them up, so one open does not pay for a second walk.
|
|
86
|
+
scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
|
|
87
|
+
scanDone: sessionId => this.session.rememberScanDone(sessionId),
|
|
59
88
|
});
|
|
60
89
|
if (memoryLogPath !== undefined)
|
|
61
90
|
this.memoryLog = new MemoryLog(memoryLogPath, () => this.memorySample());
|
|
@@ -69,6 +98,8 @@ export class Controller {
|
|
|
69
98
|
snapshot = () => this.state;
|
|
70
99
|
/** Current projection store; replaced at each connection generation. */
|
|
71
100
|
get telemetry() { return this.connection.telemetry; }
|
|
101
|
+
/** Record of the selected session; the one strong owner lives in `State.session`. */
|
|
102
|
+
get record() { return this.state.session.record; }
|
|
72
103
|
/** @returns The current selector generation. */
|
|
73
104
|
selection() { return this.selector; }
|
|
74
105
|
/** Advance the selector generation when the selected workspace or session changes. */
|
|
@@ -88,6 +119,7 @@ export class Controller {
|
|
|
88
119
|
start() { this.connection.start(); this.memoryLog?.start(); }
|
|
89
120
|
/** Cancel retries and HTTP, close the socket, and release session and catalog work. */
|
|
90
121
|
async stop() {
|
|
122
|
+
await this.shell.stop();
|
|
91
123
|
await this.connection.stop();
|
|
92
124
|
await this.session.settle();
|
|
93
125
|
await this.catalog.settle();
|
|
@@ -137,7 +169,7 @@ export class Controller {
|
|
|
137
169
|
*/
|
|
138
170
|
memorySample() {
|
|
139
171
|
const memory = process.memoryUsage();
|
|
140
|
-
const transcript = this.state.
|
|
172
|
+
const transcript = this.state.session.record;
|
|
141
173
|
const ledger = this.costs?.summary();
|
|
142
174
|
const layout = layoutStats(transcript);
|
|
143
175
|
const markdown = markdownCacheStats();
|
|
@@ -189,6 +221,11 @@ export class Controller {
|
|
|
189
221
|
this.update({ online: true, status: 'Connected', error: '', pending: [] });
|
|
190
222
|
const screen = this.state.screen;
|
|
191
223
|
await this.session.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
|
|
224
|
+
// Starting inside a registered workspace's directory already answers the first question, so the
|
|
225
|
+
// reader lands on that workspace's sessions instead of a list they would pick from by hand.
|
|
226
|
+
if (screen !== 'sessions' && !this.initialSession && this.session.adoptLocalWorkspace(this.localDirectory) !== undefined) {
|
|
227
|
+
this.update({ status: 'Workspace from this directory · ← to switch' });
|
|
228
|
+
}
|
|
192
229
|
const sessionId = this.state.sessionId ?? this.initialSession;
|
|
193
230
|
if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
|
|
194
231
|
await this.session.selectSession(sessionId);
|
|
@@ -251,10 +288,116 @@ export class Controller {
|
|
|
251
288
|
* @returns True when the caller may exit.
|
|
252
289
|
*/
|
|
253
290
|
interrupt(force = false) { return this.session.interrupt(force); }
|
|
291
|
+
/** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
|
|
292
|
+
get sessionCostText() {
|
|
293
|
+
const sessionId = this.state.sessionId;
|
|
294
|
+
return this.costs?.hasSession(sessionId) ? costText(this.costs.total(sessionId)) : '?';
|
|
295
|
+
}
|
|
254
296
|
/** Keep history stable while the user reads, searches, or expands it.
|
|
255
297
|
* @param pinned - Whether the main transcript is being read away from its tail.
|
|
256
298
|
*/
|
|
257
299
|
pinHistory(pinned) { this.session.pinHistory(pinned); }
|
|
300
|
+
/** Recall one step through the selected session's prompt index; never touches the network.
|
|
301
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
302
|
+
* @param current - Composer content before recall began, restored at the newest position.
|
|
303
|
+
* @returns The recalled prompt, or the unsent draft.
|
|
304
|
+
*/
|
|
305
|
+
recall(direction, current) { return this.session.recall(direction, current); }
|
|
306
|
+
/** Remember a locally submitted command, which never becomes a durable session record.
|
|
307
|
+
* @param value - Submitted command text.
|
|
308
|
+
*/
|
|
309
|
+
recordRecall(value) { this.session.recordRecall(value); }
|
|
310
|
+
/** Leave recall navigation because the composer was edited or replaced. */
|
|
311
|
+
resetRecall() { this.session.resetRecall(); }
|
|
312
|
+
/** Whether recall is parked on the oldest prompt the session retains. */
|
|
313
|
+
get recallAtOldest() { return this.session.recallAtOldest; }
|
|
314
|
+
/** How many prompts the selected session retains for recall. */
|
|
315
|
+
get recallLength() { return this.session.recallLength; }
|
|
316
|
+
/** Whether an older prompt is reachable, in the loaded window or on the host. */
|
|
317
|
+
get recallHasOlder() { return this.session.recallHasOlder; }
|
|
318
|
+
/** Recover older prompts from the loaded window before spending a page request.
|
|
319
|
+
* @returns Whether any older prompt was recovered.
|
|
320
|
+
*/
|
|
321
|
+
refillRecall() { return this.session.refillRecall(); }
|
|
322
|
+
/** Composer draft, caret and parked draft of the selected session. */
|
|
323
|
+
get composer() { return this.session.composer; }
|
|
324
|
+
/** Replace the composer text and caret.
|
|
325
|
+
* @param draft - New text.
|
|
326
|
+
* @param cursor - Caret column; defaults to the end of the text.
|
|
327
|
+
*/
|
|
328
|
+
setComposer(draft, cursor) { this.session.setComposer(draft, cursor); }
|
|
329
|
+
/** Move the caret without changing the text.
|
|
330
|
+
* @param cursor - Caret column.
|
|
331
|
+
*/
|
|
332
|
+
setComposerCursor(cursor) { this.session.setComposerCursor(cursor); }
|
|
333
|
+
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
334
|
+
parkComposer() { this.session.parkComposer(); }
|
|
335
|
+
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
336
|
+
restoreComposer() { this.session.restoreComposer(); }
|
|
337
|
+
/** How the selected session's record is being read right now. */
|
|
338
|
+
get view() { return this.session.view; }
|
|
339
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
340
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
341
|
+
*/
|
|
342
|
+
setViewWindow(window) { this.session.setViewWindow(window); }
|
|
343
|
+
/** Move the reader's position inside the displayed record.
|
|
344
|
+
* @param scroll - Rows scrolled back from the live end.
|
|
345
|
+
*/
|
|
346
|
+
setScroll(scroll) { this.session.setScroll(scroll); }
|
|
347
|
+
/** Replace the set of expanded reasoning blocks.
|
|
348
|
+
* @param folds - Sequences to expand beyond the default fold.
|
|
349
|
+
*/
|
|
350
|
+
setFolds(folds) { this.session.setFolds(folds); }
|
|
351
|
+
/** Set the fold mode of the live attempt's completed reasoning.
|
|
352
|
+
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
353
|
+
*/
|
|
354
|
+
setLiveReasoning(reasoning) { this.session.setLiveReasoning(reasoning); }
|
|
355
|
+
/** Local answer state for the selected session's pending waterfalls. */
|
|
356
|
+
get interaction() { return this.session.interaction; }
|
|
357
|
+
/** Replace the partly collected answers, keyed by waterfall event id.
|
|
358
|
+
* @param answers - Answers collected so far, by event id.
|
|
359
|
+
*/
|
|
360
|
+
setAnswers(answers) { this.session.setAnswers(answers); }
|
|
361
|
+
/** Replace the pending question's option keyboard state.
|
|
362
|
+
* @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
|
|
363
|
+
*/
|
|
364
|
+
setOption(option) { this.session.setOption(option); }
|
|
365
|
+
/** Replace the pending approval's selected row.
|
|
366
|
+
* @param approval - Selected approval row; undefined clears the highlight.
|
|
367
|
+
*/
|
|
368
|
+
setApproval(approval) { this.session.setApproval(approval); }
|
|
369
|
+
/** Composer-adjacent `@` reference menu state. */
|
|
370
|
+
get reference() { return this.session.reference; }
|
|
371
|
+
/** Highlight one row of the open reference menu.
|
|
372
|
+
* @param index - Row index into the current matches.
|
|
373
|
+
*/
|
|
374
|
+
setReferenceIndex(index) { this.session.setReferenceIndex(index); }
|
|
375
|
+
/** Remember the draft that dismissed the reference menu.
|
|
376
|
+
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
377
|
+
*/
|
|
378
|
+
setReferenceDismissed(draft) { this.session.setReferenceDismissed(draft); }
|
|
379
|
+
/** Panels the selected session has open. */
|
|
380
|
+
get panels() { return this.session.panels; }
|
|
381
|
+
/** Show or hide the reasoning panel.
|
|
382
|
+
* @param open - Whether `/think` is open.
|
|
383
|
+
*/
|
|
384
|
+
openThoughts(open) { this.session.openThoughts(open); }
|
|
385
|
+
/** Show or hide the pending-input panel.
|
|
386
|
+
* @param open - Whether `/queue` is open.
|
|
387
|
+
*/
|
|
388
|
+
openQueue(open) { this.session.openQueue(open); }
|
|
389
|
+
/** Show the model dialog at one step, or close it.
|
|
390
|
+
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
391
|
+
*/
|
|
392
|
+
setModelPanel(model) { this.session.setModelPanel(model); }
|
|
393
|
+
/** Show the history or content-search dialog, or close it.
|
|
394
|
+
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
395
|
+
*/
|
|
396
|
+
setHistoryPanel(history) { this.session.setHistoryPanel(history); }
|
|
397
|
+
/** Show the host session-search results, or close them.
|
|
398
|
+
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
399
|
+
*/
|
|
400
|
+
setSearchPanel(search) { this.session.setSearchPanel(search); }
|
|
258
401
|
/** Refresh all HTTP-visible sessions without changing the selected conversation.
|
|
259
402
|
* @param signal - Optional cancellation for an explicit /cost refresh.
|
|
260
403
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Periodic billing scans; the ledger outlives any single connection generation. */
|
|
2
2
|
import type { Client } from '../transport/client.ts';
|
|
3
|
+
import { type Json } from '../transport/wire.ts';
|
|
3
4
|
import type { CostLedger } from './ledger.ts';
|
|
4
5
|
/** Host access a scan needs; supplied by the controller facade. */
|
|
5
6
|
export interface CostHost {
|
|
@@ -11,6 +12,10 @@ export interface CostHost {
|
|
|
11
12
|
signal(): AbortSignal;
|
|
12
13
|
/** Re-publish controller state after the ledger changes. */
|
|
13
14
|
publish(): void;
|
|
15
|
+
/** Hand one already-read history page to another consumer, which owns what it does with it. */
|
|
16
|
+
scanPage?(sessionId: string, records: readonly Json[]): void;
|
|
17
|
+
/** Report that a session's history was read to its beginning. */
|
|
18
|
+
scanDone?(sessionId: string): void;
|
|
14
19
|
}
|
|
15
20
|
/** Owns the background scan: startup, the minute timer, turn-completion refresh, and `/cost`. */
|
|
16
21
|
export declare class CostController {
|
package/dist/cost/controller.js
CHANGED
|
@@ -77,7 +77,8 @@ export class CostController {
|
|
|
77
77
|
if (!session.running && typeof session.updatedAt === 'number' && this.updates.get(sessionId) === session.updatedAt)
|
|
78
78
|
continue;
|
|
79
79
|
try {
|
|
80
|
-
const history = await sessionCostHistory(client, session, combined, () => { pages++; });
|
|
80
|
+
const history = await sessionCostHistory(client, session, combined, () => { pages++; }, records => this.host.scanPage?.(sessionId, records));
|
|
81
|
+
this.host.scanDone?.(sessionId);
|
|
81
82
|
scanned++;
|
|
82
83
|
events += history.events.length;
|
|
83
84
|
await ledger.replace(sessionId, history.cursor, history.events);
|
package/dist/cost/scanner.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Address and page one session's complete billing history over the host connection. */
|
|
2
2
|
import { type Client } from '../transport/client.ts';
|
|
3
|
-
import { type ObjectValue } from '../transport/wire.ts';
|
|
3
|
+
import { type Json, type ObjectValue } from '../transport/wire.ts';
|
|
4
4
|
/** Wire addresses for one `session/list` row, in the order the cost scan should try them.
|
|
5
5
|
*
|
|
6
6
|
* A subagent child is reachable only under its durable parent, and the list row omits the delivery
|
|
@@ -14,9 +14,11 @@ export declare function costAddresses(session: ObjectValue): ObjectValue[];
|
|
|
14
14
|
* @param session - One row from the host session list.
|
|
15
15
|
* @param signal - Cancels paging without cancelling any agent work.
|
|
16
16
|
* @param onPage - Counts each history request, so a scan can report how much it re-read.
|
|
17
|
+
* @param onRecords - Hands each page's raw records to the caller, which owns any further reading of
|
|
18
|
+
* them; the billing fold itself keeps only the minimal events below.
|
|
17
19
|
* @returns Opening cursor and the minimal billing events behind it.
|
|
18
20
|
*/
|
|
19
|
-
export declare function sessionCostHistory(client: Client, session: ObjectValue, signal: AbortSignal, onPage?: () => void): Promise<{
|
|
21
|
+
export declare function sessionCostHistory(client: Client, session: ObjectValue, signal: AbortSignal, onPage?: () => void, onRecords?: (records: readonly Json[]) => void): Promise<{
|
|
20
22
|
cursor: number;
|
|
21
23
|
events: ObjectValue[];
|
|
22
24
|
}>;
|
package/dist/cost/scanner.js
CHANGED
|
@@ -24,13 +24,15 @@ export function costAddresses(session) {
|
|
|
24
24
|
* @param session - One row from the host session list.
|
|
25
25
|
* @param signal - Cancels paging without cancelling any agent work.
|
|
26
26
|
* @param onPage - Counts each history request, so a scan can report how much it re-read.
|
|
27
|
+
* @param onRecords - Hands each page's raw records to the caller, which owns any further reading of
|
|
28
|
+
* them; the billing fold itself keeps only the minimal events below.
|
|
27
29
|
* @returns Opening cursor and the minimal billing events behind it.
|
|
28
30
|
*/
|
|
29
|
-
export async function sessionCostHistory(client, session, signal, onPage) {
|
|
31
|
+
export async function sessionCostHistory(client, session, signal, onPage, onRecords) {
|
|
30
32
|
let lastError;
|
|
31
33
|
for (const address of costAddresses(session)) {
|
|
32
34
|
try {
|
|
33
|
-
return await readCostHistory(client, address, signal, onPage);
|
|
35
|
+
return await readCostHistory(client, address, signal, onPage, onRecords);
|
|
34
36
|
}
|
|
35
37
|
catch (error) {
|
|
36
38
|
lastError = error;
|
|
@@ -42,7 +44,7 @@ export async function sessionCostHistory(client, session, signal, onPage) {
|
|
|
42
44
|
throw lastError;
|
|
43
45
|
}
|
|
44
46
|
/** Page one addressed session's history into the billing events the ledger folds. */
|
|
45
|
-
async function readCostHistory(client, address, signal, onPage) {
|
|
47
|
+
async function readCostHistory(client, address, signal, onPage, onRecords) {
|
|
46
48
|
onPage?.();
|
|
47
49
|
const snapshot = await new Promise((resolve, reject) => {
|
|
48
50
|
let sub;
|
|
@@ -77,6 +79,7 @@ async function readCostHistory(client, address, signal, onPage) {
|
|
|
77
79
|
while (true) {
|
|
78
80
|
signal.throwIfAborted();
|
|
79
81
|
const records = array(page.records);
|
|
82
|
+
onRecords?.(records);
|
|
80
83
|
events.push(...costRecords(records));
|
|
81
84
|
if (!page.hasMore)
|
|
82
85
|
break;
|