@toddzheng024/dscode-bundle 0.7.21 → 0.7.23

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/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.7.21",
2
+ "version": "0.7.23",
3
3
  "type": "module",
4
4
  "license": "MIT",
5
5
  "author": "Todd Zheng",
@@ -306,9 +306,9 @@
306
306
  "@deepseek-ai/schemastery": "3.18.2",
307
307
  "@anionex/dsh-computer-use": "0.3.2",
308
308
  "chrome-devtools-mcp": "1.9.0",
309
- "imapflow": "2.0.2",
310
- "mailparser": "3.9.26",
311
- "nodemailer": "10.0.9",
309
+ "imapflow": "2.0.5",
310
+ "mailparser": "3.9.28",
311
+ "nodemailer": "10.0.10",
312
312
  "react": "18.3.1",
313
313
  "commander": "15.0.0",
314
314
  "eventsource-parser": "3.1.1"
@@ -1,3 +1,5 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
1
3
  export const name = 'dscode-execution-policy';
2
4
  export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals'];
3
5
  export const SHELL_POLICY = `Use bash as the persistent shell for reading, searching and modifying files. Prefer rg/rg --files, sed and standard CLI tools. Use apply_patch with a standard unified diff on stdin (git apply format, a/ and b/ paths); apply_patch --check validates before writing. It is not the *** Begin Patch format. Quote heredoc delimiters to avoid shell interpolation.
@@ -23,8 +25,16 @@ export const CHILD_NAME = /^[A-Za-z](?:[A-Za-z0-9_]{0,8}[A-Za-z])?$/;
23
25
  export const CHILD_NAME_RULE = 'name must be 1-10 characters of letters, digits or underscores, starting and ending with a letter';
24
26
  const DELEGATION_TOOLS = ['subagent', 'subagent_fork', 'workflow', 'ralph'];
25
27
 
28
+ // Where the version-matched user guides live. Source and tar installs ship docs/ next
29
+ // to the plugins; the npm/Hub bundle does not, so the section falls back to the public
30
+ // repository there instead of pointing at a path that does not exist.
31
+ const DOCS_DIR = fileURLToPath(new URL('../../docs/', import.meta.url));
32
+ const DOCS_REPO = 'https://github.com/qiz029/dscode/blob/main/docs/';
33
+ export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, email.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, demo.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
34
+
26
35
  export function apply(ctx) {
27
36
  ctx.systemPrompt.section({ name: 'dscode:shell-policy', order: 1050, text: SHELL_POLICY });
37
+ ctx.systemPrompt.section({ name: 'dscode:docs', order: 1052, text: DOCS_SECTION });
28
38
  ctx.systemPrompt.section({ name: 'dscode:code-discipline', order: 1053, text: CODE_DISCIPLINE });
29
39
  ctx.systemPrompt.section({ name: 'dscode:working-discipline', order: 1054, text: WORKING_DISCIPLINE });
30
40
  ctx.systemPrompt.section({ name: 'dscode:child-policy', order: 1051, text: ({ scope }) => scope?.session?.header?.origin === 'subagent' && scope.session.header.agentPreset === 'dscode'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Types for the DSCODE message tables, for the vendored terminal's TypeScript.
3
+ * The module is plain JS on purpose (it ships beside the plugins and is read by
4
+ * both surfaces), so this declares exactly the surface `packages/tui/src` imports:
5
+ * the language list, the alias normalizer, the lookup and the display name.
6
+ */
7
+ export interface LanguageEntry {
8
+ code: string;
9
+ name: string;
10
+ }
11
+
12
+ export const LANGUAGES: readonly LanguageEntry[];
13
+ export const ALIASES: Readonly<Record<string, readonly string[]>>;
14
+ export const MESSAGES: Readonly<Record<string, Readonly<Record<string, string>>>>;
15
+
16
+ /** Resolve a typed or persisted language name to its code, or null when unknown. */
17
+ export function normalizeLanguage(value: unknown): string | null;
18
+ /** The display name of a language code, or the code itself. */
19
+ export function languageName(code: string): string;
20
+ /** One message in one locale, with `{name}` parameters interpolated. */
21
+ export function t(locale: string, key: string, params?: Readonly<Record<string, unknown>>): string;
@@ -26,6 +26,7 @@ export const ALIASES = {
26
26
  export const MESSAGES = {
27
27
  en: {
28
28
  'activity.replying': 'Replying', 'activity.thinking': 'Thinking', 'activity.running': 'Running',
29
+ 'communication.sending': 'sending to {peer}', 'communication.waiting': 'waiting for {peer}',
29
30
  'activity.turn': 'this turn', 'activity.interrupt': 'Esc to interrupt',
30
31
  'agents.running': 'running', 'agents.idle': 'idle', 'agents.done': 'done', 'agents.total': 'total',
31
32
  'welcome.model': 'model', 'welcome.effort': 'effort', 'welcome.project': 'project',
@@ -40,10 +41,11 @@ export const MESSAGES = {
40
41
  'language.set': 'language → {name}', 'language.title': '/language — interface language', 'language.currentMark': 'current', 'language.unknown': 'Unknown language "{value}". Choose en, zh-CN, zh-TW, ja, ko or es.',
41
42
  'language.saveFailed': 'language save failed: {error}',
42
43
  'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
43
- 'footer.current': 'current', 'footer.average': 'average', 'footer.context': 'context', 'footer.cache': 'cache hit', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
44
+ 'footer.average': 'avg', 'footer.context': 'ctx', 'footer.cache': 'cache', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
44
45
  'compaction.running': 'Compacting context, please wait', 'compaction.confirm.title': 'Switching to {model} will compact the conversation',
45
46
  'compaction.confirm.usage': 'context {used} ≥ compaction threshold {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'the next step compacts older history first',
46
47
  'compaction.confirm.overflow': 'it already exceeds the {window} window: the next request compacts and retries', 'compaction.confirm.hint': 'y switch · n/esc back',
48
+ 'budget.confirm.title': 'Session spend {spent} has reached the {limit} budget ({percent})', 'budget.confirm.spent': 'the session has already recorded {spent} of the {limit} limit ({percent})', 'budget.confirm.next': 'confirming runs the next turn anyway; Esc or n cancels it', 'budget.confirm.hint': 'y send anyway · n/esc cancel',
47
49
  'doctor.nearTimeout': 'Assessment: {count} Bash calls ended at about 300 seconds, which matches the tool timeout; traces alone cannot prove the root cause. Next, check those calls\' shell completion markers and terminal errors.',
48
50
  'doctor.logs.none': 'No log file yet; earlier console logs were not persisted and cannot be recovered.',
49
51
  'doctor.evidence': 'Diagnostic evidence: {traces} recent sessions, {logs} warnings/errors.',
@@ -54,6 +56,7 @@ export const MESSAGES = {
54
56
  },
55
57
  'zh-CN': {
56
58
  'activity.replying': '正在回复', 'activity.thinking': '正在思考', 'activity.running': '正在执行',
59
+ 'communication.sending': '正在向 {peer} 发送', 'communication.waiting': '正在等待 {peer} 回应',
57
60
  'activity.turn': '本轮', 'activity.interrupt': 'Esc 中断',
58
61
  'agents.running': '运行中', 'agents.idle': '空闲', 'agents.done': '已完成', 'agents.total': '总计',
59
62
  'welcome.model': '模型', 'welcome.effort': '推理', 'welcome.project': '项目',
@@ -68,10 +71,11 @@ export const MESSAGES = {
68
71
  'language.set': '语言 → {name}', 'language.title': '/language — 界面语言', 'language.currentMark': '当前', 'language.unknown': '未知语言 "{value}"。可选 en、zh-CN、zh-TW、ja、ko、es。',
69
72
  'language.saveFailed': '语言设置保存失败:{error}',
70
73
  'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
71
- 'footer.current': '当前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '缓存命中', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
74
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '缓存', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
72
75
  'compaction.running': '正在压缩上下文,请稍后', 'compaction.confirm.title': '切换到 {model} 会触发自动压缩',
73
76
  'compaction.confirm.usage': '当前上下文 {used} ≥ 压缩阈值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步开始前会先压缩较早的对话',
74
77
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文窗口:下一次请求会先压缩再重试', 'compaction.confirm.hint': 'y 确认切换 · n/esc 返回',
78
+ 'budget.confirm.title': '会话花费 {spent} 已达预算 {limit}({percent})', 'budget.confirm.spent': '本会话已记录 {spent} / 上限 {limit}({percent})', 'budget.confirm.next': '确认后仍会执行下一轮;esc 或 n 取消', 'budget.confirm.hint': 'y 仍然发送 · n/esc 取消',
75
79
  'doctor.nearTimeout': '判断:{count} 次 Bash 调用在约 300 秒结束,符合工具超时特征;trace 不能单独证明触发超时的根因。下一步检查这些调用的 shell 完成标记和终端错误。',
76
80
  'doctor.logs.none': '日志文件尚未建立;旧版控制台日志未持久化,无法回溯。',
77
81
  'doctor.evidence': '诊断证据:{traces} 个近期会话、{logs} 条 warning/error。',
@@ -82,6 +86,7 @@ export const MESSAGES = {
82
86
  },
83
87
  'zh-TW': {
84
88
  'activity.replying': '正在回覆', 'activity.thinking': '正在思考', 'activity.running': '正在執行',
89
+ 'communication.sending': '正在向 {peer} 傳送', 'communication.waiting': '正在等待 {peer} 回覆',
85
90
  'activity.turn': '本輪', 'activity.interrupt': 'Esc 中斷',
86
91
  'agents.running': '執行中', 'agents.idle': '閒置', 'agents.done': '已完成', 'agents.total': '總計',
87
92
  'welcome.model': '模型', 'welcome.effort': '推理', 'welcome.project': '專案',
@@ -96,10 +101,11 @@ export const MESSAGES = {
96
101
  'language.set': '語言 → {name}', 'language.title': '/language — 介面語言', 'language.currentMark': '目前', 'language.unknown': '未知語言 "{value}"。可選 en、zh-CN、zh-TW、ja、ko、es。',
97
102
  'language.saveFailed': '語言設定儲存失敗:{error}',
98
103
  'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
99
- 'footer.current': '目前', 'footer.average': '平均', 'footer.context': '上下文', 'footer.cache': '快取命中', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
104
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '快取', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
100
105
  'compaction.running': '正在壓縮上下文,請稍候', 'compaction.confirm.title': '切換到 {model} 會觸發自動壓縮',
101
106
  'compaction.confirm.usage': '目前上下文 {used} ≥ 壓縮閾值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步開始前會先壓縮較早的對話',
102
107
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文視窗:下一次請求會先壓縮再重試', 'compaction.confirm.hint': 'y 確認切換 · n/esc 返回',
108
+ 'budget.confirm.title': '工作階段花費 {spent} 已達預算 {limit}({percent})', 'budget.confirm.spent': '本工作階段已記錄 {spent} / 上限 {limit}({percent})', 'budget.confirm.next': '確認後仍會執行下一輪;esc 或 n 取消', 'budget.confirm.hint': 'y 仍然送出 · n/esc 取消',
103
109
  'doctor.nearTimeout': '判斷:{count} 次 Bash 呼叫在約 300 秒結束,符合工具逾時特徵;trace 無法單獨證明觸發逾時的根因。下一步檢查這些呼叫的 shell 完成標記和終端錯誤。',
104
110
  'doctor.logs.none': '日誌檔尚未建立;舊版主控台日誌未持久化,無法回溯。',
105
111
  'doctor.evidence': '診斷證據:{traces} 個近期工作階段、{logs} 筆 warning/error。',
@@ -110,6 +116,7 @@ export const MESSAGES = {
110
116
  },
111
117
  ja: {
112
118
  'activity.replying': '応答中', 'activity.thinking': '思考中', 'activity.running': '実行中',
119
+ 'communication.sending': '{peer} へ送信中', 'communication.waiting': '{peer} の応答を待機中',
113
120
  'activity.turn': '今回のターン', 'activity.interrupt': 'Esc で中断',
114
121
  'agents.running': '実行中', 'agents.idle': '待機中', 'agents.done': '完了', 'agents.total': '合計',
115
122
  'welcome.model': 'モデル', 'welcome.effort': '推論', 'welcome.project': 'プロジェクト',
@@ -124,10 +131,11 @@ export const MESSAGES = {
124
131
  'language.set': '言語 → {name}', 'language.title': '/language — 表示言語', 'language.currentMark': '現在', 'language.unknown': '不明な言語 "{value}"。en、zh-CN、zh-TW、ja、ko、es から選んでください。',
125
132
  'language.saveFailed': '言語設定の保存に失敗しました:{error}',
126
133
  'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
127
- 'footer.current': '現在', 'footer.average': '平均', 'footer.context': 'コンテキスト', 'footer.cache': 'キャッシュヒット', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
134
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': 'キャッシュ', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
128
135
  'compaction.running': 'コンテキストを圧縮しています。しばらくお待ちください', 'compaction.confirm.title': '{model} に切り替えると自動圧縮が実行されます',
129
136
  'compaction.confirm.usage': '現在のコンテキスト {used} ≥ 圧縮しきい値 {threshold}({window} × {ratio})', 'compaction.confirm.next': '次のステップの前に古い履歴を圧縮します',
130
137
  'compaction.confirm.overflow': '新しいモデルの {window} ウィンドウを超えています。次のリクエストで圧縮して再試行します', 'compaction.confirm.hint': 'y 切り替え · n/esc 戻る',
138
+ 'budget.confirm.title': 'セッションの支出 {spent} が予算 {limit} に達しました({percent})', 'budget.confirm.spent': 'このセッションは {spent} / 上限 {limit}({percent})を記録済みです', 'budget.confirm.next': '確認すると次のターンを実行します。esc または n で中止', 'budget.confirm.hint': 'y 送信 · n/esc 中止',
131
139
  'doctor.nearTimeout': '判断:Bash 呼び出し {count} 件が約 300 秒で終了しており、ツールのタイムアウトの特徴に一致します。トレースだけでは根本原因を証明できません。次はこれらの呼び出しのシェル完了マーカーと端末エラーを確認してください。',
132
140
  'doctor.logs.none': 'ログファイルはまだありません。以前のコンソールログは保存されておらず、遡れません。',
133
141
  'doctor.evidence': '診断の根拠:直近のセッション {traces} 件、warning/error {logs} 件。',
@@ -138,6 +146,7 @@ export const MESSAGES = {
138
146
  },
139
147
  ko: {
140
148
  'activity.replying': '응답 중', 'activity.thinking': '생각 중', 'activity.running': '실행 중',
149
+ 'communication.sending': '{peer}에게 전송 중', 'communication.waiting': '{peer}의 응답을 기다리는 중',
141
150
  'activity.turn': '이번 턴', 'activity.interrupt': 'Esc 중단',
142
151
  'agents.running': '실행 중', 'agents.idle': '대기', 'agents.done': '완료', 'agents.total': '전체',
143
152
  'welcome.model': '모델', 'welcome.effort': '추론', 'welcome.project': '프로젝트',
@@ -152,10 +161,11 @@ export const MESSAGES = {
152
161
  'language.set': '언어 → {name}', 'language.title': '/language — 인터페이스 언어', 'language.currentMark': '현재', 'language.unknown': '알 수 없는 언어 "{value}". en, zh-CN, zh-TW, ja, ko, es 중에서 선택하세요.',
153
162
  'language.saveFailed': '언어 설정 저장 실패: {error}',
154
163
  'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
155
- 'footer.current': '현재', 'footer.average': '평균', 'footer.context': '컨텍스트', 'footer.cache': '캐시 적중', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
164
+ 'footer.average': '평균', 'footer.context': 'ctx', 'footer.cache': '캐시', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
156
165
  'compaction.running': '컨텍스트를 압축하는 중입니다. 잠시만 기다려 주세요', 'compaction.confirm.title': '{model}(으)로 전환하면 자동 압축이 실행됩니다',
157
166
  'compaction.confirm.usage': '현재 컨텍스트 {used} ≥ 압축 임계값 {threshold} ({window} × {ratio})', 'compaction.confirm.next': '다음 단계 전에 이전 대화를 먼저 압축합니다',
158
167
  'compaction.confirm.overflow': '새 모델의 {window} 창을 이미 넘었습니다. 다음 요청에서 압축한 뒤 재시도합니다', 'compaction.confirm.hint': 'y 전환 · n/esc 뒤로',
168
+ 'budget.confirm.title': '세션 지출 {spent}이(가) 예산 {limit}에 도달했습니다({percent})', 'budget.confirm.spent': '이 세션은 {spent} / 한도 {limit}({percent})을 기록했습니다', 'budget.confirm.next': '확인하면 다음 턴을 계속 실행합니다. esc 또는 n으로 취소', 'budget.confirm.hint': 'y 그래도 전송 · n/esc 취소',
159
169
  'doctor.nearTimeout': '판단: Bash 호출 {count}건이 약 300초에 종료되어 도구 시간 초과 특징과 일치합니다. 트레이스만으로는 근본 원인을 증명할 수 없습니다. 다음으로 해당 호출의 셸 완료 표시와 터미널 오류를 확인하세요.',
160
170
  'doctor.logs.none': '아직 로그 파일이 없습니다. 이전 콘솔 로그는 저장되지 않아 복구할 수 없습니다.',
161
171
  'doctor.evidence': '진단 근거: 최근 세션 {traces}개, warning/error {logs}건.',
@@ -166,6 +176,7 @@ export const MESSAGES = {
166
176
  },
167
177
  es: {
168
178
  'activity.replying': 'Respondiendo', 'activity.thinking': 'Pensando', 'activity.running': 'Ejecutando',
179
+ 'communication.sending': 'enviando a {peer}', 'communication.waiting': 'esperando a {peer}',
169
180
  'activity.turn': 'este turno', 'activity.interrupt': 'Esc para interrumpir',
170
181
  'agents.running': 'en ejecución', 'agents.idle': 'inactivo', 'agents.done': 'terminado', 'agents.total': 'en total',
171
182
  'welcome.model': 'modelo', 'welcome.effort': 'esfuerzo', 'welcome.project': 'proyecto',
@@ -180,10 +191,11 @@ export const MESSAGES = {
180
191
  'language.set': 'idioma → {name}', 'language.title': '/language — idioma de la interfaz', 'language.currentMark': 'actual', 'language.unknown': 'Idioma desconocido "{value}". Elige en, zh-CN, zh-TW, ja, ko o es.',
181
192
  'language.saveFailed': 'no se pudo guardar el idioma: {error}',
182
193
  'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
183
- 'footer.current': 'actual', 'footer.average': 'promedio', 'footer.context': 'contexto', 'footer.cache': 'éxitos de caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
194
+ 'footer.average': 'prom', 'footer.context': 'ctx', 'footer.cache': 'caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
184
195
  'compaction.running': 'Compactando el contexto, espera un momento', 'compaction.confirm.title': 'Cambiar a {model} compactará la conversación',
185
196
  'compaction.confirm.usage': 'contexto {used} ≥ umbral de compactación {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'el siguiente paso compacta antes el historial antiguo',
186
197
  'compaction.confirm.overflow': 'ya supera la ventana de {window}: la siguiente petición compacta y reintenta', 'compaction.confirm.hint': 'y cambiar · n/esc volver',
198
+ 'budget.confirm.title': 'El gasto de la sesión {spent} alcanzó el presupuesto {limit} ({percent})', 'budget.confirm.spent': 'la sesión ya registró {spent} de un límite de {limit} ({percent})', 'budget.confirm.next': 'confirmar ejecuta el siguiente turno igualmente; esc o n lo cancela', 'budget.confirm.hint': 'y enviar igual · n/esc cancelar',
187
199
  'doctor.nearTimeout': 'Valoración: {count} llamadas a Bash terminaron a unos 300 segundos, lo que coincide con el tiempo de espera de la herramienta; las trazas por sí solas no prueban la causa raíz. A continuación, revisa los marcadores de finalización del shell y los errores de terminal de esas llamadas.',
188
200
  'doctor.logs.none': 'Aún no hay archivo de registro; los registros de consola anteriores no se conservaron y no se pueden recuperar.',
189
201
  'doctor.evidence': 'Evidencia del diagnóstico: {traces} sesiones recientes, {logs} warnings/errores.',
@@ -1,5 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
+ import { communicationPanel, createCommunicationFeed, foldCommunication } from './tasks.mjs';
3
4
  import { SessionBridge } from './server.mjs';
4
5
  import { CommunicationService } from './communication.mjs';
5
6
  import { defineTool } from '@deepseek-ai/dsh-tools';
@@ -21,6 +22,11 @@ export async function apply(ctx) {
21
22
  const value = action === 'cancel' ? await communication.cancel(agent, id, true) : communication.store.list(agent.id);
22
23
  return { kind: 'success', text: JSON.stringify(value, null, 2) };
23
24
  } });
25
+ // The same fold the terminal's activity line uses, so the panels agree.
26
+ ctx.commands.register({ name: 'tasks', description: 'Cross-session messages and what is still awaiting a reply', handler({ agent }) {
27
+ const view = agent.session.snapshotEvents().reduce((acc, event) => foldCommunication(acc, event), createCommunicationFeed());
28
+ return { kind: 'success', text: communicationPanel(view) };
29
+ } });
24
30
  ctx.commands.register({ name: 'session-new-task', description: 'Explicitly start a fresh communication budget while idle', handler({ agent }) {
25
31
  return { kind: 'success', text: `New task chains: ${communication.newTask(agent).join(', ')}` };
26
32
  } });
@@ -0,0 +1,227 @@
1
+ // Cross-session traffic as a data fold, for `/tasks` in the terminal's plugin
2
+ // half. This is the same fold the TUI's live activity line and notices use
3
+ // (`packages/tui/src/communication.ts`): the bridge can only import from the
4
+ // shipped `plugins/` tree, because the Hub bundle carries `packages/tui/lib`
5
+ // without `packages/tui/src`. The two must stay in step — the rules are:
6
+ //
7
+ // - a `send_session` / `reply_session` call is an outbound row; its paired
8
+ // result settles it, and an errored result is `failed`, never `awaiting`;
9
+ // - a settled `request` awaits its answer until a relay whose bridge header
10
+ // says `reply` arrives from that peer;
11
+ // - a bridge relayed `user/message` is an inbound row;
12
+ // - rows are bounded, and outbound/inbound ids are namespaced apart.
13
+
14
+ /** Hard row cap for `/tasks`; the newest rows survive. */
15
+ export const MAX_COMMUNICATION_ROWS = 32
16
+
17
+ /** Bound on outstanding requests tracked at once (display state, not a budget). */
18
+ const MAX_WAITING_REQUESTS = 16
19
+
20
+ /** Bounded body preview, matching the activity line's display budget. */
21
+ const MAX_PREVIEW_CHARS = 72
22
+
23
+ /** The plugin name the session bridge stamps on its relayed messages. */
24
+ const BRIDGE_PLUGIN = 'dscode-session-bridge'
25
+
26
+ /** Tool names that address another session. */
27
+ const SEND_TOOLS = new Set(['send_session', 'reply_session'])
28
+
29
+ /**
30
+ * One peer identity across both directions. Outbound rows carry the bare
31
+ * session id from the tool arguments; an inbound relay's bridge label is
32
+ * `session:<id>`, so the prefix is stripped before they are compared.
33
+ */
34
+ function peerKey(peer) {
35
+ return peer.startsWith('session:') ? peer.slice('session:'.length) : peer
36
+ }
37
+
38
+ /** Fold one tool-call argument JSON string into the fields a row needs. */
39
+ function callFields(raw) {
40
+ if (typeof raw !== 'string' || raw.length > 8192) return { preview: '' }
41
+ let parsed
42
+ try {
43
+ parsed = JSON.parse(raw)
44
+ } catch {
45
+ return { preview: '' }
46
+ }
47
+ if (parsed === null || typeof parsed !== 'object') return { preview: '' }
48
+ const text = value => (typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '')
49
+ const peer = text(parsed.session_id) || text(parsed.request_message_id)
50
+ const preview = text(parsed.text)
51
+ return {
52
+ ...(peer === '' ? {} : { peer }),
53
+ ...(text(parsed.kind) === '' ? {} : { kind: text(parsed.kind) }),
54
+ ...(text(parsed.mode) === '' ? {} : { mode: text(parsed.mode) }),
55
+ preview: preview.length > MAX_PREVIEW_CHARS ? `${preview.slice(0, MAX_PREVIEW_CHARS - 1)}…` : preview,
56
+ }
57
+ }
58
+
59
+ /**
60
+ * One relayed message's peer, message kind and bounded text. The bridge stamps
61
+ * the kind only inside the body header (`[External source: …] [kind/mode]`),
62
+ * which is its documented presentation contract, so an unrecognised header
63
+ * leaves the kind undefined and the caller stays conservative: an unknown
64
+ * inbound message never counts as an answer.
65
+ */
66
+ function relayFields(data) {
67
+ if (data === null || typeof data !== 'object') return undefined
68
+ const source = data.source
69
+ if (source === null || typeof source !== 'object') return undefined
70
+ if (source.kind !== 'plugin' || source.plugin !== BRIDGE_PLUGIN || source.form !== 'relay') return undefined
71
+ const label = typeof source.label === 'string' && source.label !== '' ? source.label : undefined
72
+ const id = typeof source.communicationId === 'string' && source.communicationId !== '' ? source.communicationId : undefined
73
+ if (label === undefined && id === undefined) return undefined
74
+ const texts = []
75
+ if (Array.isArray(data.content)) {
76
+ for (const block of data.content) {
77
+ if (typeof block === 'object' && block !== null && block.type === 'text' && typeof block.text === 'string') texts.push(block.text)
78
+ }
79
+ }
80
+ const body = texts.join(' ').replace(/\s+/gu, ' ').trim()
81
+ const kind = body.match(/\[([a-z]+)\/[a-z]+\]/u)?.[1]
82
+ const joinLines = texts.join('\n')
83
+ const joined = joinLines
84
+ .replace(/^\[External source:[^\]]*\](\s*\[[a-z]+\/[a-z]+\])?\s*/u, '')
85
+ .replace(/^Message ID:[^\n]*\n?/u, '')
86
+ .replace(/\s+/gu, ' ')
87
+ .trim()
88
+ return {
89
+ peer: label ?? id ?? 'unknown',
90
+ ...(kind === undefined ? {} : { kind }),
91
+ preview: joined.length > MAX_PREVIEW_CHARS ? `${joined.slice(0, MAX_PREVIEW_CHARS - 1)}…` : joined,
92
+ }
93
+ }
94
+
95
+ /** Keep only the newest rows. */
96
+ function bound(rows) {
97
+ return rows.length <= MAX_COMMUNICATION_ROWS ? rows : rows.slice(rows.length - MAX_COMMUNICATION_ROWS)
98
+ }
99
+
100
+ /** The call id a tool result pairs with. */
101
+ function resultCallId(data) {
102
+ if (!Array.isArray(data.message?.content)) return undefined
103
+ for (const block of data.message.content) {
104
+ if (typeof block === 'object' && block !== null && typeof block.toolCallId === 'string') return block.toolCallId
105
+ }
106
+ return undefined
107
+ }
108
+
109
+ /** True when the tool result's block carries `isError`. */
110
+ function resultFailed(data) {
111
+ if (!Array.isArray(data.message?.content)) return false
112
+ return data.message.content.some(block => typeof block === 'object' && block !== null && block.isError === true)
113
+ }
114
+
115
+ /** The relay's stable message identity, when the bridge stamped one. */
116
+ function relayId(data) {
117
+ const source = data?.source
118
+ return typeof source?.communicationId === 'string' && source.communicationId !== '' ? source.communicationId : undefined
119
+ }
120
+
121
+ /** Outbound row identity, namespaced so an inbound id can never collide with it. */
122
+ function outboundId(callId) {
123
+ return `sent:${callId}`
124
+ }
125
+
126
+ /** Inbound row identity, namespaced and falling back to the event sequence. */
127
+ function inboundId(data, seq) {
128
+ return `recv:${relayId(data) ?? seq}`
129
+ }
130
+
131
+ /** Record one settled request as awaiting its answer. Keyed by the outbound row. */
132
+ function markWaiting(waiting, request) {
133
+ if (waiting.some(entry => entry.id === request.id)) return waiting
134
+ const next = [...waiting, { id: request.id, peer: request.peer, since: request.at }]
135
+ return next.length <= MAX_WAITING_REQUESTS ? next : next.slice(next.length - MAX_WAITING_REQUESTS)
136
+ }
137
+
138
+ /** Clear the outstanding requests a reply answers. */
139
+ function clearWaiting(waiting, peer) {
140
+ const next = waiting.filter(entry => peerKey(entry.peer) !== peerKey(peer))
141
+ return next.length === waiting.length ? waiting : next
142
+ }
143
+
144
+ /** The empty view the fold starts from. */
145
+ export function createCommunicationFeed() {
146
+ return { rows: [], waiting: [] }
147
+ }
148
+
149
+ /**
150
+ * Fold one root-session event into the feed, returning the same view identity
151
+ * when the event is not communication traffic.
152
+ * @param view - the current view.
153
+ * @param event - one session event from the root session.
154
+ * @returns the next view, or the same object when nothing changed.
155
+ */
156
+ export function foldCommunication(view, event) {
157
+ if (event.type === 'tool/call') {
158
+ const data = event.data
159
+ if (!SEND_TOOLS.has(data.name)) return view
160
+ const fields = callFields(data.arguments)
161
+ const row = {
162
+ id: outboundId(data.callId),
163
+ direction: 'sent',
164
+ peer: fields.peer ?? 'unknown',
165
+ ...(fields.kind === undefined ? {} : { kind: fields.kind }),
166
+ ...(fields.mode === undefined ? {} : { mode: fields.mode }),
167
+ preview: fields.preview,
168
+ at: event.time,
169
+ pending: true,
170
+ failed: false,
171
+ }
172
+ return { rows: bound([...view.rows, row]), waiting: view.waiting }
173
+ }
174
+ if (event.type === 'tool/result') {
175
+ const data = event.data
176
+ const callId = resultCallId(data)
177
+ if (callId === undefined) return view
178
+ const index = view.rows.findIndex(row => row.id === outboundId(callId))
179
+ if (index === -1) return view
180
+ const settled = { ...view.rows[index], pending: false, failed: resultFailed(data) }
181
+ const rows = view.rows.map((row, at) => (at === index ? settled : row))
182
+ const answers = settled.kind === 'request' && !settled.failed
183
+ return { rows, waiting: answers ? markWaiting(view.waiting, settled) : view.waiting }
184
+ }
185
+ if (event.type === 'user/message') {
186
+ const relay = relayFields(event.data)
187
+ if (relay === undefined) return view
188
+ const row = {
189
+ id: inboundId(event.data, event.seq), direction: 'received',
190
+ ...(relay.kind === undefined ? {} : { kind: relay.kind }),
191
+ peer: relay.peer, preview: relay.preview, at: event.time, pending: false, failed: false,
192
+ }
193
+ return {
194
+ rows: bound([...view.rows, row]),
195
+ // Only the single final reply answers a request; a bare notify from the
196
+ // same peer must not clear a turn that is still blocked on an answer.
197
+ waiting: relay.kind === 'reply' ? clearWaiting(view.waiting, row.peer) : view.waiting,
198
+ }
199
+ }
200
+ return view
201
+ }
202
+
203
+ /**
204
+ * Render the `/tasks` listing: one line per communication, newest last, with
205
+ * the same arrows the activity line and the notices use.
206
+ * @param view - the folded view.
207
+ * @param limit - the newest rows to show.
208
+ * @returns the panel text, or a short explanation when there is no traffic.
209
+ */
210
+ export function communicationPanel(view, limit = 20) {
211
+ if (view.rows.length === 0) return 'No cross-session messages in this session.'
212
+ const waiting = new Set(view.waiting.map(entry => peerKey(entry.peer)))
213
+ const shown = view.rows.slice(-limit)
214
+ const lines = shown.map(row => {
215
+ const arrow = row.direction === 'received' ? '←' : '→'
216
+ const state = row.direction === 'received' ? 'received' : row.failed ? 'failed' : row.pending ? 'sending' : waiting.has(peerKey(row.peer)) ? 'awaiting reply' : 'sent'
217
+ const kind = row.kind === undefined ? '' : ` ${row.kind}`
218
+ const mode = row.mode === undefined ? '' : `/${row.mode}`
219
+ const text = row.preview === '' ? '' : ` — ${row.preview}`
220
+ return `${arrow} ${row.peer}${kind}${mode} · ${state}${text}`
221
+ })
222
+ const hidden = view.rows.length - shown.length
223
+ return [
224
+ ...(hidden > 0 ? [`(${hidden} older message${hidden === 1 ? '' : 's'} hidden)`] : []),
225
+ ...lines,
226
+ ].join('\n')
227
+ }
@@ -0,0 +1,134 @@
1
+ // Per-turn cost attribution and the session budget gate. The footer reports the
2
+ // whole session's spend and /usage reports tokens per turn; these functions
3
+ // answer what neither does with that ledger: which turn spent the money, so the
4
+ // footer can show the last completed turn and /usage can price each one.
5
+
6
+ /**
7
+ * Turn windows from the session's own events. `turn/start` opens a window and
8
+ * `turn/end` closes it; an unterminated turn stays open to `Infinity` so the
9
+ * turn in flight still attributes its settled calls. Windows come back in
10
+ * ascending start order, which the attribution lookup below depends on.
11
+ */
12
+ export function turnWindows(events = []) {
13
+ const open = new Map();
14
+ const windows = [];
15
+ for (const event of events) {
16
+ const turn = event.data?.turn;
17
+ // A boundary without a time cannot place a window or a call inside one. It
18
+ // is skipped rather than stored: a NaN edge would make the ascending-search
19
+ // contract false and silently orphan every call of that turn.
20
+ if (!Number.isFinite(turn) || !Number.isFinite(event.time)) continue;
21
+ if (event.type === 'turn/start') {
22
+ const existing = open.get(turn);
23
+ // A resumed turn can re-emit its start; keep the earliest boundary.
24
+ open.set(turn, existing === undefined ? event.time : Math.min(existing, event.time));
25
+ } else if (event.type === 'turn/end') {
26
+ const start = open.get(turn);
27
+ if (start === undefined) continue;
28
+ open.delete(turn);
29
+ windows.push({ turn, start, end: event.time });
30
+ }
31
+ }
32
+ for (const [turn, start] of open) windows.push({ turn, start, end: Infinity });
33
+ return windows.sort((left, right) => left.start - right.start || left.turn - right.turn);
34
+ }
35
+
36
+ /**
37
+ * The clauses a call is priced against: each turn's window, with an open turn
38
+ * (no `turn/end`) bounded by the next turn's start. An interrupted turn must not
39
+ * swallow every later turn, while the turn genuinely in flight keeps running to
40
+ * `Infinity`. Satisfies {@link attributeCostByTurn}'s ascending-start contract.
41
+ */
42
+ export function turnBounds(events = []) {
43
+ return turnWindows(events).map((window, index, windows) => ({
44
+ turn: window.turn,
45
+ start: window.start,
46
+ // Math.max keeps the window non-inverted even for a log whose timestamps
47
+ // are out of order; an inverted bound would break the ascending search.
48
+ end: window.end === Infinity && index + 1 < windows.length ? Math.max(window.start, windows[index + 1].start) : window.end,
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * The window containing `time`, by binary search over the ascending bounds. The
54
+ * index is the FIRST window starting after `time`, so the one before it is the
55
+ * only candidate that can contain it; `-1` means no turn was running.
56
+ * @param bounds - ascending {@link turnBounds} rows.
57
+ * @param time - the call's start time (`time` prices the call, so it is also when the turn ran).
58
+ * @returns the bounds index, or -1 when `time` falls in no window.
59
+ */
60
+ export function turnAt(bounds, time) {
61
+ let low = 0, high = bounds.length;
62
+ while (low < high) {
63
+ const mid = (low + high) >> 1;
64
+ if (bounds[mid].start <= time) low = mid + 1;
65
+ else high = mid;
66
+ }
67
+ const candidate = bounds[low - 1];
68
+ return candidate !== undefined && time <= candidate.end ? low - 1 : -1;
69
+ }
70
+
71
+ /**
72
+ * Cost and call count per turn. Roughly O(calls × log turns), so the footer can
73
+ * ask for the last turn once a second. A call is attributed by its start time,
74
+ * and a call outside every window (before the first `turn/start`, in a gap
75
+ * between two turns, or a backfilled row without one) is reported under
76
+ * `unattributed` rather than dropped.
77
+ * @param rows - ledger rows (`{ kind, time, cost, purpose }`).
78
+ * @param events - the session's events, for the turn windows.
79
+ * @returns One bucket per turn, plus `lastTurn` (the newest turn that is no
80
+ * longer running, undefined while none has stopped), the `unattributed` total
81
+ * and whether any of it was unpriceable. A turn that never emitted `turn/end`
82
+ * still counts as stopped once the next turn opens its window.
83
+ */
84
+ export function attributeCostByTurn(rows = [], events = []) {
85
+ const bounds = turnBounds(events);
86
+ const turns = bounds.map(bound => ({ turn: bound.turn, cost: 0, calls: 0, unknown: false }));
87
+ let unattributed = 0;
88
+ let unattributedUnknown = false;
89
+ for (const row of rows ?? []) {
90
+ if (row?.kind !== 'end') continue;
91
+ const at = turnAt(bounds, row.time);
92
+ if (at === -1) {
93
+ // Outside every window the spend still exists: an unpriceable row must be
94
+ // visible as such rather than counted as nothing.
95
+ if (Number.isFinite(row.cost)) unattributed += row.cost;
96
+ else unattributedUnknown = true;
97
+ continue;
98
+ }
99
+ turns[at].calls += 1;
100
+ if (Number.isFinite(row.cost)) turns[at].cost += row.cost;
101
+ else turns[at].unknown = true;
102
+ }
103
+ let lastTurn;
104
+ for (let index = bounds.length - 1; index >= 0; index -= 1) {
105
+ if (bounds[index].end !== Infinity) { lastTurn = turns[index]; break; }
106
+ }
107
+ return { turns, lastTurn, unattributed, unattributedUnknown };
108
+ }
109
+
110
+ /**
111
+ * The budget gate's verdict. A missing or non-positive limit means no budget is
112
+ * set, so the gate never fires and the footer shows no budget slot. `warn` is
113
+ * the alerting share; `over` means the next prompt would spend past the limit.
114
+ */
115
+ export function evaluateBudget(spent, limit, options = {}) {
116
+ const warnPercent = Number.isFinite(options.warnPercent) ? options.warnPercent : 80
117
+ if (!Number.isFinite(limit) || limit <= 0) return { state: 'unset', limit: null, spent, ratio: null, percent: null, warnPercent }
118
+ const safeSpent = Number.isFinite(spent) ? spent : 0
119
+ const ratio = safeSpent / limit
120
+ return {
121
+ state: ratio >= 1 ? 'over' : ratio * 100 >= warnPercent ? 'warn' : 'ok',
122
+ limit, spent: safeSpent, ratio,
123
+ percent: Math.round(ratio * 100),
124
+ warnPercent,
125
+ }
126
+ }
127
+
128
+ /** Parse the `DSCODE_SESSION_BUDGET_USD`-style value; anything else is no budget. */
129
+ export function parseBudget(value) {
130
+ if (typeof value === 'number') return Number.isFinite(value) && value > 0 ? value : null
131
+ if (typeof value !== 'string') return null
132
+ const parsed = Number.parseFloat(value.trim())
133
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null
134
+ }