mirror of
https://github.com/Orkas-AI/Orkas.git
synced 2026-11-07 23:02:03 -10:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1778b95f6c | ||
|
|
0e088a8928 | ||
|
|
8694d88f36 | ||
|
|
eaf9d46b7c | ||
|
|
dd835b8ed8 | ||
|
|
cf34a88aa2 | ||
|
|
625b1f3c02 | ||
|
|
0c96a56567 | ||
|
|
fed4ac6b3c | ||
|
|
3cd40b5f4b | ||
|
|
60b67b1f24 | ||
|
|
e086a5d686 | ||
|
|
45549c6fd5 | ||
|
|
61fb9aa450 | ||
|
|
5d8ae061ee | ||
|
|
8bb39f9139 | ||
|
|
5b2eda377f | ||
|
|
4bf14ac693 | ||
|
|
9ccafcee31 | ||
|
|
5af9ef25f5 | ||
|
|
b47acced4f | ||
|
|
5806eb005f | ||
|
|
fc5feb6910 | ||
|
|
52f8265ec2 | ||
|
|
de739d63f1 | ||
|
|
f994b00563 | ||
|
|
1b26502eb1 | ||
|
|
50a0b4a623 | ||
|
|
dac57633dc | ||
|
|
b7c4e12476 | ||
|
|
9bb098d175 | ||
|
|
ab7cb222f3 | ||
|
|
429bc16522 | ||
|
|
74e1e96756 |
No files matched your search
@@ -0,0 +1,82 @@
|
||||
name: Linux source smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linux-source:
|
||||
name: Linux ${{ matrix.arch }} source
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: ubuntu-22.04
|
||||
- arch: arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out source
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Restore verified built-in dependency cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: |
|
||||
resources/embedding-model
|
||||
resources/officecli
|
||||
resources/runtime/python
|
||||
resources/runtime/uv
|
||||
resources/runtime/node
|
||||
resources/runtime/ffmpeg
|
||||
resources/runtime/whisper
|
||||
key: linux-source-${{ matrix.arch }}-${{ hashFiles('package-lock.json', 'resources/runtime/manifest.json', 'bin/runtime-gate.cjs', 'bin/packaged-resource-gate.cjs', 'scripts/fetch-embedding-model.mjs', 'scripts/fetch-officecli.cjs', 'scripts/fetch-ffmpeg.cjs', 'scripts/fetch-whisper.cjs') }}
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Xvfb when the runner image does not provide it
|
||||
run: |
|
||||
if ! command -v xvfb-run >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes xvfb
|
||||
fi
|
||||
|
||||
- name: Prepare and verify built-in Linux dependencies
|
||||
run: node scripts/ensure-dev-dependencies.cjs
|
||||
|
||||
- name: Run Linux platform-native test lane
|
||||
run: xvfb-run --auto-servernum npm run test:platform-native
|
||||
|
||||
- name: Type-check
|
||||
run: node node_modules/typescript/bin/tsc --noEmit
|
||||
|
||||
- name: Launch the real Orkas application and renderer
|
||||
run: >-
|
||||
ORKAS_E2E_SHOW_WINDOW=1 xvfb-run --auto-servernum
|
||||
npm run test:e2e --
|
||||
test/e2e/app_e2e_smoke.spec.ts
|
||||
--grep "launches the hidden real renderer and preload bridge"
|
||||
|
||||
- name: Run native inference and Electron GUI smoke
|
||||
run: xvfb-run --auto-servernum npm run test:linux-source
|
||||
|
||||
- name: Run real Linux local transcription smoke
|
||||
run: npm run test:linux-whisper
|
||||
|
||||
- name: Verify first-use Linux OCR runtime
|
||||
run: npm run test:linux-ocr
|
||||
@@ -57,7 +57,7 @@ Installed and ready the first time you launch Orkas — each with its own skills
|
||||
|
||||
## Download
|
||||
|
||||
Packaged installers for macOS and Windows. Linux runs from source today — see [Quick start](#quick-start).
|
||||
Packaged installers for macOS and Windows. glibc-based Linux x64/arm64 runs from source today — see [Quick start](#quick-start).
|
||||
|
||||
- **macOS Apple Silicon** → [Orkas-mac-arm64.dmg](https://orkas.ai/download/?source=gh-orkas&entry_point=github_readme&os=mac&arch=arm64&download=1)
|
||||
- **macOS Intel** → [Orkas-mac-x64.dmg](https://orkas.ai/download/?source=gh-orkas&entry_point=github_readme&os=mac&arch=x64&download=1)
|
||||
@@ -121,7 +121,7 @@ Yes. Beyond its own Commander and specialist agents, Orkas can drive external CL
|
||||
Claude Desktop is a single assistant; CrewAI and LangChain are code-first frameworks. Orkas is a local-first multi-agent desktop app: the Commander coordinates specialist agents, keeps data and keys local, and gives each agent its own private skills and memory. See the [full comparisons](https://orkas.ai/compare/orkas-vs-langchain?source=gh-orkas).
|
||||
|
||||
**Which platforms does Orkas support?**
|
||||
macOS (Apple Silicon and Intel) and Windows 10+ have packaged installers. Linux runs from source today — same app, no installer yet. The source build needs Node 20+ and Python 3.
|
||||
macOS (Apple Silicon and Intel) and Windows 10+ have packaged installers. glibc 2.34+ Linux x64/arm64 runs from source today, with no installer yet. Local speech transcription is supported on Linux through the pinned whisper.cpp runtime prepared on first launch. Alpine and other musl-based distributions are not supported. The source bootstrap needs Node 20+; Python 3 and a C/C++ build toolchain are needed only when a native npm package has no compatible prebuilt binary and must be rebuilt locally.
|
||||
|
||||
**Is Orkas free and open source?**
|
||||
Yes — the app is MIT licensed and free to use. Bring your own model keys and you pay only your model providers; Orkas never takes a cut. Optionally, the desktop app also offers a built-in **Orkas model** for people who don't want to manage API keys — that one is billed by Orkas in credits (membership or credit packs). It is entirely opt-in, and every other feature works on your own keys. [Pricing →](https://orkas.ai/pricing/?source=gh-orkas)
|
||||
@@ -132,7 +132,7 @@ Yes — the app is MIT licensed and free to use. Bring your own model keys and y
|
||||
|
||||
Want a packaged installer instead? See [Download](#download) above. To run from source — currently the way to run Orkas on Linux:
|
||||
|
||||
**Requirements**: Node 20+ · Python 3 · macOS / Windows 10+ / recent Linux
|
||||
**Requirements**: Node 20+ · macOS / Windows 10+ / glibc 2.34+ Linux x64 or arm64. Keep Python 3 and a C/C++ build toolchain available for the uncommon native-module source-build fallback.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Orkas-AI/Orkas.git
|
||||
@@ -141,7 +141,9 @@ cd Orkas
|
||||
run.cmd # Windows
|
||||
```
|
||||
|
||||
`run.sh` / `run.cmd` auto-installs dependencies and downloads the embedding model (~95 MB). First launch creates a workspace under `~/.orkas/` (macOS / Linux) or `<smallest non-system drive>:\.orkas\` (Windows). Then open **Settings → AI Providers** to add an API key or OAuth.
|
||||
Linux source runs require glibc 2.34+. Alpine and other musl-based distributions are not supported. Local speech transcription downloads and verifies the target-native whisper.cpp runtime and multilingual model during the first source launch.
|
||||
|
||||
`run.sh` / `run.cmd` installs the locked npm dependency tree and prepares the pinned Python, uv, Node, embedding model (~95 MB), OfficeCLI, FFmpeg, whisper.cpp, and multilingual speech model resources on first launch. Linux startup then verifies the platform-native modules and Whisper runtime under Electron's ABI before opening the app. OCR and Skill-specific Python packages are installed into isolated local environments when those features are first used. First launch creates a workspace under `~/.orkas/` (macOS / Linux) or `<smallest non-system drive>:\.orkas\` (Windows). Then open **Settings → AI Providers** to add an API key or OAuth.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+6
-4
@@ -57,7 +57,7 @@ Orkas 是一个开源、本地优先的多智能体桌面应用。你描述目
|
||||
|
||||
## 下载
|
||||
|
||||
macOS 和 Windows 提供安装包。Linux 目前需从源码运行 —— 见 [快速开始](#快速开始)。
|
||||
macOS 和 Windows 提供安装包。基于 glibc 的 Linux x64/arm64 目前需从源码运行 —— 见 [快速开始](#快速开始)。
|
||||
|
||||
- **macOS Apple 芯片** → [Orkas-mac-arm64.dmg](https://orkas.ai/download/?source=gh-orkas&entry_point=github_readme&os=mac&arch=arm64&download=1)
|
||||
- **macOS Intel** → [Orkas-mac-x64.dmg](https://orkas.ai/download/?source=gh-orkas&entry_point=github_readme&os=mac&arch=x64&download=1)
|
||||
@@ -121,7 +121,7 @@ Orkas 是一个开源、本地优先的多智能体桌面应用。一个超强
|
||||
Claude Desktop 是单个助理;CrewAI 和 LangChain 是代码优先的框架。Orkas 是一个本地优先的多智能体桌面应用:指挥官协调多个专业智能体,数据与 key 留在本地,每个智能体拥有私有技能与记忆。见[逐项对比](https://orkas.ai/compare/orkas-vs-langchain?source=gh-orkas)。
|
||||
|
||||
**支持哪些平台?**
|
||||
macOS(Apple 芯片与 Intel)和 Windows 10+ 提供安装包。Linux 目前从源码运行 —— 功能相同,只是还没有安装包。源码运行需要 Node 20+ 与 Python 3。
|
||||
macOS(Apple 芯片与 Intel)和 Windows 10+ 提供安装包。基于 glibc 2.34+ 的 Linux x64/arm64 目前从源码运行,暂时没有安装包。本地语音转写通过首次启动时准备的固定版本 whisper.cpp 运行时提供。当前不支持 Alpine 及其他基于 musl 的发行版。源码启动需要 Node 20+;只有在原生 npm 包没有匹配的预编译二进制、必须本地编译时,才额外需要 Python 3 和 C/C++ 构建工具链。
|
||||
|
||||
**Orkas 免费且开源吗?**
|
||||
是的 —— 应用本身 MIT 许可证、免费使用。自带模型 key 时,你只需为你的模型服务商付费,Orkas 不抽成。此外,桌面版还提供一个可选的内置 **Orkas 模型**,供不想自己配置 key 的用户使用 —— 这部分由 Orkas 按 credits 计费(会员或 credits 包)。它完全可选,其余所有功能用你自己的 key 即可。[价格 →](https://orkas.ai/pricing/?source=gh-orkas)
|
||||
@@ -132,7 +132,7 @@ macOS(Apple 芯片与 Intel)和 Windows 10+ 提供安装包。Linux 目前
|
||||
|
||||
想直接用安装包?见上方 [下载](#下载)。以下是从源码运行的方式 —— 也是目前在 Linux 上运行 Orkas 的方式:
|
||||
|
||||
**环境要求**:Node 20+ · Python 3 · macOS / Windows 10+ / 较新的 Linux
|
||||
**环境要求**:Node 20+ · macOS / Windows 10+ / 基于 glibc 2.34+ 的 Linux x64 或 arm64。建议保留 Python 3 与 C/C++ 构建工具链,用于少数原生模块需要源码编译的兜底场景。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Orkas-AI/Orkas.git
|
||||
@@ -141,7 +141,9 @@ cd Orkas
|
||||
run.cmd # Windows
|
||||
```
|
||||
|
||||
`run.sh` / `run.cmd` 会自动安装依赖并下载嵌入模型(约 95 MB)。首次启动会在 `~/.orkas/`(macOS / Linux)或 `<最小的非系统盘>:\.orkas\`(Windows)下创建工作区。随后进入 **设置 → AI 服务商** 配置 API key 或 OAuth。
|
||||
Linux 源码运行需要 glibc 2.34+,不支持 Alpine 及其他基于 musl 的发行版。本地语音转写会在首次源码启动时下载并校验目标架构对应的 whisper.cpp 运行时及多语言模型。
|
||||
|
||||
`run.sh` / `run.cmd` 会在首次启动时安装 lockfile 固定的 npm 依赖,并准备固定版本的 Python、uv、Node、嵌入模型(约 95 MB)、OfficeCLI、FFmpeg、whisper.cpp 与多语言语音模型。Linux 启动还会在 Electron ABI 下验证平台原生模块及 Whisper 运行时后再打开应用。OCR 与 Skill 专属 Python 包会在首次使用对应功能时安装到隔离的本地环境。首次启动会在 `~/.orkas/`(macOS / Linux)或 `<最小的非系统盘>:\.orkas\`(Windows)下创建工作区。随后进入 **设置 → AI 服务商** 配置 API key 或 OAuth。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const DEFAULT_KILL_GRACE_MS = 5_000;
|
||||
const DEFAULT_SETTLE_MS = 6_000;
|
||||
const DEFAULT_SHUTDOWN_SETTLE_MS = 1_000;
|
||||
const OUTPUT_REF_RE = /^[a-f0-9]{32}$/;
|
||||
const WINDOWS_OUTPUT_CLEANUP_DELAYS_MS = [0, 25, 75, 150, 300];
|
||||
|
||||
function positiveInt(value, fallback) {
|
||||
return Number.isFinite(value) && value > 0 ? Math.max(1, Math.trunc(value)) : fallback;
|
||||
@@ -28,6 +29,10 @@ function assertSafeScriptBase(scriptBase) {
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delayMs) {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
function windowsSystem32Tool(name) {
|
||||
const root = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
||||
return path.win32.join(root, 'System32', name);
|
||||
@@ -203,6 +208,20 @@ function createBridgeSkillRunner(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOutputDirectory() {
|
||||
const delays = process.platform === 'win32' ? WINDOWS_OUTPUT_CLEANUP_DELAYS_MS : [0];
|
||||
for (const delayMs of delays) {
|
||||
if (delayMs > 0) await wait(delayMs);
|
||||
try {
|
||||
fs.rmSync(outputDir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return;
|
||||
if (!error || (error.code !== 'EPERM' && error.code !== 'EBUSY')) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function streamPreview(outputRef, stream, spool) {
|
||||
try {
|
||||
const chunk = readUtf8Chunk(streamPath(outputRef, stream), 0, previewBytes);
|
||||
@@ -395,7 +414,8 @@ function createBridgeSkillRunner(options) {
|
||||
shuttingDown = true;
|
||||
const pending = Array.from(activeRuns);
|
||||
for (const entry of pending) entry.terminate();
|
||||
shutdownPromise = Promise.allSettled(pending.map((entry) => entry.completion)).then(() => {});
|
||||
shutdownPromise = Promise.allSettled(pending.map((entry) => entry.completion))
|
||||
.then(removeOutputDirectory);
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,16 @@ function sameJson(left, right) {
|
||||
}
|
||||
|
||||
function packageRootFromManifestPath(manifestPath) {
|
||||
const normalized = String(manifestPath).replace(/^\/+/, '');
|
||||
const normalized = String(manifestPath).replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
if (normalized === 'package.json') return '';
|
||||
if (!normalized.endsWith('/package.json')) return null;
|
||||
const packageRoot = normalized.slice(0, -'/package.json'.length);
|
||||
return PACKAGE_ROOT_PATTERN.test(packageRoot) ? packageRoot : null;
|
||||
}
|
||||
|
||||
function readAsarJson(appAsar, manifestPath) {
|
||||
function readAsarJson(appAsar, archivePath, manifestPath) {
|
||||
try {
|
||||
return JSON.parse(asar.extractFile(appAsar, manifestPath).toString('utf8'));
|
||||
return JSON.parse(asar.extractFile(appAsar, archivePath).toString('utf8'));
|
||||
} catch (err) {
|
||||
fail(`invalid ${manifestPath} in ${appAsar}: ${err.message}`);
|
||||
}
|
||||
@@ -69,13 +69,14 @@ function readPackagedManifests(appAsar) {
|
||||
|
||||
const manifests = new Map();
|
||||
for (const entry of entries) {
|
||||
const manifestPath = String(entry).replace(/^\/+/, '');
|
||||
const archivePath = String(entry).replace(/^[\\/]+/, '');
|
||||
const manifestPath = archivePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
const packageRoot = packageRootFromManifestPath(manifestPath);
|
||||
if (packageRoot === null) continue;
|
||||
if (manifests.has(packageRoot)) {
|
||||
fail(`duplicate package manifest root in app.asar: ${packageRoot || '(app root)'}`);
|
||||
}
|
||||
const manifest = readAsarJson(appAsar, manifestPath);
|
||||
const manifest = readAsarJson(appAsar, archivePath, manifestPath);
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
fail(`package manifest is not an object: ${manifestPath}`);
|
||||
}
|
||||
|
||||
@@ -116,6 +116,55 @@ const WHISPER_RUNTIME_CONTRACT = Object.freeze({
|
||||
}),
|
||||
appLocalFiles: appLocalVcFiles('bin'),
|
||||
}),
|
||||
'linux-x64': Object.freeze({
|
||||
version: '1.9.1',
|
||||
minimumGlibc: '2.34',
|
||||
source: 'official-release-ubuntu-cpu-dispatch',
|
||||
archive: Object.freeze({
|
||||
cacheName: 'whisper-bin-ubuntu-x64-v1.9.1.tar.gz',
|
||||
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-bin-ubuntu-x64.tar.gz',
|
||||
bytes: 9_379_235,
|
||||
sha256: 'f3bf3b4369a99b54665b0f19b88483b30de27f25963b0414235dea03198515c5',
|
||||
}),
|
||||
files: Object.freeze({
|
||||
'bin/whisper-cli': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/whisper-cli', bytes: 976_312, sha256: '427dfb509f2c04d0f01c101978b5666102c6f7e3abf2a236452db5939f5b533a', executable: true }),
|
||||
'bin/libwhisper.so.1': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libwhisper.so.1.9.1', bytes: 610_672, sha256: 'b7d624111b72c70a7351d07d403efae15990aa1112b950557457a12cb90cebee', executable: true }),
|
||||
'bin/libggml.so.0': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml.so.0.15.1', bytes: 54_936, sha256: '1c2d4b9d4a46a481c3c153fabad00dbba6342b107255667d60727b05c3935a00', executable: true }),
|
||||
'bin/libggml-base.so.0': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-base.so.0.15.1', bytes: 901_704, sha256: '8924f4eef14b7b2f4d5f44d5744ace615601257fee2d7e1e2f02d21188824c26', executable: true }),
|
||||
'bin/libggml-cpu-x64.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-x64.so', bytes: 857_072, sha256: '6a8d1913baaa9d7f99bd46df7624fb5f45a8fa87230f52fc5f075cf5e48691c2', executable: true }),
|
||||
'bin/libggml-cpu-sse42.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-sse42.so', bytes: 860_992, sha256: '4ac045e80cbbd159d912cfef558b24108571f997b484ae0431508a9228080ab7', executable: true }),
|
||||
'bin/libggml-cpu-piledriver.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-piledriver.so', bytes: 934_848, sha256: 'fbdbedd47c6bf7f5ca4c4b7128c6fb6b7e69a630b4a79111d2966bbdf26c85fc', executable: true }),
|
||||
'bin/libggml-cpu-sandybridge.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-sandybridge.so', bytes: 938_944, sha256: 'e56a2f68a5e77c1fae9216b6985490ada833ffbfc02625e6a49f1bd7bf7d4f40', executable: true }),
|
||||
'bin/libggml-cpu-ivybridge.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-ivybridge.so', bytes: 934_848, sha256: 'b9103c1af212acd5834bc67dcbed688eb187315013da0829e69ab284fa5c7c9d', executable: true }),
|
||||
'bin/libggml-cpu-haswell.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-haswell.so', bytes: 1_000_600, sha256: 'd296d5e08a889a9ef6cef925ea594e421f7139edc0064484494e47342d975e66', executable: true }),
|
||||
'bin/libggml-cpu-alderlake.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-alderlake.so', bytes: 1_000_600, sha256: 'ea900a9e0b2be2b2218ae43ad9886a4a0ad657c86f6b73dd3627603d1dc04522', executable: true }),
|
||||
'bin/libggml-cpu-cannonlake.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-cannonlake.so', bytes: 1_058_056, sha256: '4a01017310b60b8bf7e4cde4a07b2ddef38c87e4ce2174359fdf71a1d9bc0826', executable: true }),
|
||||
'bin/libggml-cpu-skylakex.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-skylakex.so', bytes: 1_058_056, sha256: '83eb651f2c71a6c9a495ba50dd9361122600550e6b33ec0d348d82b4c213b7af', executable: true }),
|
||||
'bin/libggml-cpu-cascadelake.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-cascadelake.so', bytes: 1_053_960, sha256: 'c1ec6a03ed9473ab4d5ff66bb6253dcc4e93e3a83946d83407c38b8b2c01bc3b', executable: true }),
|
||||
'bin/libggml-cpu-cooperlake.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-cooperlake.so', bytes: 1_053_960, sha256: 'cd581c6b77f93ca840dcdcdeb792d167243bffdfd9ddd6631591dbdc559b4475', executable: true }),
|
||||
'bin/libggml-cpu-icelake.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-icelake.so', bytes: 1_053_960, sha256: 'bbc71cf146645f8c9d2aea68de095785d6a66fa099007009f7f59d3e666b95a1', executable: true }),
|
||||
'bin/libggml-cpu-sapphirerapids.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-sapphirerapids.so', bytes: 1_320_320, sha256: 'bccc143b4dc92095ee3fcc1f83cd5f7d38cc48e0fad901bef15fbd5f180a5336', executable: true }),
|
||||
'bin/libggml-cpu-zen4.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-x64/libggml-cpu-zen4.so', bytes: 1_053_960, sha256: '41ed84f5414d76197b055a04c5a5261459f09eba5d63f4189f0ff5ca7fc3c3ac', executable: true }),
|
||||
}),
|
||||
}),
|
||||
'linux-arm64': Object.freeze({
|
||||
version: '1.9.1',
|
||||
minimumGlibc: '2.34',
|
||||
source: 'official-release-ubuntu',
|
||||
archive: Object.freeze({
|
||||
cacheName: 'whisper-bin-ubuntu-arm64-v1.9.1.tar.gz',
|
||||
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.9.1/whisper-bin-ubuntu-arm64.tar.gz',
|
||||
bytes: 4_555_819,
|
||||
sha256: 'e0b66cd551ff6f2a28fabe3c6e89691eea037bb76833493abb9a71ca788994b3',
|
||||
}),
|
||||
files: Object.freeze({
|
||||
'bin/whisper-cli': Object.freeze({ archivePath: 'whisper-bin-ubuntu-arm64/whisper-cli', bytes: 943_928, sha256: '511ee3ef9bbe7acfabaafc463c1ccceb34bf8f4caab9aaf18b260883eab32b98', executable: true }),
|
||||
'bin/libwhisper.so.1': Object.freeze({ archivePath: 'whisper-bin-ubuntu-arm64/libwhisper.so.1.9.1', bytes: 553_208, sha256: 'ae31d9c3f094d5aa7e2dcf049d7ca3238811b738fccdadfee3e8fc8b1a680051', executable: true }),
|
||||
'bin/libggml.so.0': Object.freeze({ archivePath: 'whisper-bin-ubuntu-arm64/libggml.so.0.15.1', bytes: 44_152, sha256: 'ca15013bacf6c8b2281a10b02191741db14fd5d189eedda89ec210c5deab1a94', executable: true }),
|
||||
'bin/libggml-base.so.0': Object.freeze({ archivePath: 'whisper-bin-ubuntu-arm64/libggml-base.so.0.15.1', bytes: 815_504, sha256: '066b7c88f3a2d9d884a9344acaa0cea312968a6d342bf68c1c41920221baa496', executable: true }),
|
||||
'bin/libggml-cpu.so': Object.freeze({ archivePath: 'whisper-bin-ubuntu-arm64/libggml-cpu.so', bytes: 831_520, sha256: '56fe7e9ecc212ae32ff88837f812d450eee1e842cb5bb759ef60a533f2e9f2eb', executable: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Generated
+69
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "orkas",
|
||||
"version": "2026.8.11",
|
||||
"version": "2026.8.29",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "orkas",
|
||||
"version": "2026.8.11",
|
||||
"version": "2026.8.29",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -40,6 +40,7 @@
|
||||
"electron": "42.8.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"semver": "7.8.5",
|
||||
"tar": "7.5.21",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
@@ -65,23 +66,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers/-/tokenizers-0.0.0.tgz",
|
||||
"integrity": "sha512-IQD9wkVReKAhsEAbDjh/0KrBGTEXelqZLpOBRDaIRvlzZ9sjmUP+gKbpvzyJnei2JHQiE8JAgj7YcNloINbGBw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers/-/tokenizers-0.6.0.tgz",
|
||||
"integrity": "sha512-vbKFjlFM15Ljm6HMh2FSiyPq4236xHoe31XMveTMdwqowHf1/UCwySPT331wl/wRWpdHWdQ3eU5ENl5XbXnPQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anush008/tokenizers-darwin-universal": "0.0.0",
|
||||
"@anush008/tokenizers-linux-x64-gnu": "0.0.0",
|
||||
"@anush008/tokenizers-win32-x64-msvc": "0.0.0"
|
||||
"@anush008/tokenizers-darwin-universal": "0.6.0",
|
||||
"@anush008/tokenizers-linux-arm64-gnu": "0.6.0",
|
||||
"@anush008/tokenizers-linux-arm64-musl": "0.6.0",
|
||||
"@anush008/tokenizers-linux-x64-gnu": "0.6.0",
|
||||
"@anush008/tokenizers-linux-x64-musl": "0.6.0",
|
||||
"@anush008/tokenizers-win32-x64-msvc": "0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-darwin-universal": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-darwin-universal/-/tokenizers-darwin-universal-0.0.0.tgz",
|
||||
"integrity": "sha512-SACpWEooTjFX89dFKRVUhivMxxcZRtA3nJGVepdLyrwTkQ1TZQ8581B5JoXp0TcTMHfgnDaagifvVoBiFEdNCQ==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-darwin-universal/-/tokenizers-darwin-universal-0.6.0.tgz",
|
||||
"integrity": "sha512-s27bL2x22NiDlhthi7UjGm+Is1tpAg4riLMBzna7Knzp26RdmnhbclWNEDd5UflRzFD49sdHzqv2XP0F2XFrqA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -91,10 +95,58 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-linux-arm64-gnu": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-linux-arm64-gnu/-/tokenizers-linux-arm64-gnu-0.6.0.tgz",
|
||||
"integrity": "sha512-Go1ssG7AylDFpsDzLkh+nqXt1RcIGhL9pSpPH1nLF504lutxkCZcihESUt6vobtjnCONOAIwCWe1AVEoHui+9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-linux-arm64-musl": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-linux-arm64-musl/-/tokenizers-linux-arm64-musl-0.6.0.tgz",
|
||||
"integrity": "sha512-xfnDkjmOUXkVp5jNCnFzd8hTfAbei6UnoYetiadqbfX1o/K+drCV6sHzKadhHuPQlqa+erXdSyQDOtWkAAjN8Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-linux-x64-gnu": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-linux-x64-gnu/-/tokenizers-linux-x64-gnu-0.0.0.tgz",
|
||||
"integrity": "sha512-TLjByOPWUEq51L3EJkS+slyH57HKJ7lAz/aBtEt7TIPq4QsE2owOPGovByOLIq1x5Wgh9b+a4q2JasrEFSDDhg==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-linux-x64-gnu/-/tokenizers-linux-x64-gnu-0.6.0.tgz",
|
||||
"integrity": "sha512-4C9KiC0tdsOJCyOE2sD23zZHW16RbytRfMf81bdkDOLOrvOy0dYT9ZTnpwEl2k7jgzSdSpk9YKCkfEpkqbVvVg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-linux-x64-musl": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-linux-x64-musl/-/tokenizers-linux-x64-musl-0.6.0.tgz",
|
||||
"integrity": "sha512-4CiqyQEFQpr7xTHv2IGZAoOKr/hIN7obTCaLvxUEjv3MHcpCGYBFpKa5RiRrcskY5k3w06dN9cRaxwsITeAHjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -108,9 +160,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anush008/tokenizers-win32-x64-msvc": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-win32-x64-msvc/-/tokenizers-win32-x64-msvc-0.0.0.tgz",
|
||||
"integrity": "sha512-/5kP0G96+Cr6947F0ZetXnmL31YCaN15dbNbh2NHg7TXXRwfqk95+JtPP5Q7v4jbR2xxAmuseBqB4H/V7zKWuw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@anush008/tokenizers-win32-x64-msvc/-/tokenizers-win32-x64-msvc-0.6.0.tgz",
|
||||
"integrity": "sha512-ydjCu4wLdtcS8xOr0uMbRAOHviuFNEMCFM/T/Sw4zxLb+iHS9QboSJZtxMN1VF2dOkjRchtQmLiKK0ttE1iJEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
||||
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orkas",
|
||||
"version": "2026.8.11",
|
||||
"version": "2026.8.29",
|
||||
"private": true,
|
||||
"description": "Orkas desktop — Electron + Node one-piece app",
|
||||
"main": "bootstrap.cjs",
|
||||
@@ -32,6 +32,9 @@
|
||||
"test:platform-native": "node scripts/run-platform-native-tests.mjs",
|
||||
"test:windows-native": "node scripts/run-windows-native-tests.mjs",
|
||||
"test:windows-stability": "node scripts/run-windows-stability-tests.mjs",
|
||||
"test:linux-source": "node scripts/verify-linux-source-dependencies.cjs --full-embedding --gui",
|
||||
"test:linux-ocr": "tsx scripts/verify-linux-ocr-runtime.mjs",
|
||||
"test:linux-whisper": "ORKAS_REAL_WHISPER_TEST=1 node scripts/run-tests.mjs run --maxWorkers=1 -t \"Linux real bundled whisper transcribes within the performance budget\" test/main/features/video_studio_native_qa.test.ts",
|
||||
"runtime:ensure": "node bin/ensure-runtime.cjs --root resources/runtime",
|
||||
"officecli:policy-check": "node bin/officecli-policy-gate.cjs",
|
||||
"officecli:fetch": "node scripts/fetch-officecli.cjs && node bin/officecli-policy-gate.cjs --require-host-binary",
|
||||
@@ -59,6 +62,7 @@
|
||||
"electron": "42.8.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"semver": "7.8.5",
|
||||
"tar": "7.5.21",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
@@ -82,6 +86,7 @@
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@anush008/tokenizers": "0.6.0",
|
||||
"@hono/node-server": "2.0.11",
|
||||
"esbuild": "0.28.1",
|
||||
"tar": "7.5.21"
|
||||
@@ -265,10 +270,6 @@
|
||||
],
|
||||
"rfc3161TimeStampServer": "http://ts.ssl.com"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"target": "AppImage",
|
||||
"category": "Utility"
|
||||
}
|
||||
},
|
||||
"license": "MIT"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"content_tree_sha256": "d9031626322133bc758ef086eacf147f3bd2d82ec9509e80922814f3ee249607",
|
||||
"content_tree_sha256": "535d923aa8eb9ed696a3b24f31edf5711076bdb835eb010270ebb3df6e74a131",
|
||||
"files": [
|
||||
{
|
||||
"path": "marketplace/agents/173d4235a431/_meta.json",
|
||||
@@ -19,8 +19,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/agents/78900d8758bc/agent.json",
|
||||
"bytes": 4331,
|
||||
"sha256": "056511550346c82163b65428aa936cf6dd69cc79f97e934356e1e33b4c007dc0"
|
||||
"bytes": 4344,
|
||||
"sha256": "da67a5e0092a547ea28cbe0f4ddf4f3181fe500328152112665ee17541b90fa3"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/agents/79df9cc89f5f/agent.json",
|
||||
@@ -945,7 +945,7 @@
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/_meta.json",
|
||||
"bytes": 146,
|
||||
"sha256": "f501c5f987c76087a2668c4a1ced3c375e1b467941abed344cfda44b590a53d9"
|
||||
"sha256": "ed65a6445bbe4eb2b34cdeb7ff40972e1f42b07ee965ea9e1cc635b493db4b99"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/references/citation-style.md",
|
||||
@@ -954,8 +954,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/references/compact-landscape.md",
|
||||
"bytes": 12357,
|
||||
"sha256": "c7084f55f4078af94a638034016e6d533e577fcdbe21b9d1fe33c78381f48201"
|
||||
"bytes": 12358,
|
||||
"sha256": "4df1f9428340ff8aff1c334767222b72cb2dfbb310859fe059e245fe73385ff8"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/references/evidence-standards.md",
|
||||
@@ -964,8 +964,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/references/operations-and-report.md",
|
||||
"bytes": 10005,
|
||||
"sha256": "08337e2a15441bb826926cd32e53ae9b35abd29bd0720de265cc1351565e76ed"
|
||||
"bytes": 10309,
|
||||
"sha256": "0f4d73d08f0661a7310e46c088e1a5af6232807cb02e1c25a2ab437a6ef70798"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/references/report-structure.md",
|
||||
@@ -1004,8 +1004,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/scripts/citations.py",
|
||||
"bytes": 61326,
|
||||
"sha256": "7d99bf9ea0679ce7bbf993eb6f59ceffc4d90c35495d8c70b742c9e0cb4118a0"
|
||||
"bytes": 62055,
|
||||
"sha256": "c43aca918b093887d3a55ef824b10cb490fbb2d8cbab1166a2182c6a8c29a66a"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/scripts/compress.py",
|
||||
@@ -1014,8 +1014,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/SKILL.md",
|
||||
"bytes": 9009,
|
||||
"sha256": "e6be318ea6fa499092f91a9d691397c28e95c04915bac1664a63a1a43e23e518"
|
||||
"bytes": 9211,
|
||||
"sha256": "417cee8df9160d150ecbe35eeff2aa21cd28b833a31158cc9f02824b64b437d7"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/test/test_academic.py",
|
||||
@@ -1029,8 +1029,8 @@
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/test/test_citations.py",
|
||||
"bytes": 63468,
|
||||
"sha256": "c7b6ac8f87c3780d078f99d2359ed06cd91b1189da21770c5148563250b9c1ae"
|
||||
"bytes": 64694,
|
||||
"sha256": "fed9abed91b4841ec602bb8ffa5467e54350648c9fab61b2b8de0a345cd75915"
|
||||
},
|
||||
{
|
||||
"path": "marketplace/skills/ee99fbb42964/test/test_compress.py",
|
||||
@@ -1369,8 +1369,8 @@
|
||||
},
|
||||
{
|
||||
"path": "system/skills/agent-creator/references/cli-and-prose.md",
|
||||
"bytes": 1993,
|
||||
"sha256": "d8e29fa21a88615c6e545c7493ed141a51421bbe8e6771855785953741f1aa27"
|
||||
"bytes": 1850,
|
||||
"sha256": "2682012568ee3144844820cb4cbd0ef9bb58ec89f96bd33f619a91eab7042e2a"
|
||||
},
|
||||
{
|
||||
"path": "system/skills/agent-creator/references/llm-agent-fields.md",
|
||||
@@ -1379,13 +1379,13 @@
|
||||
},
|
||||
{
|
||||
"path": "system/skills/agent-creator/references/source-and-editing.md",
|
||||
"bytes": 3702,
|
||||
"sha256": "f4999737cdcb18faa3b38de0bcc0cad13033a29b5b9ba665f24a5b373f9a77fd"
|
||||
"bytes": 3193,
|
||||
"sha256": "7a114a05d1e6b91d6f755050b6306dca0911a5944e9043a2a40ee5a0d784d5c1"
|
||||
},
|
||||
{
|
||||
"path": "system/skills/agent-creator/SKILL.md",
|
||||
"bytes": 6526,
|
||||
"sha256": "00f5decc6cbd73e7c71a8a201e334fa9394a2b1c90f5c40e2965b9e50ee3845a"
|
||||
"bytes": 6429,
|
||||
"sha256": "d9df1fdcafb0be2870f6165ab1752564190f9476237dc25cf717f9f1758a8b4a"
|
||||
},
|
||||
{
|
||||
"path": "system/skills/autotask-creator/SKILL.md",
|
||||
@@ -1394,8 +1394,8 @@
|
||||
},
|
||||
{
|
||||
"path": "system/skills/memory-manager/SKILL.md",
|
||||
"bytes": 3654,
|
||||
"sha256": "a124936ec7a7c29c878c7970001f208db871f9407063f3998cc97bb1388ffa8a"
|
||||
"bytes": 3764,
|
||||
"sha256": "6f6dc8f418c5327a3a4c0f455d84db81dea627e78fdfacf614ba453ee5d2ee73"
|
||||
},
|
||||
{
|
||||
"path": "system/skills/package-installer/SKILL.md",
|
||||
@@ -1409,8 +1409,8 @@
|
||||
},
|
||||
{
|
||||
"path": "system/skills/skill-creator/references/authoring.md",
|
||||
"bytes": 6229,
|
||||
"sha256": "ce5ab09738177d77b9fd70e107aa8b7f176879157f3818c41d1b868d02b912a8"
|
||||
"bytes": 6461,
|
||||
"sha256": "e6404f3f688f18c4d3e40e08ecafdc8301f65d4cf8ca77c5e5ba902807582b29"
|
||||
},
|
||||
{
|
||||
"path": "system/skills/skill-creator/references/file-deletion.md",
|
||||
@@ -1429,8 +1429,8 @@
|
||||
},
|
||||
{
|
||||
"path": "system/skills/skill-creator/SKILL.md",
|
||||
"bytes": 7238,
|
||||
"sha256": "19e3b573aded1c409cf90a645b5c11b4a4ec9a232f6d47fc627e746ab9aa993d"
|
||||
"bytes": 7281,
|
||||
"sha256": "587b246c0584c90d37096ff2862d19512b0e689c9abb69085d91ad5479c72bdf"
|
||||
}
|
||||
],
|
||||
"inventory": {
|
||||
@@ -1476,10 +1476,10 @@
|
||||
{
|
||||
"id": "78900d8758bc",
|
||||
"name": "DeepResearcher",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.61",
|
||||
"icon": "search",
|
||||
"color": "sky",
|
||||
"updated_at": "2026-08-21T09:33:00.000Z",
|
||||
"updated_at": "2026-08-29T05:04:02.000Z",
|
||||
"skill_list": [
|
||||
"6743aa0797a2",
|
||||
"9be6fda271a5",
|
||||
@@ -1746,8 +1746,8 @@
|
||||
{
|
||||
"id": "ee99fbb42964",
|
||||
"name": "deep-research",
|
||||
"version": "1.0.49",
|
||||
"updated_at": "2026-08-23T09:48:36.000Z"
|
||||
"version": "1.0.50",
|
||||
"updated_at": "2026-08-27T22:10:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "f283632103ba",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"agent_id": "78900d8758bc",
|
||||
"version": "1.0.60",
|
||||
"version": "1.0.61",
|
||||
"name": "DeepResearcher",
|
||||
"description_zh": "复杂主题的证据型深度研究:拆解问题、规划研究路径、检索网页与学术来源、整理用户材料、压缩证据、核验引用与来源质量,输出带置信度、矛盾点、限制说明和参考来源的可复核研究报告;适合\"深度研究这个主题\"\"做一份带引用的行业/文献综述\"\"核验这些说法有没有可靠出处\";触发词:深度研究、证据分析、文献综述、趋势报告、行业研究、来源核验、引用核验、研究计划",
|
||||
"description_en": "Evidence-grounded deep research for complex topics: decomposes questions, plans the research path, searches web and scholarly sources, organizes user-provided materials, compresses evidence, checks citation and source quality, and delivers auditable reports with confidence, contradictions, limitations, and references; For: \"research this topic in depth\", \"prepare a cited industry or literature review\", \"check whether these claims have reliable sources\"; Triggers: deep research, evidence analysis, literature review, trend report, industry research, source verification, citation check, research plan",
|
||||
@@ -9,7 +9,7 @@
|
||||
"Append-only ledgers account for every attempted research call without unrecorded retries or refetches; saved caps are enforced whenever a user, cost, task, or resume budget applies.",
|
||||
"Every delivered claim and comparison cell aligns with its own cited evidence; unsupported content is repaired, narrowed, removed, or marked Not verified.",
|
||||
"Recommend only from verified choice-changing facts; unresolved choice-changing gaps make a candidate conditional and the result partial, never complete.",
|
||||
"The handoff links a validated RESEARCH-REPORT.md containing retained evidence, limitations, run status, named gaps, and a decision-useful next step."
|
||||
"The handoff links a validated topic-named RESEARCH-<topic>.md containing retained evidence, limitations, run status, named gaps, and a decision-useful next step."
|
||||
],
|
||||
"workflow": "### 1. Scope\n- Read `deep-research` as the governing Skill. Choose its normal, durable-resume, or compact path and load a supplemental Skill only when that evidence domain is actually required.\n- Ask only when the answer materially changes scope, cost, risk, audience, or deliverable; otherwise state bounded assumptions.\n\n### 2. Preserve State\n- Initialize durable ledgers and any applicable user, cost, task, or resume caps once, or resume only unfinished subquestions from saved state. Batch independent work and honor every recorded limit.\n\n### 3. Gather\n- Collect only decision-changing primary evidence. Preserve exact quotes, dates, limitations, contradictions, confidence, and failed-source gaps; continue while distinct sources add decision value, and stop on sufficient evidence, repeated no-gain strategies, or an applicable cap.\n\n### 4. Verify\n- Build narrow candidate-local claims, then judge recommendations from verifier-supported facts. Resolve only worthwhile choice-changing gaps; otherwise deliver a conditional partial.\n\n### 5. Deliver\n- Assemble the canonical report from clean verifier output, account when a cap applies, publish once, and hand over the report with findings, limitations, run status, named gaps, and the decision-useful next step.",
|
||||
"tool_list": ["workspace.read", "workspace.write.output", "workspace.execute.command", "web"],
|
||||
@@ -42,6 +42,6 @@
|
||||
"icon": "search",
|
||||
"color": "sky",
|
||||
"created_at": "2026-06-04T18:10:00",
|
||||
"updated_at": "2026-08-21T09:33:00.000Z",
|
||||
"updated_at": "2026-08-29T05:04:02.000Z",
|
||||
"category": "data"
|
||||
}
|
||||
@@ -116,11 +116,14 @@ bounded result.
|
||||
|
||||
On the compact path, after the final fetch batch emit the final fetch-ledger
|
||||
write, field-tagged evidence-ledger write, compact citations-input write, and
|
||||
canonical citations command with `--report-out RESEARCH-REPORT.md` as ordered
|
||||
calls in one model response. The verifier builds the report from that compact
|
||||
state; do not transform ledgers, read its full audit, or assemble the report
|
||||
with shell code. Then emit the final plan update and publication together; add
|
||||
final caps accounting only when a user, cost, resume, or task budget applies.
|
||||
canonical citations command with `--report-out RESEARCH-<topic>.md` as ordered
|
||||
calls in one model response. `<topic>` is a short slug of this research
|
||||
question; never reuse a report filename that already exists in the workspace,
|
||||
because a later research run is a new report, not a revision of an earlier one.
|
||||
The verifier builds the report from that compact state; do not transform
|
||||
ledgers, read its full audit, or assemble the report with shell code. Then
|
||||
emit the final plan update and publication together; add final caps
|
||||
accounting only when a user, cost, resume, or task budget applies.
|
||||
On other paths, keep the equivalent known writes and deterministic command
|
||||
batched.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0.49",
|
||||
"updated_at": "2026-08-23T09:48:36.000Z",
|
||||
"version": "1.0.50",
|
||||
"updated_at": "2026-08-27T22:10:00.000Z",
|
||||
"category": "data",
|
||||
"reseed_if_deleted_before": "2026-07-27T12:00:00Z"
|
||||
}
|
||||
@@ -24,7 +24,7 @@ omits one:
|
||||
4. Keep newly written unchanged plan and ledger state in memory; do not reread it
|
||||
merely to continue the next known step.
|
||||
5. Persist exact evidence, run `citations --op verify`, assemble and validate
|
||||
`RESEARCH-REPORT.md`, then publish it.
|
||||
`RESEARCH-<topic>.md`, then publish it.
|
||||
6. Make one final plan-status update after verified assembly. Prefer a batched
|
||||
update when several statuses are already known, but one isolated update stays
|
||||
valid and work never waits merely to form a batch.
|
||||
|
||||
+7
-2
@@ -81,7 +81,7 @@ retained comparison cells; when evidence does not support a field, expose the
|
||||
gap instead of manufacturing another claim. Audit every canonical comparison
|
||||
column before running citations.
|
||||
|
||||
On the compact landscape path, let the verifier write `RESEARCH-REPORT.md`
|
||||
On the compact landscape path, let the verifier write `RESEARCH-<topic>.md`
|
||||
directly from the field-tagged evidence ledger and compact candidate profiles.
|
||||
Do not read `citations_output.json`, author another recommendation list or
|
||||
comparison table, reconstruct fields from memory, or use shell code to assemble
|
||||
@@ -168,9 +168,14 @@ rewrite the payload merely to remove non-material warnings.
|
||||
Generate the compact verified report once with:
|
||||
|
||||
```bash
|
||||
"$ORKAS_NODE" "$ORKAS_PC_DIR/bin/run-skill.cjs" deep-research citations -- --op verify --input citations_input.json --out citations_output.json --report-out RESEARCH-REPORT.md
|
||||
"$ORKAS_NODE" "$ORKAS_PC_DIR/bin/run-skill.cjs" deep-research citations -- --op verify --input citations_input.json --out citations_output.json --report-out RESEARCH-<topic>.md
|
||||
```
|
||||
|
||||
Replace `<topic>` with a short slug of this research question, so the report
|
||||
carries a name a reader can recognise in the file list. The command refuses an
|
||||
existing target: a second research run in the same conversation is a new report,
|
||||
not a revision of the earlier one, and must never overwrite it.
|
||||
|
||||
The report contains the supplied title and boundary, then evidence-informed
|
||||
model-selected paths with complete or partial factual coverage, the only
|
||||
systematic comparison table, and the final Evidence used section. Do not
|
||||
|
||||
@@ -1412,6 +1412,19 @@ def _load(path):
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _write_new_report(report_path: str, report_markdown: str) -> None:
|
||||
"""Create one report atomically; never replace an earlier deliverable."""
|
||||
try:
|
||||
with open(report_path, "x", encoding="utf-8") as fh:
|
||||
fh.write(report_markdown)
|
||||
except FileExistsError:
|
||||
raise ValueError(
|
||||
"--report-out target already exists: {}. Pass a topic-specific "
|
||||
"RESEARCH-<topic>.md name that does not exist yet; a previous "
|
||||
"report is never overwritten.".format(report_path)
|
||||
) from None
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(prog="deep-research/citations")
|
||||
ap.add_argument("--op", choices=["verify", "references"], default="verify")
|
||||
@@ -1445,8 +1458,10 @@ def main(argv):
|
||||
if value:
|
||||
parts.append(value)
|
||||
report_markdown = "\n\n".join(parts) + "\n"
|
||||
with open(args.report_out, "w", encoding="utf-8") as fh:
|
||||
fh.write(report_markdown)
|
||||
# A later research run in the same workspace is a new report, not a
|
||||
# revision of the earlier one. Exclusive creation makes that guarantee
|
||||
# atomic even when two same-topic runs finish concurrently.
|
||||
_write_new_report(args.report_out, report_markdown)
|
||||
data["report"] = {
|
||||
"path": args.report_out,
|
||||
"characters": len(report_markdown),
|
||||
|
||||
@@ -404,6 +404,31 @@ class CompactLandscapeInput(unittest.TestCase):
|
||||
0,
|
||||
)
|
||||
|
||||
# A later research run is a new report, not a revision: rerunning
|
||||
# onto an existing target must fail instead of truncating it.
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"ORKAS_DEEP_RESEARCH_EVIDENCE_FILE": snapshot_path},
|
||||
):
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
citations.main([
|
||||
"--op", "verify",
|
||||
"--input", input_path,
|
||||
"--out", output_path,
|
||||
"--report-out", report_path,
|
||||
])
|
||||
self.assertIn("already exists", str(caught.exception))
|
||||
with open(report_path, encoding="utf-8") as fh:
|
||||
self.assertEqual(fh.read(), report)
|
||||
|
||||
# The no-overwrite contract must be one filesystem operation, not
|
||||
# exists()+write(), or concurrent runs can both pass the probe.
|
||||
with mock.patch("builtins.open", mock.mock_open()) as report_open:
|
||||
citations._write_new_report("RESEARCH-atomic.md", "# report\n")
|
||||
report_open.assert_called_once_with(
|
||||
"RESEARCH-atomic.md", "x", encoding="utf-8"
|
||||
)
|
||||
|
||||
stdout = _cli_stdout(result, [
|
||||
"--input", input_path,
|
||||
"--out", output_path,
|
||||
|
||||
@@ -13,7 +13,7 @@ Create or edit a custom Agent by emitting one inline `<agent>...</agent>` contai
|
||||
The root file is the execution gate. Before emitting a container, read the matching reference through this Skill's read ref:
|
||||
|
||||
- New LLM-managed Agent, or an edit to its name, icon, description, workflow, guidance, Skills, tools, inputs, interaction mode, or category → [llm-agent-fields.md](references/llm-agent-fields.md).
|
||||
- Creation from supplied files/prompts/specs, conversation crystallization, similarity checking, or an unbound edit of an existing Agent → also read [source-and-editing.md](references/source-and-editing.md).
|
||||
- Creation from supplied files/prompts/specs, conversation crystallization, or an unbound edit of an existing Agent → also read [source-and-editing.md](references/source-and-editing.md). Current-turn requirements for inputs, checks, workflow, outputs, or non-goals count as a supplied spec, so this reference is required.
|
||||
- CLI-backed Agent edit or a request about its external runtime boundary → read [cli-and-prose.md](references/cli-and-prose.md).
|
||||
|
||||
Do not read unrelated references. A simple bound edit normally needs only `llm-agent-fields.md`; a CLI-backed edit normally needs only `cli-and-prose.md`.
|
||||
@@ -21,10 +21,10 @@ Do not read unrelated references. A simple bound edit normally needs only `llm-a
|
||||
## Non-negotiable protocol
|
||||
|
||||
- **Mutation only via the inline container.** Never use `edit_file`, `write_file`, or `bash` to change `agent.json`. Never dump the container into a workspace file.
|
||||
- **No container, no mutation, no success claim.** An Agent exists or changes only after this reply contains a valid container for that Agent and the host applies it. Without one, describe the result only as a proposal, clarification, or blocker; never say the Agent is ready, created, updated, installed, or available.
|
||||
- **No container, no mutation, no success claim.** Without a valid container, describe the result only as a proposal, clarification, or blocker; never say the Agent is ready, created, updated, installed, or available. A valid container only requests a mutation; only the host result proves it was applied. Until then, use pending wording such as “将创建” or “已提交,等待应用”.
|
||||
- **One container per Agent.** Multiple containers are legal only when the user asked to create or edit multiple distinct Agents, and the number of valid containers must equal the number of Agents you claim to have created. End the turn after emitting them; do not dispatch the Agent.
|
||||
- **Validate every container independently before sending.** Read its tags in order: every opening tag must have the exact matching closing tag, with no crossed, missing, or reused closer. For a multi-Agent reply, repeat this structural check for each container rather than copying an unchecked suffix.
|
||||
- **Preserve source material.** If the user supplied a prompt, spec, examples, files, or an Agent directory, read the relevant source before authoring and preserve its role, goals, workflow order, tool rules, outputs, examples, safety rules, and stop points. For an attachment path, missing inline body text is not a missing source: call `read_files` with the exact supplied path in `paths` and ask for another upload only if that read fails. Adapt only what Orkas requires unless the user requests a rewrite.
|
||||
- **Read attachment sources.** For an injected attachment, missing inline body text is not a missing source; call `read_files` with the exact supplied path in `paths` and ask for another upload only if that read fails.
|
||||
- **Use the user's current UI language** for display copy and user-visible prose. Keep XML tags, tool/Skill names, paths, JSON keys, and closed-set values unchanged. Use localized description tags only when the user explicitly requests multilingual descriptions.
|
||||
- **Keep user-visible prose outcome-based.** State what the Agent does, when to use it, and the substantive change; do not expose XML/field/schema/id mechanics or source provenance by default.
|
||||
- **Agent roles are not model runtimes.** Do not imitate or claim an unavailable provider by naming an ordinary role Agent after a model. Real external CLI runtimes are handled by their smaller editable contract.
|
||||
@@ -51,7 +51,7 @@ For a new Agent, first compare its name and its typical objects/actions with the
|
||||
3. Read any required current Agent spec or user-supplied source.
|
||||
4. Validate the intended fields, category, capability dependencies, interaction shape, and safety boundary.
|
||||
5. Emit concise user-facing prose plus the smallest valid patch container. On create, include every required creation field. On edit, omit unchanged fields; list-like fields replace the entire list when present.
|
||||
6. Stop. The host applies the container and supplies persistence evidence.
|
||||
6. Stop after emitting the container; the host supplies persistence evidence.
|
||||
|
||||
## Safety gate
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ The host hides the container. The prose outside it is what the user sees.
|
||||
- Describe concepts rather than implementation fields: say “what it asks before running”, “what capabilities it uses”, “the steps it follows”, or “it now runs autonomously”.
|
||||
- Do not expose XML tags, field names, schema/config terminology, ids, internal source mechanics, or data-structure jargon.
|
||||
- For CLI-backed Agents, do not mention the selected CLI, vendor, or model in success prose.
|
||||
- Claim success only when a valid container is present for the exact Agent. Otherwise call the response a proposal, clarification, or blocker.
|
||||
|
||||
Example:
|
||||
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
# Source-backed creation and editing
|
||||
|
||||
Read this file when source material or conversation history defines the Agent, when checking similarity for a new Agent, or when editing an existing Agent from an unbound Commander session.
|
||||
|
||||
## Pre-create similarity check
|
||||
|
||||
Before creating, compare the proposed name and its typical objects/actions with the injected Agent index.
|
||||
|
||||
- If an existing entry overlaps, emit no container. Name the existing Agent in one current-language paragraph, explain the overlap, and ask whether to use it or still create a new one.
|
||||
- If none overlaps, create in the same turn without a pre-announcement.
|
||||
Read this file when source material or conversation history defines the Agent, or when editing an existing Agent from an unbound Commander session.
|
||||
|
||||
## Source-preservation contract
|
||||
|
||||
Source includes current-turn attachments or referenced files, pasted prompts, `agent.json`, YAML/JSON specs, README/workflow documents, examples, or Agent directories.
|
||||
|
||||
1. Read the source first. A filename plus a short request is not enough. For an injected attachment path, call `read_files` with that exact path in `paths`; ask for another upload only if the read fails.
|
||||
1. Read the source first. A filename plus a short request is not enough; for an injected path, apply the root attachment-source gate.
|
||||
2. Preserve the core prompt: role/persona, objective, boundaries, step order, tool rules, examples, output format, evaluation checks, and safety/confirmation points.
|
||||
3. Adapt format rather than substance. Map content into Orkas display, workflow, guidance, capability dependency, input, interaction, and category fields.
|
||||
4. Treat an `agent_id` found in exported or supplied source as provenance, not mutation intent. For import-as-new, copy, or create-from-source requests, never copy or emit that source ID. Follow the mutation controls in `llm-agent-fields.md`; only an explicit unbound edit resolved from the current index and spec emits the canonical current id.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: memory-manager
|
||||
description_zh: "添加、替换或删除持久的 Commander、用户、共享或项目记忆及项目指令;用于“记住这个”、长期偏好、持久决策、项目规则,以及遗忘或更新请求,不用于只读询问已提供的上下文。"
|
||||
description_en: "Add, replace, or remove durable Commander, user, shared, or project memories and project instructions. Use for “remember this,” standing preferences, durable decisions, project rules, and forget or update requests; not for read-only questions about context already supplied."
|
||||
description_zh: "当用户要求让信息、偏好、决定或规则在未来对话持续生效,或要求更新、纠正、遗忘这些持久信息时,先读取本 Skill,再通过实际工具完成添加、替换或删除;覆盖 Commander、用户、共享、项目记忆及项目指令。不用于只读回忆或保存普通文件。"
|
||||
description_en: "Read this Skill before “remember this” or any request to preserve facts, preferences, decisions, or rules across future conversations, or correct/forget durable state. It governs mutations to Commander, user, shared, and project memory/instructions; exclude read-only recall and ordinary file saving."
|
||||
---
|
||||
|
||||
# memory-manager
|
||||
|
||||
@@ -21,20 +21,18 @@ Do not read unrelated references. A category-only edit needs only `metadata.md`;
|
||||
|
||||
## Routing gate
|
||||
|
||||
**Explicit creation intent required.** Use this Skill only when the user explicitly asks to create, edit, convert into, or import a Skill, or when already inside a bound Skill editor. Do **not** consult this skill for a plain "install this URL / install this GitHub repo / add this project" request. A runnable external package belongs to `package-installer` unless the user explicitly wants a portable custom Skill.
|
||||
**Explicit creation intent required.** Use this Skill only when the user explicitly asks to create, edit, convert into, or import a Skill, or when already inside a bound Skill editor. Do **not** consult this skill for a plain "install this URL / install this GitHub repo / add this project" request. A runnable external package belongs to `package-installer` unless the user explicitly wants a portable custom Skill. If that outcome is genuinely ambiguous, ask one concise clarification.
|
||||
|
||||
## Non-negotiable protocol
|
||||
|
||||
- **Use host-owned mutation paths only.** Use `import_skill_package` for an explicitly supplied local Skill directory/ZIP when available, metadata tags for metadata-only changes, and `<<<skill-file>>>` for changed file content. Never use `edit_file`, `write_file`, or shell redirects under the Skill directory.
|
||||
- **Whole-file replacement.** Every `<<<skill-file>>>` block replaces one complete relative file. Read an existing file before changing it; a partial body erases omitted content.
|
||||
- **Protocol completeness beats prose depth.** Close every file block with `>>>` on its own line and close the outer `</skill>` before optional user-visible prose. If response space is tight, shorten the Skill body; never truncate or omit a protocol delimiter.
|
||||
- **No protocol files.** Do not dump `<skill>`, `<skill-meta>`, or file blocks into the workspace. The host parses them inline.
|
||||
- **Explicit creation intent.** A URL, README, or tool document alone does not authorize Skill creation. Ask one concise clarification when intent is genuinely ambiguous.
|
||||
- **One container per Skill.** Multiple containers are legal only when the request or source contains multiple distinct Skills. Never merge multiple source `SKILL.md` files into one Skill.
|
||||
- **Skills are independent.** Do not name, invoke, or read another Skill from this Skill's body, references, scripts, or examples. Orchestration belongs to the caller.
|
||||
- **Preserve source material.** Read relevant source contents before authoring; a filename plus a short request is not enough. For an existing Skill, preserve its body, scripts, references, examples, filenames, layout, and behavior unless an Orkas compatibility change is required or the user asks for a rewrite.
|
||||
- **Bundled scripts use the standard Skill Runner.** Never expose or construct an installation path or depend on the caller's working directory.
|
||||
- **Use the user's UI language** for newly authored human-readable instructions and user-visible prose. Keep frontmatter keys, identifiers, paths, commands, and code unchanged. Imported prose remains faithful by default.
|
||||
- **No silent defaults or residue.** Validate category and audit imported files before claiming success.
|
||||
- **The host owns completion.** A valid `<skill>` container or file block only requests a mutation. Only a successful host result proves it was applied; before that, or after a failed or omitted mutation, never say the Skill is created, updated, saved, ready, or available. If visible prose is useful, use pending wording such as “将创建” or “已提交,等待应用”.
|
||||
|
||||
## Decide create versus edit
|
||||
|
||||
@@ -96,9 +94,9 @@ description: compact current-language routing description
|
||||
1. Confirm explicit Skill intent and classify create, edit, or import.
|
||||
2. Read only the references required by the routing table.
|
||||
3. Read the current Skill and any user-supplied source material.
|
||||
4. Preserve source or design the smallest self-contained capability; choose guide versus script deliberately.
|
||||
4. Preserve source or design the smallest self-contained capability; choose guide versus script deliberately. For a straightforward guide, prefer the required use/non-use boundary, preconditions, 3–7 steps, and output shape over a long tutorial or repeated examples.
|
||||
5. Validate metadata, category, runner commands, safety, relative links, and the final resource inventory.
|
||||
6. Emit only the minimal metadata tags and complete changed files, followed by concise user-facing status when the surface allows it.
|
||||
6. Emit only the minimal metadata tags and complete changed files, then stop for the host result.
|
||||
|
||||
## Safety gate
|
||||
|
||||
@@ -106,4 +104,4 @@ Do not author credential-store reads, dynamic or decoded execution, download-and
|
||||
|
||||
## User-visible prose
|
||||
|
||||
State only what the Skill now does, when it is used, which meaningful files changed, and any required next step. Do not expose protocol tags, frontmatter mechanics, internal modes, ids, or design jargon. Do **not** show source provenance by default. Mention it only when asked or when a failed read/import needs repair. Never claim completion after a failed or omitted mutation.
|
||||
State only what the Skill now does, when it is used, which meaningful files changed, and any required next step. Do not expose protocol tags, frontmatter mechanics, internal modes, ids, or design jargon. Do **not** show source provenance by default. Mention it only when asked or when a failed read/import needs repair.
|
||||
@@ -12,6 +12,7 @@ A Skill is an independent reusable capability, not a tutorial or an Agent.
|
||||
- Prefer a guide using existing tools. Add a script only for deterministic repeated logic or a real integration boundary.
|
||||
- Keep the root SKILL.md as the routing and common execution spine. Move conditional variants, long schemas, detailed examples, and deep domain material into one-level `references/` files. In the root, retain only each branch's discriminator/read condition; do not repeat branch checklists, branch schemas, or empty branch templates there.
|
||||
- Aim to keep a newly authored root comfortably below 12,000 characters. This is a context budget, not a reason to omit required safety or execution rules; move conditional depth rather than compressing it into ambiguity.
|
||||
- A straightforward single-path guide should be much shorter than that ceiling. Do not expand a compact request into a tutorial: cover the required boundaries, preconditions, 3–7 execution steps, and output shape once, then stop.
|
||||
|
||||
## Design workflow
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ runtime/
|
||||
python/python.exe # Windows python-build-standalone
|
||||
darwin-arm64/
|
||||
darwin-x64/
|
||||
linux-arm64/
|
||||
linux-x64/
|
||||
win32-x64/
|
||||
uv/
|
||||
current/
|
||||
@@ -42,6 +44,8 @@ runtime/
|
||||
uv.exe
|
||||
darwin-arm64/
|
||||
darwin-x64/
|
||||
linux-arm64/
|
||||
linux-x64/
|
||||
win32-x64/
|
||||
node/
|
||||
current/
|
||||
@@ -51,7 +55,19 @@ runtime/
|
||||
node.exe # Windows official Node archive
|
||||
darwin-arm64/
|
||||
darwin-x64/
|
||||
linux-arm64/
|
||||
linux-x64/
|
||||
win32-x64/
|
||||
ffmpeg/
|
||||
linux-arm64/
|
||||
linux-x64/
|
||||
whisper/
|
||||
linux-arm64/
|
||||
bin/whisper-cli
|
||||
models/ggml-base-q5_1.bin
|
||||
linux-x64/
|
||||
bin/whisper-cli
|
||||
models/ggml-base-q5_1.bin
|
||||
```
|
||||
|
||||
The app resolves `current` first, then `<platform>-<arch>`. It injects
|
||||
|
||||
@@ -69,6 +69,15 @@ wait_for_relaunch_owner() {
|
||||
|
||||
wait_for_relaunch_owner
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "[Orkas] Node.js 20+ is required to bootstrap the source checkout. Install Node.js, then run ./run.sh again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(uname -s)" = "Linux" ]; then
|
||||
node "$APP_DIR/scripts/verify-linux-source-dependencies.cjs" --host-only
|
||||
fi
|
||||
|
||||
node "$APP_DIR/scripts/ensure-deps.cjs"
|
||||
node "$APP_DIR/scripts/ensure-dev-dependencies.cjs"
|
||||
# macOS source runs need the same connector callback declaration that electron-builder adds to
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/** Fixed gate messages: the extracted header message must EQUAL the key.
|
||||
* Substring matching is deliberately avoided — a future "context compaction
|
||||
@@ -532,5 +533,5 @@ function main() {
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& path.resolve(process.argv[1]) === new URL(import.meta.url).pathname;
|
||||
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
||||
if (invokedDirectly) main();
|
||||
@@ -37,6 +37,10 @@ function removeDarwinOutputs(exceptArch = null, root = outputDir) {
|
||||
* binary, whose dist ships no headers. `npm_node_execpath` is the Node that
|
||||
* launched the npm script, which is where a nvm/fnm install keeps its headers
|
||||
* — without it, a machine with no Homebrew or system Node cannot build at all.
|
||||
* `ORKAS_TEST_NODE` is the same outer Node, stamped by `scripts/run-tests.mjs`
|
||||
* itself: it covers the runner being invoked directly (`node
|
||||
* scripts/run-tests.mjs ...`), where npm sets no variables at all and the
|
||||
* whole macOS suite would otherwise fail to build for the wrong reason.
|
||||
*/
|
||||
function findNodeHeaders({
|
||||
env = process.env,
|
||||
@@ -48,6 +52,7 @@ function findNodeHeaders({
|
||||
env.npm_config_nodedir && path.resolve(env.npm_config_nodedir, 'include', 'node'),
|
||||
includeDirOf(execPath),
|
||||
env.npm_node_execpath && includeDirOf(env.npm_node_execpath),
|
||||
env.ORKAS_TEST_NODE && includeDirOf(env.ORKAS_TEST_NODE),
|
||||
'/opt/homebrew/include/node',
|
||||
'/usr/local/include/node',
|
||||
'/usr/include/node',
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { WHISPER_RUNTIME_CONTRACT } = require('../bin/runtime-gate.cjs');
|
||||
|
||||
const pcRoot = path.resolve(__dirname, '..');
|
||||
|
||||
function run(label, script, args = []) {
|
||||
@@ -16,8 +18,15 @@ function run(label, script, args = []) {
|
||||
if (result.status !== 0) throw new Error(`${label} failed with status ${result.status}`);
|
||||
}
|
||||
|
||||
function shouldProvisionWhisper(platform = process.platform, arch = process.arch) {
|
||||
return Boolean(WHISPER_RUNTIME_CONTRACT.targets[`${platform}-${arch}`]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log(`[dev-deps] preparing built-in dependencies for ${process.platform}-${process.arch}`);
|
||||
if (process.platform === 'linux') {
|
||||
run('Linux host preflight', 'scripts/verify-linux-source-dependencies.cjs', ['--host-only']);
|
||||
}
|
||||
run('notification permission addon', 'scripts/build-notification-permission-addon.cjs', [
|
||||
'--platform', process.platform,
|
||||
'--arch', process.arch,
|
||||
@@ -37,16 +46,31 @@ function main() {
|
||||
'--arch', process.arch,
|
||||
]);
|
||||
}
|
||||
run('Whisper', 'scripts/fetch-whisper.cjs', [
|
||||
'--platform', process.platform,
|
||||
'--arch', process.arch,
|
||||
]);
|
||||
// Whisper ships pinned prebuilt CLIs for a fixed target list only. On hosts
|
||||
// without a contract target the fetch script must keep failing (packaging
|
||||
// gates rely on that strictness); here it is simply not required — speech
|
||||
// transcription degrades to an actionable error at runtime instead.
|
||||
if (shouldProvisionWhisper(process.platform, process.arch)) {
|
||||
run('Whisper', 'scripts/fetch-whisper.cjs', [
|
||||
'--platform', process.platform,
|
||||
'--arch', process.arch,
|
||||
]);
|
||||
} else {
|
||||
console.log(`[dev-deps] Whisper not provisioned for ${process.platform}-${process.arch}; skipping (local speech transcription unavailable)`);
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
run('Linux source dependencies', 'scripts/verify-linux-source-dependencies.cjs');
|
||||
}
|
||||
console.log('[dev-deps] built-in dependencies ready');
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(`[dev-deps] failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
module.exports = Object.freeze({ shouldProvisionWhisper });
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(`[dev-deps] failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
*
|
||||
* Selection: by default fetches the CURRENT platform/arch. Multi-arch release
|
||||
* builds pass explicit --platform targets, just like ensure-runtime. Flags:
|
||||
* --all every shippable target (mac + win, both arches)
|
||||
* --all every shippable target (linux + mac + win, both arches)
|
||||
* --root=<dir> destination root (default: PC/resources/officecli)
|
||||
* --platform=<key> a specific `${platform}-${arch}` (repeatable)
|
||||
* --force re-download even if a valid copy exists
|
||||
@@ -53,9 +53,11 @@ const STALL_TIMEOUT_MS = Number(process.env.OFFICECLI_FETCH_STALL_MS || 120_000)
|
||||
const pcRoot = path.resolve(__dirname, '..');
|
||||
let destDir = path.join(pcRoot, 'resources', 'officecli');
|
||||
|
||||
// `${process.platform}-${process.arch}` -> release asset name. Desktop targets
|
||||
// only (mac + win); linux assets exist upstream but we do not ship them.
|
||||
// `${process.platform}-${process.arch}` -> release asset name. Linux assets are
|
||||
// the upstream glibc builds (the *-alpine-* musl variants are not shipped).
|
||||
const ASSETS = {
|
||||
'linux-x64': 'officecli-linux-x64',
|
||||
'linux-arm64': 'officecli-linux-arm64',
|
||||
'darwin-arm64': 'officecli-mac-arm64',
|
||||
'darwin-x64': 'officecli-mac-x64',
|
||||
'win32-x64': 'officecli-win-x64.exe',
|
||||
@@ -64,6 +66,8 @@ const ASSETS = {
|
||||
|
||||
// Pinned sha256 for VERSION (from the release SHA256SUMS). Update with VERSION.
|
||||
const SHA256 = {
|
||||
'officecli-linux-x64': 'da07d4f787d7c85724104294ac023c89971ddfaee93ebb183b289282b8f869cc',
|
||||
'officecli-linux-arm64': '39008c7f76d202858637810553ef14e2cd3e7f61485fdcf2011f26967a7babd1',
|
||||
'officecli-mac-arm64': '393874f79db58222bdbede7f4f942f2536580386923857d1b5ad9754efe80c19',
|
||||
'officecli-mac-x64': '6a931d424975dded6ae413c8c1f63d00dfb30a4bd4bd50352964782d13299f5c',
|
||||
'officecli-win-x64.exe': '864e0580c8e8c91a6aa4a4c1e8900551c8d4aa648ff10136ceed3a6ba5310888',
|
||||
|
||||
@@ -15,6 +15,7 @@ const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const AdmZip = require('adm-zip');
|
||||
const tar = require('tar');
|
||||
const { WHISPER_RUNTIME_CONTRACT } = require('../bin/runtime-gate.cjs');
|
||||
const { installWindowsVcRuntime } = require('./fetch-win-vc-runtime.cjs');
|
||||
|
||||
@@ -29,6 +30,24 @@ function runtimeVersion(target) {
|
||||
|
||||
function noticeForTarget(target) {
|
||||
const version = runtimeVersion(target);
|
||||
const windowsNotice = target.source?.startsWith('official-release-openblas') ? `
|
||||
The Windows CLI is the official whisper.cpp x64 release. Its required Microsoft
|
||||
Visual C++ runtime DLLs are deployed application-locally beside whisper-cli;
|
||||
Orkas does not install or modify the machine-wide Visual C++ Redistributable.
|
||||
Microsoft redistributable terms apply:
|
||||
https://visualstudio.microsoft.com/license-terms/
|
||||
` : '';
|
||||
const linuxNotice = target.source?.startsWith('official-release-ubuntu') ? `
|
||||
The Linux CLI and its CPU runtime libraries are the official whisper.cpp Ubuntu
|
||||
release for the target architecture. They are deployed application-locally
|
||||
beside whisper-cli and selected by the dynamic loader through the executable's
|
||||
origin-relative run path.
|
||||
` : '';
|
||||
const macNotice = target.source === 'vendored-static-cli' ? `
|
||||
The macOS CLI is a static target-native build whose pinned source, toolchain,
|
||||
flags, size, and hash are documented in
|
||||
vendor/whisper/v${WHISPER_RUNTIME_CONTRACT.version}/BUILD.md.
|
||||
` : '';
|
||||
const openBlasNotice = target.openBlasVersion ? `
|
||||
Windows OpenBLAS acceleration:
|
||||
https://github.com/OpenMathLib/OpenBLAS/tree/v${target.openBlasVersion}
|
||||
@@ -48,15 +67,10 @@ Multilingual Whisper base-q5_1 model:
|
||||
https://huggingface.co/ggerganov/whisper.cpp
|
||||
Derived from OpenAI Whisper; MIT; see LICENSE.model.
|
||||
|
||||
The Windows CLI is the official whisper.cpp x64 release. Its required Microsoft
|
||||
Visual C++ runtime DLLs are deployed application-locally beside whisper-cli;
|
||||
Orkas does not install or modify the machine-wide Visual C++ Redistributable.
|
||||
Microsoft redistributable terms apply:
|
||||
https://visualstudio.microsoft.com/license-terms/
|
||||
|
||||
${windowsNotice}
|
||||
${linuxNotice}
|
||||
${macNotice}
|
||||
${openBlasNotice}
|
||||
The macOS CLIs are static target-native builds whose pinned source, toolchain,
|
||||
flags, sizes, and hashes are documented in vendor/whisper/v${WHISPER_RUNTIME_CONTRACT.version}/BUILD.md.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -284,6 +298,38 @@ function writeWindowsFiles(archiveFile, tempDir, target) {
|
||||
}
|
||||
}
|
||||
|
||||
function safeArchivePath(value) {
|
||||
const normalized = String(value || '').replaceAll('\\', '/');
|
||||
if (!normalized || normalized.startsWith('/') || normalized.split('/').includes('..')) {
|
||||
throw new Error(`unsafe whisper.cpp archive path: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function writeLinuxFiles(archiveFile, tempDir, target) {
|
||||
const extractDir = path.join(tempDir, '.archive');
|
||||
const archivePaths = new Set(Object.values(target.files).map(expected => (
|
||||
safeArchivePath(expected.archivePath)
|
||||
)));
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
try {
|
||||
tar.x({
|
||||
cwd: extractDir,
|
||||
file: archiveFile,
|
||||
filter: entryPath => archivePaths.has(safeArchivePath(entryPath)),
|
||||
preservePaths: false,
|
||||
strict: true,
|
||||
sync: true,
|
||||
});
|
||||
for (const [relativePath, expected] of Object.entries(target.files)) {
|
||||
const source = rel(extractDir, safeArchivePath(expected.archivePath));
|
||||
copyPinned(source, rel(tempDir, relativePath), expected, expected.executable === true);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeWindowsAppLocalFiles(vcRuntimeDir, tempDir, target) {
|
||||
for (const [relativePath, expected] of Object.entries(target.appLocalFiles || {})) {
|
||||
copyPinned(path.join(vcRuntimeDir, path.basename(relativePath)), rel(tempDir, relativePath), expected, true);
|
||||
@@ -376,6 +422,9 @@ async function installWhisper(options = targetOptions()) {
|
||||
const archive = await cachedDownload(target.archive.cacheName || `whisper-bin-x64-v${version}.zip`, target.archive);
|
||||
writeWindowsFiles(archive, tempDir, target);
|
||||
writeWindowsAppLocalFiles(vcRuntimeDir, tempDir, target);
|
||||
} else if (options.platform === 'linux') {
|
||||
const archive = await cachedDownload(target.archive.cacheName || `whisper-bin-ubuntu-${options.arch}-v${version}.tar.gz`, target.archive);
|
||||
writeLinuxFiles(archive, tempDir, target);
|
||||
}
|
||||
|
||||
const model = await cachedDownload('ggml-base-q5_1.bin', WHISPER_RUNTIME_CONTRACT.model);
|
||||
@@ -413,5 +462,6 @@ module.exports = {
|
||||
runtimeVersion,
|
||||
targetOptions,
|
||||
writeCapabilityState,
|
||||
writeLinuxFiles,
|
||||
writeWindowsAppLocalFiles,
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('[linux-electron-smoke] timed out waiting for Electron');
|
||||
app.exit(1);
|
||||
}, 30_000);
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
app.whenReady().then(async () => {
|
||||
const window = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await window.loadURL('data:text/html;charset=utf-8,%3Cbody%3Eorkas-linux-smoke%3C%2Fbody%3E');
|
||||
const text = await window.webContents.executeJavaScript('document.body.textContent');
|
||||
if (text !== 'orkas-linux-smoke') throw new Error('hidden BrowserWindow did not render expected content');
|
||||
console.log('[linux-electron-smoke] Electron app and hidden BrowserWindow are ready');
|
||||
clearTimeout(timeout);
|
||||
window.destroy();
|
||||
app.quit();
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
window.destroy();
|
||||
console.error(`[linux-electron-smoke] failed: ${err.message}`);
|
||||
app.exit(1);
|
||||
}
|
||||
}).catch((err) => {
|
||||
clearTimeout(timeout);
|
||||
console.error(`[linux-electron-smoke] failed: ${err.message}`);
|
||||
app.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const pcRoot = path.resolve(__dirname, '..');
|
||||
const modelRoot = path.join(
|
||||
pcRoot,
|
||||
'resources',
|
||||
'embedding-model',
|
||||
'fast-bge-small-zh-v1.5',
|
||||
);
|
||||
|
||||
function requireCondition(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
async function probeSqlite() {
|
||||
const Database = require('better-sqlite3');
|
||||
const sqliteVec = require('sqlite-vec');
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
sqliteVec.load(db);
|
||||
const row = db.prepare('SELECT vec_version() AS version').get();
|
||||
requireCondition(typeof row?.version === 'string' && row.version.length > 0, 'sqlite-vec returned no version');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function probeSharp() {
|
||||
const sharp = require('sharp');
|
||||
const png = await sharp({
|
||||
create: {
|
||||
width: 2,
|
||||
height: 2,
|
||||
channels: 4,
|
||||
background: { r: 20, g: 40, b: 60, alpha: 1 },
|
||||
},
|
||||
}).png().toBuffer();
|
||||
requireCondition(
|
||||
png.length > 8 && png.subarray(1, 4).toString('ascii') === 'PNG',
|
||||
'sharp did not produce a PNG',
|
||||
);
|
||||
}
|
||||
|
||||
async function probeCanvas() {
|
||||
const { createCanvas } = require('@napi-rs/canvas');
|
||||
const canvas = createCanvas(2, 2);
|
||||
const context = canvas.getContext('2d');
|
||||
context.fillStyle = '#123456';
|
||||
context.fillRect(0, 0, 2, 2);
|
||||
const png = canvas.toBuffer('image/png');
|
||||
requireCondition(Buffer.isBuffer(png) && png.length > 8, '@napi-rs/canvas did not produce a PNG');
|
||||
}
|
||||
|
||||
async function probeEmbedding(fullEmbedding) {
|
||||
const tokenizers = require('@anush008/tokenizers');
|
||||
const tokenizerFile = path.join(modelRoot, 'tokenizer.json');
|
||||
requireCondition(fs.existsSync(tokenizerFile), 'embedding tokenizer is missing');
|
||||
const tokenizer = tokenizers.Tokenizer.fromFile(tokenizerFile);
|
||||
const encoded = await tokenizer.encode('Orkas Linux dependency probe');
|
||||
requireCondition(encoded.getIds().length > 0, 'tokenizer returned no token ids');
|
||||
|
||||
// fastembed eagerly loads onnxruntime-node as well as tokenizers. Requiring
|
||||
// the public entrypoint catches missing shared libraries even in the fast
|
||||
// startup probe. CI additionally creates a real ONNX session and inference.
|
||||
const fastembed = require('fastembed');
|
||||
requireCondition(typeof fastembed.FlagEmbedding?.init === 'function', 'fastembed entrypoint is incomplete');
|
||||
if (!fullEmbedding) return;
|
||||
|
||||
const embedder = await fastembed.FlagEmbedding.init({
|
||||
model: fastembed.EmbeddingModel.BGESmallZH,
|
||||
cacheDir: path.dirname(modelRoot),
|
||||
showDownloadProgress: false,
|
||||
});
|
||||
const batches = embedder.embed(['Orkas Linux dependency probe'], 1);
|
||||
let vector = null;
|
||||
for await (const batch of batches) {
|
||||
vector = batch?.[0] || null;
|
||||
break;
|
||||
}
|
||||
requireCondition(vector && vector.length === 512, 'embedding inference did not return a 512-dimensional vector');
|
||||
requireCondition(Array.from(vector).every(Number.isFinite), 'embedding inference returned a non-finite value');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'linux') {
|
||||
console.log(`[linux-native-probe] skipped on ${process.platform}-${process.arch}`);
|
||||
return;
|
||||
}
|
||||
const fullEmbedding = process.argv.includes('--full-embedding');
|
||||
await probeSqlite();
|
||||
await probeSharp();
|
||||
await probeCanvas();
|
||||
await probeEmbedding(fullEmbedding);
|
||||
console.log(
|
||||
`[linux-native-probe] verified better-sqlite3, sqlite-vec, sharp, canvas, tokenizers, onnxruntime`
|
||||
+ `${fullEmbedding ? ', embedding-inference' : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((err) => {
|
||||
console.error(`[linux-native-probe] failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { probeCanvas, probeEmbedding, probeSharp, probeSqlite };
|
||||
@@ -104,6 +104,22 @@ function assertContains(output, expected, label) {
|
||||
assert(output.includes(expected), `${label} did not contain ${JSON.stringify(expected)}:\n${output}`);
|
||||
}
|
||||
|
||||
function firstOfficeReadMatch(result, label) {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(result.content);
|
||||
} catch {
|
||||
throw new Error(`${label} did not return JSON:\n${result.content}`);
|
||||
}
|
||||
const batchResult = payload?.results?.[0];
|
||||
const match = batchResult?.data?.results?.[0];
|
||||
assert(
|
||||
payload?.status === 'complete' && batchResult?.ok === true && match,
|
||||
`${label} did not return one successful match:\n${result.content}`,
|
||||
);
|
||||
return match;
|
||||
}
|
||||
|
||||
function assertRenderedHtml(file, officeFile) {
|
||||
const html = fs.readFileSync(file, 'utf8');
|
||||
assert(html.length > 1_000, `${path.basename(officeFile)} rendered HTML is unexpectedly small (${html.length} chars)`);
|
||||
@@ -272,20 +288,31 @@ try {
|
||||
const editedTextCell = await officeRead.execute({
|
||||
path: workbook,
|
||||
mode: 'get',
|
||||
target: '/数据/A2',
|
||||
targets: ['/数据/A2'],
|
||||
}, { workingDir: tempDir, state: {} });
|
||||
assert(!editedTextCell.isError, `office_read XLSX text cell failed: ${editedTextCell.content}`);
|
||||
assertContains(editedTextCell.content, '00999', 'edited XLSX text value');
|
||||
assertContains(editedTextCell.content, '"numberformat": "@"', 'edited XLSX text number format');
|
||||
const editedTextMatch = firstOfficeReadMatch(editedTextCell, 'edited XLSX text cell');
|
||||
assert(editedTextMatch.text === '00999', `edited XLSX text value was ${JSON.stringify(editedTextMatch.text)}`);
|
||||
assert(
|
||||
editedTextMatch.format?.numberformat === '@',
|
||||
`edited XLSX text number format was ${JSON.stringify(editedTextMatch.format?.numberformat)}`,
|
||||
);
|
||||
|
||||
const editedFormulaCell = await officeRead.execute({
|
||||
path: workbook,
|
||||
mode: 'get',
|
||||
target: '/数据/B2',
|
||||
targets: ['/数据/B2'],
|
||||
}, { workingDir: tempDir, state: {} });
|
||||
assert(!editedFormulaCell.isError, `office_read XLSX formula cell failed: ${editedFormulaCell.content}`);
|
||||
assertContains(editedFormulaCell.content, '"formula": "SUM(40,2)"', 'edited XLSX formula');
|
||||
assertContains(editedFormulaCell.content, '"numberformat": "0.00"', 'edited XLSX formula number format');
|
||||
const editedFormulaMatch = firstOfficeReadMatch(editedFormulaCell, 'edited XLSX formula cell');
|
||||
assert(
|
||||
editedFormulaMatch.format?.formula === 'SUM(40,2)',
|
||||
`edited XLSX formula was ${JSON.stringify(editedFormulaMatch.format?.formula)}`,
|
||||
);
|
||||
assert(
|
||||
editedFormulaMatch.format?.numberformat === '0.00',
|
||||
`edited XLSX formula number format was ${JSON.stringify(editedFormulaMatch.format?.numberformat)}`,
|
||||
);
|
||||
|
||||
run(binary, ['create', deck, '--force', '--json']);
|
||||
await batch(binary, deck, [
|
||||
|
||||
@@ -8,17 +8,17 @@ const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pcRoot = resolve(here, '..');
|
||||
const runner = resolve(here, 'run-tests.mjs');
|
||||
|
||||
if (process.platform !== 'win32' && process.platform !== 'darwin') {
|
||||
if (!['win32', 'darwin', 'linux'].includes(process.platform)) {
|
||||
console.log(`[platform-native-tests] skipped: unsupported host ${process.platform}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Keep system/native tests explicit and serial. This lane intentionally
|
||||
// overlaps the general suite: a Windows-only process-tree or PowerShell case
|
||||
// must remain visible as a dedicated CI failure instead of being buried among
|
||||
// hundreds of platform-neutral tests. Real bundled Whisper remains the
|
||||
// release-only `test:windows-native` lane because it requires downloaded
|
||||
// FFmpeg, model, VC runtime, and CPU-dispatch payloads.
|
||||
// overlaps the general suite: host-specific process, native-module, and OS
|
||||
// integration cases must remain visible as a dedicated CI failure instead of
|
||||
// being buried among hundreds of platform-neutral tests. Real bundled Whisper
|
||||
// runs after dependency provisioning in the platform-specific Windows and
|
||||
// Linux lanes because it requires downloaded FFmpeg, model, and native payloads.
|
||||
const commonSuites = [
|
||||
'src/core-agent/test/oauth-flow.test.ts',
|
||||
'src/core-agent/test/tools.test.ts',
|
||||
@@ -57,12 +57,19 @@ const platformSuites = process.platform === 'win32'
|
||||
'test/main/util/prepare-win-native-deps.test.ts',
|
||||
'test/main/util/fetch-win-vc-runtime.test.ts',
|
||||
]
|
||||
: [
|
||||
'test/main/features/platform-foundations.test.ts',
|
||||
'test/main/features/user_workspace.test.ts',
|
||||
'test/main/conversation-files.test.ts',
|
||||
'test/main/native/notification-permissions-darwin.test.ts',
|
||||
];
|
||||
: process.platform === 'darwin'
|
||||
? [
|
||||
'test/main/features/platform-foundations.test.ts',
|
||||
'test/main/features/user_workspace.test.ts',
|
||||
'test/main/conversation-files.test.ts',
|
||||
'test/main/native/notification-permissions-darwin.test.ts',
|
||||
]
|
||||
: [
|
||||
'test/main/util/linux-source-dependencies.test.ts',
|
||||
'test/main/util/officecli-fetch.test.ts',
|
||||
'test/main/util/ensure-dev-dependencies.test.ts',
|
||||
'test/main/util/ensure-sqlite-electron-abi.test.ts',
|
||||
];
|
||||
|
||||
console.log(`[platform-native-tests] host=${process.platform}; suites=${commonSuites.length + platformSuites.length}`);
|
||||
const result = spawnSync(process.execPath, [
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
console.log(`[linux-ocr-probe] skipped on ${process.platform}-${process.arch}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ownsWorkspace = !process.env.ORKAS_WORKSPACE_ROOT;
|
||||
const workspace = process.env.ORKAS_WORKSPACE_ROOT
|
||||
|| fs.mkdtempSync(path.join(os.tmpdir(), 'orkas-linux-ocr-'));
|
||||
process.env.ORKAS_WORKSPACE_ROOT = workspace;
|
||||
|
||||
try {
|
||||
const { _ensureOcrRuntimeForTest } = await import('../src/main/features/ocr_runtime.ts');
|
||||
const result = await _ensureOcrRuntimeForTest((event) => {
|
||||
if (!event.data?.heartbeat) console.log(`[linux-ocr-probe] ${event.phase}: ${event.message}`);
|
||||
});
|
||||
if (!result.ok) throw new Error(`${result.errorCode}: ${result.message}`);
|
||||
console.log(`[linux-ocr-probe] RapidOCR runtime verified (${result.installed ? 'installed' : 'reused'})`);
|
||||
} catch (err) {
|
||||
console.error(`[linux-ocr-probe] failed: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (ownsWorkspace) fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const { verifyEmbeddingModelRoot } = require('../bin/packaged-resource-gate.cjs');
|
||||
const {
|
||||
WHISPER_RUNTIME_CONTRACT,
|
||||
verifyFfmpegRuntimeDir,
|
||||
verifyRuntimeDir,
|
||||
verifyWhisperRuntimeDir,
|
||||
} = require('../bin/runtime-gate.cjs');
|
||||
|
||||
const PC_ROOT = path.resolve(__dirname, '..');
|
||||
const MINIMUM_NODE_MAJOR = 20;
|
||||
// The official whisper.cpp v1.9.1 Ubuntu x64/arm64 releases require symbols
|
||||
// through GLIBC_2.34, so the source bootstrap must reject older hosts before
|
||||
// downloading runtimes that cannot execute there.
|
||||
const MINIMUM_GLIBC = WHISPER_RUNTIME_CONTRACT.targets['linux-x64'].minimumGlibc;
|
||||
if (!MINIMUM_GLIBC
|
||||
|| WHISPER_RUNTIME_CONTRACT.targets['linux-arm64'].minimumGlibc !== MINIMUM_GLIBC) {
|
||||
throw new Error('Linux Whisper targets must declare one shared minimum glibc version');
|
||||
}
|
||||
const SUPPORTED_ARCHES = Object.freeze(['x64', 'arm64']);
|
||||
|
||||
function versionTuple(value) {
|
||||
const match = /^(\d+)\.(\d+)/.exec(String(value || '').trim());
|
||||
return match ? [Number(match[1]), Number(match[2])] : null;
|
||||
}
|
||||
|
||||
function versionAtLeast(actual, minimum) {
|
||||
const a = versionTuple(actual);
|
||||
const b = versionTuple(minimum);
|
||||
if (!a || !b) return false;
|
||||
return a[0] > b[0] || (a[0] === b[0] && a[1] >= b[1]);
|
||||
}
|
||||
|
||||
function runtimeGlibcVersion(report = process.report) {
|
||||
try {
|
||||
return String(report?.getReport()?.header?.glibcVersionRuntime || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function assertLinuxHost({
|
||||
platform = process.platform,
|
||||
arch = process.arch,
|
||||
nodeVersion = process.versions.node,
|
||||
glibcVersionRuntime = runtimeGlibcVersion(),
|
||||
} = {}) {
|
||||
if (platform !== 'linux') return Object.freeze([]);
|
||||
if (!SUPPORTED_ARCHES.includes(arch)) {
|
||||
throw new Error(`unsupported Linux architecture ${arch}; Orkas source runs support x64 and arm64`);
|
||||
}
|
||||
const nodeMajor = Number.parseInt(String(nodeVersion).split('.', 1)[0], 10);
|
||||
if (!Number.isFinite(nodeMajor) || nodeMajor < MINIMUM_NODE_MAJOR) {
|
||||
throw new Error(`Node.js ${MINIMUM_NODE_MAJOR}+ is required; found ${nodeVersion || 'unknown'}`);
|
||||
}
|
||||
if (!glibcVersionRuntime) {
|
||||
throw new Error('glibc is required; Alpine and other musl-based Linux distributions are not supported');
|
||||
}
|
||||
if (!versionAtLeast(glibcVersionRuntime, MINIMUM_GLIBC)) {
|
||||
throw new Error(`glibc ${MINIMUM_GLIBC}+ is required; found ${glibcVersionRuntime}`);
|
||||
}
|
||||
return Object.freeze([
|
||||
`host:linux-${arch}`,
|
||||
`host:node-${nodeMajor}`,
|
||||
`host:glibc-${glibcVersionRuntime}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function requiredLinuxPackages(arch) {
|
||||
if (!SUPPORTED_ARCHES.includes(arch)) {
|
||||
throw new Error(`unsupported Linux architecture ${arch}`);
|
||||
}
|
||||
return Object.freeze([
|
||||
'@anush008/tokenizers',
|
||||
`@anush008/tokenizers-linux-${arch}-gnu`,
|
||||
`@esbuild/linux-${arch}`,
|
||||
'@napi-rs/canvas',
|
||||
`@napi-rs/canvas-linux-${arch}-gnu`,
|
||||
`@img/sharp-linux-${arch}`,
|
||||
`@img/sharp-libvips-linux-${arch}`,
|
||||
'better-sqlite3',
|
||||
'fastembed',
|
||||
'sharp',
|
||||
'sqlite-vec',
|
||||
`sqlite-vec-linux-${arch}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function readJson(file, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
throw new Error(`${label} is missing or invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function packageDir(root, packageName) {
|
||||
return path.join(root, 'node_modules', ...packageName.split('/'));
|
||||
}
|
||||
|
||||
function verifyPackageContract(root, arch) {
|
||||
const lock = readJson(path.join(root, 'package-lock.json'), 'package-lock.json');
|
||||
const verified = [];
|
||||
for (const packageName of requiredLinuxPackages(arch)) {
|
||||
const locked = lock.packages?.[`node_modules/${packageName}`]?.version;
|
||||
if (!locked) throw new Error(`package-lock.json does not declare ${packageName}`);
|
||||
const installed = readJson(path.join(packageDir(root, packageName), 'package.json'), packageName).version;
|
||||
if (installed !== locked) {
|
||||
throw new Error(`${packageName} version mismatch: package-lock=${locked}, installed=${installed || 'missing'}`);
|
||||
}
|
||||
verified.push(`package:${packageName}@${installed}`);
|
||||
}
|
||||
|
||||
const onnxCandidates = [
|
||||
path.join(root, 'node_modules', 'fastembed', 'node_modules', 'onnxruntime-node'),
|
||||
path.join(root, 'node_modules', 'onnxruntime-node'),
|
||||
].filter((candidate) => fs.existsSync(path.join(candidate, 'package.json')));
|
||||
if (onnxCandidates.length !== 1) {
|
||||
throw new Error(`expected exactly one onnxruntime-node installation; found ${onnxCandidates.length}`);
|
||||
}
|
||||
const onnxDir = onnxCandidates[0];
|
||||
for (const name of ['onnxruntime_binding.node', 'libonnxruntime.so.1']) {
|
||||
const file = path.join(onnxDir, 'bin', 'napi-v3', 'linux', arch, name);
|
||||
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
|
||||
throw new Error(`onnxruntime-node is missing its linux-${arch} ${name}`);
|
||||
}
|
||||
}
|
||||
verified.push(`package:onnxruntime-node:linux-${arch}`);
|
||||
return Object.freeze(verified);
|
||||
}
|
||||
|
||||
function firstUsefulLine(value) {
|
||||
return String(value || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
|
||||
}
|
||||
|
||||
function processFailureReason(result) {
|
||||
const output = `${result.stderr || ''}\n${result.stdout || ''}`;
|
||||
const sharedLibrary = /error while loading shared libraries:\s*([^:\s]+)[^\n]*/i.exec(output);
|
||||
if (sharedLibrary) {
|
||||
return `missing Linux shared library ${sharedLibrary[1]}; install the Electron runtime library from your distribution and retry`;
|
||||
}
|
||||
return result.error?.message
|
||||
|| firstUsefulLine(result.stderr)
|
||||
|| firstUsefulLine(result.stdout)
|
||||
|| `exit ${result.status}`;
|
||||
}
|
||||
|
||||
function runChecked(label, command, args, options = {}) {
|
||||
const result = (options.spawn || spawnSync)(command, args, {
|
||||
cwd: options.cwd || PC_ROOT,
|
||||
encoding: 'utf8',
|
||||
env: options.env || process.env,
|
||||
timeout: options.timeout || 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error(`${label} failed: ${processFailureReason(result)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function verifyRuntimeContract(root, arch) {
|
||||
const runtimeRoot = path.join(root, 'resources', 'runtime');
|
||||
const manifest = readJson(path.join(runtimeRoot, 'manifest.json'), 'runtime manifest');
|
||||
const key = `linux-${arch}`;
|
||||
const verified = [];
|
||||
for (const kind of ['python', 'uv', 'node']) {
|
||||
const spec = manifest[kind];
|
||||
const asset = spec?.assets?.[key];
|
||||
if (!spec || !asset) throw new Error(`runtime manifest does not declare ${kind} for ${key}`);
|
||||
const dir = path.join(runtimeRoot, kind, key);
|
||||
const executable = verifyRuntimeDir(kind, dir, key, spec, asset, 'linux', arch);
|
||||
runChecked(`${kind} runtime`, executable, ['--version']);
|
||||
verified.push(`runtime:${kind}:${key}`);
|
||||
}
|
||||
verifyFfmpegRuntimeDir(runtimeRoot, 'linux', arch);
|
||||
verified.push(`runtime:ffmpeg:${key}`);
|
||||
verifyWhisperRuntimeDir(runtimeRoot, 'linux', arch);
|
||||
verified.push(`runtime:whisper:${key}`);
|
||||
return Object.freeze(verified);
|
||||
}
|
||||
|
||||
function verifyOfficeCli(root, arch) {
|
||||
runChecked(
|
||||
'OfficeCLI policy gate',
|
||||
process.execPath,
|
||||
[path.join(root, 'bin', 'officecli-policy-gate.cjs'), `--root=${root}`, '--require-host-binary'],
|
||||
{ cwd: root, timeout: 60_000 },
|
||||
);
|
||||
return Object.freeze([`runtime:officecli:linux-${arch}`]);
|
||||
}
|
||||
|
||||
function runNativeProbe(root, { fullEmbedding = false } = {}) {
|
||||
const electronCli = require.resolve('electron/cli.js', { paths: [root] });
|
||||
const args = [electronCli, path.join(root, 'scripts', 'linux-native-dependency-probe.cjs')];
|
||||
if (fullEmbedding) args.push('--full-embedding');
|
||||
runChecked('Electron native dependency probe', process.execPath, args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
timeout: fullEmbedding ? 2 * 60_000 : 60_000,
|
||||
});
|
||||
return Object.freeze([fullEmbedding ? 'native:embedding-inference' : 'native:load-probe']);
|
||||
}
|
||||
|
||||
function runElectronSmoke(root) {
|
||||
const electronCli = require.resolve('electron/cli.js', { paths: [root] });
|
||||
const env = { ...process.env };
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
runChecked(
|
||||
'Electron GUI smoke',
|
||||
process.execPath,
|
||||
[electronCli, path.join(root, 'scripts', 'linux-electron-smoke.cjs')],
|
||||
{ cwd: root, env, timeout: 60_000 },
|
||||
);
|
||||
return Object.freeze(['electron:hidden-window']);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { fullEmbedding: false, gui: false, hostOnly: false, root: PC_ROOT };
|
||||
for (const arg of argv) {
|
||||
if (arg === '--full-embedding') options.fullEmbedding = true;
|
||||
else if (arg === '--gui') options.gui = true;
|
||||
else if (arg === '--host-only') options.hostOnly = true;
|
||||
else if (arg.startsWith('--root=')) options.root = path.resolve(arg.slice('--root='.length));
|
||||
else throw new Error(`unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const options = parseArgs(argv);
|
||||
if (process.platform !== 'linux') {
|
||||
console.log(`[linux-deps] skipped on ${process.platform}-${process.arch}`);
|
||||
return;
|
||||
}
|
||||
const verified = [...assertLinuxHost()];
|
||||
if (!options.hostOnly) {
|
||||
verified.push(...verifyPackageContract(options.root, process.arch));
|
||||
verified.push(verifyEmbeddingModelRoot(path.join(options.root, 'resources', 'embedding-model')));
|
||||
verified.push(...verifyRuntimeContract(options.root, process.arch));
|
||||
verified.push(...verifyOfficeCli(options.root, process.arch));
|
||||
verified.push(...runNativeProbe(options.root, { fullEmbedding: options.fullEmbedding }));
|
||||
if (options.gui) verified.push(...runElectronSmoke(options.root));
|
||||
}
|
||||
console.log(`[linux-deps] verified ${verified.join(', ')}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error(`[linux-deps] failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MINIMUM_GLIBC,
|
||||
MINIMUM_NODE_MAJOR,
|
||||
SUPPORTED_ARCHES,
|
||||
assertLinuxHost,
|
||||
firstUsefulLine,
|
||||
parseArgs,
|
||||
processFailureReason,
|
||||
requiredLinuxPackages,
|
||||
runtimeGlibcVersion,
|
||||
verifyPackageContract,
|
||||
versionAtLeast,
|
||||
};
|
||||
@@ -2067,6 +2067,54 @@ export class AgentRunner {
|
||||
// Check for tool use
|
||||
const toolCalls = result.content.filter((c) => c.type === "tool_use");
|
||||
|
||||
// A user can steer while the provider is deciding which tools to run.
|
||||
// Reconcile that newer instruction before any proposed side effect,
|
||||
// not only after the whole tool batch has already executed. The
|
||||
// assistant tool_use blocks are already committed, so pair each with a
|
||||
// synthetic skipped result before appending the steer and re-running
|
||||
// inference; this preserves provider protocol without executing stale
|
||||
// work.
|
||||
if (toolCalls.length > 0) {
|
||||
const preExecutionSteer = await this.drainSteer(params);
|
||||
if (this.hasUnappliedSteer(preExecutionSteer, appliedSteerIds)) {
|
||||
const skipped = "Tool call skipped because a newer user instruction arrived before execution.";
|
||||
for (const call of toolCalls as ReadonlyArray<ToolUseCall>) {
|
||||
yield { type: "tool_start", id: call.id, name: call.name, input: call.input };
|
||||
this.session.withContextMutationBatch(() => {
|
||||
this.session.addToolResult(call.id, skipped, undefined, true);
|
||||
recordCompletedToolWork(
|
||||
this.session,
|
||||
call,
|
||||
{ content: skipped, isError: true },
|
||||
"skipped",
|
||||
compactionCount,
|
||||
);
|
||||
});
|
||||
yield {
|
||||
type: "tool_end",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
result: skipped,
|
||||
isError: true,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
toolBoundarySynthesisPending = false;
|
||||
await this.appendSteerMessages(preExecutionSteer, false, appliedSteerIds);
|
||||
log.info("interrupt-steer: skipped stale proposed tool calls before execution", {
|
||||
toolCalls: toolCalls.length,
|
||||
});
|
||||
attempt = -1;
|
||||
continue;
|
||||
}
|
||||
// An acknowledgement may have failed after the structured message
|
||||
// was already accepted on an earlier boundary. Retry only the ACK;
|
||||
// do not suppress this tool batch or duplicate the user message.
|
||||
if (preExecutionSteer.length > 0) {
|
||||
await this.appendSteerMessages(preExecutionSteer, false, appliedSteerIds);
|
||||
}
|
||||
}
|
||||
|
||||
// Tools were intentionally withheld for a boundary synthesis. A
|
||||
// provider/model that nevertheless emits a tool call must never regain
|
||||
// side effects or turn this one-shot explanation into another loop.
|
||||
|
||||
@@ -1176,10 +1176,11 @@ export class Session {
|
||||
|
||||
// Workspace state is persistent structured evidence. When its latest
|
||||
// file change has a visible causal tool result, place the net state
|
||||
// immediately after that result so later read/command rounds can reuse it
|
||||
// as prefix. If checkpointing hides the result, place it after the
|
||||
// checkpoint; unattributed reconciliation remains conservatively at the
|
||||
// tail. Visible command results are not repeated in this projection.
|
||||
// immediately after that result's complete provider tool-result cluster
|
||||
// so later read/command rounds can reuse it as prefix without splitting
|
||||
// parallel tool responses. If checkpointing hides the result, place it
|
||||
// after the checkpoint; unattributed reconciliation remains
|
||||
// conservatively at the tail. Visible command results are not repeated.
|
||||
//
|
||||
// Completed work and the plan remain deterministic tail state.
|
||||
// Completed-work entries whose exact raw tool_result is still present in
|
||||
@@ -1202,7 +1203,7 @@ export class Session {
|
||||
content: [{ type: "text", text: markPrivateRuntimeContext(workspace.text) }],
|
||||
};
|
||||
const anchoredIndex = workspace.anchorToolCallId
|
||||
? insertionIndexAfterToolResult(result, workspace.anchorToolCallId)
|
||||
? insertionIndexAfterToolResultCluster(result, workspace.anchorToolCallId)
|
||||
: undefined;
|
||||
const insertionIndex = anchoredIndex
|
||||
?? (workspace.anchorToolCallId && checkpointContextIndex !== undefined
|
||||
@@ -2101,12 +2102,20 @@ export class Session {
|
||||
const objectiveNote = plan.objectiveTruncated || objective !== plan.objective
|
||||
? " (bounded deterministic excerpts; raw user messages remain canonical)"
|
||||
: " (deterministically anchored from user instructions)";
|
||||
const latestUserDigest = this.latestUserTextInActiveTurn()?.digest;
|
||||
const latestUser = this.latestUserTextInActiveTurn();
|
||||
const latestUserDigest = latestUser?.digest;
|
||||
const needsReconciliation = plan.updatedTurnId !== activeTurnId
|
||||
|| !plan.updatedUserMessageDigest
|
||||
|| plan.updatedUserMessageDigest !== latestUserDigest;
|
||||
const lines = [
|
||||
"[Execution plan anchor — authoritative runtime state, not a summary]",
|
||||
...(needsReconciliation && latestUser
|
||||
? [
|
||||
"Latest user instruction — authoritative; replaces conflicting earlier requirements:",
|
||||
truncateMiddle(latestUser.text, EXECUTION_PLAN_MAX_ANCHOR_OBJECTIVE_CHARS),
|
||||
"The prior objective and steps below are stale until reconciled; retain only non-conflicting requirements.",
|
||||
]
|
||||
: []),
|
||||
`Objective${objectiveNote}:`,
|
||||
objective,
|
||||
`Revision: ${plan.revision}`,
|
||||
@@ -2891,7 +2900,7 @@ function visibleToolResultIds(messages: readonly Message[]): Set<string> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function insertionIndexAfterToolResult(
|
||||
function insertionIndexAfterToolResultCluster(
|
||||
messages: readonly Message[],
|
||||
toolCallId: string,
|
||||
): number | undefined {
|
||||
@@ -2900,14 +2909,18 @@ function insertionIndexAfterToolResult(
|
||||
)));
|
||||
if (resultIndex < 0) return undefined;
|
||||
let insertionIndex = resultIndex + 1;
|
||||
// Tool-result images are separate user messages by provider contract. Keep
|
||||
// those trailers attached to the result rather than inserting runtime state
|
||||
// between the text result and its image evidence.
|
||||
// A single assistant message can declare several tool calls. Session stores
|
||||
// each result (and any image trailer) as a separate user message, but the
|
||||
// provider protocol treats the entire consecutive sequence as one atomic
|
||||
// response cluster. Inserting host runtime context after only the anchored
|
||||
// result would detach every later tool result from assistant.tool_calls.
|
||||
while (
|
||||
insertionIndex < messages.length
|
||||
&& messages[insertionIndex].role === "user"
|
||||
&& messages[insertionIndex].content.length > 0
|
||||
&& messages[insertionIndex].content.every((content) => content.type === "image")
|
||||
&& messages[insertionIndex].content.every((content) => (
|
||||
content.type === "tool_result" || content.type === "image"
|
||||
))
|
||||
) {
|
||||
insertionIndex++;
|
||||
}
|
||||
|
||||
@@ -374,12 +374,93 @@ function mapUsage(u: PiAssistantMessage["usage"]): Usage {
|
||||
};
|
||||
}
|
||||
|
||||
type MapContentOptions = {
|
||||
stripLeadingThinkText?: boolean;
|
||||
};
|
||||
|
||||
type LeadingThinkTextFilter = {
|
||||
push(text: string): string;
|
||||
finish(): string;
|
||||
};
|
||||
|
||||
const LEADING_THINK_OPEN = "<think>";
|
||||
const LEADING_THINK_CLOSE = "</think>";
|
||||
|
||||
/**
|
||||
* Some user-configured OpenAI-compatible endpoints serialize private
|
||||
* reasoning as a literal leading `<think>...</think>` text block instead of
|
||||
* the protocol's structured reasoning field. Hold only the ambiguous prefix
|
||||
* while streaming, suppress each block in a consecutive leading sequence,
|
||||
* and pass every non-leading lookalike through unchanged.
|
||||
*/
|
||||
function createLeadingThinkTextFilter(): LeadingThinkTextFilter {
|
||||
let state: "detect" | "suppress" | "pass" = "detect";
|
||||
let pending = "";
|
||||
let suppressedLeadingBlock = false;
|
||||
|
||||
return {
|
||||
push(text: string): string {
|
||||
if (!text) return "";
|
||||
if (state === "pass") return text;
|
||||
pending += text;
|
||||
|
||||
while (true) {
|
||||
if (state === "detect") {
|
||||
const candidate = pending.replace(/^\s+/, "");
|
||||
if (!candidate || LEADING_THINK_OPEN.startsWith(candidate)) return "";
|
||||
if (!candidate.startsWith(LEADING_THINK_OPEN)) {
|
||||
state = "pass";
|
||||
const visible = suppressedLeadingBlock ? candidate : pending;
|
||||
pending = "";
|
||||
return visible;
|
||||
}
|
||||
state = "suppress";
|
||||
pending = candidate.slice(LEADING_THINK_OPEN.length);
|
||||
}
|
||||
|
||||
if (state === "suppress") {
|
||||
const closeIndex = pending.indexOf(LEADING_THINK_CLOSE);
|
||||
if (closeIndex < 0) {
|
||||
pending = pending.slice(-(LEADING_THINK_CLOSE.length - 1));
|
||||
return "";
|
||||
}
|
||||
pending = pending.slice(closeIndex + LEADING_THINK_CLOSE.length);
|
||||
suppressedLeadingBlock = true;
|
||||
state = "detect";
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
finish(): string {
|
||||
const visible = state === "detect"
|
||||
? (suppressedLeadingBlock ? pending.replace(/^\s+/, "") : pending)
|
||||
: "";
|
||||
pending = "";
|
||||
state = "pass";
|
||||
return visible;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripLeadingThinkText(text: string): string {
|
||||
const filter = createLeadingThinkTextFilter();
|
||||
return filter.push(text) + filter.finish();
|
||||
}
|
||||
|
||||
/** Convert pi-ai AssistantMessage content to our MessageContent[]. */
|
||||
function mapContent(content: PiAssistantMessage["content"]): MessageContent[] {
|
||||
function mapContent(
|
||||
content: PiAssistantMessage["content"],
|
||||
options: MapContentOptions = {},
|
||||
): MessageContent[] {
|
||||
const result: MessageContent[] = [];
|
||||
let atTextStart = options.stripLeadingThinkText === true;
|
||||
for (const block of content) {
|
||||
if (block.type === "text") {
|
||||
result.push({ type: "text", text: block.text });
|
||||
const text = atTextStart ? stripLeadingThinkText(block.text) : block.text;
|
||||
if (text) {
|
||||
result.push({ type: "text", text });
|
||||
atTextStart = false;
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
result.push({
|
||||
type: "tool_use",
|
||||
@@ -405,10 +486,16 @@ function mapContent(content: PiAssistantMessage["content"]): MessageContent[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mapContentForTest(content: PiAssistantMessage["content"]): MessageContent[] {
|
||||
return mapContent(content);
|
||||
export function mapContentForTest(
|
||||
content: PiAssistantMessage["content"],
|
||||
options: MapContentOptions = {},
|
||||
): MessageContent[] {
|
||||
return mapContent(content, options);
|
||||
}
|
||||
|
||||
export const createLeadingThinkTextFilterForTest = createLeadingThinkTextFilter;
|
||||
export const stripLeadingThinkTextForTest = stripLeadingThinkText;
|
||||
|
||||
const REASONING_LEVELS = ["minimal", "low", "medium", "high"] as const;
|
||||
type PiReasoningLevel = typeof REASONING_LEVELS[number];
|
||||
|
||||
@@ -470,6 +557,7 @@ export function createPiProvider(config: {
|
||||
supportedReasoning?: ReadonlyArray<"minimal" | "low" | "medium" | "high">;
|
||||
}): LLMProvider {
|
||||
const providerId = config.provider as KnownProvider;
|
||||
const shouldStripLeadingThinkText = config.provider === "custom";
|
||||
|
||||
// Resolve the model object. `customModel` shortcuts pi-ai's catalog
|
||||
// lookup for providers pi-ai doesn't know about.
|
||||
@@ -552,7 +640,9 @@ export function createPiProvider(config: {
|
||||
}
|
||||
|
||||
return {
|
||||
content: mapContent(result.content),
|
||||
content: mapContent(result.content, {
|
||||
stripLeadingThinkText: shouldStripLeadingThinkText,
|
||||
}),
|
||||
stopReason: mapStopReason(result.stopReason),
|
||||
usage: mapUsage(result.usage),
|
||||
model: resolvedResponseModel(result, model.id),
|
||||
@@ -574,6 +664,9 @@ export function createPiProvider(config: {
|
||||
log.debug(`stream ${providerId}/${model.id}`);
|
||||
|
||||
try {
|
||||
const leadingThinkFilter = shouldStripLeadingThinkText
|
||||
? createLeadingThinkTextFilter()
|
||||
: null;
|
||||
const eventStream = reasoning
|
||||
? piStreamSimple(model, context, {
|
||||
apiKey: config.apiKey,
|
||||
@@ -603,9 +696,16 @@ export function createPiProvider(config: {
|
||||
yield { type: "message_start" };
|
||||
break;
|
||||
case "text_delta":
|
||||
yield { type: "text_delta", text: event.delta };
|
||||
{
|
||||
const text = leadingThinkFilter?.push(event.delta) ?? event.delta;
|
||||
if (text) yield { type: "text_delta", text };
|
||||
}
|
||||
break;
|
||||
case "toolcall_start":
|
||||
{
|
||||
const text = leadingThinkFilter?.finish() ?? "";
|
||||
if (text) yield { type: "text_delta", text };
|
||||
}
|
||||
yield {
|
||||
type: "tool_use_start",
|
||||
id: event.partial.content[event.contentIndex]?.type === "toolCall"
|
||||
@@ -623,12 +723,18 @@ export function createPiProvider(config: {
|
||||
yield { type: "tool_use_end", id: event.toolCall.id };
|
||||
break;
|
||||
case "done":
|
||||
{
|
||||
const text = leadingThinkFilter?.finish() ?? "";
|
||||
if (text) yield { type: "text_delta", text };
|
||||
}
|
||||
const serverFallbackReason = serverFallbackReasonFromResponseId(event.message.responseId);
|
||||
yield {
|
||||
type: "message_end",
|
||||
stopReason: mapStopReason(event.reason),
|
||||
usage: mapUsage(event.message.usage),
|
||||
content: mapContent(event.message.content),
|
||||
content: mapContent(event.message.content, {
|
||||
stripLeadingThinkText: shouldStripLeadingThinkText,
|
||||
}),
|
||||
model: resolvedResponseModel(event.message, model.id),
|
||||
...(serverFallbackReason ? { serverFallbackReason } : {}),
|
||||
providerTermination: {
|
||||
@@ -640,6 +746,10 @@ export function createPiProvider(config: {
|
||||
};
|
||||
break;
|
||||
case "error":
|
||||
{
|
||||
const text = leadingThinkFilter?.finish() ?? "";
|
||||
if (text) yield { type: "text_delta", text };
|
||||
}
|
||||
const errorServerFallbackReason = serverFallbackReasonFromResponseId(event.error.responseId);
|
||||
const responseModel = resolvedResponseModel(event.error, model.id);
|
||||
// Surface everything we can from the stream error event.
|
||||
|
||||
@@ -6840,10 +6840,10 @@ describe("AgentRunner", () => {
|
||||
expect(result.meta.error?.kind).not.toBe("context_overflow");
|
||||
});
|
||||
|
||||
it("interrupt-steer: folds drainSteer messages into the next LLM round", async () => {
|
||||
// round 1 calls a no-op tool → loop boundary → drainSteer yields a steer →
|
||||
// round 2 must see it as a user message; round 1 must NOT (folded only after
|
||||
// the tool round).
|
||||
it("interrupt-steer: skips stale proposed tools before folding the newer instruction", async () => {
|
||||
// A steer lands while round 1 is deciding to call a tool. The old proposal
|
||||
// must receive a protocol-safe synthetic result without executing, and
|
||||
// round 2 must see the newer user instruction.
|
||||
const captured: Message[][] = [];
|
||||
let streamCalls = 0;
|
||||
const provider: LLMProvider = {
|
||||
@@ -6854,16 +6854,23 @@ describe("AgentRunner", () => {
|
||||
streamCalls++;
|
||||
captured.push([...params.messages]);
|
||||
if (streamCalls === 1) {
|
||||
const id = "c1";
|
||||
const firstId = "c1";
|
||||
const secondId = "c2";
|
||||
yield { type: "message_start" as const };
|
||||
yield { type: "tool_use_start" as const, id, name: "noop" };
|
||||
yield { type: "tool_use_delta" as const, id, input: "{}" };
|
||||
yield { type: "tool_use_end" as const, id };
|
||||
yield { type: "tool_use_start" as const, id: firstId, name: "noop" };
|
||||
yield { type: "tool_use_delta" as const, id: firstId, input: "{}" };
|
||||
yield { type: "tool_use_end" as const, id: firstId };
|
||||
yield { type: "tool_use_start" as const, id: secondId, name: "noop" };
|
||||
yield { type: "tool_use_delta" as const, id: secondId, input: "{}" };
|
||||
yield { type: "tool_use_end" as const, id: secondId };
|
||||
yield {
|
||||
type: "message_end" as const,
|
||||
stopReason: "tool_use" as const,
|
||||
usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 },
|
||||
content: [{ type: "tool_use" as const, id, name: "noop", input: {} }],
|
||||
content: [
|
||||
{ type: "tool_use" as const, id: firstId, name: "noop", input: {} },
|
||||
{ type: "tool_use" as const, id: secondId, name: "noop", input: {} },
|
||||
],
|
||||
model: "mock-model",
|
||||
};
|
||||
} else {
|
||||
@@ -6883,11 +6890,15 @@ describe("AgentRunner", () => {
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.registerFactory("mock", () => provider);
|
||||
let executions = 0;
|
||||
const noop = defineTool({
|
||||
name: "noop",
|
||||
description: "no-op",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
async execute() { return { content: "ok" }; },
|
||||
async execute() {
|
||||
executions++;
|
||||
return { content: "ok" };
|
||||
},
|
||||
});
|
||||
const config = createConfig({ agent: { defaultProvider: "mock", defaultModel: "mock-model" } });
|
||||
const runner = new AgentRunner({ config, providers: registry, tools: [noop] });
|
||||
@@ -6908,7 +6919,14 @@ describe("AgentRunner", () => {
|
||||
msgs.some((m) => m.role === "user"
|
||||
&& m.content.some((c) => c.type === "text" && c.text.includes(STEER)));
|
||||
expect(sawSteer(captured[1])).toBe(true); // round 2 sees the folded steer
|
||||
expect(sawSteer(captured[0])).toBe(false); // round 1 (pre-tool) does not
|
||||
expect(sawSteer(captured[0])).toBe(false); // round 1 predates the steer
|
||||
expect(executions).toBe(0);
|
||||
const skippedResults = captured[1]
|
||||
.flatMap((message) => message.content)
|
||||
.filter((content) => content.type === "tool_result"
|
||||
&& content.content === "Tool call skipped because a newer user instruction arrived before execution.");
|
||||
expect(skippedResults.map((content) => content.type === "tool_result" ? content.toolUseId : null))
|
||||
.toEqual(["c1", "c2"]);
|
||||
});
|
||||
|
||||
it("interrupt-steer: persists structured text, image, and resource context before acknowledging", async () => {
|
||||
@@ -6951,11 +6969,15 @@ describe("AgentRunner", () => {
|
||||
};
|
||||
const registry = new ProviderRegistry();
|
||||
registry.registerFactory("mock", () => provider);
|
||||
let executions = 0;
|
||||
const noop = defineTool({
|
||||
name: "noop",
|
||||
description: "no-op",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
async execute() { return { content: "ok" }; },
|
||||
async execute() {
|
||||
executions++;
|
||||
return { content: "ok" };
|
||||
},
|
||||
});
|
||||
const config = createConfig({ agent: { defaultProvider: "mock", defaultModel: "mock-model" } });
|
||||
const session = new Session();
|
||||
@@ -6983,6 +7005,7 @@ describe("AgentRunner", () => {
|
||||
|
||||
expect(result.text).toBe("used rich update");
|
||||
expect(streamCalls).toBe(2);
|
||||
expect(executions).toBe(0);
|
||||
expect(captured[0].some((message) => message.content.some((content) => content.type === "image")))
|
||||
.toBe(false);
|
||||
expect(captured[1]).toContainEqual(expect.objectContaining({
|
||||
@@ -7202,7 +7225,7 @@ describe("AgentRunner", () => {
|
||||
message: "do the task",
|
||||
drainSteer: () => {
|
||||
drainCalls++;
|
||||
return drainCalls === 2 ? [STEER] : [];
|
||||
return drainCalls === 3 ? [STEER] : [];
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7277,11 +7300,15 @@ describe("AgentRunner", () => {
|
||||
|
||||
const registry = new ProviderRegistry();
|
||||
registry.registerFactory("mock", () => provider);
|
||||
let executions = 0;
|
||||
const noop = defineTool({
|
||||
name: "noop",
|
||||
description: "no-op",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
async execute() { return { content: "ok" }; },
|
||||
async execute() {
|
||||
executions++;
|
||||
return { content: "ok" };
|
||||
},
|
||||
});
|
||||
const config = createConfig({ agent: { defaultProvider: "mock", defaultModel: "mock-model" } });
|
||||
const runner = new AgentRunner({ config, providers: registry, tools: [noop] });
|
||||
@@ -7289,6 +7316,7 @@ describe("AgentRunner", () => {
|
||||
await runner.run({ message: "do the task", drainSteer: () => [] });
|
||||
|
||||
expect(streamCalls).toBe(2);
|
||||
expect(executions).toBe(1);
|
||||
// No steer is folded. The visible tool_result is not duplicated into the
|
||||
// completed-work projection, so the only added user-text block is the
|
||||
// deterministic execution objective anchor.
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ProviderRegistry } from "../src/providers/registry.js";
|
||||
import { createAnthropicProvider, createOpenAIProvider, createPiProvider, listPiProviders, listPiModels } from "../src/providers/pi-provider.js";
|
||||
import {
|
||||
createAnthropicProvider,
|
||||
createLeadingThinkTextFilterForTest,
|
||||
createOpenAIProvider,
|
||||
createPiProvider,
|
||||
listPiProviders,
|
||||
listPiModels,
|
||||
mapContentForTest,
|
||||
stripLeadingThinkTextForTest,
|
||||
} from "../src/providers/pi-provider.js";
|
||||
import { createConfig } from "../src/config/loader.js";
|
||||
|
||||
describe("Providers (pi-ai backed)", () => {
|
||||
@@ -45,6 +54,38 @@ describe("Providers (pi-ai backed)", () => {
|
||||
expect(provider.id).toBe("google");
|
||||
});
|
||||
|
||||
it("removes leading private think blocks from custom-provider content", () => {
|
||||
const raw = "<think>private chain of thought</think>\n\nUser-facing result";
|
||||
expect(mapContentForTest([{ type: "text", text: raw }], { stripLeadingThinkText: true }))
|
||||
.toEqual([{ type: "text", text: "User-facing result" }]);
|
||||
expect(mapContentForTest([{ type: "text", text: raw }]))
|
||||
.toEqual([{ type: "text", text: raw }]);
|
||||
});
|
||||
|
||||
it("suppresses consecutive leading think blocks split across deltas", () => {
|
||||
expect(stripLeadingThinkTextForTest(
|
||||
"<think>first</think>\n<think>second</think>\nVisible result",
|
||||
)).toBe("Visible result");
|
||||
const filter = createLeadingThinkTextFilterForTest();
|
||||
expect(filter.push("<think>first</think>\n<th")).toBe("");
|
||||
expect(filter.push("ink>second</thi")).toBe("");
|
||||
expect(filter.push("nk>\nVisible result")).toBe("Visible result");
|
||||
expect(filter.finish()).toBe("");
|
||||
});
|
||||
|
||||
it("preserves non-leading and partial think-tag lookalikes", () => {
|
||||
for (const text of [
|
||||
"Answer with an inline <think> example",
|
||||
"<thinker>visible element</thinker>",
|
||||
"\n<thin",
|
||||
]) {
|
||||
expect(stripLeadingThinkTextForTest(text)).toBe(text);
|
||||
}
|
||||
const afterBlock = createLeadingThinkTextFilterForTest();
|
||||
expect(afterBlock.push("<think>private</think>\n<thin")).toBe("");
|
||||
expect(afterBlock.finish()).toBe("<thin");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("listPiProviders / listPiModels", () => {
|
||||
|
||||
@@ -1445,6 +1445,13 @@ describe("Session execution plan anchor", () => {
|
||||
session.beginUserTurn([{ type: "text", text: "Actually switch to the replacement task" }]);
|
||||
let view = JSON.stringify(session.getMessagesForModel());
|
||||
expect(view).toContain("Reconciliation required");
|
||||
const stalePlanTail = JSON.stringify(session.getMessagesForModel().at(-1));
|
||||
expect(stalePlanTail).toContain(
|
||||
"Latest user instruction — authoritative; replaces conflicting earlier requirements",
|
||||
);
|
||||
expect(stalePlanTail).toContain("Actually switch to the replacement task");
|
||||
expect(stalePlanTail.indexOf("Actually switch to the replacement task"))
|
||||
.toBeLessThan(stalePlanTail.indexOf("Objective"));
|
||||
expect(session.getExecutionPlan()?.objective).toBe("Original task");
|
||||
|
||||
session.updateExecutionPlan({
|
||||
@@ -1474,7 +1481,11 @@ describe("Session execution plan anchor", () => {
|
||||
session.updateExecutionPlan({ steps: [{ step: "Work", status: "in_progress" }] });
|
||||
|
||||
session.addMessage("user", [{ type: "text", text: "Pause that and account for this new constraint" }]);
|
||||
expect(JSON.stringify(session.getMessagesForModel())).toContain("Reconciliation required");
|
||||
const stalePlanTail = JSON.stringify(session.getMessagesForModel().at(-1));
|
||||
expect(stalePlanTail).toContain("Reconciliation required");
|
||||
expect(stalePlanTail).toContain("Pause that and account for this new constraint");
|
||||
expect(stalePlanTail.indexOf("Pause that and account for this new constraint"))
|
||||
.toBeLessThan(stalePlanTail.indexOf("Initial in-flight goal"));
|
||||
|
||||
session.updateExecutionPlan({ steps: [{ step: "Account for constraint", status: "in_progress" }] });
|
||||
const view = JSON.stringify(session.getMessagesForModel());
|
||||
@@ -1908,6 +1919,58 @@ describe("Session execution plan anchor", () => {
|
||||
expect(session.estimateModelTokens()).toBe(tokensBeforeCommandObservation);
|
||||
});
|
||||
|
||||
it("keeps workspace state outside a parallel tool-result cluster", () => {
|
||||
const session = new Session();
|
||||
session.beginUserTurn([{ type: "text", text: "Write the manifest and render the image" }]);
|
||||
session.addAssistantMessage([
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "write-manifest",
|
||||
name: "write_file",
|
||||
input: { path: "/workspace/manifest.json" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "render-image",
|
||||
name: "image_studio",
|
||||
input: { manifest: "/workspace/manifest.json" },
|
||||
},
|
||||
]);
|
||||
session.recordToolObservations({
|
||||
toolCallId: "write-manifest",
|
||||
tool: "write_file",
|
||||
observations: {
|
||||
fileChanges: [{
|
||||
operation: "create",
|
||||
sourcePath: "/workspace/manifest.json",
|
||||
beforeExists: false,
|
||||
afterExists: true,
|
||||
afterHash: "sha256:manifest",
|
||||
coverage: "exact",
|
||||
}],
|
||||
},
|
||||
});
|
||||
session.addToolResult("write-manifest", "MANIFEST_WRITTEN", undefined, false);
|
||||
session.addToolResult("render-image", "E_MANIFEST_REFERENCE_ROLE", undefined, true);
|
||||
|
||||
const modelView = session.getMessagesForModel();
|
||||
const indexOfContent = (predicate: (content: typeof modelView[number]["content"][number]) => boolean) => (
|
||||
modelView.findIndex((message) => message.content.some(predicate))
|
||||
);
|
||||
const writeResultIndex = indexOfContent((content) => (
|
||||
content.type === "tool_result" && content.toolUseId === "write-manifest"
|
||||
));
|
||||
const renderResultIndex = indexOfContent((content) => (
|
||||
content.type === "tool_result" && content.toolUseId === "render-image"
|
||||
));
|
||||
const workspaceIndex = indexOfContent((content) => (
|
||||
content.type === "text" && content.text.startsWith("[Workspace changes")
|
||||
));
|
||||
|
||||
expect(renderResultIndex).toBe(writeResultIndex + 1);
|
||||
expect(workspaceIndex).toBe(renderResultIndex + 1);
|
||||
});
|
||||
|
||||
it("moves the workspace anchor only after the latest causal file change", () => {
|
||||
const session = new Session();
|
||||
session.beginUserTurn([{ type: "text", text: "Update both files in order" }]);
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../paths';
|
||||
import {
|
||||
conversationLayout,
|
||||
chatAttachmentDirForConversation,
|
||||
conversationMessageFile,
|
||||
conversationMessageReadFile,
|
||||
listProjectIds,
|
||||
@@ -52,6 +53,11 @@ import {
|
||||
} from '../util/record_sync_fields';
|
||||
import { limitNameDisplayText } from '../util/name-limit';
|
||||
import { versionChatMediaLocalUrlsInText } from '../util/chat-media-url';
|
||||
import { isPathAllowed } from '../util/path-sandbox';
|
||||
import {
|
||||
persistedCodexOutputCitationPaths,
|
||||
sanitizePersistedCodexFileCitations,
|
||||
} from './local_agents/public-output';
|
||||
|
||||
const log = createLogger('chats');
|
||||
// A boot-time stale-state sweep may run twice: once before the first window
|
||||
@@ -163,10 +169,83 @@ export interface Conversation {
|
||||
* canonical type is `GroupMessage` from `group_chat/visibility`. */
|
||||
export type MessageRecord = GroupMessage;
|
||||
|
||||
function _messageForDisplay(message: MessageRecord): MessageRecord {
|
||||
interface MessageDisplayContext {
|
||||
citationWorkingDir?: string;
|
||||
allowedCitationRoots: string[];
|
||||
}
|
||||
|
||||
async function _messageDisplayContext(
|
||||
userId: string,
|
||||
cid: string,
|
||||
projectIdHint?: string | null,
|
||||
): Promise<MessageDisplayContext> {
|
||||
const layout = conversationLayout(userId, cid, projectIdHint);
|
||||
const state = await readState(userId, cid, layout.projectId);
|
||||
const allowedCitationRoots = [
|
||||
state.coding_project_dir,
|
||||
...(state.tool_extra_roots || []),
|
||||
chatAttachmentDirForConversation(userId, cid, layout.projectId),
|
||||
].filter((root): root is string => typeof root === 'string' && path.isAbsolute(root));
|
||||
|
||||
let workspaceRoot: string | undefined;
|
||||
try {
|
||||
const userWorkspace = await import('./user_workspace');
|
||||
workspaceRoot = userWorkspace.getWorkspacePath(userId, layout.projectId || undefined);
|
||||
if (path.isAbsolute(workspaceRoot)) allowedCitationRoots.push(workspaceRoot);
|
||||
} catch (err) {
|
||||
log.warn(`resolve workspace for legacy Codex output cid=${maskId(cid)}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
citationWorkingDir: state.coding_project_dir && path.isAbsolute(state.coding_project_dir)
|
||||
? state.coding_project_dir
|
||||
: workspaceRoot,
|
||||
allowedCitationRoots,
|
||||
};
|
||||
}
|
||||
|
||||
function _isAuthorizedLegacyOutputFile(candidate: string, allowedRoots: readonly string[]): boolean {
|
||||
if (!path.isAbsolute(candidate) || !isPathAllowed(candidate, allowedRoots)) return false;
|
||||
try { return fs.statSync(candidate).isFile(); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
function _messageForDisplay(message: MessageRecord, context: MessageDisplayContext): MessageRecord {
|
||||
if (message.from === 'user') return message;
|
||||
const text = versionChatMediaLocalUrlsInText(message.text);
|
||||
return text === message.text ? message : { ...message, text };
|
||||
const storedProduced = Array.isArray(message.produced)
|
||||
? message.produced.filter((item): item is string => typeof item === 'string' && !!item.trim())
|
||||
: [];
|
||||
const produced = [...storedProduced];
|
||||
const producedKeys = new Set(produced.map((item) => path.resolve(item)));
|
||||
const citedOutputs: string[] = [];
|
||||
for (const candidate of persistedCodexOutputCitationPaths(
|
||||
message.text,
|
||||
context.citationWorkingDir,
|
||||
)) {
|
||||
const resolved = path.resolve(candidate);
|
||||
if (!producedKeys.has(resolved)) {
|
||||
if (!_isAuthorizedLegacyOutputFile(resolved, context.allowedCitationRoots)) continue;
|
||||
produced.push(resolved);
|
||||
producedKeys.add(resolved);
|
||||
}
|
||||
citedOutputs.push(resolved);
|
||||
}
|
||||
// A native output citation is an exact publication declaration. Historical
|
||||
// rows predate a separate `published` field, so project only the cited files
|
||||
// into the renderer footer while retaining the complete list as sanitizer
|
||||
// backing. Without a trusted citation, preserve the stored footer verbatim.
|
||||
const displayProduced = citedOutputs.length ? citedOutputs : storedProduced;
|
||||
const text = versionChatMediaLocalUrlsInText(
|
||||
sanitizePersistedCodexFileCitations(message.text, produced, context.citationWorkingDir),
|
||||
);
|
||||
const producedUnchanged = displayProduced.length === storedProduced.length
|
||||
&& displayProduced.every((item, index) => item === storedProduced[index]);
|
||||
if (text === message.text && producedUnchanged) return message;
|
||||
return {
|
||||
...message,
|
||||
text,
|
||||
...(displayProduced.length ? { produced: displayProduced } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── CRUD ─────────────────────────────────────────────────────────────────
|
||||
@@ -2025,7 +2104,8 @@ export async function getMessagesPage(
|
||||
if (page.nextCursor === null) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
return { history: history.map(_messageForDisplay), nextCursor };
|
||||
const displayContext = await _messageDisplayContext(userId, cid, projectIdHint);
|
||||
return { history: history.map((message) => _messageForDisplay(message, displayContext)), nextCursor };
|
||||
}
|
||||
|
||||
/** Load from the fixed-size page containing a search hit through the newest
|
||||
@@ -2053,8 +2133,9 @@ export async function getMessagesPageAtIndex(
|
||||
const visible = page.records
|
||||
.map((message, offset) => ({ message, index: pageStart + offset }))
|
||||
.filter(({ message }) => !message.deleted_at);
|
||||
const displayContext = await _messageDisplayContext(userId, cid, projectIdHint);
|
||||
return {
|
||||
history: visible.map(({ message }) => _messageForDisplay(message)),
|
||||
history: visible.map(({ message }) => _messageForDisplay(message, displayContext)),
|
||||
// Keep the source JSONL indexes aligned with `history`. The renderer uses
|
||||
// this as the final navigation identity for old records that predate
|
||||
// stable message ids/timestamps; filtering a tombstone must not shift the
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { finished } from 'node:stream/promises';
|
||||
|
||||
import { userChatAttachmentsDir, userFileCacheDir, projectChatAttachmentsDir } from '../paths';
|
||||
import { listProjectIds } from '../util/project-layout';
|
||||
@@ -282,6 +283,16 @@ function hashUtf8Text(body: string): string {
|
||||
return `sha256:${crypto.createHash('sha256').update(body, 'utf8').digest('hex')}`;
|
||||
}
|
||||
|
||||
async function closeReadStream(stream: fs.ReadStream): Promise<void> {
|
||||
if (stream.closed) return;
|
||||
stream.destroy();
|
||||
try {
|
||||
await finished(stream);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ABORT_ERR') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Count characters and hash decoded UTF-8 text without retaining a large
|
||||
* source. Hashing decoded text preserves the existing edit_file hash contract. */
|
||||
async function analyseUtf8File(absPath: string, sizeBytes: number): Promise<Utf8Analysis> {
|
||||
@@ -337,22 +348,26 @@ async function readUtf8CharSlice(
|
||||
let absoluteChar = 0;
|
||||
let startLine = 1;
|
||||
const stream = fs.createReadStream(absPath, { encoding: 'utf8' });
|
||||
for await (const chunk of stream) {
|
||||
const text = String(chunk);
|
||||
const chunkStart = absoluteChar;
|
||||
const chunkEnd = chunkStart + text.length;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const text = String(chunk);
|
||||
const chunkStart = absoluteChar;
|
||||
const chunkEnd = chunkStart + text.length;
|
||||
|
||||
const prefixEnd = Math.max(0, Math.min(text.length, charStart - chunkStart));
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
if (text.charCodeAt(i) === 10) startLine++;
|
||||
const prefixEnd = Math.max(0, Math.min(text.length, charStart - chunkStart));
|
||||
for (let i = 0; i < prefixEnd; i++) {
|
||||
if (text.charCodeAt(i) === 10) startLine++;
|
||||
}
|
||||
|
||||
const takeStart = Math.max(0, charStart - chunkStart);
|
||||
const takeEnd = Math.min(text.length, charEnd - chunkStart);
|
||||
if (takeEnd > takeStart) content.push(text.slice(takeStart, takeEnd));
|
||||
|
||||
absoluteChar = chunkEnd;
|
||||
if (absoluteChar >= charEnd) break;
|
||||
}
|
||||
|
||||
const takeStart = Math.max(0, charStart - chunkStart);
|
||||
const takeEnd = Math.min(text.length, charEnd - chunkStart);
|
||||
if (takeEnd > takeStart) content.push(text.slice(takeStart, takeEnd));
|
||||
|
||||
absoluteChar = chunkEnd;
|
||||
if (absoluteChar >= charEnd) break;
|
||||
} finally {
|
||||
await closeReadStream(stream);
|
||||
}
|
||||
|
||||
return { content: content.join(''), charStart, charEnd, startLine };
|
||||
@@ -406,34 +421,38 @@ async function readUtf8LineSlice(
|
||||
let charEnd: number | undefined;
|
||||
const stream = fs.createReadStream(absPath, { encoding: 'utf8' });
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const text = String(chunk);
|
||||
const chunkStart = absoluteChar;
|
||||
const chunkEnd = chunkStart + text.length;
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
const text = String(chunk);
|
||||
const chunkStart = absoluteChar;
|
||||
const chunkEnd = chunkStart + text.length;
|
||||
|
||||
for (let searchFrom = 0; searchFrom < text.length;) {
|
||||
const newline = text.indexOf('\n', searchFrom);
|
||||
if (newline === -1) break;
|
||||
const newlineChar = chunkStart + newline;
|
||||
if (charStart !== undefined && currentLine === requestedEnd) {
|
||||
charEnd = newlineChar;
|
||||
break;
|
||||
for (let searchFrom = 0; searchFrom < text.length;) {
|
||||
const newline = text.indexOf('\n', searchFrom);
|
||||
if (newline === -1) break;
|
||||
const newlineChar = chunkStart + newline;
|
||||
if (charStart !== undefined && currentLine === requestedEnd) {
|
||||
charEnd = newlineChar;
|
||||
break;
|
||||
}
|
||||
currentLine++;
|
||||
if (charStart === undefined && currentLine === requestedStart) {
|
||||
charStart = newlineChar + 1;
|
||||
}
|
||||
searchFrom = newline + 1;
|
||||
}
|
||||
currentLine++;
|
||||
if (charStart === undefined && currentLine === requestedStart) {
|
||||
charStart = newlineChar + 1;
|
||||
|
||||
if (charStart !== undefined) {
|
||||
const takeStart = Math.max(0, charStart - chunkStart);
|
||||
const takeEnd = Math.min(text.length, (charEnd ?? chunkEnd) - chunkStart);
|
||||
if (takeEnd > takeStart) content.push(text.slice(takeStart, takeEnd));
|
||||
}
|
||||
searchFrom = newline + 1;
|
||||
}
|
||||
|
||||
if (charStart !== undefined) {
|
||||
const takeStart = Math.max(0, charStart - chunkStart);
|
||||
const takeEnd = Math.min(text.length, (charEnd ?? chunkEnd) - chunkStart);
|
||||
if (takeEnd > takeStart) content.push(text.slice(takeStart, takeEnd));
|
||||
absoluteChar = chunkEnd;
|
||||
if (charEnd !== undefined) break;
|
||||
}
|
||||
|
||||
absoluteChar = chunkEnd;
|
||||
if (charEnd !== undefined) break;
|
||||
} finally {
|
||||
await closeReadStream(stream);
|
||||
}
|
||||
|
||||
if (charStart === undefined) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Read-only app-health probes for the Commander `app_health` tool.
|
||||
*
|
||||
* Every probe is side-effect free and sanitized: status kinds, counts, ids,
|
||||
* and already-localized user-facing messages only — never credentials, base
|
||||
* URLs, file paths, or free-form provider error text. The `tasks` probe is
|
||||
* injected by the bus (its running-state maps live there) to avoid an import
|
||||
* cycle. A probe failure degrades to an `unavailable` marker instead of
|
||||
* failing the whole snapshot.
|
||||
*/
|
||||
|
||||
import * as auth from '../auth';
|
||||
import * as connectorManager from '../connectors/manager';
|
||||
import { isConnectorRuntimeEnabled } from '../connectors/availability';
|
||||
import { isConnectorUsable } from '../connectors/types';
|
||||
import {
|
||||
isConnectorEnabledFromSnapshot,
|
||||
readEnabledMap,
|
||||
} from '../component_enabled';
|
||||
import * as kbVector from '../kb_vector';
|
||||
|
||||
export type AppHealthDomain = 'model' | 'connectors' | 'kb' | 'tasks';
|
||||
export const APP_HEALTH_DOMAINS: readonly AppHealthDomain[] = ['model', 'connectors', 'kb', 'tasks'];
|
||||
|
||||
export function isAppHealthDomain(value: string): value is AppHealthDomain {
|
||||
return (APP_HEALTH_DOMAINS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export interface AppHealthTasksProbe {
|
||||
active_work: boolean;
|
||||
active_conversation_count: number;
|
||||
other_active_conversation_count: number;
|
||||
current_conversation: {
|
||||
processing: boolean;
|
||||
in_flight_actor_count: number;
|
||||
active_turn_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
const CONNECTOR_ITEM_CAP = 20;
|
||||
|
||||
async function modelProbe(): Promise<Record<string, unknown>> {
|
||||
const configured = auth.hasConfiguredModel().configured;
|
||||
const cfg = await auth.getConfig();
|
||||
const cooldown = auth.getConfiguredModelCooldown();
|
||||
return {
|
||||
configured,
|
||||
default_provider: cfg.provider || null,
|
||||
default_model: cfg.model || null,
|
||||
// `kind` is the bounded failure classification; the free-form reason and
|
||||
// profile label are deliberately dropped.
|
||||
credential_cooldown: cooldown
|
||||
? {
|
||||
kind: String(cooldown.kind),
|
||||
seconds_remaining: Math.max(0, Math.ceil((cooldown.cooledUntil - Date.now()) / 1000)),
|
||||
}
|
||||
: null,
|
||||
// Already a localized, sanitized user-facing sentence (or null).
|
||||
oauth_expired: auth.getConfiguredModelOAuthExpiredMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
async function connectorsProbe(uid: string): Promise<Record<string, unknown>> {
|
||||
const instances = connectorManager.listInstances(uid);
|
||||
const enabledSnapshot = readEnabledMap(uid);
|
||||
const items = instances.map((instance) => {
|
||||
const enabled = isConnectorRuntimeEnabled(instance.id)
|
||||
&& isConnectorEnabledFromSnapshot(enabledSnapshot, instance.id);
|
||||
return {
|
||||
id: instance.id,
|
||||
name: instance.display_name,
|
||||
status: String(instance.status?.kind || 'unknown'),
|
||||
enabled,
|
||||
usable: enabled && isConnectorUsable(instance.status),
|
||||
};
|
||||
});
|
||||
return {
|
||||
configured_count: items.length,
|
||||
connected_count: items.filter((item) => item.enabled && item.status === 'connected').length,
|
||||
usable_count: items.filter((item) => item.usable).length,
|
||||
items: items.slice(0, CONNECTOR_ITEM_CAP),
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAppHealth(
|
||||
uid: string,
|
||||
domains: readonly AppHealthDomain[],
|
||||
tasksProbe: () => AppHealthTasksProbe,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const domain of domains) {
|
||||
try {
|
||||
if (domain === 'model') out.model = await modelProbe();
|
||||
else if (domain === 'connectors') out.connectors = await connectorsProbe(uid);
|
||||
else if (domain === 'kb') out.kb = kbVector.statusSummary(uid);
|
||||
else if (domain === 'tasks') out.tasks = tasksProbe();
|
||||
} catch {
|
||||
out[domain] = { unavailable: true };
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Commander-navigable app surfaces for the `open_app_view` tool.
|
||||
*
|
||||
* The tool stages a navigation card on the commander's reply; the renderer
|
||||
* executes the navigation only when the user clicks the card, so this
|
||||
* registry carries no side effects — it is the single source of truth for
|
||||
* which surfaces exist and what the tool schema advertises. The renderer's
|
||||
* executor map (conversation.js) must cover exactly these ids;
|
||||
* `test/main/features/group_chat/app-nav-registry.test.ts` pins that parity.
|
||||
*/
|
||||
|
||||
export type AppNavAction = 'open' | 'create' | 'add_custom' | 'configure';
|
||||
|
||||
export interface AppNavSurface {
|
||||
id: string;
|
||||
/** Review-facing one-liner; the model schema receives the compact matrix. */
|
||||
summary: string;
|
||||
/** Closed actions supported by this surface. `open` is the default. */
|
||||
actions: readonly AppNavAction[];
|
||||
/** Actions that require a stable business id resolved by the renderer. */
|
||||
targetRequiredFor?: readonly AppNavAction[];
|
||||
}
|
||||
|
||||
export const APP_NAV_SURFACES: readonly AppNavSurface[] = [
|
||||
{ id: 'settings.models', summary: 'Settings → Models: model providers, API keys, priority order, connection tests.', actions: ['open'] },
|
||||
{ id: 'settings.general', summary: 'Settings → General: interface language and app preferences.', actions: ['open'] },
|
||||
{ id: 'settings.data', summary: 'Settings → Data: local data and access permissions.', actions: ['open'] },
|
||||
{
|
||||
id: 'connectors',
|
||||
summary: 'Connectors: open the catalog, add a custom MCP server, or focus one configured connector.',
|
||||
actions: ['open', 'add_custom', 'configure'],
|
||||
targetRequiredFor: ['configure'],
|
||||
},
|
||||
{ id: 'library', summary: 'Library: durable documents imported for retrieval.', actions: ['open'] },
|
||||
{
|
||||
id: 'projects',
|
||||
summary: 'Projects: focus the sidebar list, start creation, or open one existing Project by id.',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
targetRequiredFor: ['configure'],
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
summary: 'AI Team: open installed Agents, start creation, or open one Agent by id.',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
targetRequiredFor: ['configure'],
|
||||
},
|
||||
{ id: 'skills', summary: 'Skills: open installed Skills or start Skill creation.', actions: ['open', 'create'] },
|
||||
{
|
||||
id: 'auto',
|
||||
summary: 'Automation: open scheduled tasks, start creation, or edit one task by id.',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
targetRequiredFor: ['configure'],
|
||||
},
|
||||
{ id: 'apps', summary: 'My Apps: saved interactive results that can be launched again.', actions: ['open'] },
|
||||
{ id: 'marketplace', summary: 'Marketplace: installable Agents and Skills.', actions: ['open'] },
|
||||
];
|
||||
|
||||
export const APP_NAV_ACTIONS: readonly AppNavAction[] = Array.from(new Set(
|
||||
APP_NAV_SURFACES.flatMap((surface) => surface.actions),
|
||||
));
|
||||
|
||||
export function findAppNavSurface(id: string): AppNavSurface | null {
|
||||
return APP_NAV_SURFACES.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
|
||||
export function appNavSurfaceDescription(): string {
|
||||
const idsByActionSet = new Map<string, string[]>();
|
||||
for (const surface of APP_NAV_SURFACES) {
|
||||
const actionSet = surface.actions.join('|');
|
||||
const ids = idsByActionSet.get(actionSet) ?? [];
|
||||
ids.push(surface.id);
|
||||
idsByActionSet.set(actionSet, ids);
|
||||
}
|
||||
return `Actions: ${[...idsByActionSet.entries()]
|
||||
.map(([actions, ids]) => `${ids.join(', ')}=${actions}`)
|
||||
.join('; ')}.`;
|
||||
}
|
||||
|
||||
export function validateAppNavRequest(input: {
|
||||
surface_id: string;
|
||||
action?: string;
|
||||
target_id?: string;
|
||||
}): { ok: true; request: Omit<AppNavRequest, 'requested_at'> } | { ok: false; error: string } {
|
||||
const surface = findAppNavSurface(input.surface_id);
|
||||
if (!surface) return { ok: false, error: `unknown surface_id: ${input.surface_id || '(empty)'}` };
|
||||
const action = (input.action || 'open') as AppNavAction;
|
||||
if (!surface.actions.includes(action)) {
|
||||
return { ok: false, error: `action ${action} is not supported for ${surface.id}; allowed: ${surface.actions.join(', ')}` };
|
||||
}
|
||||
const targetId = String(input.target_id || '').trim();
|
||||
if (targetId.length > 160 || /[\u0000-\u001f\u007f]/.test(targetId)) {
|
||||
return { ok: false, error: 'target_id must be a bounded printable identifier' };
|
||||
}
|
||||
const requiresTarget = surface.targetRequiredFor?.includes(action) === true;
|
||||
if (requiresTarget && !targetId) {
|
||||
return { ok: false, error: `target_id is required for ${surface.id}/${action}` };
|
||||
}
|
||||
if (!requiresTarget && targetId) {
|
||||
if (surface.actions.includes('configure')) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `target_id requires action configure for ${surface.id}; retry with action configure and the same target_id`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: `target_id is not supported for ${surface.id}/${action}` };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
request: {
|
||||
surface_id: surface.id,
|
||||
action,
|
||||
...(targetId ? { target_id: targetId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One staged navigation card (GroupMessage.app_nav_requests entry). */
|
||||
export interface AppNavRequest {
|
||||
surface_id: string;
|
||||
action: AppNavAction;
|
||||
target_id?: string;
|
||||
requested_at: string;
|
||||
}
|
||||
@@ -49,7 +49,7 @@ import type {
|
||||
import {
|
||||
Actor, ActorKind, COMMANDER_ID, USER_ID, RESERVED_IDS,
|
||||
actorSessionId, addMember, ensureAgentMember, readMembers, seedReservedActors,
|
||||
setStatus, markInFlight, readState, transitionStatus, setCodingProjectDir, touchActivity,
|
||||
setStatus, markInFlight, readState, transitionStatus, setCodingProjectDir, setCodingProjectDirOnce, touchActivity,
|
||||
setActiveRecipient, setOrchestrationLedger, markOrchestrationInterrupted,
|
||||
takeOrchestrationLedgerForAgent, takeOrchestrationLedgerForForm, clearOrchestrationLedger,
|
||||
beginAgentHandoff, rollbackAgentHandoff, clearOrchestrationForCancellation,
|
||||
@@ -57,6 +57,10 @@ import {
|
||||
import type { StateFile } from './state';
|
||||
import { maxToolLoopsForActorKind } from './actor-budgets';
|
||||
import { resolveDeliveryChecks } from './terminal-checks';
|
||||
import {
|
||||
DurableMemoryWriteEvidence,
|
||||
scrubCompletedDurableMemoryWriteClaims,
|
||||
} from './memory-write-verification';
|
||||
import {
|
||||
GroupMessage, appendVisible, readSlice,
|
||||
buildGroupConversationHistory, buildGroupConversationHistoryTail,
|
||||
@@ -98,6 +102,14 @@ import { buildLanguageDirective, normalizeLang, t, type Lang } from '../../i18n'
|
||||
import { resolveLanguageForUser } from '../config';
|
||||
import * as marketplaceFeat from '../marketplace';
|
||||
import { readInstalls } from '../marketplace_installs';
|
||||
import {
|
||||
APP_NAV_ACTIONS,
|
||||
APP_NAV_SURFACES,
|
||||
appNavSurfaceDescription,
|
||||
validateAppNavRequest,
|
||||
type AppNavRequest,
|
||||
} from './app_nav';
|
||||
import { APP_HEALTH_DOMAINS, collectAppHealth, isAppHealthDomain } from './app_health';
|
||||
import { createSkillTurnBuffer, onAgentTurnEnd, onUserMessage } from '../expert_signals/turn_hooks';
|
||||
import {
|
||||
bindRuntimeSkillTarget,
|
||||
@@ -135,7 +147,10 @@ import {
|
||||
createPhasedTextState,
|
||||
resolvedPhasedText,
|
||||
} from '../local_agents/text-phase';
|
||||
import { sanitizeLocalAgentPublicOutput } from '../local_agents/public-output';
|
||||
import {
|
||||
CodexFileCitationStreamFilter,
|
||||
normalizeLocalAgentPublicOutput,
|
||||
} from '../local_agents/public-output';
|
||||
import {
|
||||
buildCliConversationContext,
|
||||
buildCliDurableInstructions,
|
||||
@@ -875,6 +890,28 @@ interface WorkerState {
|
||||
* metadata such as agent avatar tokens without relying on the model to
|
||||
* copy every field back. */
|
||||
marketplaceSearchResults?: Map<string, Partial<MarketplaceInstallRequest>>;
|
||||
/** Navigation cards staged by `open_app_view` during a commander turn.
|
||||
* Attached to the final message; the renderer navigates only on user
|
||||
* click, so staging has no side effect. */
|
||||
pendingAppNavRequests?: AppNavRequest[];
|
||||
}
|
||||
|
||||
function stageAppNavRequest(
|
||||
w: WorkerState,
|
||||
input: { surface_id: string; action?: string; target_id?: string },
|
||||
): ReturnType<typeof validateAppNavRequest> {
|
||||
const checked = validateAppNavRequest(input);
|
||||
if (!checked.ok) return checked;
|
||||
const request = checked.request;
|
||||
if (!w.pendingAppNavRequests) w.pendingAppNavRequests = [];
|
||||
if (!w.pendingAppNavRequests.some((existing) => (
|
||||
existing.surface_id === request.surface_id
|
||||
&& existing.action === request.action
|
||||
&& (existing.target_id || '') === (request.target_id || '')
|
||||
))) {
|
||||
w.pendingAppNavRequests.push({ ...request, requested_at: nowIso() });
|
||||
}
|
||||
return checked;
|
||||
}
|
||||
|
||||
interface CidState {
|
||||
@@ -1336,6 +1373,17 @@ export function hasActiveWork(uid?: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Number of non-quiescent conversations for one user. App support exposes
|
||||
* only this aggregate so Commander can distinguish the current task from
|
||||
* work elsewhere without receiving conversation ids or content. */
|
||||
export function activeConversationCount(uid: string): number {
|
||||
let count = 0;
|
||||
for (const state of _cids.values()) {
|
||||
if (state.uid === uid && !isQuiescent(state.uid, state.cid)) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function runtimeSnapshot(uid: string, cid: string): { processing: boolean; inFlight: string[]; activeTurns: ActiveTurn[] } {
|
||||
const s = _cids.get(cidKey(uid, cid));
|
||||
if (!s) return { processing: false, inFlight: [], activeTurns: [] };
|
||||
@@ -1709,6 +1757,7 @@ export interface EnqueueParams {
|
||||
* interaction result back to it. */
|
||||
artifacts?: Array<{ id: string; title: string; agent_id: string }>;
|
||||
marketplace_requests?: MarketplaceInstallRequest[];
|
||||
app_nav_requests?: AppNavRequest[];
|
||||
plan_announcement?: boolean;
|
||||
/** Override resolved recipients (commander emitting plan announcement
|
||||
* uses this to force `to=[user]`). Otherwise router decides. */
|
||||
@@ -2117,6 +2166,9 @@ async function _enqueueBody(params: EnqueueParams, state: CidState): Promise<Gro
|
||||
...(params.marketplace_requests && params.marketplace_requests.length
|
||||
? { marketplace_requests: params.marketplace_requests }
|
||||
: {}),
|
||||
...(params.app_nav_requests && params.app_nav_requests.length
|
||||
? { app_nav_requests: params.app_nav_requests }
|
||||
: {}),
|
||||
...(params.plan_announcement ? { plan_announcement: true } : {}),
|
||||
...(params.dispatch ? { dispatch: true } : {}),
|
||||
...(params.commanderRetryContinuation ? {
|
||||
@@ -4150,6 +4202,10 @@ async function runActorTurn(
|
||||
turnFailurePhase = _taskFailurePhase(phase);
|
||||
};
|
||||
let agentRunTimingData: Record<string, unknown> | undefined;
|
||||
// Durable-memory success is a host-observed fact, never a prose inference.
|
||||
// Keep the evidence across the one allowed channel retry because both
|
||||
// attempts belong to the same persisted turn.
|
||||
const durableMemoryWriteEvidence = new DurableMemoryWriteEvidence();
|
||||
// Wire the commander segment flush now that `streamingText` exists. Called
|
||||
// from a visible-dispatch tool BEFORE the dispatched agent runs, so the
|
||||
// commander's reasoning since the last flush is persisted as its own `seg`
|
||||
@@ -4470,6 +4526,7 @@ async function runActorTurn(
|
||||
},
|
||||
});
|
||||
for (const p of cliOut.produced || []) await onFileWritten(p);
|
||||
if (cliOut.published?.length) onOutputsPublished(cliOut.published);
|
||||
finalText = cliOut.text;
|
||||
streamingText = cliOut.text;
|
||||
cliCommanderHandoff = cliOut.commanderHandoff || null;
|
||||
@@ -4531,6 +4588,15 @@ async function runActorTurn(
|
||||
getPublishableOutputPaths,
|
||||
hasProducedPath,
|
||||
onArtifactCreated,
|
||||
...(isCommander ? {
|
||||
onCustomConnectorAdded: (connectorId: string) => {
|
||||
stageAppNavRequest(w, {
|
||||
surface_id: 'connectors',
|
||||
action: 'configure',
|
||||
target_id: connectorId,
|
||||
});
|
||||
},
|
||||
} : {}),
|
||||
onSkillAdvertised: (id, sys) => skillBuffer.recordAdvertised(id, sys),
|
||||
onSkillInvoked: (id, sys, trig) => skillBuffer.recordInvoked(id, sys, trig),
|
||||
cacheRetention: 'short',
|
||||
@@ -4571,6 +4637,7 @@ async function runActorTurn(
|
||||
// Skills are NOT project-scoped this round; agent skillList still
|
||||
// gates in-process agents' rendered skills and SkillStore.
|
||||
})) {
|
||||
if (isCommander) durableMemoryWriteEvidence.observe(ev);
|
||||
// Stream events → process channel.
|
||||
if (ev.type === 'final') {
|
||||
finalText = ev.text || '';
|
||||
@@ -4740,6 +4807,25 @@ async function runActorTurn(
|
||||
} // end LLM branch (paired with `if (cliAgent) { ... } else {` above)
|
||||
|
||||
let workingText = finalText || '';
|
||||
let correctedUnsupportedMemoryClaim = false;
|
||||
if (isCommander
|
||||
&& workingText
|
||||
&& !errText
|
||||
&& !aborted
|
||||
&& !durableMemoryWriteEvidence.hasSuccessfulWrite()) {
|
||||
const scrubbed = scrubCompletedDurableMemoryWriteClaims(workingText);
|
||||
if (scrubbed.removedClaims > 0) {
|
||||
correctedUnsupportedMemoryClaim = true;
|
||||
const notice = t('chat.memory_write_unconfirmed', undefined, turnLanguage);
|
||||
workingText = scrubbed.text ? `${notice}\n\n${scrubbed.text}` : notice;
|
||||
markTurnFailure('validation', 'memory_write_unconfirmed');
|
||||
log.warn('removed unsupported durable-memory success claim', {
|
||||
cid: maskId(cid),
|
||||
actor: actor.id,
|
||||
removed_claims: scrubbed.removedClaims,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (turnSyncConflictResolution.length && workingText && !errText && !aborted) {
|
||||
const results = extractSyncConflictResults(workingText);
|
||||
const allowedIds = new Set(turnSyncConflictResolution.map((item) => item.id));
|
||||
@@ -5224,6 +5310,17 @@ async function runActorTurn(
|
||||
: verb === 'enabled' ? 'enabled'
|
||||
: 'disabled';
|
||||
appendCommanderMutationNotice(`<span>Automation ${label}: ${name}</span>`);
|
||||
// Keep a successful direct operation inspectable. Creation and
|
||||
// updates run through the full auto_tasks feature above; this
|
||||
// sidecar only offers the user a click-to-open route to the
|
||||
// resulting business object after that workflow has succeeded.
|
||||
if (result.taskId && result.kind !== 'deleted') {
|
||||
stageAppNavRequest(w, {
|
||||
surface_id: 'auto',
|
||||
action: 'configure',
|
||||
target_id: result.taskId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
markTurnFailure('operation', 'auto_task_operation_failed');
|
||||
appendCommanderMutationNotice(`<span style="color:var(--danger)">⚠️ Automation operation failed: ${escapeHtmlForBubble(result.error || 'unknown error')}</span>`);
|
||||
@@ -5328,8 +5425,17 @@ async function runActorTurn(
|
||||
const turnMarketplaceRequests = actor.kind === 'commander' && w.pendingMarketplaceRequests?.length
|
||||
? w.pendingMarketplaceRequests.slice()
|
||||
: [];
|
||||
if (actor.kind === 'commander') w.pendingMarketplaceRequests = undefined;
|
||||
if (turnMarketplaceRequests.length > 0 && outcome.kind === 'silent') {
|
||||
// Navigation cards staged by `open_app_view` follow the same flush shape:
|
||||
// attached to the final commander message so the renderer can show the
|
||||
// click-to-open button; the card itself is the visible outcome.
|
||||
const turnAppNavRequests = actor.kind === 'commander' && w.pendingAppNavRequests?.length
|
||||
? w.pendingAppNavRequests.slice()
|
||||
: [];
|
||||
if (actor.kind === 'commander') {
|
||||
w.pendingMarketplaceRequests = undefined;
|
||||
w.pendingAppNavRequests = undefined;
|
||||
}
|
||||
if ((turnMarketplaceRequests.length > 0 || turnAppNavRequests.length > 0) && outcome.kind === 'silent') {
|
||||
outcome = { kind: 'persist', text: '' };
|
||||
}
|
||||
|
||||
@@ -5377,6 +5483,34 @@ async function runActorTurn(
|
||||
outcome = (!tail.trim() && !hasSide) ? { kind: 'silent' } : { ...outcome, text: tail };
|
||||
}
|
||||
|
||||
// Segment-tail replacement above reads the raw streamed text and can
|
||||
// otherwise reintroduce a success claim removed from `workingText`. Make the
|
||||
// correction the last authority for this non-aborted turn. If the claim was
|
||||
// in a segment already persisted before a dispatch boundary, the terminal
|
||||
// notice still corrects it explicitly instead of leaving the user with a
|
||||
// false durable-write confirmation.
|
||||
if (correctedUnsupportedMemoryClaim) {
|
||||
const notice = t('chat.memory_write_unconfirmed', undefined, turnLanguage);
|
||||
const current = outcome.kind === 'persist' ? outcome.text : '';
|
||||
const scrubbed = scrubCompletedDurableMemoryWriteClaims(current);
|
||||
const body = scrubbed.text === notice || scrubbed.text.startsWith(`${notice}\n`)
|
||||
? scrubbed.text
|
||||
: scrubbed.text ? `${notice}\n\n${scrubbed.text}` : notice;
|
||||
outcome = outcome.kind === 'persist'
|
||||
? {
|
||||
...outcome,
|
||||
text: body,
|
||||
failureKind: 'validation',
|
||||
failureCode: 'memory_write_unconfirmed',
|
||||
}
|
||||
: {
|
||||
kind: 'persist',
|
||||
text: body,
|
||||
failureKind: 'validation',
|
||||
failureCode: 'memory_write_unconfirmed',
|
||||
};
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
// Keep the process trail on abort so a stopped tool run isn't lost — EXCEPT
|
||||
// a commander turn that only routed (a delegation call + the reads it did to
|
||||
@@ -5487,6 +5621,7 @@ async function runActorTurn(
|
||||
? { artifacts: turnArtifacts.map((a) => ({ id: a.id, title: a.title, agent_id: actor.id })) }
|
||||
: {}),
|
||||
...(turnMarketplaceRequests.length ? { marketplace_requests: turnMarketplaceRequests } : {}),
|
||||
...(turnAppNavRequests.length ? { app_nav_requests: turnAppNavRequests } : {}),
|
||||
...(tailProcessItems.length ? { process: tailProcessItems } : {}),
|
||||
// Segment this reply closes. Always present, including for turns that
|
||||
// were never split — an unsplit turn is simply segment 0. The renderer
|
||||
@@ -7262,6 +7397,91 @@ async function buildCommanderExtraTools(
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'open_app_view',
|
||||
description:
|
||||
'Stage a click-to-open card for a supported Orkas destination. The renderer navigates only after the user clicks.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
surface_id: {
|
||||
type: 'string',
|
||||
enum: APP_NAV_SURFACES.map((s) => s.id),
|
||||
description: appNavSurfaceDescription(),
|
||||
},
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: APP_NAV_ACTIONS,
|
||||
description: 'Defaults to open for a list or landing page. Use create for a new resource, add_custom for a custom MCP server, and configure with target_id for one existing Project, Agent, connector, or automation.',
|
||||
},
|
||||
target_id: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 160,
|
||||
description: 'Required with action=configure for one existing Project, Agent, connector, or automation. If target_id is present, action must be configure even when the user says open or view.',
|
||||
},
|
||||
},
|
||||
required: ['surface_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
async execute(input: Record<string, unknown>) {
|
||||
const surfaceId = String(input.surface_id ?? '').trim();
|
||||
const checked = stageAppNavRequest(w, {
|
||||
surface_id: surfaceId,
|
||||
action: String(input.action ?? '').trim(),
|
||||
target_id: String(input.target_id ?? '').trim(),
|
||||
});
|
||||
if ('error' in checked) return _toolError(checked.error);
|
||||
const request = checked.request;
|
||||
return _toolJson({
|
||||
ok: true,
|
||||
status: 'navigation_card_staged',
|
||||
...request,
|
||||
instruction: 'A click-to-open card appears with your reply. Tell the user in one line what to click and what to do on that screen.',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
tools.push({
|
||||
name: 'app_health',
|
||||
description:
|
||||
'Return a sanitized read-only snapshot of supported Orkas app-health domains. It never returns credentials, paths, provider error text, or user content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
domain: {
|
||||
type: 'string',
|
||||
enum: [...APP_HEALTH_DOMAINS],
|
||||
description: 'Optional single domain; omit for the full snapshot.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
executionMode: 'parallel',
|
||||
async execute(input: Record<string, unknown>) {
|
||||
const domainRaw = String(input.domain ?? '').trim();
|
||||
if (domainRaw && !isAppHealthDomain(domainRaw)) {
|
||||
return _toolError(`domain must be one of: ${APP_HEALTH_DOMAINS.join(', ')}`);
|
||||
}
|
||||
const domains = isAppHealthDomain(domainRaw) ? [domainRaw] : APP_HEALTH_DOMAINS;
|
||||
const snapshot = await collectAppHealth(uid, domains, () => {
|
||||
const s = runtimeSnapshot(uid, cid);
|
||||
const activeCount = activeConversationCount(uid);
|
||||
return {
|
||||
active_work: activeCount > 0,
|
||||
active_conversation_count: activeCount,
|
||||
other_active_conversation_count: Math.max(0, activeCount - (s.processing ? 1 : 0)),
|
||||
current_conversation: {
|
||||
processing: s.processing,
|
||||
in_flight_actor_count: s.inFlight.length,
|
||||
active_turn_count: s.activeTurns.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
return _toolJson({ ok: true, ...snapshot });
|
||||
},
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -7453,11 +7673,18 @@ async function _initializeCodingProjectDir(
|
||||
}
|
||||
const target = info.effective_path;
|
||||
if (!target) return;
|
||||
await setCodingProjectDir(uid, cid, target, { explicit: info.mode === 'custom' && info.exists });
|
||||
log.info('coding project directory initialized', {
|
||||
cid: maskId(cid),
|
||||
source: info.mode,
|
||||
// Set-once under the state lock: two coding agents' first turns can race
|
||||
// this initialisation, and both must converge on ONE winner (the read above
|
||||
// is only a fast path). The caller re-reads state and uses the recorded dir.
|
||||
const { applied } = await setCodingProjectDirOnce(uid, cid, target, {
|
||||
explicit: info.mode === 'custom' && info.exists,
|
||||
});
|
||||
if (applied) {
|
||||
log.info('coding project directory initialized', {
|
||||
cid: maskId(cid),
|
||||
source: info.mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Build an `<agent-input-form>` block listing the agent's required
|
||||
@@ -7513,6 +7740,7 @@ async function _runCliAgentTurn(opts: {
|
||||
error?: string;
|
||||
aborted?: boolean;
|
||||
produced?: string[];
|
||||
published?: string[];
|
||||
commanderHandoff?: import('../local_agents/bridge').CommanderHandoffRequest;
|
||||
failureKind?: GroupMessageFailureKind;
|
||||
failureCode?: string;
|
||||
@@ -7628,6 +7856,9 @@ async function _runCliAgentTurn(opts: {
|
||||
|
||||
const textState = createPhasedTextState();
|
||||
const bufferPublicOutput = runtime.cli === 'hermes';
|
||||
const codexStreamFilter = runtime.cli === 'codex'
|
||||
? new CodexFileCitationStreamFilter()
|
||||
: null;
|
||||
let resultText = '';
|
||||
let aborted = false;
|
||||
let backendSessionId: string | undefined;
|
||||
@@ -7676,6 +7907,7 @@ async function _runCliAgentTurn(opts: {
|
||||
if (typeof (e as any).text === 'string') {
|
||||
const text = (e as any).text as string;
|
||||
const phased = appendPhasedText(textState, text, (e as any).phase);
|
||||
const streamText = codexStreamFilter ? codexStreamFilter.push(text) : text;
|
||||
// Slash-command turns: buffer text-delta in `textState` instead
|
||||
// of streaming to the bubble. The success-return path below
|
||||
// either swaps the body for "已发送命令 …" (CLI returned
|
||||
@@ -7692,11 +7924,13 @@ async function _runCliAgentTurn(opts: {
|
||||
to: 'final_answer',
|
||||
});
|
||||
}
|
||||
opts.onProcess({
|
||||
type: 'delta',
|
||||
text,
|
||||
...(phased.phase ? { phase: phased.phase } : {}),
|
||||
});
|
||||
if (streamText) {
|
||||
opts.onProcess({
|
||||
type: 'delta',
|
||||
text: streamText,
|
||||
...(phased.phase ? { phase: phased.phase } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -7810,6 +8044,10 @@ async function _runCliAgentTurn(opts: {
|
||||
opts.onProcess({ type: 'event', event: { stream: 'cli', data: e as unknown as Record<string, unknown> } });
|
||||
break;
|
||||
case 'done':
|
||||
if (codexStreamFilter && !slashCommandName) {
|
||||
const streamTail = codexStreamFilter.flush();
|
||||
if (streamTail) opts.onProcess({ type: 'delta', text: streamTail });
|
||||
}
|
||||
if ((e as any).resumeRejected === true) resumeRejected = true;
|
||||
if (runtime.cli === 'claude') {
|
||||
const reportedModel = (e as any).usage?.model;
|
||||
@@ -7935,18 +8173,26 @@ async function _runCliAgentTurn(opts: {
|
||||
failureCode: result.cliError || 'missing_cli',
|
||||
};
|
||||
}
|
||||
const publicText = () => appendCliGeneratedMediaMarkdown(
|
||||
sanitizeLocalAgentPublicOutput({
|
||||
cli: runtime.cli as LocalCliType,
|
||||
text: resolvedPhasedText(textState, resultText),
|
||||
userTask: publicTaskBody,
|
||||
}),
|
||||
const publicOutput = normalizeLocalAgentPublicOutput({
|
||||
cli: runtime.cli as LocalCliType,
|
||||
text: resolvedPhasedText(textState, resultText),
|
||||
userTask: publicTaskBody,
|
||||
workingDir: opts.workingDir,
|
||||
producedPaths: Array.from(produced),
|
||||
});
|
||||
const publicText = appendCliGeneratedMediaMarkdown(
|
||||
publicOutput.text,
|
||||
inlineGeneratedImages,
|
||||
opts.cid,
|
||||
inlineRemoteMedia.values(),
|
||||
);
|
||||
if (result.status === 'cancelled') {
|
||||
return { text: publicText(), aborted: true, produced: Array.from(produced) };
|
||||
return {
|
||||
text: publicText,
|
||||
aborted: true,
|
||||
produced: Array.from(produced),
|
||||
published: publicOutput.publishedPaths,
|
||||
};
|
||||
}
|
||||
if (result.status === 'failed' || result.status === 'timeout') {
|
||||
const vars = { name: opts.agent.name || runtime.cli, cli: runtime.cli };
|
||||
@@ -7966,9 +8212,10 @@ async function _runCliAgentTurn(opts: {
|
||||
? t('cli_agent.upgrade_required_detail', vars)
|
||||
: t('cli_agent.run_failed_detail', vars);
|
||||
return {
|
||||
text: publicText(),
|
||||
text: publicText,
|
||||
error: detail,
|
||||
produced: Array.from(produced),
|
||||
published: publicOutput.publishedPaths,
|
||||
failureKind: runtimeFailure === 'upgrade_required' ? 'dependency' : 'runtime',
|
||||
failureCode: result.status === 'timeout'
|
||||
? 'cli_timeout'
|
||||
@@ -7977,17 +8224,19 @@ async function _runCliAgentTurn(opts: {
|
||||
: 'cli_failed',
|
||||
};
|
||||
}
|
||||
const finalText = publicText();
|
||||
const finalText = publicText;
|
||||
if (slashCommandName && _looksLikeNoOutput(finalText)) {
|
||||
return {
|
||||
text: t('cli_agent.slash_no_output', { cmd: slashCommandName }),
|
||||
produced: Array.from(produced),
|
||||
published: publicOutput.publishedPaths,
|
||||
...(result.commanderHandoff ? { commanderHandoff: result.commanderHandoff } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: finalText,
|
||||
produced: Array.from(produced),
|
||||
published: publicOutput.publishedPaths,
|
||||
historySyncEligible: !contextPlan.passthrough,
|
||||
...(result.commanderHandoff ? { commanderHandoff: result.commanderHandoff } : {}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Host-side evidence for durable-memory success claims.
|
||||
*
|
||||
* A model may only tell the user that durable memory was written when this
|
||||
* turn produced a successful `add` or `replace` result from
|
||||
* `cross_session_memory`.
|
||||
* The bus owns that evidence because it observes the validated tool lifecycle;
|
||||
* prose, reasoning, and a read-only `list` call are not evidence of a write.
|
||||
*/
|
||||
|
||||
const MEMORY_TOOL = 'cross_session_memory';
|
||||
const MEMORY_WRITES = new Set(['add', 'replace']);
|
||||
|
||||
type ToolCall = {
|
||||
name: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Tracks successful durable-memory writes from the mapped stream events. */
|
||||
export class DurableMemoryWriteEvidence {
|
||||
private readonly calls = new Map<string, ToolCall>();
|
||||
private successfulWrite = false;
|
||||
|
||||
observe(value: unknown): void {
|
||||
const outer = record(value);
|
||||
if (outer?.type !== 'event') return;
|
||||
const event = record(outer.event);
|
||||
if (event?.stream !== 'tool') return;
|
||||
const data = record(event.data);
|
||||
if (!data) return;
|
||||
|
||||
const id = typeof data.id === 'string' ? data.id.trim() : '';
|
||||
const phase = typeof data.phase === 'string' ? data.phase : '';
|
||||
const previous = id ? this.calls.get(id) : undefined;
|
||||
const name = typeof data.name === 'string' && data.name
|
||||
? data.name
|
||||
: previous?.name || '';
|
||||
|
||||
if ((phase === 'start' || phase === 'progress') && id) {
|
||||
const args = record(data.arguments);
|
||||
const action = typeof args?.action === 'string'
|
||||
? args.action.trim().toLowerCase()
|
||||
: previous?.action || '';
|
||||
if (name === MEMORY_TOOL || previous?.name === MEMORY_TOOL) {
|
||||
this.calls.set(id, { name: MEMORY_TOOL, action });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase !== 'end') return;
|
||||
if (id) this.calls.delete(id);
|
||||
const action = previous?.action || '';
|
||||
if (name === MEMORY_TOOL
|
||||
&& MEMORY_WRITES.has(action)
|
||||
&& data.isError === false) {
|
||||
this.successfulWrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
hasSuccessfulWrite(): boolean {
|
||||
return this.successfulWrite;
|
||||
}
|
||||
}
|
||||
|
||||
// `project profile`, `user preferences`, `项目档案`, and `项目笔记` are
|
||||
// also ordinary file/artifact descriptions. Scope words alone therefore only
|
||||
// qualify the unambiguous noun "memory"; profile/note/preference needs an
|
||||
// explicit durable-time qualifier before a success claim can be scrubbed.
|
||||
const DURABLE_MEMORY_ZH = /(?:(?:长期|持久|跨会话).{0,10}(?:记忆|笔记|偏好|档案)|(?:记忆|笔记|偏好|档案).{0,10}(?:长期|持久|跨会话)|(?:项目|共享|用户|智能体|个人).{0,10}记忆|记忆.{0,10}(?:项目|共享|用户|智能体|个人)|(?:下次|以后|未来).{0,12}(?:记得|沿用|使用))/u;
|
||||
const COMPLETED_WRITE_ZH = /(?:(?:已|已经|刚刚|成功|现已|全部|都|均).{0,24}(?:记下|记住|保存|记录|写入|存入|录入|更新)|(?:记下|记住|保存|记录|写入|存入|录入|更新).{0,8}(?:了|好|完成|成功|完毕))/u;
|
||||
const NEGATED_OR_NONCURRENT_ZH = /(?:(?:未|没有|并未|尚未|没能|无法|不能|不曾).{0,18}(?:记下|记住|保存|记录|写入|存入|录入|更新)|(?:会|将|准备|打算|计划|可以|需要|应该|请|尝试|待).{0,18}(?:记下|记住|保存|记录|写入|存入|录入|更新)|(?:之前|此前|上次|过去|先前|早已).{0,18}(?:记下|记住|保存|记录|写入|存入|录入|更新)|(?:不要|不应|禁止|避免).{0,18}(?:声称|说|表示))/u;
|
||||
|
||||
const DURABLE_MEMORY_EN = /(?:(?:long[- ]term|persistent|cross[- ]session).{0,16}(?:memory|notes?|preferences?|profile)|(?:memory|notes?|preferences?|profile).{0,16}(?:long[- ]term|persistent|cross[- ]session)|(?:project|shared|user|agent|personal).{0,16}memory|memory.{0,16}(?:project|shared|user|agent|personal)|(?:for (?:the )?future|next time))/iu;
|
||||
const COMPLETED_WRITE_EN = /(?:(?:i(?:'ve| have)?|we(?:'ve| have)?|already|just|successfully|now|all|everything).{0,24}(?:saved|stored|recorded|written|added|updated|remembered)|(?:saved|stored|recorded|written|added|updated|remembered).{0,12}(?:successfully|already|now|complete|completed))/iu;
|
||||
const NEGATED_OR_NONCURRENT_EN = /(?:(?:not|never|didn't|did not|haven't|have not|hasn't|has not|failed to|couldn't|could not|unable to).{0,24}(?:save|store|record|write|add|update|remember)|(?:will|would|can|could|should|plan(?:ning)? to|intend to|need to|please|try to).{0,24}(?:save|store|record|write|add|update|remember)|(?:earlier|previously|last time|in the past|before).{0,24}(?:saved|stored|recorded|written|added|updated|remembered)|(?:do not|don't|should not|must not|avoid).{0,18}(?:claim|say|state))/iu;
|
||||
|
||||
const DURABLE_MEMORY_JA = /(?:(?:長期|永続).{0,10}(?:メモリ|メモ|記憶)|(?:メモリ|メモ|記憶).{0,10}(?:長期|永続)|(?:プロジェクト|共有|ユーザー|エージェント).{0,10}(?:メモリ|記憶)|(?:メモリ|記憶).{0,10}(?:プロジェクト|共有|ユーザー|エージェント))/u;
|
||||
const COMPLETED_WRITE_JA = /(?:保存|記録|書き込|登録|覚え).{0,8}(?:しました|済み|完了|ました|てあります)/u;
|
||||
const NEGATED_OR_NONCURRENT_JA = /(?:保存|記録|書き込|登録|覚え).{0,10}(?:していません|できませんでした|しません|する予定|します)/u;
|
||||
|
||||
const DURABLE_MEMORY_PT = /(?:(?:memória|notas?|preferências?|perfil).{0,18}(?:persistente|longo prazo)|(?:persistente|longo prazo).{0,18}(?:memória|notas?|preferências?|perfil)|memória.{0,18}(?:projeto|compartilhad[ao]|usuário|agente)|(?:projeto|compartilhad[ao]|usuário|agente).{0,18}memória)/iu;
|
||||
const COMPLETED_WRITE_PT = /(?:salvei|guardei|gravei|registrei|adicionei|atualizei|foi salv[ao]|foram salv[ao]s)/iu;
|
||||
const NEGATED_OR_NONCURRENT_PT = /(?:(?:não|ainda não|falhou).{0,24}(?:salvar|guardar|gravar|registrar|adicionar|atualizar)|(?:vou|iremos|posso|podemos|devo|pretendo).{0,20}(?:salvar|guardar|gravar|registrar|adicionar|atualizar))/iu;
|
||||
|
||||
function proseForDetection(value: string): string {
|
||||
return value
|
||||
.replace(/`[^`]*`/gu, ' ')
|
||||
.replace(/“[^”]*”|"[^"]*"/gu, ' ')
|
||||
.replace(/[*_~#]/gu, ' ')
|
||||
.replace(/\s+/gu, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Narrowly identifies a completed write claim that requires tool evidence. */
|
||||
export function claimsCompletedDurableMemoryWrite(value: string): boolean {
|
||||
const prose = proseForDetection(String(value || ''));
|
||||
if (!prose) return false;
|
||||
|
||||
const zh = DURABLE_MEMORY_ZH.test(prose)
|
||||
&& COMPLETED_WRITE_ZH.test(prose)
|
||||
&& !NEGATED_OR_NONCURRENT_ZH.test(prose);
|
||||
const en = DURABLE_MEMORY_EN.test(prose)
|
||||
&& COMPLETED_WRITE_EN.test(prose)
|
||||
&& !NEGATED_OR_NONCURRENT_EN.test(prose);
|
||||
const ja = DURABLE_MEMORY_JA.test(prose)
|
||||
&& COMPLETED_WRITE_JA.test(prose)
|
||||
&& !NEGATED_OR_NONCURRENT_JA.test(prose);
|
||||
const pt = DURABLE_MEMORY_PT.test(prose)
|
||||
&& COMPLETED_WRITE_PT.test(prose)
|
||||
&& !NEGATED_OR_NONCURRENT_PT.test(prose);
|
||||
return zh || en || ja || pt;
|
||||
}
|
||||
|
||||
export interface MemoryClaimScrubResult {
|
||||
text: string;
|
||||
removedClaims: number;
|
||||
}
|
||||
|
||||
function scrubProseLine(line: string): MemoryClaimScrubResult {
|
||||
const pieces = line.match(/[^。!?.!?;;]+[。!?.!?;;]*\s*/gu) || [line];
|
||||
let removedClaims = 0;
|
||||
const kept = pieces.filter((piece) => {
|
||||
if (!claimsCompletedDurableMemoryWrite(piece)) return true;
|
||||
removedClaims += 1;
|
||||
return false;
|
||||
});
|
||||
return { text: kept.join('').trimEnd(), removedClaims };
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only unsupported success-claim sentences. Fenced code and quoted
|
||||
* Markdown are evidence examples, not the actor's own assertions, so they are
|
||||
* deliberately left untouched.
|
||||
*/
|
||||
export function scrubCompletedDurableMemoryWriteClaims(value: string): MemoryClaimScrubResult {
|
||||
const lines = String(value || '').split('\n');
|
||||
const out: string[] = [];
|
||||
let inFence = false;
|
||||
let removedClaims = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^\s*(?:```|~~~)/u.test(line)) {
|
||||
inFence = !inFence;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFence || /^\s*>/u.test(line)) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const scrubbed = scrubProseLine(line);
|
||||
removedClaims += scrubbed.removedClaims;
|
||||
if (scrubbed.text || !line.trim()) out.push(scrubbed.text);
|
||||
}
|
||||
|
||||
return {
|
||||
text: out.join('\n').replace(/^\s+|\s+$/gu, ''),
|
||||
removedClaims,
|
||||
};
|
||||
}
|
||||
@@ -1008,6 +1008,26 @@ export async function setCodingProjectDir(
|
||||
});
|
||||
}
|
||||
|
||||
/** Initialise the coding-agent project directory only if the conversation has
|
||||
* none yet. The already-set check runs inside the state lock so concurrent
|
||||
* first turns converge on one working directory. */
|
||||
export async function setCodingProjectDirOnce(
|
||||
uid: string, cid: string, dir: string,
|
||||
opts: { explicit: boolean },
|
||||
): Promise<{ state: StateFile; applied: boolean }> {
|
||||
return _stateLock(uid, cid).runExclusive(async () => {
|
||||
const s = await readState(uid, cid);
|
||||
const trimmed = String(dir || '').trim();
|
||||
if (s.coding_project_dir || !trimmed) return { state: s, applied: false };
|
||||
s.coding_project_dir = trimmed;
|
||||
if (opts.explicit) s.coding_project_dir_explicit = true;
|
||||
else delete s.coding_project_dir_explicit;
|
||||
s.last_active_at = nowIso();
|
||||
await writeStateRaw(uid, cid, s);
|
||||
return { state: s, applied: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function setToolExtraRoots(uid: string, cid: string, roots: readonly string[]): Promise<StateFile> {
|
||||
return _stateLock(uid, cid).runExclusive(async () => {
|
||||
const s = await readState(uid, cid);
|
||||
|
||||
@@ -147,6 +147,15 @@ export interface GroupMessage {
|
||||
* official marketplace and request a user decision, but the install only
|
||||
* happens after the human clicks the rendered card. */
|
||||
marketplace_requests?: MarketplaceInstallRequest[];
|
||||
/** Commander-staged navigation cards (`open_app_view`). The renderer shows
|
||||
* a click-to-open button; navigation happens only on the user's click, so
|
||||
* the record carries only the validated surface/action/target request. */
|
||||
app_nav_requests?: Array<{
|
||||
surface_id: string;
|
||||
action: 'open' | 'create' | 'add_custom' | 'configure';
|
||||
target_id?: string;
|
||||
requested_at: string;
|
||||
}>;
|
||||
/** Marks this message as a plan announcement (rendered with a folded
|
||||
* plan card in UI). Set by `plan_set` first-time emission. */
|
||||
plan_announcement?: boolean;
|
||||
@@ -345,7 +354,7 @@ function commanderHistoryRoute(record: CommanderHistoryRecord): {
|
||||
return { from, recipients, attributes };
|
||||
}
|
||||
|
||||
/** Remove only the old host-owned prefix that can be derived from this
|
||||
/** Remove only a host-owned prefix that can be derived from this
|
||||
* authoritative non-user record. Canonical JSONL remains untouched. User
|
||||
* examples, embedded mentions, quotes/code fences and unknown look-alikes do
|
||||
* not match this leading-line contract and remain verbatim. */
|
||||
@@ -358,6 +367,9 @@ function stripLegacyHistoryRoutePrefix(
|
||||
const legacyHeaders = new Set([
|
||||
`[${from} -> ${recipients}]`,
|
||||
...(attributes.length ? [`[${from} -> ${recipients}; ${attributes.join('; ')}]`] : []),
|
||||
record.dispatch
|
||||
? `${from} delegated this step to ${recipients}:`
|
||||
: `${from} replied to ${recipients}:`,
|
||||
]);
|
||||
const lines = body.split(/\r?\n/);
|
||||
let removed = false;
|
||||
|
||||
@@ -40,6 +40,28 @@ export type LocalEventType =
|
||||
| 'idle'
|
||||
| 'done';
|
||||
|
||||
/** Tool names whose result carries an image because the agent READ a file,
|
||||
* not because it produced one. CLI file readers hand the file back as an
|
||||
* image block, which is indistinguishable from generated media at the
|
||||
* event layer — without this gate a plain "read that screenshot" bounces
|
||||
* the user's own attachment back into the conversation as
|
||||
* `![generated image]`. */
|
||||
const FILE_READ_TOOL_NAMES = new Set([
|
||||
'read',
|
||||
'read_file',
|
||||
'readfile',
|
||||
'notebookread',
|
||||
'view',
|
||||
]);
|
||||
|
||||
/** True when a tool result's media came from reading a file rather than
|
||||
* producing one. Unknown tool names return false so genuinely generated
|
||||
* media is never dropped. */
|
||||
export function isFileReadToolName(name: unknown): boolean {
|
||||
if (typeof name !== 'string') return false;
|
||||
return FILE_READ_TOOL_NAMES.has(name.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export interface LocalEvent {
|
||||
type: LocalEventType;
|
||||
/** Free-form payload — exact keys vary per type. Documented inline at
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
armKillWatchdog,
|
||||
LineSplitter,
|
||||
levelOrInfo,
|
||||
isFileReadToolName,
|
||||
} from './base.js';
|
||||
|
||||
const log = createLogger('local-agents:claude');
|
||||
@@ -86,7 +87,10 @@ export const claudeBackend: LocalBackend = {
|
||||
// versions that ignore `--include-partial-messages` never emit
|
||||
// stream_events; in that case we fall back to emitting from the
|
||||
// assistant block so the user still sees the final text.
|
||||
const partialState = { sawTextStreamEvent: false };
|
||||
const partialState: ClaudeParseState = {
|
||||
sawTextStreamEvent: false,
|
||||
toolNamesByCallId: new Map<string, string>(),
|
||||
};
|
||||
|
||||
opts.onEvent({
|
||||
type: 'process-info',
|
||||
@@ -492,6 +496,9 @@ export const claudeBackend: LocalBackend = {
|
||||
// stream-json process. A later self-woken turn may fall back to full
|
||||
// assistant blocks even when the previous turn streamed deltas.
|
||||
partialState.sawTextStreamEvent = false;
|
||||
// Tool calls never span turns; drop any callId whose result
|
||||
// never arrived so the map stays turn-scoped.
|
||||
partialState.toolNamesByCallId?.clear();
|
||||
if (liveTasks.size > 0) {
|
||||
enterBackground();
|
||||
// `result` would otherwise be the latest activity row. Reassert the
|
||||
@@ -665,10 +672,19 @@ export function buildClaudeArgs(opts: Pick<BackendRunOptions,
|
||||
* tool_use blocks because the partial input_json deltas alone
|
||||
* aren't enough to render a useful tool-event.
|
||||
*/
|
||||
export type ClaudeParseState = {
|
||||
sawTextStreamEvent: boolean;
|
||||
/** callId -> tool name, captured from the assistant `tool_use` block so the
|
||||
* `tool_result` branch can tell agent-produced media apart from a file the
|
||||
* agent merely read. Optional: callers that only care about text (and the
|
||||
* parser tests) may pass a bare `{ sawTextStreamEvent }`. */
|
||||
toolNamesByCallId?: Map<string, string>;
|
||||
};
|
||||
|
||||
export function mapClaudeEvent(
|
||||
obj: any,
|
||||
_sessionIdSoFar: string | undefined,
|
||||
partialState: { sawTextStreamEvent: boolean } = { sawTextStreamEvent: false },
|
||||
partialState: ClaudeParseState = { sawTextStreamEvent: false },
|
||||
):
|
||||
| undefined
|
||||
| {
|
||||
@@ -921,10 +937,17 @@ export function mapClaudeEvent(
|
||||
continue;
|
||||
}
|
||||
if (part?.type === 'tool_use') {
|
||||
const useCallId = String(part.id || '');
|
||||
const useToolName = String(part.name || 'unknown');
|
||||
// Recorded so the matching tool_result can classify any image it
|
||||
// carries. Consumed (and deleted) there; cleared at turn end.
|
||||
if (useCallId && partialState.toolNamesByCallId) {
|
||||
partialState.toolNamesByCallId.set(useCallId, useToolName);
|
||||
}
|
||||
events.push({
|
||||
type: 'tool-event',
|
||||
tool: String(part.name || 'unknown'),
|
||||
callId: String(part.id || ''),
|
||||
tool: useToolName,
|
||||
callId: useCallId,
|
||||
phase: 'use',
|
||||
input: part.input ?? {},
|
||||
});
|
||||
@@ -951,7 +974,14 @@ export function mapClaudeEvent(
|
||||
output: out,
|
||||
});
|
||||
const mediaItems = claudeToolResultImages(part.content);
|
||||
if (mediaItems.length) {
|
||||
const originTool = callId
|
||||
? partialState.toolNamesByCallId?.get(callId) || ''
|
||||
: '';
|
||||
if (callId) partialState.toolNamesByCallId?.delete(callId);
|
||||
// `Read` returns the file itself as an image block. Publishing that
|
||||
// republished whatever the agent looked at — including the user's own
|
||||
// upload — as `![generated image]`.
|
||||
if (mediaItems.length && !isFileReadToolName(originTool)) {
|
||||
events.push({
|
||||
type: 'media-output',
|
||||
source: 'claude',
|
||||
|
||||
@@ -1102,9 +1102,15 @@ export function buildCodexTurnRuntimeOverrides(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexArgs(opts: BackendRunOptions): string[] {
|
||||
export function buildCodexArgs(opts: BackendRunOptions): string[] {
|
||||
// Per multica: `codex app-server --listen stdio://` is the entry
|
||||
// point for the JSON-RPC protocol. customArgs trail.
|
||||
// Keep Codex's built-in OpenAI provider intact. Current Codex versions
|
||||
// reserve that provider id and reject `model_providers.openai.*` overrides
|
||||
// before JSON-RPC initialization; ChatGPT login also relies on the built-in
|
||||
// provider owning its WebSocket-to-HTTPS fallback. Orkas observes Codex
|
||||
// retry events and applies its process watchdog, but does not select the
|
||||
// provider transport itself.
|
||||
const args = ['app-server', '--listen', 'stdio://'];
|
||||
// orkas-bridge: codex takes config-layer overrides (`-c key=value`,
|
||||
// TOML-parsed) instead of a config file. Codex spawns stdio MCP servers
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
*/
|
||||
|
||||
import * as crypto from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLogger } from '../../../logger.js';
|
||||
import { logErrorSummary } from '../../../util/log-redact.js';
|
||||
import {
|
||||
@@ -124,6 +125,7 @@ export const openclawBackend: LocalBackend = {
|
||||
const parsed = parseOpenclawReply(fullStderr);
|
||||
const replyText = parsed?.text || '';
|
||||
const media = parsed?.media || [];
|
||||
const files = parsed?.files || [];
|
||||
const sid = parsed?.sessionId || sessionId;
|
||||
|
||||
if (replyText) {
|
||||
@@ -139,12 +141,24 @@ export const openclawBackend: LocalBackend = {
|
||||
items: media.map(uri => ({ uri })),
|
||||
});
|
||||
}
|
||||
if (files.length) {
|
||||
// OpenClaw's structured attachment envelope can point at arbitrary
|
||||
// documents (PDF/Office/etc.), not only previewable image/video
|
||||
// media. Keep those paths on the generic produced-file channel; the
|
||||
// group-chat boundary validates cwd ownership and existence before
|
||||
// exposing them in the deliverable footer.
|
||||
opts.onEvent({
|
||||
type: 'file-change',
|
||||
source: 'openclaw_attachment',
|
||||
paths: files,
|
||||
});
|
||||
}
|
||||
|
||||
const usage = parsed?.usage;
|
||||
if (code === 0 && (replyText || media.length)) {
|
||||
if (code === 0 && (replyText || media.length || files.length)) {
|
||||
return finish('completed', { output: replyText, sessionId: sid, ...(usage ? { usage } : {}) });
|
||||
}
|
||||
if (code === 0 && !replyText && !media.length) {
|
||||
if (code === 0 && !replyText && !media.length && !files.length) {
|
||||
// Exit clean but no parseable reply → treat as failed so
|
||||
// the user sees an error bubble instead of an empty turn.
|
||||
return finish('failed', {
|
||||
@@ -206,7 +220,7 @@ function hasOpenclawTimeoutArg(args: string[] | undefined): boolean {
|
||||
*/
|
||||
export function parseOpenclawReply(stderrText: string):
|
||||
| null
|
||||
| { text: string; media: string[]; sessionId?: string; error?: string; usage?: Record<string, number | string> } {
|
||||
| { text: string; media: string[]; files: string[]; sessionId?: string; error?: string; usage?: Record<string, number | string> } {
|
||||
if (!stderrText) return null;
|
||||
const clean = stripAnsi(stderrText);
|
||||
|
||||
@@ -230,17 +244,28 @@ export function parseOpenclawReply(stderrText: string):
|
||||
.map(p => (p && typeof p.text === 'string') ? p.text : '')
|
||||
.filter(s => s.length)
|
||||
.join('\n');
|
||||
const media = Array.from(new Set((obj.payloads as any[]).flatMap((payload): string[] => {
|
||||
const mediaCandidates = (obj.payloads as any[]).flatMap((payload): string[] => {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
const candidates = [
|
||||
typeof payload.mediaUrl === 'string' ? payload.mediaUrl : '',
|
||||
...(Array.isArray(payload.mediaUrls) ? payload.mediaUrls : []),
|
||||
];
|
||||
return candidates
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
return [payload.media, payload.mediaUrl, payload.mediaUrls]
|
||||
.flatMap(openclawStringValues)
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
})));
|
||||
});
|
||||
const explicitFileCandidates = (obj.payloads as any[]).flatMap((payload): string[] => {
|
||||
if (!payload || typeof payload !== 'object') return [];
|
||||
return [payload.path, payload.filePath]
|
||||
.flatMap(openclawStringValues)
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
});
|
||||
const media: string[] = [];
|
||||
const files: string[] = [];
|
||||
for (const candidate of mediaCandidates) {
|
||||
const localPath = openclawLocalAttachmentPath(candidate);
|
||||
if (localPath) files.push(localPath);
|
||||
else media.push(candidate);
|
||||
}
|
||||
files.push(...explicitFileCandidates);
|
||||
const sessionId = obj.meta?.agentMeta?.sessionId
|
||||
|| obj.meta?.sessionId
|
||||
|| undefined;
|
||||
@@ -248,15 +273,42 @@ export function parseOpenclawReply(stderrText: string):
|
||||
obj.meta?.agentMeta?.usage || obj.meta?.usage,
|
||||
obj.meta?.agentMeta?.model || obj.meta?.agentMeta?.provider,
|
||||
);
|
||||
return { text, media, sessionId, ...(usage ? { usage } : {}) };
|
||||
return {
|
||||
text,
|
||||
media: Array.from(new Set(media)),
|
||||
files: Array.from(new Set(files)),
|
||||
sessionId,
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
}
|
||||
if (obj && typeof obj === 'object' && typeof obj.error === 'string') {
|
||||
return { text: '', media: [], error: String(obj.error) };
|
||||
return { text: '', media: [], files: [], error: String(obj.error) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function openclawStringValues(value: unknown): string[] {
|
||||
if (typeof value === 'string') return [value];
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((item): item is string => typeof item === 'string');
|
||||
}
|
||||
|
||||
function openclawLocalAttachmentPath(value: string): string | null {
|
||||
const candidate = value.trim();
|
||||
if (!candidate) return null;
|
||||
if (/^file:/iu.test(candidate)) {
|
||||
try { return fileURLToPath(candidate); } catch { return candidate; }
|
||||
}
|
||||
if (/^(?:https?:|data:)/iu.test(candidate)) return null;
|
||||
// Treat another URI scheme as media rather than resolving it relative to
|
||||
// cwd. Exclude Windows drive paths (`C:\...`) from the scheme check.
|
||||
if (/^[A-Za-z][A-Za-z\d+.-]*:/u.test(candidate) && !/^[A-Za-z]:[\\/]/u.test(candidate)) {
|
||||
return null;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/** Normalize openclaw's `meta.agentMeta.usage` block (or a fallback at
|
||||
* `meta.usage`) into our shared usage shape. openclaw passes through
|
||||
* whatever the upstream provider returned (Anthropic / OpenAI / etc.),
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
bindAbort,
|
||||
armKillWatchdog,
|
||||
LineSplitter,
|
||||
isFileReadToolName,
|
||||
} from './base.js';
|
||||
|
||||
const log = createLogger('local-agents:opencode');
|
||||
@@ -243,7 +244,9 @@ export function mapOpencodeEvent(obj: any):
|
||||
...(isError ? { isError: true } : {}),
|
||||
};
|
||||
const mediaItems = opencodeImageAttachments(state.attachments);
|
||||
if (mediaItems.length) {
|
||||
// Same guard as the claude backend: a file-read tool's attachments are
|
||||
// the file it read, not media the agent produced.
|
||||
if (mediaItems.length && !isFileReadToolName(part.tool)) {
|
||||
out!.event = toolEvent;
|
||||
out!.events = [toolEvent, {
|
||||
type: 'media-output',
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* Public-output boundary for local CLI agents.
|
||||
*
|
||||
* External runtimes remain free to emit their native output. Hermes
|
||||
* installations in the wild can emit a KSTAR work record alongside the public
|
||||
* result. Parse that known wire shape only at the Orkas presentation boundary,
|
||||
* leaving the CLI prompt and raw runtime behavior unchanged.
|
||||
* External runtimes remain free to emit their native output. Normalize known
|
||||
* native wire shapes only at the Orkas presentation boundary, leaving the CLI
|
||||
* prompt and raw runtime behavior unchanged:
|
||||
*
|
||||
* - Hermes installations in the wild can emit a KSTAR work record alongside
|
||||
* the public result.
|
||||
* - Codex emits `:codex-file-citation{...}` directives for files it considers
|
||||
* final outputs. Those directives are transport metadata, not user-facing
|
||||
* prose; Orkas turns them into an explicit produced-file selection after
|
||||
* verifying the path was already registered by this turn.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type { LocalCliType } from './registry.js';
|
||||
|
||||
type KstarSectionId = 'K' | 'S' | 'T' | 'A' | 'EXPECTED' | 'R' | 'DELTA' | 'AAR';
|
||||
@@ -114,14 +122,279 @@ function extractHermesKstarResult(text: string): { matched: boolean; result: str
|
||||
return { matched: true, result };
|
||||
}
|
||||
|
||||
interface CodexFileCitation {
|
||||
path: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
interface ParsedCodexFileCitationLine {
|
||||
citation: CodexFileCitation;
|
||||
/** Text to keep when the directive is consumed. Empty for a standalone directive. */
|
||||
replacement: string;
|
||||
}
|
||||
|
||||
const CODEX_FILE_CITATION_PREFIX = ':codex-file-citation{';
|
||||
const CODEX_FILE_CITATION_LINE_RE = /^\s*:codex-file-citation\{([\s\S]*)\}\s*$/u;
|
||||
const CODEX_FILE_CITATION_ATTR_RE = /([A-Za-z_][\w-]*)\s*=\s*"((?:\\.|[^"\\])*)"/gy;
|
||||
|
||||
function decodeCodexAttribute(value: string): string {
|
||||
// Codex currently escapes only quote and backslash in directive attributes.
|
||||
// Preserve unknown escape sequences so a Windows path such as `C:\tmp`
|
||||
// does not silently lose characters if an older runtime emits it verbatim.
|
||||
return value.replace(/\\(["\\])/g, '$1');
|
||||
}
|
||||
|
||||
function parseCodexFileCitationLine(line: string): CodexFileCitation | null {
|
||||
const match = CODEX_FILE_CITATION_LINE_RE.exec(line);
|
||||
if (!match) return null;
|
||||
const body = match[1];
|
||||
const attributes = new Map<string, string>();
|
||||
let offset = 0;
|
||||
while (offset < body.length) {
|
||||
while (/\s/u.test(body[offset] || '')) offset += 1;
|
||||
if (offset >= body.length) break;
|
||||
CODEX_FILE_CITATION_ATTR_RE.lastIndex = offset;
|
||||
const attribute = CODEX_FILE_CITATION_ATTR_RE.exec(body);
|
||||
if (!attribute || attribute.index !== offset) return null;
|
||||
attributes.set(attribute[1], decodeCodexAttribute(attribute[2]));
|
||||
offset = CODEX_FILE_CITATION_ATTR_RE.lastIndex;
|
||||
}
|
||||
const citationPath = String(attributes.get('path') || '').trim();
|
||||
const purpose = String(attributes.get('purpose') || '').trim();
|
||||
return citationPath && purpose ? { path: citationPath, purpose } : null;
|
||||
}
|
||||
|
||||
function codexCitationPathIdentity(rawPath: string): string {
|
||||
let value = String(rawPath || '').trim();
|
||||
if (value.startsWith('<') && value.endsWith('>')) value = value.slice(1, -1).trim();
|
||||
try { value = decodeURI(value); } catch { /* preserve malformed legacy text */ }
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Besides the current standalone wire shape, some older Codex builds emitted
|
||||
* the directive immediately after a Markdown link to the same file. Keep that
|
||||
* link as prose, but consume the adjacent transport suffix. Requiring an exact
|
||||
* path match keeps ordinary inline examples untouched.
|
||||
*/
|
||||
function parseCodexFileCitationPresentationLine(line: string): ParsedCodexFileCitationLine | null {
|
||||
const standalone = parseCodexFileCitationLine(line);
|
||||
if (standalone) return { citation: standalone, replacement: '' };
|
||||
|
||||
const markerIndex = line.lastIndexOf(CODEX_FILE_CITATION_PREFIX);
|
||||
if (markerIndex <= 0) return null;
|
||||
const citation = parseCodexFileCitationLine(line.slice(markerIndex));
|
||||
if (!citation) return null;
|
||||
const leading = line.slice(0, markerIndex);
|
||||
const link = /\[[^\]\n]+\]\(([^)\n]+)\)\s*$/u.exec(leading);
|
||||
if (!link) return null;
|
||||
if (codexCitationPathIdentity(link[1]) !== codexCitationPathIdentity(citation.path)) return null;
|
||||
return { citation, replacement: leading.trimEnd() };
|
||||
}
|
||||
|
||||
function markdownFenceToken(line: string): string {
|
||||
const match = /^ {0,3}(`{3,}|~{3,})/u.exec(line);
|
||||
return match?.[1] || '';
|
||||
}
|
||||
|
||||
function isClosingMarkdownFence(token: string, openFence: string): boolean {
|
||||
return !!token
|
||||
&& token[0] === openFence[0]
|
||||
&& token.length >= openFence.length;
|
||||
}
|
||||
|
||||
function resolveCitationPath(rawPath: string, workingDir?: string): string | null {
|
||||
if (path.isAbsolute(rawPath)) return path.normalize(rawPath);
|
||||
if (!workingDir) return null;
|
||||
return path.resolve(workingDir, rawPath);
|
||||
}
|
||||
|
||||
function normalizeCodexFileCitations(args: {
|
||||
text: string;
|
||||
producedPaths?: readonly string[];
|
||||
workingDir?: string;
|
||||
removeUnmatched: boolean;
|
||||
}): { text: string; publishedPaths: string[] } {
|
||||
const text = String(args.text || '');
|
||||
if (!text.includes(CODEX_FILE_CITATION_PREFIX)) {
|
||||
return { text, publishedPaths: [] };
|
||||
}
|
||||
|
||||
const produced = new Map<string, string>();
|
||||
for (const rawPath of args.producedPaths || []) {
|
||||
if (typeof rawPath !== 'string' || !rawPath.trim()) continue;
|
||||
const resolved = path.resolve(rawPath);
|
||||
produced.set(resolved, resolved);
|
||||
}
|
||||
|
||||
const published = new Set<string>();
|
||||
const kept: string[] = [];
|
||||
let openFence = '';
|
||||
let removed = false;
|
||||
for (const line of text.replace(/\r/g, '').split('\n')) {
|
||||
const fence = markdownFenceToken(line);
|
||||
if (fence) {
|
||||
if (!openFence) openFence = fence;
|
||||
else if (isClosingMarkdownFence(fence, openFence)) openFence = '';
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
if (openFence) {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseCodexFileCitationPresentationLine(line);
|
||||
if (!parsed) {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
const citation = parsed.citation;
|
||||
const resolved = resolveCitationPath(citation.path, args.workingDir);
|
||||
const registered = resolved ? produced.get(path.resolve(resolved)) : undefined;
|
||||
if (citation.purpose === 'output' && registered) published.add(registered);
|
||||
if (registered || args.removeUnmatched) {
|
||||
removed = true;
|
||||
if (parsed.replacement) kept.push(parsed.replacement);
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
|
||||
const normalizedText = removed
|
||||
? kept.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()
|
||||
: text;
|
||||
return { text: normalizedText, publishedPaths: Array.from(published) };
|
||||
}
|
||||
|
||||
export interface LocalAgentPublicOutput {
|
||||
text: string;
|
||||
/** Exact current-turn produced files selected by native output metadata. */
|
||||
publishedPaths: string[];
|
||||
}
|
||||
|
||||
export function normalizeLocalAgentPublicOutput(args: {
|
||||
cli: LocalCliType;
|
||||
text: string;
|
||||
userTask?: string;
|
||||
workingDir?: string;
|
||||
producedPaths?: readonly string[];
|
||||
}): LocalAgentPublicOutput {
|
||||
const text = String(args.text || '');
|
||||
if (args.cli === 'codex') {
|
||||
return normalizeCodexFileCitations({
|
||||
text,
|
||||
workingDir: args.workingDir,
|
||||
producedPaths: args.producedPaths,
|
||||
removeUnmatched: true,
|
||||
});
|
||||
}
|
||||
if (args.cli !== 'hermes' || !text.trim()) return { text, publishedPaths: [] };
|
||||
if (hasExplicitKstarRequest(String(args.userTask || ''))) return { text, publishedPaths: [] };
|
||||
const extracted = extractHermesKstarResult(text);
|
||||
return {
|
||||
text: extracted.matched ? extracted.result : text,
|
||||
publishedPaths: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeLocalAgentPublicOutput(args: {
|
||||
cli: LocalCliType;
|
||||
text: string;
|
||||
userTask?: string;
|
||||
}): string {
|
||||
const text = String(args.text || '');
|
||||
if (args.cli !== 'hermes' || !text.trim()) return text;
|
||||
if (hasExplicitKstarRequest(String(args.userTask || ''))) return text;
|
||||
const extracted = extractHermesKstarResult(text);
|
||||
return extracted.matched ? extracted.result : text;
|
||||
return normalizeLocalAgentPublicOutput(args).text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only compatibility projection for messages persisted by builds that
|
||||
* displayed Codex's native directive. Only citations already backed by that
|
||||
* message's structured `produced` list are removed; unmatched prose remains
|
||||
* intact and the canonical JSONL is never rewritten.
|
||||
*/
|
||||
export function sanitizePersistedCodexFileCitations(
|
||||
text: string,
|
||||
producedPaths?: readonly string[],
|
||||
workingDir?: string,
|
||||
): string {
|
||||
return normalizeCodexFileCitations({
|
||||
text,
|
||||
producedPaths,
|
||||
workingDir,
|
||||
removeUnmatched: false,
|
||||
}).text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract only recognized `purpose="output"` paths from persisted Codex
|
||||
* transport metadata. The caller remains responsible for checking existence
|
||||
* and conversation-scoped path authorization before treating them as files.
|
||||
*/
|
||||
export function persistedCodexOutputCitationPaths(
|
||||
text: string,
|
||||
workingDir?: string,
|
||||
): string[] {
|
||||
const source = String(text || '');
|
||||
if (!source.includes(CODEX_FILE_CITATION_PREFIX)) return [];
|
||||
|
||||
const found = new Set<string>();
|
||||
let openFence = '';
|
||||
for (const line of source.replace(/\r/g, '').split('\n')) {
|
||||
const fence = markdownFenceToken(line);
|
||||
if (fence) {
|
||||
if (!openFence) openFence = fence;
|
||||
else if (isClosingMarkdownFence(fence, openFence)) openFence = '';
|
||||
continue;
|
||||
}
|
||||
if (openFence) continue;
|
||||
const parsed = parseCodexFileCitationPresentationLine(line);
|
||||
if (!parsed || parsed.citation.purpose !== 'output') continue;
|
||||
const resolved = resolveCitationPath(parsed.citation.path, workingDir);
|
||||
if (resolved) found.add(path.resolve(resolved));
|
||||
}
|
||||
return Array.from(found);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming filter used by the Codex adapter boundary. It buffers at most one
|
||||
* incomplete line, so a directive split across arbitrary protocol deltas is
|
||||
* never briefly painted as chat prose. Fenced examples remain visible.
|
||||
*/
|
||||
export class CodexFileCitationStreamFilter {
|
||||
private buffer = '';
|
||||
|
||||
private openFence = '';
|
||||
|
||||
push(chunk: string): string {
|
||||
this.buffer += String(chunk || '').replace(/\r/g, '');
|
||||
let output = '';
|
||||
let newline = this.buffer.indexOf('\n');
|
||||
while (newline >= 0) {
|
||||
const line = this.buffer.slice(0, newline);
|
||||
this.buffer = this.buffer.slice(newline + 1);
|
||||
output += this.filterCompleteLine(line, '\n');
|
||||
newline = this.buffer.indexOf('\n');
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
flush(): string {
|
||||
const line = this.buffer;
|
||||
this.buffer = '';
|
||||
return this.filterCompleteLine(line, '');
|
||||
}
|
||||
|
||||
private filterCompleteLine(line: string, ending: string): string {
|
||||
const fence = markdownFenceToken(line);
|
||||
if (fence) {
|
||||
if (!this.openFence) this.openFence = fence;
|
||||
else if (isClosingMarkdownFence(fence, this.openFence)) this.openFence = '';
|
||||
return `${line}${ending}`;
|
||||
}
|
||||
if (!this.openFence) {
|
||||
const parsed = parseCodexFileCitationPresentationLine(line);
|
||||
if (parsed) return `${parsed.replacement}${parsed.replacement ? ending : ''}`;
|
||||
}
|
||||
return `${line}${ending}`;
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,17 @@ interface StartOptions {
|
||||
getActiveUserId: () => string;
|
||||
hasFocusedWindow: () => boolean;
|
||||
enabled?: boolean;
|
||||
emit?: (record: OpenLifecycleRecord) => void;
|
||||
emit?: (record: OpenLifecycleRecord) => void | Promise<void>;
|
||||
setTimer?: typeof setTimeout;
|
||||
clearTimer?: typeof clearTimeout;
|
||||
blurSettleMs?: number;
|
||||
}
|
||||
|
||||
export interface OpenLifecycleTracking {
|
||||
stop: () => void;
|
||||
flushQuit: () => Promise<void>;
|
||||
}
|
||||
|
||||
const log = createLogger('open-lifecycle');
|
||||
const REQUEST_TIMEOUT_MS = 5_000;
|
||||
const BLUR_SETTLE_MS = 200;
|
||||
@@ -52,12 +57,12 @@ export function openLifecycleRequestBody(
|
||||
return { uid, event: record.event, trigger: record.trigger };
|
||||
}
|
||||
|
||||
export function reportOpenLifecycle(uid: string, record: OpenLifecycleRecord): void {
|
||||
export async function reportOpenLifecycle(uid: string, record: OpenLifecycleRecord): Promise<void> {
|
||||
if (!safeId(uid) || uid.length > 50) {
|
||||
warnTransport('lifecycle event skipped because the active uid is invalid', { reason: 'invalid_uid' });
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetchWithRetry(
|
||||
'open-lifecycle',
|
||||
`${apiBase()}/analytics/open-app-lifecycle`,
|
||||
@@ -81,49 +86,75 @@ export function reportOpenLifecycle(uid: string, record: OpenLifecycleRecord): v
|
||||
return;
|
||||
}
|
||||
await response.body?.cancel().catch(() => {});
|
||||
})().catch((error) => {
|
||||
} catch (error) {
|
||||
warnTransport('lifecycle event delivery failed', { error: logErrorRef(error) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenLifecycleState {
|
||||
private started = false;
|
||||
private foreground = false;
|
||||
private quitReported = false;
|
||||
|
||||
constructor(private readonly emit: (record: OpenLifecycleRecord) => void) {}
|
||||
constructor(private readonly emit: (
|
||||
record: OpenLifecycleRecord,
|
||||
) => void | Promise<void>) {}
|
||||
|
||||
start(): void {
|
||||
start(): void | Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
this.foreground = true;
|
||||
this.emit({ event: 'enter', trigger: 'cold_start' });
|
||||
return this.emit({ event: 'enter', trigger: 'cold_start' });
|
||||
}
|
||||
|
||||
enterForeground(): void {
|
||||
if (!this.started || this.foreground) return;
|
||||
enterForeground(): void | Promise<void> {
|
||||
if (!this.started || this.foreground || this.quitReported) return;
|
||||
this.foreground = true;
|
||||
this.emit({ event: 'enter', trigger: 'foreground' });
|
||||
return this.emit({ event: 'enter', trigger: 'foreground' });
|
||||
}
|
||||
|
||||
leaveForeground(trigger: 'background' | 'quit'): void {
|
||||
if (!this.started || !this.foreground) return;
|
||||
leaveForeground(trigger: 'background' | 'quit'): void | Promise<void> {
|
||||
if (!this.started || this.quitReported) return;
|
||||
if (trigger === 'quit') {
|
||||
// A process exit is a terminal application event, not another focus
|
||||
// transition. macOS commonly blurs the last window before before-quit;
|
||||
// suppressing quit in that state made real exits disappear entirely.
|
||||
this.quitReported = true;
|
||||
this.foreground = false;
|
||||
return this.emit({ event: 'leave', trigger });
|
||||
}
|
||||
if (!this.foreground) return;
|
||||
this.foreground = false;
|
||||
this.emit({ event: 'leave', trigger });
|
||||
return this.emit({ event: 'leave', trigger });
|
||||
}
|
||||
}
|
||||
|
||||
export function startOpenLifecycleTracking(options: StartOptions): () => void {
|
||||
if (options.enabled === false) return () => {};
|
||||
export function startOpenLifecycleTracking(options: StartOptions): OpenLifecycleTracking {
|
||||
if (options.enabled === false) {
|
||||
return {
|
||||
stop: () => {},
|
||||
flushQuit: async () => {},
|
||||
};
|
||||
}
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const state = new OpenLifecycleState(options.emit || ((record) => {
|
||||
const deliver = (record: OpenLifecycleRecord): Promise<void> => {
|
||||
try {
|
||||
reportOpenLifecycle(options.getActiveUserId(), record);
|
||||
return Promise.resolve(
|
||||
options.emit
|
||||
? options.emit(record)
|
||||
: reportOpenLifecycle(options.getActiveUserId(), record),
|
||||
).catch((error) => {
|
||||
warnTransport('lifecycle event delivery failed', { error: logErrorRef(error) });
|
||||
});
|
||||
} catch (error) {
|
||||
warnTransport('lifecycle event preparation failed', { error: logErrorRef(error) });
|
||||
return Promise.resolve();
|
||||
}
|
||||
}));
|
||||
};
|
||||
const state = new OpenLifecycleState(deliver);
|
||||
let blurTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let quitDelivery: Promise<void> | null = null;
|
||||
|
||||
const cancelBlurTimer = () => {
|
||||
if (blurTimer === null) return;
|
||||
@@ -141,20 +172,30 @@ export function startOpenLifecycleTracking(options: StartOptions): () => void {
|
||||
if (!options.hasFocusedWindow()) state.leaveForeground('background');
|
||||
}, options.blurSettleMs ?? BLUR_SETTLE_MS);
|
||||
};
|
||||
const onBeforeQuit = () => {
|
||||
const flushQuit = (): Promise<void> => {
|
||||
cancelBlurTimer();
|
||||
state.leaveForeground('quit');
|
||||
if (!quitDelivery) {
|
||||
quitDelivery = Promise.resolve(state.leaveForeground('quit'));
|
||||
}
|
||||
return quitDelivery;
|
||||
};
|
||||
const onBeforeQuit = () => {
|
||||
// The main shutdown barrier awaits the same promise. Starting it here
|
||||
// keeps this module correct for callers with their own quit sequence too.
|
||||
void flushQuit();
|
||||
};
|
||||
|
||||
options.app.on('browser-window-focus', onFocus);
|
||||
options.app.on('browser-window-blur', onBlur);
|
||||
options.app.on('before-quit', onBeforeQuit);
|
||||
state.start();
|
||||
void state.start();
|
||||
|
||||
return () => {
|
||||
const stop = () => {
|
||||
cancelBlurTimer();
|
||||
options.app.off('browser-window-focus', onFocus);
|
||||
options.app.off('browser-window-blur', onBlur);
|
||||
options.app.off('before-quit', onBeforeQuit);
|
||||
};
|
||||
|
||||
return { stop, flushQuit };
|
||||
}
|
||||
@@ -41,7 +41,7 @@ import { dialog, BrowserWindow, shell } from 'electron';
|
||||
import { DEFAULT_USER_WORKSPACE, userWorkspaceConfigFile } from '../paths';
|
||||
import { createLogger } from '../logger';
|
||||
import { t } from '../i18n';
|
||||
import { macosTccWorkspaceBlockedPath } from '../util/macos-tcc';
|
||||
import { macosTccSensitivePath } from '../util/macos-tcc';
|
||||
import { logPathRef, logPathRefs } from '../util/log-redact';
|
||||
import { pruneOrphans } from './file_indexer';
|
||||
|
||||
@@ -55,6 +55,9 @@ const log = createLogger('user-workspace');
|
||||
interface ScopeEntry {
|
||||
selectedPath: string;
|
||||
recentPaths: string[];
|
||||
/** Persisted selection-time classification. A protected workspace remains
|
||||
* usable, but passive workspace resolution must not probe it on macOS. */
|
||||
macosTccSensitive?: boolean;
|
||||
}
|
||||
|
||||
interface WorkspaceConfig {
|
||||
@@ -69,6 +72,7 @@ function configPath(userId: string): string {
|
||||
|
||||
const MAX_RECENT = 5;
|
||||
const EMPTY_ENTRY: ScopeEntry = { selectedPath: '', recentPaths: [] };
|
||||
const _tccClassificationMigrationChecked = new Set<string>();
|
||||
|
||||
function _normaliseEntry(raw: any): ScopeEntry {
|
||||
if (!raw || typeof raw !== 'object') return { ...EMPTY_ENTRY };
|
||||
@@ -77,6 +81,9 @@ function _normaliseEntry(raw: any): ScopeEntry {
|
||||
recentPaths: Array.isArray(raw.recentPaths)
|
||||
? raw.recentPaths.filter((p: unknown) => typeof p === 'string')
|
||||
: [],
|
||||
...(raw.macosTccSensitive === true
|
||||
? { macosTccSensitive: true }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -108,12 +115,29 @@ function _normaliseConfig(raw: any): WorkspaceConfig {
|
||||
|
||||
function readConfig(userId: string): WorkspaceConfig {
|
||||
const p = configPath(userId);
|
||||
let cfg: WorkspaceConfig;
|
||||
try {
|
||||
const raw = fs.readFileSync(p, 'utf-8');
|
||||
return _normaliseConfig(JSON.parse(raw));
|
||||
cfg = _normaliseConfig(JSON.parse(raw));
|
||||
} catch {
|
||||
return { default: { ...EMPTY_ENTRY }, projects: {}, updatedAt: '' };
|
||||
}
|
||||
// A short-lived release accepted symlink aliases into TCC roots before the
|
||||
// classification field existed. Resolve those legacy entries once, persist
|
||||
// only a positive classification, and avoid repeating that probe on every
|
||||
// workspace read or app launch.
|
||||
if (!_tccClassificationMigrationChecked.has(p)) {
|
||||
_tccClassificationMigrationChecked.add(p);
|
||||
if (_markMacosTccSensitiveSelections(cfg)) {
|
||||
try { writeConfig(userId, cfg); }
|
||||
catch (err) {
|
||||
log.warn('workspace TCC classification migration could not be persisted', {
|
||||
error_type: err instanceof Error ? err.name : 'unknown',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
function writeConfig(userId: string, cfg: WorkspaceConfig): void {
|
||||
@@ -144,21 +168,24 @@ function _writeEntry(cfg: WorkspaceConfig, projectId: string | undefined, entry:
|
||||
return { ...cfg, default: entry, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
function _isWorkspaceSelectionBlocked(dirPath: string): ReturnType<typeof macosTccWorkspaceBlockedPath> {
|
||||
return macosTccWorkspaceBlockedPath(path.resolve(dirPath));
|
||||
function _macosTccSensitiveWorkspacePath(
|
||||
dirPath: string,
|
||||
): ReturnType<typeof macosTccSensitivePath> {
|
||||
return macosTccSensitivePath(path.resolve(dirPath), { recursive: true });
|
||||
}
|
||||
|
||||
/** Resolve an explicitly selected path before persisting/using it so a
|
||||
* symlink or case-variant alias cannot disguise a protected macOS root. The
|
||||
* direct lexical check runs first and therefore never probes a known TCC
|
||||
* directory merely to reject it. */
|
||||
/** Classify an explicitly selected path before persisting it so a symlink or
|
||||
* case-variant alias cannot disguise a protected macOS root. The direct
|
||||
* lexical check runs first and therefore never probes a known TCC directory.
|
||||
* Protected selections are allowed; the classification tells passive callers
|
||||
* to use the stored path without touching the directory. */
|
||||
function _resolveWorkspaceSelection(dirPath: string): {
|
||||
path: string;
|
||||
blocked: ReturnType<typeof macosTccWorkspaceBlockedPath>;
|
||||
macosTccSensitive: boolean;
|
||||
} {
|
||||
const lexicalPath = path.resolve(dirPath);
|
||||
const lexicalBlock = macosTccWorkspaceBlockedPath(lexicalPath);
|
||||
if (lexicalBlock) return { path: lexicalPath, blocked: lexicalBlock };
|
||||
const lexicalSensitivity = _macosTccSensitiveWorkspacePath(lexicalPath);
|
||||
if (lexicalSensitivity) return { path: lexicalPath, macosTccSensitive: true };
|
||||
let canonicalPath = lexicalPath;
|
||||
try { canonicalPath = fs.realpathSync.native(lexicalPath); }
|
||||
catch { /* setWorkspacePath/stat below returns the canonical missing-path error */ }
|
||||
@@ -166,26 +193,42 @@ function _resolveWorkspaceSelection(dirPath: string): {
|
||||
// Preserve the user-facing spelling (including /var vs /private/var and
|
||||
// safe symlink aliases); canonicalPath is only security-comparison input.
|
||||
path: lexicalPath,
|
||||
blocked: macosTccWorkspaceBlockedPath(canonicalPath),
|
||||
macosTccSensitive: !!_macosTccSensitiveWorkspacePath(canonicalPath),
|
||||
};
|
||||
}
|
||||
|
||||
function _entryIsMacosTccSensitive(entry: ScopeEntry): boolean {
|
||||
if (entry.macosTccSensitive === true) return true;
|
||||
if (_macosTccSensitiveWorkspacePath(entry.selectedPath)) return true;
|
||||
return _resolveWorkspaceSelection(entry.selectedPath).macosTccSensitive;
|
||||
}
|
||||
|
||||
function _markMacosTccSensitiveSelections(cfg: WorkspaceConfig): boolean {
|
||||
let changed = false;
|
||||
const entries = [cfg.default, ...Object.values(cfg.projects)];
|
||||
for (const entry of entries) {
|
||||
if (!entry.selectedPath || entry.macosTccSensitive === true) continue;
|
||||
if (_resolveWorkspaceSelection(entry.selectedPath).macosTccSensitive) {
|
||||
entry.macosTccSensitive = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Effective path for a given scope: project's selection (if any) → default's
|
||||
* selection (if any) → DEFAULT_USER_WORKSPACE. Privacy-protected legacy
|
||||
* selections are never probed and never returned as execution roots. */
|
||||
* selection (if any) → DEFAULT_USER_WORKSPACE. A macOS privacy-protected
|
||||
* selection is returned without stat: it came from the native picker, while
|
||||
* background scanners independently opt out before walking TCC roots. */
|
||||
function _effectivePath(cfg: WorkspaceConfig, projectId?: string): string {
|
||||
if (projectId) {
|
||||
const entry = cfg.projects[projectId];
|
||||
if (entry?.selectedPath) {
|
||||
const selection = _resolveWorkspaceSelection(entry.selectedPath);
|
||||
const blocked = selection.blocked;
|
||||
if (blocked) {
|
||||
log.warn('project workspace path is privacy-protected — falling back without stat', {
|
||||
projectId, path: logPathRef(entry.selectedPath), reason: blocked.reason,
|
||||
});
|
||||
if (_entryIsMacosTccSensitive(entry)) {
|
||||
return path.resolve(entry.selectedPath);
|
||||
} else {
|
||||
try {
|
||||
if (fs.statSync(selection.path).isDirectory()) return selection.path;
|
||||
if (fs.statSync(entry.selectedPath).isDirectory()) return path.resolve(entry.selectedPath);
|
||||
} catch {
|
||||
log.warn('project workspace path missing — falling back to default', {
|
||||
projectId, path: logPathRef(entry.selectedPath),
|
||||
@@ -195,15 +238,11 @@ function _effectivePath(cfg: WorkspaceConfig, projectId?: string): string {
|
||||
}
|
||||
}
|
||||
if (cfg.default.selectedPath) {
|
||||
const selection = _resolveWorkspaceSelection(cfg.default.selectedPath);
|
||||
const blocked = selection.blocked;
|
||||
if (blocked) {
|
||||
log.warn('default workspace path is privacy-protected — using DEFAULT_USER_WORKSPACE without stat', {
|
||||
path: logPathRef(cfg.default.selectedPath), reason: blocked.reason,
|
||||
});
|
||||
if (_entryIsMacosTccSensitive(cfg.default)) {
|
||||
return path.resolve(cfg.default.selectedPath);
|
||||
} else {
|
||||
try {
|
||||
if (fs.statSync(selection.path).isDirectory()) return selection.path;
|
||||
if (fs.statSync(cfg.default.selectedPath).isDirectory()) return path.resolve(cfg.default.selectedPath);
|
||||
} catch {
|
||||
log.warn('default workspace path missing — using DEFAULT_USER_WORKSPACE', {
|
||||
path: logPathRef(cfg.default.selectedPath),
|
||||
@@ -217,10 +256,10 @@ function _effectivePath(cfg: WorkspaceConfig, projectId?: string): string {
|
||||
function _configuredDisplayPath(cfg: WorkspaceConfig, projectId?: string): string {
|
||||
if (projectId) {
|
||||
const projectSelected = cfg.projects[projectId]?.selectedPath;
|
||||
if (projectSelected && !_isWorkspaceSelectionBlocked(projectSelected)) return projectSelected;
|
||||
if (projectSelected) return projectSelected;
|
||||
}
|
||||
const selected = cfg.default.selectedPath;
|
||||
if (selected && !_isWorkspaceSelectionBlocked(selected)) return selected;
|
||||
if (selected) return selected;
|
||||
return DEFAULT_USER_WORKSPACE;
|
||||
}
|
||||
|
||||
@@ -252,7 +291,12 @@ export function getWorkspacePath(userId: string, projectId?: string): string {
|
||||
*/
|
||||
export function consumePickerFirstOpenDefault(userId: string): string | undefined {
|
||||
if (!userId) return undefined;
|
||||
try { return getWorkspacePath(userId); }
|
||||
try {
|
||||
const workspacePath = getWorkspacePath(userId);
|
||||
return _macosTccSensitiveWorkspacePath(workspacePath)
|
||||
? DEFAULT_USER_WORKSPACE
|
||||
: workspacePath;
|
||||
}
|
||||
catch { return undefined; }
|
||||
}
|
||||
|
||||
@@ -276,8 +320,10 @@ export async function resolveProjectIdForCid(userId: string, cid?: string): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a user-chosen workspace path for a given scope. The directory
|
||||
* must exist. Returns `{ ok: true, path }` or `{ ok: false, error }`.
|
||||
* Persist a user-chosen workspace path for a given scope. Ordinary paths are
|
||||
* validated on disk. A macOS TCC-sensitive path comes from NSOpenPanel and is
|
||||
* persisted without another probe so selection itself does not cause a second
|
||||
* authorization attempt. Returns `{ ok: true, path }` or `{ ok: false, error }`.
|
||||
*/
|
||||
export function setWorkspacePath(
|
||||
userId: string,
|
||||
@@ -286,18 +332,13 @@ export function setWorkspacePath(
|
||||
): { ok: true; path: string } | { ok: false; error: string } {
|
||||
const selection = _resolveWorkspaceSelection(dirPath);
|
||||
const resolved = selection.path;
|
||||
const protectedSelection = selection.blocked;
|
||||
if (protectedSelection) {
|
||||
log.warn('refused privacy-protected workspace selection', {
|
||||
userId, projectId: projectId || '(default)', path: logPathRef(resolved), reason: protectedSelection.reason,
|
||||
});
|
||||
return { ok: false, error: t('errors.workspace_privacy_protected') };
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isDirectory()) return { ok: false, error: t('errors.path_not_dir') };
|
||||
} catch {
|
||||
return { ok: false, error: t('errors.dir_not_exists') };
|
||||
if (!selection.macosTccSensitive) {
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isDirectory()) return { ok: false, error: t('errors.path_not_dir') };
|
||||
} catch {
|
||||
return { ok: false, error: t('errors.dir_not_exists') };
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = readConfig(userId);
|
||||
@@ -315,6 +356,7 @@ export function setWorkspacePath(
|
||||
const next = _writeEntry(cfg, projectId, {
|
||||
selectedPath: resolved,
|
||||
recentPaths: recents.slice(0, MAX_RECENT),
|
||||
...(selection.macosTccSensitive ? { macosTccSensitive: true } : {}),
|
||||
});
|
||||
writeConfig(userId, next);
|
||||
log.info('workspace path updated', { userId, projectId: projectId || '(default)', path: logPathRef(resolved) });
|
||||
@@ -376,16 +418,15 @@ export function getWorkspaceInfo(userId: string, projectId?: string): {
|
||||
} {
|
||||
const cfg = readConfig(userId);
|
||||
const entry = _readEntry(cfg, projectId);
|
||||
// This runs on renderer boot to paint the workspace chip. Do not touch
|
||||
// protected external directories here: macOS will surface TCC prompts for
|
||||
// paths like ~/Downloads even for a simple stat. Actual selection/use still
|
||||
// validates through setWorkspacePath/getWorkspacePath.
|
||||
// This runs on renderer boot to paint the workspace chip. It uses configured
|
||||
// strings only and never touches external directories.
|
||||
const currentPath = _configuredDisplayPath(cfg, projectId);
|
||||
const selectedBlocked = entry.selectedPath ? _isWorkspaceSelectionBlocked(entry.selectedPath) : null;
|
||||
const isDefault = !entry.selectedPath || !!selectedBlocked;
|
||||
const isDefault = !entry.selectedPath;
|
||||
const recentPaths = (entry.recentPaths || [])
|
||||
.filter((p) => p !== currentPath && p !== DEFAULT_USER_WORKSPACE)
|
||||
.filter((p) => !_isWorkspaceSelectionBlocked(p))
|
||||
// Re-select protected recents through NSOpenPanel so the action carries
|
||||
// fresh user intent instead of causing a passive filesystem probe.
|
||||
.filter((p) => !_macosTccSensitiveWorkspacePath(p))
|
||||
.slice(0, MAX_RECENT);
|
||||
return {
|
||||
currentPath,
|
||||
@@ -399,7 +440,7 @@ export function getWorkspaceInfo(userId: string, projectId?: string): {
|
||||
|
||||
function _safeDirectoryPickerDefault(): string | undefined {
|
||||
const abs = path.resolve(DEFAULT_USER_WORKSPACE);
|
||||
if (macosTccWorkspaceBlockedPath(abs)) return undefined;
|
||||
if (_macosTccSensitiveWorkspacePath(abs)) return undefined;
|
||||
try { fs.mkdirSync(abs, { recursive: true }); } catch { /* best-effort; stat below decides */ }
|
||||
try { return fs.statSync(abs).isDirectory() ? abs : undefined; }
|
||||
catch { return undefined; }
|
||||
@@ -492,12 +533,9 @@ export async function openWorkspaceInFileManager(
|
||||
const target = _effectivePath(cfg, projectId);
|
||||
const fallbackUsed = !!configuredPath
|
||||
&& path.resolve(configuredPath) !== path.resolve(target);
|
||||
try {
|
||||
const stat = fs.statSync(target);
|
||||
if (!stat.isDirectory()) return { ok: false, error: t('errors.target_not_dir') };
|
||||
} catch {
|
||||
return { ok: false, error: t('errors.dir_not_exists') };
|
||||
}
|
||||
// This is an explicit user action. Delegate availability and directory
|
||||
// handling to the OS instead of probing first: a preflight stat can trigger
|
||||
// macOS Files & Folders authorization before Finder opens.
|
||||
const err = await shell.openPath(target);
|
||||
if (err) {
|
||||
log.warn('failed to open workspace path', { userId, path: logPathRef(target), err });
|
||||
|
||||
@@ -4896,14 +4896,28 @@ async function buildMediaQa(
|
||||
};
|
||||
}
|
||||
|
||||
/** Did a resumable segment-assembly failure cache at least one NEW segment?
|
||||
* Forward progress means an unchanged-input retry genuinely advances, so the
|
||||
* draft repair budget must not count it as a repeated failure; zero-progress
|
||||
* repetition still spends the budget and stays bounded. */
|
||||
/** Did a resumable segment-assembly failure preserve useful render progress?
|
||||
* A newly rendered segment advances the retry. A complete cache hit also
|
||||
* proves that content rendering already succeeded and the failure happened in
|
||||
* a later assembly/environment stage. Both are budget-neutral; incomplete or
|
||||
* contradictory counters stay bounded by the ordinary repair budget. */
|
||||
export function resumableRenderFailureMadeProgress(render: VideoStudioResult): boolean {
|
||||
if (render.ok !== false || render.errorCode !== 'E_SEGMENT_ASSEMBLY_INCOMPLETE') return false;
|
||||
const rendered = (render.scene_segments as { rendered?: unknown } | undefined)?.rendered;
|
||||
return typeof rendered === 'number' && rendered > 0;
|
||||
const segments = render.scene_segments as {
|
||||
total?: unknown;
|
||||
rendered?: unknown;
|
||||
reused?: unknown;
|
||||
failed?: unknown;
|
||||
pending?: unknown;
|
||||
} | undefined;
|
||||
if (typeof segments?.rendered === 'number' && segments.rendered > 0) return true;
|
||||
return typeof segments?.total === 'number'
|
||||
&& Number.isInteger(segments.total)
|
||||
&& segments.total > 0
|
||||
&& typeof segments.reused === 'number'
|
||||
&& segments.reused === segments.total
|
||||
&& segments.failed === 0
|
||||
&& segments.pending === 0;
|
||||
}
|
||||
|
||||
async function failDraft(
|
||||
|
||||
@@ -395,6 +395,10 @@ export type VideoProductionActiveOperation = {
|
||||
operation_id: string;
|
||||
op: string;
|
||||
input_hash?: string;
|
||||
/** Effective renderer quality for draft/export. Kept separate from the
|
||||
* authored-input hash because identical bytes can have different render
|
||||
* costs and deterministic QA outcomes at different quality tiers. */
|
||||
render_quality?: 'unset' | 'draft' | 'standard' | 'high';
|
||||
stage: VideoProductionStage;
|
||||
revision: number;
|
||||
turn_id?: string;
|
||||
@@ -408,6 +412,9 @@ export type VideoProductionOperationJournalEntry = {
|
||||
operation_id: string;
|
||||
op: string;
|
||||
input_hash?: string;
|
||||
/** Effective renderer quality for draft/export. Legacy entries omit it and
|
||||
* therefore cannot prove that a new render is an identical attempt. */
|
||||
render_quality?: 'unset' | 'draft' | 'standard' | 'high';
|
||||
status: 'started' | 'passed' | 'failed' | 'interrupted';
|
||||
turn_id?: string;
|
||||
output_path?: string;
|
||||
|
||||
+16
-4
@@ -194,7 +194,10 @@ import * as clientConfigFeature from './features/client_config';
|
||||
import * as connectorsFeature from './features/connectors';
|
||||
import * as taskNotifications from './features/task_notifications';
|
||||
import * as notificationPermissions from './features/notification_permissions';
|
||||
import { startOpenLifecycleTracking } from './features/open_lifecycle';
|
||||
import {
|
||||
startOpenLifecycleTracking,
|
||||
type OpenLifecycleTracking,
|
||||
} from './features/open_lifecycle';
|
||||
import {
|
||||
consumeColdLaunchConnectorCallback,
|
||||
registerConnectorProtocol,
|
||||
@@ -205,6 +208,7 @@ import * as windowState from './features/window_state';
|
||||
// through the open server bridge.
|
||||
|
||||
let windowsTaskBadgeIcon: ReturnType<typeof nativeImage.createFromDataURL> | null = null;
|
||||
let openLifecycleTracking: OpenLifecycleTracking | null = null;
|
||||
|
||||
function setTaskNotificationBadgeCount(count: number): void {
|
||||
const normalized = Math.max(0, Math.trunc(count));
|
||||
@@ -421,7 +425,7 @@ function registerIpc(): void {
|
||||
// crashes immediately due to missing packages. The shell script receives
|
||||
// this process as its explicit owner, waits for it to exit, then starts the
|
||||
// replacement without touching any other Electron process.
|
||||
ipcMain.handle('orkas.relaunch', () => {
|
||||
ipcMain.handle('orkas.relaunch', async () => {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const script = path.join(paths.PC_ROOT, isWindows ? 'run.cmd' : 'run.sh');
|
||||
const resolved = isWindows
|
||||
@@ -444,6 +448,7 @@ function registerIpc(): void {
|
||||
});
|
||||
child.unref();
|
||||
log.info('relaunch via shell script');
|
||||
await openLifecycleTracking?.flushQuit();
|
||||
app.exit(0);
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -1338,7 +1343,7 @@ if (!gotLock) {
|
||||
}, CONNECTORS_BOOTSTRAP_DELAY_MS);
|
||||
connectorsTimer.unref?.();
|
||||
createWindow();
|
||||
const stopOpenLifecycleTracking = startOpenLifecycleTracking({
|
||||
openLifecycleTracking = startOpenLifecycleTracking({
|
||||
app,
|
||||
getActiveUserId: () => users.getActiveUserId(),
|
||||
hasFocusedWindow: () => BrowserWindow.getAllWindows().some((win) => (
|
||||
@@ -1348,7 +1353,10 @@ if (!gotLock) {
|
||||
// write production analytics. Normal source and packaged runs behave alike.
|
||||
enabled: !E2E_USER_DATA_DIR && !IS_PACKAGED_LAUNCH_SMOKE,
|
||||
});
|
||||
app.once('will-quit', stopOpenLifecycleTracking);
|
||||
app.once('will-quit', () => {
|
||||
openLifecycleTracking?.stop();
|
||||
openLifecycleTracking = null;
|
||||
});
|
||||
await consumeColdLaunchConnectorCallback();
|
||||
|
||||
// Boot tasks declared via util/boot_init.ts. Two phases × two modes:
|
||||
@@ -1433,12 +1441,16 @@ if (!gotLock) {
|
||||
app.on('before-quit', async (e) => {
|
||||
if (shutdownFlushed) return;
|
||||
e.preventDefault();
|
||||
// Begin the network request before slower disk cleanup and do not release
|
||||
// the Electron process until Server has acknowledged the lifecycle request.
|
||||
const lifecycleFlush = openLifecycleTracking?.flushQuit();
|
||||
try { await searchFeature.flushAll(); }
|
||||
catch (err) { createLogger('search').warn('final flush failed', { error: (err as Error).message }); }
|
||||
try {
|
||||
const kb = await import('./features/kb_vector');
|
||||
kb.closeAllKb();
|
||||
} catch (err) { createLogger('kb_vector').warn('close failed', { error: (err as Error).message }); }
|
||||
await lifecycleFlush;
|
||||
shutdownFlushed = true;
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"errors.not_utf8": "Text files must be UTF-8 encoded",
|
||||
"errors.path_not_dir": "Selected path is not a directory",
|
||||
"errors.dir_not_exists": "Directory does not exist",
|
||||
"errors.workspace_privacy_protected": "This folder is protected by macOS privacy controls. Choose a folder in the Orkas default workspace or a regular project directory.",
|
||||
"errors.target_not_dir": "Target is not a directory",
|
||||
"errors.cant_edit_index": "Cannot edit the system index directly",
|
||||
"errors.cant_delete_index": "Cannot delete the system index",
|
||||
@@ -161,6 +160,7 @@
|
||||
"chat.agent_mutation_failed": "The Agent operation could not be completed. Please try again.",
|
||||
"chat.agent_mutation_partial_failed": "Some Agent changes were completed, but others were not. Please retry the remaining changes.",
|
||||
"chat.agent_edit_target_unavailable": "The Agent to edit could not be found. It may have been deleted. Confirm the target and try again.",
|
||||
"chat.memory_write_unconfirmed": "Durable memory was not saved because no memory write completed. Please try the memory request again.",
|
||||
"chat.dispatch_result_hidden": "Result handed back to the commander",
|
||||
"chat.turn_limit_reached": "Reached the automatic step limit for this run and stopped. Send your message again to continue.",
|
||||
"agent.cli.project_dir.label": "Project directory",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"errors.not_utf8": "テキストファイルは UTF-8 エンコードである必要があります",
|
||||
"errors.path_not_dir": "選択したパスはディレクトリではありません",
|
||||
"errors.dir_not_exists": "ディレクトリが存在しません",
|
||||
"errors.workspace_privacy_protected": "このフォルダは macOS のプライバシー保護対象です。Orkas のデフォルトワークスペースまたは通常のプロジェクトディレクトリ内のフォルダを選択してください。",
|
||||
"errors.target_not_dir": "対象はディレクトリではありません",
|
||||
"errors.cant_edit_index": "システムインデックスを直接編集することはできません",
|
||||
"errors.cant_delete_index": "システムインデックスを削除することはできません",
|
||||
@@ -161,6 +160,7 @@
|
||||
"chat.agent_mutation_failed": "エージェントの操作を完了できませんでした。もう一度お試しください。",
|
||||
"chat.agent_mutation_partial_failed": "一部のエージェント変更は完了しましたが、残りは完了できませんでした。未完了の操作を再実行してください。",
|
||||
"chat.agent_edit_target_unavailable": "編集対象のエージェントが見つかりません。削除された可能性があります。対象を確認してもう一度お試しください。",
|
||||
"chat.memory_write_unconfirmed": "永続メモリは保存されませんでした。このターンではメモリへの書き込みが完了していません。メモリへの保存をもう一度依頼してください。",
|
||||
"chat.dispatch_result_hidden": "結果は司令官に返されました",
|
||||
"chat.turn_limit_reached": "今回の自動実行ステップ上限に達したため停止しました。続けるにはメッセージを再送してください。",
|
||||
"agent.cli.project_dir.label": "プロジェクトディレクトリ",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"errors.not_utf8": "Os arquivos de texto devem ser codificados em UTF-8",
|
||||
"errors.path_not_dir": "O caminho selecionado não é um diretório",
|
||||
"errors.dir_not_exists": "O diretório não existe",
|
||||
"errors.workspace_privacy_protected": "Esta pasta é protegida pelos controles de privacidade do macOS. Escolha uma pasta no espaço de trabalho padrão do Orkas ou em um diretório de projeto comum.",
|
||||
"errors.target_not_dir": "O destino não é um diretório",
|
||||
"errors.cant_edit_index": "Não é possível editar o índice do sistema diretamente",
|
||||
"errors.cant_delete_index": "Não é possível excluir o índice do sistema",
|
||||
@@ -161,6 +160,7 @@
|
||||
"chat.agent_mutation_failed": "Não foi possível concluir a operação do agente. Tente novamente.",
|
||||
"chat.agent_mutation_partial_failed": "Algumas alterações do agente foram concluídas, mas outras não. Tente novamente as alterações restantes.",
|
||||
"chat.agent_edit_target_unavailable": "O agente a editar não foi encontrado. Ele pode ter sido excluído. Confirme o alvo e tente novamente.",
|
||||
"chat.memory_write_unconfirmed": "A memória persistente não foi salva porque nenhuma gravação de memória foi concluída. Tente solicitar o salvamento novamente.",
|
||||
"chat.dispatch_result_hidden": "Resultado entregue ao comandante",
|
||||
"chat.turn_limit_reached": "Atingiu o limite automático de passos para esta execução e parou. Envie sua mensagem novamente para continuar.",
|
||||
"agent.cli.project_dir.label": "Diretório do projeto",
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"errors.not_utf8": "文本类文件必须是 UTF-8 编码",
|
||||
"errors.path_not_dir": "所选路径不是目录",
|
||||
"errors.dir_not_exists": "目录不存在",
|
||||
"errors.workspace_privacy_protected": "这个文件夹属于 macOS 隐私保护范围。请在 Orkas 默认工作区或普通项目目录中选择一个文件夹。",
|
||||
"errors.target_not_dir": "目标不是目录",
|
||||
"errors.cant_edit_index": "不能直接编辑系统索引",
|
||||
"errors.cant_delete_index": "不能删除系统索引",
|
||||
@@ -161,6 +160,7 @@
|
||||
"chat.agent_mutation_failed": "本次智能体操作未能完成,请重试。",
|
||||
"chat.agent_mutation_partial_failed": "部分智能体更改已完成,其余内容未完成。请重试未完成的操作。",
|
||||
"chat.agent_edit_target_unavailable": "未找到要编辑的智能体,可能已被删除。请确认目标后重试。",
|
||||
"chat.memory_write_unconfirmed": "持久记忆未保存:本轮没有完成记忆写入。请重新发送记忆请求。",
|
||||
"chat.dispatch_result_hidden": "结果已交回指挥官",
|
||||
"chat.turn_limit_reached": "已达到本次自动执行的步数上限并已停止。如需继续,请重新发送消息。",
|
||||
"agent.cli.project_dir.label": "项目目录",
|
||||
|
||||
@@ -234,6 +234,10 @@ export interface ChatOptions {
|
||||
* and attaches a `artifacts[]` list to the assistant message so the
|
||||
* renderer embeds each interactive web-app artifact in the bubble. */
|
||||
onArtifactCreated?: (a: { id: string; title: string }) => void;
|
||||
/** Fired only after the custom-connector manager has durably accepted a
|
||||
* user-confirmed server. Group chat uses the stable id to offer a review
|
||||
* shortcut; the callback is not part of installation authorization. */
|
||||
onCustomConnectorAdded?: (connectorId: string) => void;
|
||||
/** Fired at turn start with each skill id that entered the system-prompt
|
||||
* index, split by source system (`A.custom` / `A.platform` / `B`).
|
||||
* `features/group_chat` buffers per turn and emits `skill_advertised`
|
||||
|
||||
@@ -603,7 +603,10 @@ function createChatReadTool(opts: ChatHistoryToolsOpts): AgentTool {
|
||||
type ChatHistoryAction = 'search' | 'read';
|
||||
|
||||
const CHAT_HISTORY_ACTION_FIELDS: Readonly<Record<ChatHistoryAction, ReadonlySet<string>>> = {
|
||||
search: new Set(['action', 'query', 'k', 'scope', 'include_current']),
|
||||
// `page` is part of the consolidated provider-visible schema. Some
|
||||
// providers populate that schema-valid field even for search, so tolerate
|
||||
// and ignore it instead of rejecting an otherwise valid search call.
|
||||
search: new Set(['action', 'query', 'k', 'scope', 'include_current', 'page']),
|
||||
// Legacy flat paging fields remain execution-only for model calls copied
|
||||
// from an older conversation. The provider-visible schema advertises only
|
||||
// the tagged `page` contract.
|
||||
|
||||
@@ -1542,6 +1542,7 @@ export async function* streamChatWithModel(opts: ChatOptions): AsyncGenerator<St
|
||||
getPublishableOutputPaths,
|
||||
hasProducedPath,
|
||||
onArtifactCreated,
|
||||
onCustomConnectorAdded,
|
||||
onSkillAdvertised,
|
||||
onSkillInvoked,
|
||||
cacheRetention,
|
||||
@@ -1811,6 +1812,7 @@ export async function* streamChatWithModel(opts: ChatOptions): AsyncGenerator<St
|
||||
...(getPublishableOutputPaths ? { getPublishableOutputPaths } : {}),
|
||||
...(hasProducedPath ? { hasProducedPath } : {}),
|
||||
...(onArtifactCreated ? { onArtifactCreated } : {}),
|
||||
...(onCustomConnectorAdded ? { onCustomConnectorAdded } : {}),
|
||||
...(onSkillAdvertised ? { onSkillAdvertised } : {}),
|
||||
...(onSkillInvoked ? { onSkillInvoked } : {}),
|
||||
onNativeSearchInjected: (info) => {
|
||||
|
||||
@@ -58,6 +58,9 @@ export interface ConnectorMetaToolsOpts {
|
||||
allowRuntimeRefresh?: boolean;
|
||||
/** Only the commander may offer installation of a custom MCP server. */
|
||||
allowCustomConnectorInstall?: boolean;
|
||||
/** Host-side post-success hook. It may expose a review shortcut but must
|
||||
* never authorize or perform the installation itself. */
|
||||
onCustomConnectorAdded?: (connectorId: string) => void;
|
||||
/** UI-only display metadata refreshed by the same visibility checks. */
|
||||
connectorDisplayNameById?: Map<string, string>;
|
||||
}
|
||||
@@ -329,7 +332,7 @@ function createAddCustomConnectorTool(opts: ConnectorMetaToolsOpts & { cid: stri
|
||||
return {
|
||||
name: 'add_custom_connector',
|
||||
description:
|
||||
'Add a specific custom MCP server only when explicitly requested by the user. Installation requires user confirmation; approved servers are then used through list_connector_tools and call_connector_tool.',
|
||||
'Add a specific custom MCP server only when the user explicitly requested it and already supplied the exact non-secret configuration. Installation requires user confirmation. Never ask the user to paste a missing credential, API key, or token into chat; when protected input is still required, navigate to Connectors > Add MCP server instead.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -338,8 +341,8 @@ function createAddCustomConnectorTool(opts: ConnectorMetaToolsOpts & { cid: stri
|
||||
type: 'object',
|
||||
description:
|
||||
'Either { kind: "streamable-http", url, headers? } for a remote server, or '
|
||||
+ '{ kind: "stdio", command, args?, env? } for a local command. Put API keys in '
|
||||
+ 'headers (http) or env (stdio).',
|
||||
+ '{ kind: "stdio", command, args?, env? } for a local command. Use only exact '
|
||||
+ 'values already supplied in the current user request; never invent or solicit secret values in chat.',
|
||||
},
|
||||
},
|
||||
required: ['name', 'transport'],
|
||||
@@ -362,15 +365,14 @@ function createAddCustomConnectorTool(opts: ConnectorMetaToolsOpts & { cid: stri
|
||||
}
|
||||
try {
|
||||
const inst = await manager.addCustomInstance(opts.userId, { display_name: displayName, transport });
|
||||
try { opts.onCustomConnectorAdded?.(inst.id); }
|
||||
catch { /* post-success navigation is best-effort */ }
|
||||
if (inst.status.kind === 'connected') {
|
||||
return { content: `Connected "${inst.display_name}" (id: ${inst.id}). Its tools are now available via list_connector_tools({connector_id: "${inst.id}"}).` };
|
||||
}
|
||||
const msg = (inst.status.kind === 'error' || inst.status.kind === 'degraded')
|
||||
? inst.status.message
|
||||
: inst.status.kind;
|
||||
return { content: `Added "${inst.display_name}" (id: ${inst.id}) but it could not connect yet: ${msg}. The user can retry it from the Connectors panel.` };
|
||||
return { content: `Added "${inst.display_name}" (id: ${inst.id}) but it could not connect yet. Ask the user to review the protected configuration in Connectors and retry there.` };
|
||||
} catch (err) {
|
||||
return errResult('E_INSTALL_FAILED', `could not add connector: ${(err as Error).message}`);
|
||||
return errResult('E_INSTALL_FAILED', 'could not add connector. Ask the user to review the protected Connectors form and retry there.');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -553,28 +553,60 @@ function createReadFileTool(
|
||||
let sourceStat: fs.Stats;
|
||||
try { sourceStat = fs.statSync(abs); }
|
||||
catch (err) {
|
||||
const siblings = findUniquifySiblings(abs);
|
||||
log.warn('read_files not found', {
|
||||
const osCode = typeof (err as NodeJS.ErrnoException)?.code === 'string'
|
||||
? (err as NodeJS.ErrnoException).code!
|
||||
: '';
|
||||
const detail = `${displayPath}: ${displayErrorMessage(err, abs, displayPath)}`;
|
||||
if (osCode === 'ENOENT' || osCode === 'ENOTDIR') {
|
||||
const siblings = osCode === 'ENOENT' ? findUniquifySiblings(abs) : [];
|
||||
log.warn('read_files not found', {
|
||||
user_id: maskId(opts.userId),
|
||||
path: logPathRef(abs),
|
||||
os_code: osCode,
|
||||
sibling_count: siblings.length,
|
||||
error: logErrorRef(err),
|
||||
});
|
||||
let content = errText('E_NOT_FOUND', detail);
|
||||
content +=
|
||||
'\n\n<missing-file-recovery>\n'
|
||||
+ 'Do not infer this file\'s contents. If the user supplied this exact path, ask for the correct accessible path or an attachment. '
|
||||
+ 'If you inferred the path, use search_files to locate the source before asking the user.\n'
|
||||
+ '</missing-file-recovery>';
|
||||
if (siblings.length) {
|
||||
content +=
|
||||
'\n\n<file-renamed-earlier>\n'
|
||||
+ 'This name was uniquified earlier in this conversation. Existing variants in the same directory:\n'
|
||||
+ siblings.map((b) => ` - ${b}`).join('\n')
|
||||
+ '\nUse one of those paths instead — the original requested name was never written.\n'
|
||||
+ '</file-renamed-earlier>';
|
||||
}
|
||||
return { content, isError: true };
|
||||
}
|
||||
if (osCode === 'EACCES' || osCode === 'EPERM') {
|
||||
log.warn('read_files permission denied', {
|
||||
user_id: maskId(opts.userId),
|
||||
path: logPathRef(abs),
|
||||
os_code: osCode,
|
||||
error: logErrorRef(err),
|
||||
});
|
||||
return {
|
||||
content: errText(
|
||||
'E_PERMISSION_DENIED',
|
||||
`${detail}\nos_code=${osCode}\nThe operating system denied access. Do not retry automatically or treat the file as missing; ask the user to grant access or choose an accessible file.`,
|
||||
),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
log.warn('read_files stat failed', {
|
||||
user_id: maskId(opts.userId),
|
||||
path: logPathRef(abs),
|
||||
sibling_count: siblings.length,
|
||||
...(osCode ? { os_code: osCode } : {}),
|
||||
error: logErrorRef(err),
|
||||
});
|
||||
let content = errText('E_NOT_FOUND', `${displayPath}: ${displayErrorMessage(err, abs, displayPath)}`);
|
||||
content +=
|
||||
'\n\n<missing-file-recovery>\n'
|
||||
+ 'Do not infer this file\'s contents. If the user supplied this exact path, ask for the correct accessible path or an attachment. '
|
||||
+ 'If you inferred the path, use search_files to locate the source before asking the user.\n'
|
||||
+ '</missing-file-recovery>';
|
||||
if (siblings.length) {
|
||||
content +=
|
||||
'\n\n<file-renamed-earlier>\n'
|
||||
+ 'This name was uniquified earlier in this conversation. Existing variants in the same directory:\n'
|
||||
+ siblings.map((b) => ` - ${b}`).join('\n')
|
||||
+ '\nUse one of those paths instead — the original requested name was never written.\n'
|
||||
+ '</file-renamed-earlier>';
|
||||
}
|
||||
return { content, isError: true };
|
||||
return {
|
||||
content: errText('E_READ_FAILED', `${detail}${osCode ? `\nos_code=${osCode}` : ''}`),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const kind = kindOf(abs);
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
} from './external-mutation-risk';
|
||||
import { requestBashDecision } from './bash-permissions';
|
||||
import { markdownToPdf, htmlToPdf } from '../../util/md-to-pdf';
|
||||
import { uniquifyPath, renderRenameSignal } from '../../util/uniquify-path';
|
||||
import { uniquifyPathForWrite, renderRenameSignal } from '../../util/uniquify-path';
|
||||
import { isPathAllowed } from '../../util/path-sandbox';
|
||||
import { kindOf } from '../../features/file_indexer';
|
||||
import { getWorkspacePath } from '../../features/user_workspace';
|
||||
@@ -4115,11 +4115,20 @@ function createWriteFileTool(opts: LocalToolsOpts): AgentTool {
|
||||
log.warn('write_file scope reject', { user_id: maskId(opts.userId), path: logPathRef(inputAbs) });
|
||||
return { content: scopeErr, isError: true };
|
||||
}
|
||||
const { finalPath, renamed } = await uniquifyPath(inputAbs, isMineFor(opts));
|
||||
const { finalPath, renamed, result } = await uniquifyPathForWrite(
|
||||
inputAbs,
|
||||
isMineFor(opts),
|
||||
async (decision) => ({
|
||||
...decision,
|
||||
result: await coreWriteFileTool.execute(
|
||||
decision.finalPath !== inputAbs ? { ...input, path: decision.finalPath } : input,
|
||||
ctx,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const rewritten = finalPath !== inputAbs
|
||||
? { ...input, path: finalPath }
|
||||
: input;
|
||||
const result = await coreWriteFileTool.execute(rewritten, ctx);
|
||||
if (!result.isError) {
|
||||
// Stamp the just-written bytes so a follow-up edit_file accepts an edit
|
||||
// without an intervening read_files call (the model already knows the content
|
||||
@@ -4757,28 +4766,43 @@ function createMarkdownToPdfTool(opts: LocalToolsOpts): AgentTool {
|
||||
log.warn('markdown_to_pdf scope reject', { user_id: maskId(opts.userId), path: logPathRef(inputAbs) });
|
||||
return { content: scopeErr, isError: true };
|
||||
}
|
||||
const { finalPath, renamed } = await uniquifyPath(inputAbs, isMineFor(opts));
|
||||
if (finalPath !== inputAbs) {
|
||||
const finalScopeErr = await gateEditPath(opts, finalPath, ctx);
|
||||
if (finalScopeErr) {
|
||||
log.warn('markdown_to_pdf final scope reject', { user_id: maskId(opts.userId), path: logPathRef(finalPath) });
|
||||
return { content: finalScopeErr, isError: true };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const footerText = producedDocumentFooterText({ userId: opts.userId, cid: opts.cid, source: 'markdown_to_pdf' });
|
||||
await markdownToPdf(String(input.markdown ?? ''), finalPath, {
|
||||
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
||||
...(typeof input.pageSize === 'string' ? { pageSize: input.pageSize as any } : {}),
|
||||
...(typeof input.landscape === 'boolean' ? { landscape: input.landscape } : {}),
|
||||
...(footerText ? { footerText } : {}),
|
||||
});
|
||||
if (opts.onFileWritten) await opts.onFileWritten(finalPath);
|
||||
const base = `PDF written: ${finalPath}`;
|
||||
return { content: renamed ? `${base}${renderRenameSignal(inputAbs, finalPath)}` : base };
|
||||
} catch (err) {
|
||||
return { content: `Error generating PDF: ${(err as Error).message}`, isError: true };
|
||||
const markdown = String(input.markdown ?? '');
|
||||
const footerText = producedDocumentFooterText({ userId: opts.userId, cid: opts.cid, source: 'markdown_to_pdf' });
|
||||
const renderOptions = {
|
||||
...(typeof input.title === 'string' ? { title: input.title } : {}),
|
||||
...(typeof input.pageSize === 'string' ? { pageSize: input.pageSize as any } : {}),
|
||||
...(typeof input.landscape === 'boolean' ? { landscape: input.landscape } : {}),
|
||||
...(footerText ? { footerText } : {}),
|
||||
};
|
||||
const outcome = await uniquifyPathForWrite(
|
||||
inputAbs,
|
||||
isMineFor(opts),
|
||||
async (decision): Promise<
|
||||
{ kind: 'scope'; err: string }
|
||||
| { kind: 'render'; err: string }
|
||||
| ({ kind: 'ok' } & typeof decision)
|
||||
> => {
|
||||
if (decision.finalPath !== inputAbs) {
|
||||
const finalScopeErr = await gateEditPath(opts, decision.finalPath, ctx);
|
||||
if (finalScopeErr) return { kind: 'scope', err: finalScopeErr };
|
||||
}
|
||||
try {
|
||||
await markdownToPdf(markdown, decision.finalPath, renderOptions);
|
||||
} catch (err) {
|
||||
return { kind: 'render', err: `Error generating PDF: ${(err as Error).message}` };
|
||||
}
|
||||
return { kind: 'ok', ...decision };
|
||||
},
|
||||
);
|
||||
if (outcome.kind === 'scope') {
|
||||
log.warn('markdown_to_pdf final scope reject', { user_id: maskId(opts.userId), path: logPathRef(inputAbs) });
|
||||
return { content: outcome.err, isError: true };
|
||||
}
|
||||
if (outcome.kind === 'render') return { content: outcome.err, isError: true };
|
||||
const { finalPath, renamed } = outcome;
|
||||
if (opts.onFileWritten) await opts.onFileWritten(finalPath);
|
||||
const base = `PDF written: ${finalPath}`;
|
||||
return { content: renamed ? `${base}${renderRenameSignal(inputAbs, finalPath)}` : base };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4813,27 +4837,42 @@ function createHtmlToPdfTool(opts: LocalToolsOpts): AgentTool {
|
||||
log.warn('html_to_pdf scope reject', { user_id: maskId(opts.userId), path: logPathRef(inputAbs) });
|
||||
return { content: scopeErr, isError: true };
|
||||
}
|
||||
const { finalPath, renamed } = await uniquifyPath(inputAbs, isMineFor(opts));
|
||||
if (finalPath !== inputAbs) {
|
||||
const finalScopeErr = await gateEditPath(opts, finalPath, ctx);
|
||||
if (finalScopeErr) {
|
||||
log.warn('html_to_pdf final scope reject', { user_id: maskId(opts.userId), path: logPathRef(finalPath) });
|
||||
return { content: finalScopeErr, isError: true };
|
||||
}
|
||||
}
|
||||
try {
|
||||
const footerText = producedDocumentFooterText({ userId: opts.userId, cid: opts.cid, source: 'html_to_pdf' });
|
||||
await htmlToPdf(String(input.html ?? ''), finalPath, {
|
||||
...(typeof input.pageSize === 'string' ? { pageSize: input.pageSize as any } : {}),
|
||||
...(typeof input.landscape === 'boolean' ? { landscape: input.landscape } : {}),
|
||||
...(footerText ? { footerText } : {}),
|
||||
});
|
||||
if (opts.onFileWritten) await opts.onFileWritten(finalPath);
|
||||
const base = `PDF written: ${finalPath}`;
|
||||
return { content: renamed ? `${base}${renderRenameSignal(inputAbs, finalPath)}` : base };
|
||||
} catch (err) {
|
||||
return { content: `Error generating PDF: ${(err as Error).message}`, isError: true };
|
||||
const html = String(input.html ?? '');
|
||||
const footerText = producedDocumentFooterText({ userId: opts.userId, cid: opts.cid, source: 'html_to_pdf' });
|
||||
const renderOptions = {
|
||||
...(typeof input.pageSize === 'string' ? { pageSize: input.pageSize as any } : {}),
|
||||
...(typeof input.landscape === 'boolean' ? { landscape: input.landscape } : {}),
|
||||
...(footerText ? { footerText } : {}),
|
||||
};
|
||||
const outcome = await uniquifyPathForWrite(
|
||||
inputAbs,
|
||||
isMineFor(opts),
|
||||
async (decision): Promise<
|
||||
{ kind: 'scope'; err: string }
|
||||
| { kind: 'render'; err: string }
|
||||
| ({ kind: 'ok' } & typeof decision)
|
||||
> => {
|
||||
if (decision.finalPath !== inputAbs) {
|
||||
const finalScopeErr = await gateEditPath(opts, decision.finalPath, ctx);
|
||||
if (finalScopeErr) return { kind: 'scope', err: finalScopeErr };
|
||||
}
|
||||
try {
|
||||
await htmlToPdf(html, decision.finalPath, renderOptions);
|
||||
} catch (err) {
|
||||
return { kind: 'render', err: `Error generating PDF: ${(err as Error).message}` };
|
||||
}
|
||||
return { kind: 'ok', ...decision };
|
||||
},
|
||||
);
|
||||
if (outcome.kind === 'scope') {
|
||||
log.warn('html_to_pdf final scope reject', { user_id: maskId(opts.userId), path: logPathRef(inputAbs) });
|
||||
return { content: outcome.err, isError: true };
|
||||
}
|
||||
if (outcome.kind === 'render') return { content: outcome.err, isError: true };
|
||||
const { finalPath, renamed } = outcome;
|
||||
if (opts.onFileWritten) await opts.onFileWritten(finalPath);
|
||||
const base = `PDF written: ${finalPath}`;
|
||||
return { content: renamed ? `${base}${renderRenameSignal(inputAbs, finalPath)}` : base };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -242,6 +242,8 @@ export interface BuildRunnerParams {
|
||||
/** Stable inbound message id used as the exclusive upper bound for
|
||||
* current-conversation history tools. */
|
||||
historyBoundaryMessageId?: string;
|
||||
/** Host-side post-success hook for a user-confirmed custom Connector. */
|
||||
onCustomConnectorAdded?: (connectorId: string) => void;
|
||||
/** Current real user text, used by tools that must bind an approval action
|
||||
* to explicit user intent rather than merely to the existence of a turn. */
|
||||
userMessage?: string;
|
||||
@@ -1052,6 +1054,7 @@ export async function buildRunner(params: BuildRunnerParams): Promise<{
|
||||
...(params.cid ? { cid: params.cid } : {}),
|
||||
...(params.richSteerEnabled ? { allowRuntimeRefresh: true } : {}),
|
||||
...(isCommander ? { allowCustomConnectorInstall: true } : {}),
|
||||
...(params.onCustomConnectorAdded ? { onCustomConnectorAdded: params.onCustomConnectorAdded } : {}),
|
||||
},
|
||||
exposure === 'discover+block' ? 'discover' : 'full',
|
||||
)
|
||||
@@ -1193,10 +1196,11 @@ export async function buildRunner(params: BuildRunnerParams): Promise<{
|
||||
: [
|
||||
'read_files',
|
||||
'tool_result',
|
||||
// Global Skills are deliberately absent from the initial file scope.
|
||||
// Commander needs this small discovery gate active so a successful
|
||||
// search can grant read access to only the returned Skill directories.
|
||||
...(isCommander && availableToolNames.includes('skill_search') ? ['skill_search'] : []),
|
||||
// App navigation is the one resident Commander support affordance:
|
||||
// it is broadly useful, closed-set, and remains side-effect-free
|
||||
// until the user clicks the staged card. Skill discovery and app
|
||||
// diagnosis stay behind their focused Management child groups.
|
||||
...(isCommander && availableToolNames.includes('open_app_view') ? ['open_app_view'] : []),
|
||||
...(params.onOutputsPublished ? ['publish_outputs'] : []),
|
||||
],
|
||||
restoredState: session.getToolSurfaceState(),
|
||||
|
||||
@@ -666,7 +666,7 @@ export function openSkillReadRoots(uid: string): string[] {
|
||||
* and keeping them search-only with no count keeps the cache prefix stable. */
|
||||
const OPEN_TIER_SKILL_HINT =
|
||||
'More skills may be available from your global skill folders — these are NOT listed '
|
||||
+ 'above. Call the `skill_search` tool with a capability query to find them, then '
|
||||
+ 'above. Load `management.skills`, call `skill_search` with a capability query, then '
|
||||
+ '`read_files` the returned run-scoped Skill ref before invoking.';
|
||||
|
||||
const OPEN_SEARCH_DEFAULT_LIMIT = 5;
|
||||
@@ -1136,7 +1136,7 @@ export async function getSystemSkillsPromptBlock(
|
||||
'',
|
||||
'System skills are product protocols. They are not marketplace or custom skills.',
|
||||
'',
|
||||
'Match the whole request against every description. Before other work, read the smallest complete set of matching SKILL.md files; never load nonmatches.',
|
||||
'Treat each description as an activation contract. Decide from the whole request\'s intended outcome and context, not keyword overlap. Before answering or calling another tool, read the smallest complete set whose use conditions apply; never load nonmatches.',
|
||||
'',
|
||||
'Read one match:',
|
||||
runtimeRefBySpec
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
* it as their current-state oracle without pulling the Electron runtime or the
|
||||
* tool catalog's logger/path dependency chain into model-eval processes.
|
||||
*/
|
||||
export const TOOL_CATALOG_REVISION = '10';
|
||||
export const TOOL_CATALOG_REVISION = '12';
|
||||
@@ -35,6 +35,10 @@ export type ToolGroupId =
|
||||
| 'context'
|
||||
| 'orchestration'
|
||||
| 'management'
|
||||
| 'management.app'
|
||||
| 'management.skills'
|
||||
| 'management.marketplace'
|
||||
| 'management.automation'
|
||||
| 'runtime';
|
||||
|
||||
export interface ToolGroupEntry {
|
||||
@@ -91,7 +95,11 @@ export const TOOL_GROUPS: readonly ToolGroupEntry[] = [
|
||||
{ id: 'connectors', title: 'Connectors', summary: 'Discover and call enabled third-party connector actions.', activation: 'loadable', agentDependency: true },
|
||||
{ id: 'context', title: 'Context', summary: 'Conversation, plan, memory, and project context managed by the host.', activation: 'host-managed', agentDependency: false },
|
||||
{ id: 'orchestration', title: 'Orchestration', summary: 'Commander delegation, handoff, and anonymous worker controls.', activation: 'host-managed', agentDependency: false },
|
||||
{ id: 'management', title: 'Management', summary: 'Skill, marketplace, and automation management controls.', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'management', title: 'Management', summary: 'App support, Skill, marketplace, and automation management controls.', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'management.app', title: 'App support', summary: 'Navigate supported Orkas screens and inspect sanitized app health.', parent: 'management', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'management.skills', title: 'Skill management', summary: 'Discover global-folder Skills or import a user-requested Skill package.', parent: 'management', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'management.marketplace', title: 'Marketplace management', summary: 'Search the Marketplace and request a confirmed installation.', parent: 'management', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'management.automation', title: 'Automation management', summary: 'Inspect scheduled and recurring automation tasks.', parent: 'management', activation: 'loadable', agentDependency: false },
|
||||
{ id: 'runtime', title: 'Runtime', summary: 'Tool-surface, learned-skill, and oversized-result runtime controls.', activation: 'host-managed', agentDependency: false },
|
||||
] as const;
|
||||
|
||||
@@ -179,12 +187,14 @@ export const TOOL_CATALOG: readonly ToolCatalogEntry[] = [
|
||||
{ name: 'hand_off_to', loadGroups: ['orchestration'], summary: 'Transfer terminal ownership to another Agent.' },
|
||||
{ name: 'run_worker', loadGroups: ['orchestration'], summary: 'Run an anonymous private worker.' },
|
||||
|
||||
{ name: 'skill_search', loadGroups: ['management'], summary: 'Search available open-tier skills.' },
|
||||
{ name: 'import_skill_package', loadGroups: ['management'], summary: 'Import a user-requested skill package.' },
|
||||
{ name: 'skill_search', loadGroups: ['management.skills'], summary: 'Search available global-folder skills.' },
|
||||
{ name: 'import_skill_package', loadGroups: ['management.skills'], summary: 'Import a user-requested skill package.' },
|
||||
{ name: 'skill_manage', loadGroups: ['runtime'], summary: 'Manage an Agent-owned learned skill.' },
|
||||
{ name: 'marketplace_search', loadGroups: ['management'], summary: 'Search the marketplace.' },
|
||||
{ name: 'marketplace_request_install', loadGroups: ['management'], summary: 'Request a user-confirmed marketplace installation.' },
|
||||
{ name: 'auto_tasks_list', loadGroups: ['management'], summary: 'List automation tasks.' },
|
||||
{ name: 'marketplace_search', loadGroups: ['management.marketplace'], summary: 'Search the marketplace.' },
|
||||
{ name: 'marketplace_request_install', loadGroups: ['management.marketplace'], summary: 'Request a user-confirmed marketplace installation.' },
|
||||
{ name: 'auto_tasks_list', loadGroups: ['management.automation'], summary: 'List automation tasks.' },
|
||||
{ name: 'open_app_view', loadGroups: ['management.app'], summary: 'Stage a click-to-open navigation card to an app surface.' },
|
||||
{ name: 'app_health', loadGroups: ['management.app'], summary: 'Read-only sanitized app diagnosis snapshot.' },
|
||||
|
||||
{ name: 'tool_load', loadGroups: ['runtime'], summary: 'Activate one or more loadable tool groups.' },
|
||||
{ name: 'tool_result', loadGroups: ['runtime'], summary: 'Search, aggregate, or page a persisted oversized tool result.' },
|
||||
|
||||
@@ -5860,6 +5860,7 @@ async function startVideoStudioOperationState(input: {
|
||||
statePath: string;
|
||||
compositionDirAbs: string;
|
||||
op: string;
|
||||
renderQuality?: 'unset' | RenderQuality;
|
||||
turnId?: string;
|
||||
outputPath?: string;
|
||||
reportPath?: string;
|
||||
@@ -5875,6 +5876,7 @@ async function startVideoStudioOperationState(input: {
|
||||
...(artifacts.composition_signature
|
||||
? { input_hash: artifacts.composition_signature }
|
||||
: {}),
|
||||
...(input.renderQuality ? { render_quality: input.renderQuality } : {}),
|
||||
stage: state.stage,
|
||||
revision: state.revision + 1,
|
||||
...(input.turnId ? { turn_id: input.turnId } : {}),
|
||||
@@ -5891,6 +5893,7 @@ async function startVideoStudioOperationState(input: {
|
||||
...(artifacts.composition_signature
|
||||
? { input_hash: artifacts.composition_signature }
|
||||
: {}),
|
||||
...(input.renderQuality ? { render_quality: input.renderQuality } : {}),
|
||||
status: 'started' as const,
|
||||
...(input.turnId ? { turn_id: input.turnId } : {}),
|
||||
...(input.outputPath ? { output_path: input.outputPath } : {}),
|
||||
@@ -11739,9 +11742,21 @@ export function createVideoStudioTool(opts: VideoStudioToolOpts): AgentTool {
|
||||
const renderInputSignature = (op === 'composition.draft' || op === 'composition.export')
|
||||
? (await videoProductionArtifacts(compositionDirAbs)).composition_signature || 'missing-signature'
|
||||
: '';
|
||||
// The breaker must identify the render request, not just its authored
|
||||
// bytes. A failed preview-quality delivery does not predict a failed
|
||||
// high-quality delivery (and vice versa). `unset` is deliberate for
|
||||
// draft: the renderer gives an omitted quality its own fps/CRF profile.
|
||||
const renderQualityIdentity: 'unset' | RenderQuality | undefined = op === 'composition.export'
|
||||
? quality ?? 'high'
|
||||
: op === 'composition.draft'
|
||||
? quality ?? 'unset'
|
||||
: undefined;
|
||||
const identicalRenderFailures = (stateBefore.operation_journal || []).filter(
|
||||
(entry) => entry.op === op
|
||||
&& entry.input_hash === renderInputSignature
|
||||
// Legacy entries carry no quality. Fail open because they cannot
|
||||
// prove the current render request is actually identical.
|
||||
&& entry.render_quality === renderQualityIdentity
|
||||
&& entry.status === 'failed'
|
||||
&& entry.consumes_same_input_attempt === true,
|
||||
);
|
||||
@@ -11771,6 +11786,7 @@ export function createVideoStudioTool(opts: VideoStudioToolOpts): AgentTool {
|
||||
next_action: 'repair_inputs_then_retry_render',
|
||||
operation_journal_evidence: {
|
||||
input_hash: renderInputSignature,
|
||||
render_quality: renderQualityIdentity,
|
||||
same_input_attempts: identicalRenderAttempts,
|
||||
...(identicalQaVerdicts ? { same_input_deterministic_qa_failures: identicalQaVerdicts } : {}),
|
||||
durable: true,
|
||||
@@ -11800,6 +11816,7 @@ export function createVideoStudioTool(opts: VideoStudioToolOpts): AgentTool {
|
||||
statePath: gateStatePath,
|
||||
compositionDirAbs,
|
||||
op,
|
||||
...(renderQualityIdentity ? { renderQuality: renderQualityIdentity } : {}),
|
||||
turnId: opts.turnId,
|
||||
...(outputAbsPath ? { outputPath: outputAbsPath } : {}),
|
||||
...(effectiveReportPath ? { reportPath: effectiveReportPath } : {}),
|
||||
|
||||
+13
-6
@@ -697,23 +697,30 @@ export function runtimeResourcesDir(): string {
|
||||
}
|
||||
|
||||
/** `${process.platform}-${process.arch}` → vendored OfficeCLI asset name.
|
||||
* Mirrors `scripts/fetch-officecli.cjs`. Desktop targets only (mac + win). */
|
||||
* Mirrors `scripts/fetch-officecli.cjs` (linux assets are the upstream glibc
|
||||
* builds; the *-alpine-* musl variants are not shipped). */
|
||||
const OFFICECLI_ASSETS: Readonly<Record<string, string>> = {
|
||||
'linux-x64': 'officecli-linux-x64',
|
||||
'linux-arm64': 'officecli-linux-arm64',
|
||||
'darwin-arm64': 'officecli-mac-arm64',
|
||||
'darwin-x64': 'officecli-mac-x64',
|
||||
'win32-x64': 'officecli-win-x64.exe',
|
||||
'win32-arm64': 'officecli-win-arm64.exe',
|
||||
};
|
||||
|
||||
/** Absolute path to the OfficeCLI binary for the current platform/arch, or
|
||||
* null when no asset ships for it. Shipped via electron-builder
|
||||
/** Absolute path to the OfficeCLI binary for the requested platform/arch
|
||||
* (the current host by default), or null when no asset ships for it. Shipped
|
||||
* via electron-builder
|
||||
* `extraResources`:
|
||||
* dev: PC/resources/officecli/
|
||||
* packed: <app>/Contents/Resources/officecli/ (darwin)
|
||||
* <app>/resources/officecli/ (win)
|
||||
* <app>/resources/officecli/ (win/linux)
|
||||
*/
|
||||
export function officeCliBinaryPath(): string | null {
|
||||
const asset = OFFICECLI_ASSETS[`${process.platform}-${process.arch}`];
|
||||
export function officeCliBinaryPath(
|
||||
platform: string = process.platform,
|
||||
arch: string = process.arch,
|
||||
): string | null {
|
||||
const asset = OFFICECLI_ASSETS[`${platform}-${arch}`];
|
||||
if (!asset) return null;
|
||||
const rp = (process as unknown as { resourcesPath?: string }).resourcesPath;
|
||||
const dir = (rp && !rp.includes(`${path.sep}node_modules${path.sep}electron${path.sep}`))
|
||||
|
||||
@@ -18,6 +18,8 @@ You are the **commander** of this group chat: an orchestrator with a strong gene
|
||||
|
||||
Use injected memory and project instructions directly as read-only context. Durable memory or project-instruction mutations are owned by the matching System Skill.
|
||||
|
||||
For a request to create, change, or remove durable state, select and read that System Skill before answering or calling its mutation tool. Decide from the intended future effect, not exact phrasing.
|
||||
|
||||
Never persist current task progress, temporary plans, one-off status, or TODO/dependency state.
|
||||
|
||||
$project_tasks_rules
|
||||
|
||||
@@ -190,12 +190,18 @@ export function buildPlanInteractionHint(interactive: boolean): string {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Build the final response-language instruction without loading user state. */
|
||||
/** Build the final response-language precedence without loading user state. */
|
||||
export function buildLanguageDirectiveText(languageName: string): string {
|
||||
return [
|
||||
'## User language',
|
||||
'',
|
||||
`User UI language: **${languageName}**. Write all human-readable prose in ${languageName}, including replies, status text, form labels, plan titles/inputs, and natural-language text inside XML/JSON fields.`,
|
||||
`Fallback UI language: **${languageName}**.`,
|
||||
'',
|
||||
"Choose the response language in this order: (1) a current explicit user language request; (2) otherwise, the clear language of the user's latest substantive prose; (3) otherwise, the fallback UI language above.",
|
||||
'',
|
||||
'Quoted text, code, file contents, proper nouns, and earlier assistant messages do not switch the response language.',
|
||||
'',
|
||||
'Write all human-readable prose in the chosen language, including replies, status text, form labels, plan titles/inputs, and natural-language text inside XML/JSON fields.',
|
||||
'',
|
||||
'Keep protocol tokens unchanged: XML tag names, JSON keys, tool/skill ids or names, file paths, code, and `select` / `multiselect` `value` strings.',
|
||||
'',
|
||||
|
||||
@@ -27,20 +27,14 @@ This session is bound to the skill in Runtime injection.
|
||||
|
||||
---
|
||||
|
||||
## Installing a skill from a URL — route first
|
||||
## Installing a skill from a URL — use the owning System Skill
|
||||
|
||||
Mirror of the commander routing; keep in sync with `chat_commander.md`. Applies when this session's first message asks to install/import a skill from a URL.
|
||||
When this session's first message asks to install or import a skill from a URL, use the routing gates in the injected System Skills instead of mirroring another actor's prompt:
|
||||
- Skill content or an explicit portable custom-Skill request → follow `skill-creator`.
|
||||
- A runnable repository, CLI, or external package → follow `package-installer`.
|
||||
- If the owning routing gates leave the outcome genuinely ambiguous, state the portability trade-off in one line of plain outcome language and wait for the user's choice.
|
||||
|
||||
Judge the source before authoring:
|
||||
- A doc page / raw SKILL.md / a repo whose only payload is skill content → author a custom skill here (Mode B), as usual.
|
||||
- A runnable open-source repo that ships its own CLI or dependencies → it should be installed verbatim as an external package, not authored here.
|
||||
|
||||
When it could go either way, or the choice changes the outcome, recommend one and state the trade-off in one line of plain outcome language — one follows the user across devices and their agents can use it; the other runs only on this machine and is managed in the package list — then wait for the user to confirm before installing. Never name internal mechanics to the user.
|
||||
|
||||
### When the user picks the external-package route
|
||||
1. Install with the package CLI via `bash`, following the `package-installer` skill: run install first without dependency consent; if it reports pending dependencies, show the user the exact commands, get approval, then re-run with consent. Never run npm / pip yourself.
|
||||
2. Only after the install succeeds, end your reply with this marker on its own line: `<skill-as-package name="<installed-name>"/>`. It finalizes the import — the placeholder skill opened for this URL is removed and the view switches to the installed package. Emit it ONLY on a successful external-package install; never for the custom-skill route, and never before the install succeeded.
|
||||
3. Tell the user in plain outcome language that it is installed and where to manage it. Do NOT author a `SKILL.md` for this skill.
|
||||
The external-package success handoff is specific to this bound editor: only after `package-installer` succeeds, end the reply with `<skill-as-package name="<installed-name>"/>` on its own line so the host removes the placeholder and opens the installed package. Never emit the marker for a custom Skill or a failed/incomplete install, and do not author `SKILL.md` on the external-package route. Tell the user in plain outcome language that the package is installed and where to manage it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -83,14 +83,3 @@ export function macosTccSensitivePath(
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces are long-lived execution roots: agents create, stat, list, and
|
||||
* reveal files there across turns. Even if each individual scan is guarded,
|
||||
* placing the workspace under a macOS TCC root can still trigger privacy
|
||||
* prompts during normal writes or shell commands. Treat these paths as
|
||||
* ineligible workspace/default-picker roots.
|
||||
*/
|
||||
export function macosTccWorkspaceBlockedPath(targetPath: string): TccSensitivePathResult | null {
|
||||
return macosTccSensitivePath(targetPath, { recursive: true });
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { fileEditLock } from './locks';
|
||||
|
||||
export interface UniquifyResult {
|
||||
finalPath: string;
|
||||
renamed: boolean;
|
||||
@@ -53,6 +55,29 @@ export async function uniquifyPath(
|
||||
throw new Error(`uniquifyPath: exhausted ${MAX_ATTEMPTS} attempts under ${dir}`);
|
||||
}
|
||||
|
||||
/** Decide the unique path AND perform the write under one per-path mutex.
|
||||
*
|
||||
* `uniquifyPath` alone is check-then-act: between its existence probe and the
|
||||
* caller's eventual write, a concurrent writer aimed at the same path (two
|
||||
* parallel agent turns sharing one conversation workspace) passes the same
|
||||
* probe, and both write one file — the silent overwrite this module exists to
|
||||
* prevent. Serializing probe+write on the mutex keyed by the REQUESTED path
|
||||
* (the only key both contenders share up front) makes the loser's probe see
|
||||
* the winner's file and suffix correctly.
|
||||
*
|
||||
* `write` runs with the lock held, so keep it to the write itself — do not
|
||||
* run model calls or long renders inside unless the file only materializes
|
||||
* there. It must not acquire `fileEditLock` on the same path again. */
|
||||
export async function uniquifyPathForWrite<T>(
|
||||
absPath: string,
|
||||
isMine: (p: string) => boolean,
|
||||
write: (decision: UniquifyResult) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return fileEditLock(path.resolve(absPath)).runExclusive(async () => (
|
||||
write(await uniquifyPath(absPath, isMine))
|
||||
));
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
|
||||
@@ -1691,6 +1691,7 @@
|
||||
};
|
||||
</script>
|
||||
<script src="./modules/math.js"></script>
|
||||
<script src="./modules/sidebar-sections.js"></script>
|
||||
<script src="./modules/sidebar-resize.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -333,6 +333,11 @@
|
||||
"chat.conv_del_confirm": "Delete this task?",
|
||||
"chat.loading": "Loading...",
|
||||
"chat.load_failed": "Load failed: {msg}",
|
||||
"chat.app_nav_open": "Open page: {name}",
|
||||
"chat.app_nav_create": "Create in {name}",
|
||||
"chat.app_nav_add_custom": "Add MCP server",
|
||||
"chat.app_nav_configure": "Configure in {name}",
|
||||
"chat.app_nav_failed": "Couldn’t open this page. Try again or open it from the sidebar.",
|
||||
"chat.turn_nav_label": "Conversation turns",
|
||||
"chat.turn_nav_item": "Go to turn {n} of {total}",
|
||||
"chat.turn_nav_fallback": "Turn {n}",
|
||||
|
||||
@@ -321,6 +321,11 @@
|
||||
"chat.conv_del_confirm": "このタスクを削除しますか?",
|
||||
"chat.loading": "読み込み中...",
|
||||
"chat.load_failed": "読み込みに失敗しました: {msg}",
|
||||
"chat.app_nav_open": "ページを開く:{name}",
|
||||
"chat.app_nav_create": "{name}で作成",
|
||||
"chat.app_nav_add_custom": "MCP サーバーを追加",
|
||||
"chat.app_nav_configure": "{name}で設定",
|
||||
"chat.app_nav_failed": "このページを開けませんでした。もう一度試すか、サイドバーから開いてください。",
|
||||
"chat.turn_nav_label": "会話ターンの移動",
|
||||
"chat.turn_nav_item": "全 {total} ターン中 {n} ターン目へ移動",
|
||||
"chat.turn_nav_fallback": "ターン {n}",
|
||||
|
||||
@@ -329,6 +329,11 @@
|
||||
"chat.conv_del_confirm": "Excluir esta tarefa?",
|
||||
"chat.loading": "Carregando...",
|
||||
"chat.load_failed": "Falha no carregamento: {msg}",
|
||||
"chat.app_nav_open": "Abrir página: {name}",
|
||||
"chat.app_nav_create": "Criar em {name}",
|
||||
"chat.app_nav_add_custom": "Adicionar servidor MCP",
|
||||
"chat.app_nav_configure": "Configurar em {name}",
|
||||
"chat.app_nav_failed": "Não foi possível abrir esta página. Tente novamente ou use a barra lateral.",
|
||||
"chat.turn_nav_label": "Navegação por turnos da conversa",
|
||||
"chat.turn_nav_item": "Ir para o turno {n} de {total}",
|
||||
"chat.turn_nav_fallback": "Turno {n}",
|
||||
|
||||
@@ -333,6 +333,11 @@
|
||||
"chat.conv_del_confirm": "删除这个任务?",
|
||||
"chat.loading": "加载中...",
|
||||
"chat.load_failed": "加载失败: {msg}",
|
||||
"chat.app_nav_open": "打开页面:{name}",
|
||||
"chat.app_nav_create": "在{name}中创建",
|
||||
"chat.app_nav_add_custom": "添加 MCP 服务器",
|
||||
"chat.app_nav_configure": "在{name}中配置",
|
||||
"chat.app_nav_failed": "无法打开该页面,请重试或从侧栏进入。",
|
||||
"chat.turn_nav_label": "对话轮次定位",
|
||||
"chat.turn_nav_item": "定位到第 {n} 轮,共 {total} 轮",
|
||||
"chat.turn_nav_fallback": "第 {n} 轮",
|
||||
|
||||
@@ -949,9 +949,17 @@ async function openAgentDetail(agentId, options = {}) {
|
||||
: _captureAgentDetailReturnTarget();
|
||||
const panel = document.getElementById('panel-agents');
|
||||
panel?.classList.toggle('resource-detail-overlay', _agentDetailReturnTarget.view !== 'agents');
|
||||
await _showAgentsDetailView(agentId);
|
||||
const opened = await _showAgentsDetailView(agentId);
|
||||
if (opened === false) {
|
||||
_agentDetailReturnTarget = null;
|
||||
_showAgentsGridView();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
window.openAgentDetail = openAgentDetail;
|
||||
|
||||
function _isAgentDetailReturnTargetCurrent(target) {
|
||||
if (!target || target.view !== currentView) return false;
|
||||
if (target.view === 'conversation') return target.id === currentCid;
|
||||
@@ -993,7 +1001,7 @@ async function _showAgentsDetailView(agentId) {
|
||||
if (detail) detail.style.display = 'flex';
|
||||
// Detail has a target-scoped API below. Do not refresh/enrich the complete
|
||||
// Agent catalog merely to open one agent.json.
|
||||
await selectAgent(agentId, { refreshCliOptions: true });
|
||||
return selectAgent(agentId, { refreshCliOptions: true });
|
||||
}
|
||||
|
||||
async function refreshSelectedAgentDetail() {
|
||||
@@ -1187,12 +1195,12 @@ async function selectAgent(agentId, { refreshCliOptions = false } = {}) {
|
||||
if (_isCommanderAgent(agentId)) {
|
||||
await _renderCommanderAgentDetail(false);
|
||||
_resetAgentDetailScroll();
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const res = await apiFetch(`/api/agents/${encodeURIComponent(agentId)}`);
|
||||
const data = await res.json();
|
||||
if (!data.ok || !data.agent) return;
|
||||
if (!data.ok || !data.agent) return false;
|
||||
data.agent.source = _agentSource(data.agent.source);
|
||||
await _maybeLoadAgentSkillNames(_agentSkillIds(data.agent), data.agent.agent_id, { refresh: false });
|
||||
_selectedAgent = { id: data.agent.agent_id, name: data.agent.name, source: data.agent.source };
|
||||
@@ -1203,8 +1211,10 @@ async function selectAgent(agentId, { refreshCliOptions = false } = {}) {
|
||||
// (style.css:4190). Without resetting the inner two the previous
|
||||
// agent's mid-scroll position bleeds into the next agent's view.
|
||||
_resetAgentDetailScroll();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_agentsLog.error('load agent failed', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4000,6 +4010,9 @@ async function refreshAgentPickerContext(anchorId) {
|
||||
async function _openAgentPicker(anchorBtn, entryPoint = 'unknown') {
|
||||
const picker = document.getElementById('agent-picker');
|
||||
if (!anchorBtn || !picker) return;
|
||||
if (typeof _closeOtherComposerPopovers === 'function') {
|
||||
_closeOtherComposerPopovers('recipient');
|
||||
}
|
||||
if (anchorBtn.id === 'new-chat-recipient-chip') {
|
||||
_agentsTrackClick('new_chat_recipient_picker_open', {
|
||||
entry_point: entryPoint === 'recipient_chip' || entryPoint === 'at_key'
|
||||
@@ -4007,7 +4020,12 @@ async function _openAgentPicker(anchorBtn, entryPoint = 'unknown') {
|
||||
: 'unknown',
|
||||
});
|
||||
}
|
||||
const previousAnchorId = picker.dataset.anchorId || '';
|
||||
if (previousAnchorId && previousAnchorId !== anchorBtn.id) {
|
||||
_setAgentPickerAnchorOpen(document.getElementById(previousAnchorId), false);
|
||||
}
|
||||
picker.dataset.anchorId = anchorBtn.id;
|
||||
_setAgentPickerAnchorOpen(anchorBtn, true);
|
||||
const openSeq = ++_agentPickerOpenSeq;
|
||||
_agentPickerLoadedTabs = new Set();
|
||||
// Reset project scope synchronously before painting. For project-bound
|
||||
@@ -4030,13 +4048,22 @@ async function _openAgentPicker(anchorBtn, entryPoint = 'unknown') {
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function _closeAgentPicker() {
|
||||
function _setAgentPickerAnchorOpen(anchor, open) {
|
||||
if (!anchor) return;
|
||||
anchor.classList.toggle('is-open', !!open);
|
||||
anchor.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function _closeAgentPicker({ preserveAtKey = false } = {}) {
|
||||
const picker = document.getElementById('agent-picker');
|
||||
if (picker) picker.style.display = 'none';
|
||||
// NOTE: callers that close-without-selection (Esc / click-outside) must
|
||||
// also clear `_atKeyMark` — otherwise the next picker open would consume
|
||||
// a stale `@`. Selection callers leave the mark so _triggerPickerItem can
|
||||
// use it before clearing.
|
||||
if (picker) {
|
||||
_setAgentPickerAnchorOpen(document.getElementById(picker.dataset.anchorId || ''), false);
|
||||
picker.style.display = 'none';
|
||||
}
|
||||
// A selection closes before routing the selected item, so it preserves the
|
||||
// typed `@` marker just long enough for _triggerPickerItem to consume it.
|
||||
// Every other close path owns cancellation and clears the marker here.
|
||||
if (!preserveAtKey) _atKeyMark = null;
|
||||
}
|
||||
|
||||
function _renderAgentPickerList(filterText) {
|
||||
@@ -4395,7 +4422,7 @@ function _bindAgentPickerListItems(listEl, anchorId) {
|
||||
el.addEventListener('click', async () => {
|
||||
const sourceType = el.dataset.pickerSourceType === 'keyboard' ? 'keyboard' : 'mouse';
|
||||
delete el.dataset.pickerSourceType;
|
||||
_closeAgentPicker();
|
||||
_closeAgentPicker({ preserveAtKey: true });
|
||||
await _triggerPickerItem(
|
||||
el.dataset.kind || 'agent',
|
||||
el.dataset.id,
|
||||
@@ -4715,6 +4742,8 @@ function bindRecipientAnchor(chipId, inputId) {
|
||||
const btn = document.getElementById(chipId);
|
||||
if (btn && btn.dataset.bound !== '1') {
|
||||
btn.dataset.bound = '1';
|
||||
btn.setAttribute('aria-haspopup', 'dialog');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
_atKeyMark = null; // chip click is not a `@`-keystroke trigger
|
||||
|
||||
@@ -2208,6 +2208,15 @@ if (typeof window !== 'undefined') {
|
||||
window.loadAutoList = loadAutoList;
|
||||
window.loadProjectAutoList = loadProjectAutoList;
|
||||
window.openAutoTaskDialog = openAutoTaskDialog;
|
||||
window.openAutoTaskById = async function openAutoTaskById(taskId) {
|
||||
const id = String(taskId || '').trim();
|
||||
if (!id) return false;
|
||||
await loadAutoTasks();
|
||||
const task = _autoTasks.find((candidate) => candidate && candidate.id === id);
|
||||
if (!task) return false;
|
||||
openAutoTaskDialog({ task });
|
||||
return true;
|
||||
};
|
||||
window.refreshAutoProjectOptions = _autoRefreshProjectOptions;
|
||||
window._autoUploadFilesFromComposer = _autoUploadFiles;
|
||||
window._autoAttachLibraryFile = _autoAttachLibraryFile;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// in-process agent wants to run a command or access a path the classifier
|
||||
// flagged as sensitive, main pushes `bash:permission` and this module shows an
|
||||
// allow-once / deny choice, plus a task-scoped choice only when main marks the
|
||||
// category and actor eligible. Waiting for a human click is kept alive on the
|
||||
// main side with progress heartbeats; Renderer never widens main's boundary.
|
||||
// category and actor eligible, alongside the durable local-access mode switch.
|
||||
// Waiting for a human click is kept alive on the main side with progress
|
||||
// heartbeats; Renderer never widens main's operation-approval boundary.
|
||||
//
|
||||
// Requests queue FIFO so concurrent workers can't stack overlapping dialogs.
|
||||
|
||||
@@ -362,10 +363,11 @@ function _showBashPermissionModeDialog({
|
||||
btn.addEventListener('click', () => finish(btn.dataset.id || 'deny'));
|
||||
});
|
||||
const cancelBtn = overlay.querySelector('[data-act="cancel"]');
|
||||
const allowOnceBtn = overlay.querySelector('[data-id="allow_once"]');
|
||||
cancelBtn.addEventListener('click', () => finish('deny'));
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener('click', onDocClick, true);
|
||||
setTimeout(() => cancelBtn.focus(), 0);
|
||||
setTimeout(() => allowOnceBtn?.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -407,7 +409,7 @@ async function _showBashPermissionDialog(info) {
|
||||
currentMode,
|
||||
requestId,
|
||||
allowRun: canAllowRun,
|
||||
showModeControl: !isSensitiveApproval,
|
||||
showModeControl: true,
|
||||
onPresented: () => {
|
||||
if (presented) return;
|
||||
presented = true;
|
||||
@@ -425,7 +427,7 @@ async function _showBashPermissionDialog(info) {
|
||||
return;
|
||||
}
|
||||
const choice = result && typeof result === 'object' ? result.choice : result;
|
||||
const selectedMode = !isSensitiveApproval && _bashIsMode(result && result.mode)
|
||||
const selectedMode = _bashIsMode(result && result.mode)
|
||||
? result.mode
|
||||
: currentMode;
|
||||
const requestedDecision = (choice === 'allow_once' || choice === 'allow_run' || choice === 'allow_always')
|
||||
|
||||
@@ -20,6 +20,29 @@ const _COMPOSER_MODEL_PICKER_TARGETS = [
|
||||
|
||||
const _COMPOSER_RECIPIENT_TARGETS = ['new-chat', 'conversation', 'project', 'auto'];
|
||||
|
||||
/** Keep the three composer-level pickers mutually exclusive. Their anchors
|
||||
* stop click propagation, so independent outside-click listeners cannot close
|
||||
* a picker when another composer control opens. Centralizing that transition
|
||||
* also keeps every anchor's visual/ARIA open state in sync with its panel. */
|
||||
function _closeOtherComposerPopovers(activeKind) {
|
||||
if (activeKind !== 'model' && typeof _closeComposerModelMenu === 'function') {
|
||||
_closeComposerModelMenu();
|
||||
}
|
||||
if (activeKind !== 'recipient' && typeof _closeAgentPicker === 'function') {
|
||||
_closeAgentPicker();
|
||||
}
|
||||
if (activeKind !== 'workspace') {
|
||||
const workspaceMenu = document.getElementById('workspace-menu');
|
||||
if (workspaceMenu) {
|
||||
if (typeof workspaceMenu._closeWorkspaceMenu === 'function') {
|
||||
workspaceMenu._closeWorkspaceMenu();
|
||||
} else {
|
||||
workspaceMenu.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _composerRecipientForTarget(target) {
|
||||
try {
|
||||
if (target === 'auto') {
|
||||
@@ -591,6 +614,7 @@ async function _toggleComposerModelMenu(anchor) {
|
||||
_closeComposerModelMenu();
|
||||
if (wasSameAnchor) return;
|
||||
}
|
||||
_closeOtherComposerPopovers('model');
|
||||
|
||||
await _refreshComposerModelEntries();
|
||||
await _refreshComposerModelOptions(_composerModelEntries);
|
||||
|
||||
@@ -377,6 +377,19 @@ function listUsableConnectorsForPicker() {
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base', numeric: true }));
|
||||
}
|
||||
|
||||
window.focusConnectorById = async function focusConnectorById(id) {
|
||||
const targetId = String(id || '').trim();
|
||||
if (!targetId) return false;
|
||||
await loadConnectors();
|
||||
const card = Array.from(document.querySelectorAll('.connector-card[data-id]'))
|
||||
.find((candidate) => candidate.dataset.id === targetId);
|
||||
if (!card) return false;
|
||||
card.setAttribute('tabindex', '-1');
|
||||
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
card.focus({ preventScroll: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
// Brand color hex per PC/docs/design/README §Surface E. Used as background
|
||||
// for the 32×32 letter square in `_renderCatalogCard` when no `icon_svg` is
|
||||
// shipped. Lookup is by `entry.id` (catalog id, stable across releases) with
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
const SIDECAR_FIELDS = [
|
||||
'attachments', 'produced', 'references', 'form', 'created_agents', 'created_skills',
|
||||
'artifacts', 'marketplace_requests', 'plan_announcement', 'failure_kind', 'failure_code',
|
||||
'artifacts', 'marketplace_requests', 'app_nav_requests', 'plan_announcement', 'failure_kind', 'failure_code',
|
||||
'model_text', 'source_message_id', 'unknown_mentions', 'mentions', 'to',
|
||||
];
|
||||
|
||||
|
||||
@@ -941,6 +941,8 @@ function _createProcessDisplayContext() {
|
||||
cliRunningShown: false,
|
||||
cliRetryActive: false,
|
||||
cliRetrySeries: 0,
|
||||
cliIdleActive: false,
|
||||
cliIdleSeries: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1039,6 +1041,39 @@ function _processCliRetryLifecycle(evt, displayContext) {
|
||||
return { key, terminal: true };
|
||||
}
|
||||
|
||||
// Runner idle events are periodic snapshots of one contiguous wait, not new
|
||||
// actions. Keep updating one presentation row until a real CLI event arrives.
|
||||
// A directly following terminal status replaces that same row. Persisted raw
|
||||
// events remain unchanged for diagnostics.
|
||||
function _processCliIdleLifecycle(evt, displayContext) {
|
||||
if (!evt || evt.stream !== 'cli' || !displayContext) return null;
|
||||
const data = evt.data && typeof evt.data === 'object' ? evt.data : {};
|
||||
const type = String(data.type || '').toLowerCase();
|
||||
if (type === 'idle') {
|
||||
if (!displayContext.cliIdleActive) {
|
||||
displayContext.cliIdleSeries = Math.max(0, Number(displayContext.cliIdleSeries) || 0) + 1;
|
||||
displayContext.cliIdleActive = true;
|
||||
}
|
||||
return { key: `cli-idle:${displayContext.cliIdleSeries}`, terminal: false };
|
||||
}
|
||||
if (!displayContext.cliIdleActive) return null;
|
||||
|
||||
const status = type === 'status' ? String(data.status || '').toLowerCase() : '';
|
||||
if (['timeout', 'error', 'failed', 'cancelled', 'aborted'].includes(status)) {
|
||||
const key = `cli-idle:${Math.max(1, Number(displayContext.cliIdleSeries) || 1)}`;
|
||||
displayContext.cliIdleActive = false;
|
||||
return { key, terminal: true };
|
||||
}
|
||||
|
||||
// Synthetic tool/reasoning pulses are bookkeeping, not new protocol
|
||||
// progress. All other events close this wait series so a later idle period
|
||||
// gets a chronologically separate row.
|
||||
if (data.synthetic !== true && data.heartbeat !== true) {
|
||||
displayContext.cliIdleActive = false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _isProcessPlanToolName(name) {
|
||||
return ['manage_execution_plan', 'plan_set', 'todo_write', 'todowrite', 'update_plan']
|
||||
.includes(_processToolKey(name));
|
||||
@@ -2408,15 +2443,22 @@ function _clearQueueEditRecipient(cid) {
|
||||
* current recipient may no longer be a valid agent for the new scope.
|
||||
* Reset to commander silently — the user will see the chip flip on next
|
||||
* render. Called from `projects.js` post project-pick. No-op for `commander`
|
||||
* recipients and for orphan contexts (pid empty). */
|
||||
async function validateRecipientAgainstProject(target, pid) {
|
||||
* recipients and for orphan contexts (pid empty).
|
||||
* `boundAgentIds` lets a caller that already holds this project's bindings
|
||||
* (project detail load) skip the second IPC and decide inside its own
|
||||
* load-sequence guard, so a slow response for a project the user already
|
||||
* left cannot clear the chip of the one they are on. */
|
||||
async function validateRecipientAgainstProject(target, pid, boundAgentIds) {
|
||||
const cur = _activeRecipient(target);
|
||||
if (!cur || cur.kind !== 'agent') return;
|
||||
if (!pid) return;
|
||||
try {
|
||||
const res = await window.orkas.invoke('projects.bindings.list', { projectId: pid });
|
||||
if (!res || !res.ok) return;
|
||||
const bound = new Set((res.bindings && res.bindings.agents) || []);
|
||||
let bound = boundAgentIds ? new Set(boundAgentIds) : null;
|
||||
if (!bound) {
|
||||
const res = await window.orkas.invoke('projects.bindings.list', { projectId: pid });
|
||||
if (!res || !res.ok) return;
|
||||
bound = new Set((res.bindings && res.bindings.agents) || []);
|
||||
}
|
||||
if (!bound.has(cur.id)) {
|
||||
setChatRecipient(target, { kind: 'commander' });
|
||||
_renderRecipientChip(target);
|
||||
@@ -3627,15 +3669,13 @@ async function _syncPendingActorsFromRuntime(cid, opts = {}) {
|
||||
if (hasActiveTurnsField) {
|
||||
const activeCommander = activeTurns.some((t) => t.actor === 'commander');
|
||||
if (!activeCommander) _removeEmptyActorPlaceholder(cid, 'commander');
|
||||
for (const turn of activeTurns) {
|
||||
_ensureActorPlaceholder(cid, turn.actor, loadingEl, turn.turn_id, turn.msg_id, turn.started_at_ms);
|
||||
}
|
||||
} else {
|
||||
if (!inFlight.includes('commander')) _removeEmptyActorPlaceholder(cid, 'commander');
|
||||
for (const actorId of inFlight) {
|
||||
_ensureActorPlaceholder(cid, actorId, loadingEl);
|
||||
}
|
||||
}
|
||||
const primary = _ensureRuntimeActorPlaceholders(
|
||||
cid, loadingEl, activeTurns, inFlight, hasActiveTurnsField,
|
||||
);
|
||||
if (primary && pendingConvs.get(cid) === state) state.loadingEl = primary;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4237,6 +4277,7 @@ function _groupMsgToLegacy(gm) {
|
||||
...(_normalizeCreatedSkills(gm) ? { created_skills: _normalizeCreatedSkills(gm) } : {}),
|
||||
...(Array.isArray(gm.artifacts) && gm.artifacts.length ? { artifacts: gm.artifacts } : {}),
|
||||
...(Array.isArray(gm.marketplace_requests) && gm.marketplace_requests.length ? { marketplace_requests: gm.marketplace_requests } : {}),
|
||||
...(Array.isArray(gm.app_nav_requests) && gm.app_nav_requests.length ? { app_nav_requests: gm.app_nav_requests } : {}),
|
||||
...(gm.plan_announcement ? { _plan_announcement: true } : {}),
|
||||
...(Array.isArray(gm.process) && gm.process.length ? { process: gm.process } : {}),
|
||||
...(gm.turn_id ? { _turn_id: gm.turn_id } : {}),
|
||||
@@ -6652,6 +6693,66 @@ function _ensureCreateAgentInlineObserver() {
|
||||
_ensureConvCreateAgentInline();
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending controller outlives whichever actor first adopted its loading row.
|
||||
* History recovery may only reattach that row when it is still a live target
|
||||
* for the runtime snapshot. Persisted/finalized rows are owned by canonical
|
||||
* history, and actor/turn mismatches belong to an earlier orchestration step.
|
||||
*/
|
||||
function _canReattachPendingLoadingEl(
|
||||
loadingEl,
|
||||
activeTurns = [],
|
||||
inFlightActors = [],
|
||||
hasActiveTurnsField = false,
|
||||
) {
|
||||
if (!loadingEl) return false;
|
||||
const data = loadingEl.dataset || {};
|
||||
if (data.finalized === '1' || data.msgId) return false;
|
||||
|
||||
const actorId = String(data.fromActor || '');
|
||||
const turnId = _normaliseTurnId(data.turnId);
|
||||
const renderKey = String(data.renderKey || '');
|
||||
// The controller's untouched hidden row is actor-neutral and safe to adopt
|
||||
// after the snapshot identifies the current worker.
|
||||
if (!actorId && !turnId && !renderKey) return true;
|
||||
|
||||
if (hasActiveTurnsField) {
|
||||
return activeTurns.some((turn) => {
|
||||
if (String(turn?.actor || '') !== actorId) return false;
|
||||
const activeTurnId = _normaliseTurnId(turn?.turn_id);
|
||||
return !turnId || !activeTurnId || turnId === activeTurnId;
|
||||
});
|
||||
}
|
||||
if (inFlightActors.length && actorId) return inFlightActors.includes(actorId);
|
||||
// Legacy/stale snapshots without actor identity cannot disprove ownership.
|
||||
// Keep the live row so the controller closure does not lose direct deltas.
|
||||
return true;
|
||||
}
|
||||
|
||||
function _ensureRuntimeActorPlaceholders(
|
||||
cid,
|
||||
fallbackPh,
|
||||
activeTurns,
|
||||
inFlightActors,
|
||||
hasActiveTurnsField,
|
||||
) {
|
||||
let primary = null;
|
||||
if (hasActiveTurnsField) {
|
||||
for (const turn of activeTurns) {
|
||||
const ph = _ensureActorPlaceholder(
|
||||
cid, turn.actor, fallbackPh, turn.turn_id, turn.msg_id, turn.started_at_ms,
|
||||
);
|
||||
if (!primary && ph) primary = ph;
|
||||
}
|
||||
} else {
|
||||
for (const actorId of inFlightActors) {
|
||||
const ph = _ensureActorPlaceholder(cid, actorId, fallbackPh);
|
||||
if (!primary && ph) primary = ph;
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
async function loadConversationHistory(cid, opts = {}) {
|
||||
const perfStartedAt = performance.now();
|
||||
const container = document.getElementById('chat-history');
|
||||
@@ -6854,15 +6955,11 @@ async function loadConversationHistory(cid, opts = {}) {
|
||||
needsIndicator: false,
|
||||
startedAtMs: Math.max(0, new Date(convMeta.processing_since).getTime() || 0),
|
||||
});
|
||||
if (hasActiveTurnsField) {
|
||||
for (const turn of activeTurns) {
|
||||
_ensureActorPlaceholder(cid, turn.actor, loadingEl, turn.turn_id, turn.msg_id, turn.started_at_ms);
|
||||
}
|
||||
} else {
|
||||
for (const actorId of inFlightActors) {
|
||||
_ensureActorPlaceholder(cid, actorId, loadingEl);
|
||||
}
|
||||
}
|
||||
const recoveredState = pendingConvs.get(cid);
|
||||
const primary = _ensureRuntimeActorPlaceholders(
|
||||
cid, loadingEl, activeTurns, inFlightActors, hasActiveTurnsField,
|
||||
);
|
||||
if (primary && recoveredState) recoveredState.loadingEl = primary;
|
||||
// Opening/reloading into an already-running plan still needs the
|
||||
// group event stream; runtime polling can recover placeholders, but
|
||||
// it cannot replay live text deltas.
|
||||
@@ -6870,36 +6967,34 @@ async function loadConversationHistory(cid, opts = {}) {
|
||||
_startRuntimeActorRecovery(cid);
|
||||
_updateConvSendUI(cid);
|
||||
} else if (isConvPending(cid)) {
|
||||
// User navigated away and back during an in-flight request. The stream
|
||||
// reader loop in createChatController.send() holds the original msgEl
|
||||
// in closure and keeps dispatching deltas/final to that *specific* node
|
||||
// — so we must re-attach the original node, not mint a fresh bubble.
|
||||
// Minting a new one here (as before) stranded the stream: events kept
|
||||
// landing on the orphaned node and the new bubble stayed at "thinking…"
|
||||
// until stream end / polling rescue.
|
||||
// User navigated away and back during an in-flight request. Preserve a
|
||||
// still-live controller row, but never revive a row already finalized by
|
||||
// history or owned by an earlier actor/turn. A terminal Commander hand-
|
||||
// off keeps the whole conversation pending while the Agent runs; blindly
|
||||
// reattaching the original Commander row creates the duplicate loading
|
||||
// bubble beside its canonical history record.
|
||||
let state = pendingConvs.get(cid);
|
||||
if (!state) {
|
||||
state = { loadingEl: null, needsIndicator: false, controller: null, aborted: false };
|
||||
pendingConvs.set(cid, state);
|
||||
}
|
||||
pollMsgCounts.set(cid, String(lastMsg?._msg_id || ''));
|
||||
if (state.loadingEl) {
|
||||
const reusableLoadingEl = _canReattachPendingLoadingEl(
|
||||
state.loadingEl, activeTurns, inFlightActors, hasActiveTurnsField,
|
||||
) ? state.loadingEl : null;
|
||||
if (reusableLoadingEl) {
|
||||
const emptyEl = container.querySelector('.empty');
|
||||
if (emptyEl) emptyEl.remove();
|
||||
_appendBeforeSpacer(container, state.loadingEl);
|
||||
_appendBeforeSpacer(container, reusableLoadingEl);
|
||||
state.loadingEl = reusableLoadingEl;
|
||||
} else {
|
||||
const loadingEl = _createStreamingAssistantMessage(container, { hiddenUntilActor: true });
|
||||
state.loadingEl = loadingEl;
|
||||
}
|
||||
if (hasActiveTurnsField) {
|
||||
for (const turn of activeTurns) {
|
||||
_ensureActorPlaceholder(cid, turn.actor, state.loadingEl, turn.turn_id, turn.msg_id, turn.started_at_ms);
|
||||
}
|
||||
} else {
|
||||
for (const actorId of inFlightActors) {
|
||||
_ensureActorPlaceholder(cid, actorId, state.loadingEl);
|
||||
}
|
||||
}
|
||||
const primary = _ensureRuntimeActorPlaceholders(
|
||||
cid, state.loadingEl, activeTurns, inFlightActors, hasActiveTurnsField,
|
||||
);
|
||||
if (primary) state.loadingEl = primary;
|
||||
if (!state.controller) {
|
||||
// Same recovery path for a detached pending state: reconnect to the
|
||||
// live group event stream so the bubble streams instead of waiting
|
||||
@@ -7788,6 +7883,11 @@ function appendChatMessage(message, autoScroll = true, opts = {}) {
|
||||
const bubble = msgDiv.querySelector('.chat-bubble');
|
||||
if (bubble) _mountMarketplaceInstallRequests(bubble, msgDiv, message, opts);
|
||||
}
|
||||
// Commander navigation cards are inert until the user clicks one.
|
||||
if (role === 'assistant' && Array.isArray(message.app_nav_requests) && message.app_nav_requests.length) {
|
||||
const bubble = msgDiv.querySelector('.chat-bubble');
|
||||
if (bubble) _mountAppNavRequests(bubble, message);
|
||||
}
|
||||
// Interactive web-app artifacts (assistant messages only) — sandboxed
|
||||
// `<iframe>` over the `chat-app://` protocol, appended after the form so it
|
||||
// reads as "reply text → embedded app". See chat-artifact.js.
|
||||
@@ -8173,6 +8273,193 @@ function _renderMarketplaceInstallCard(card, req, cid, msgId) {
|
||||
_hydrateMarketplaceRequestMeta(card, { ...req, kind }, cid, msgId);
|
||||
}
|
||||
|
||||
// ── Commander navigation cards (open_app_view) ──────────────────────────
|
||||
// Maps each staged surface_id to its renderer action plus a localized display
|
||||
// name reused from the owning surface's existing keys. Must cover exactly the
|
||||
// ids in src/main/features/group_chat/app_nav.ts — parity is pinned by
|
||||
// test/main/features/group_chat/app-nav-registry.test.ts.
|
||||
const _APP_NAV_SURFACES = {
|
||||
'settings.models': { nameKey: 'settings.section.models', fallback: 'Models & credentials', actions: ['open'], open: () => _appNavOpenSettingsTab('credentials') },
|
||||
'settings.general': { nameKey: 'settings.tab.general', fallback: 'General', actions: ['open'], open: () => _appNavOpenSettingsTab('general') },
|
||||
'settings.data': { nameKey: 'settings.tab.data', fallback: 'Data', actions: ['open'], open: () => _appNavOpenSettingsTab('data') },
|
||||
connectors: {
|
||||
nameKey: 'sidebar.connectors',
|
||||
fallback: 'Connectors',
|
||||
actions: ['open', 'add_custom', 'configure'],
|
||||
open: (req) => _appNavOpenFeatureView('connectors', 'connectors', async () => {
|
||||
if (req.action === 'add_custom') {
|
||||
const addButton = document.getElementById('connectors-add-custom-btn');
|
||||
if (!addButton) return false;
|
||||
addButton.click();
|
||||
} else if (req.action === 'configure' && typeof window.focusConnectorById === 'function') {
|
||||
return window.focusConnectorById(req.target_id);
|
||||
}
|
||||
return req.action === 'open' || req.action === 'add_custom';
|
||||
}),
|
||||
},
|
||||
library: { nameKey: 'sidebar.contexts', fallback: 'Library', actions: ['open'], open: () => { setView('contexts'); return true; } },
|
||||
projects: {
|
||||
nameKey: 'sidebar.projects',
|
||||
fallback: 'Projects',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
open: (req) => {
|
||||
if (typeof window.openProjectsSurface !== 'function') return false;
|
||||
return window.openProjectsSurface(req);
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
nameKey: 'sidebar.agents',
|
||||
fallback: 'AI Team',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
open: (req) => _appNavOpenFeatureView('agents', 'agents', async () => {
|
||||
if (req.action === 'create' && typeof window.openAgentModal === 'function') {
|
||||
window.openAgentModal();
|
||||
return true;
|
||||
} else if (req.action === 'configure' && typeof window.openAgentDetail === 'function') {
|
||||
return window.openAgentDetail(req.target_id);
|
||||
}
|
||||
return req.action === 'open';
|
||||
}),
|
||||
},
|
||||
skills: {
|
||||
nameKey: 'sidebar.skills',
|
||||
fallback: 'Skills',
|
||||
actions: ['open', 'create'],
|
||||
open: (req) => _appNavOpenFeatureView('skills', 'skills', () => {
|
||||
if (req.action === 'create' && typeof window.openSkillModal === 'function') {
|
||||
window.openSkillModal();
|
||||
return true;
|
||||
}
|
||||
return req.action === 'open';
|
||||
}),
|
||||
},
|
||||
auto: {
|
||||
nameKey: 'sidebar.auto',
|
||||
fallback: 'Auto',
|
||||
actions: ['open', 'create', 'configure'],
|
||||
open: (req) => _appNavOpenFeatureView('auto', 'auto', async () => {
|
||||
if (req.action === 'create' && typeof window.openAutoTaskDialog === 'function') {
|
||||
window.openAutoTaskDialog({});
|
||||
return true;
|
||||
} else if (req.action === 'configure' && typeof window.openAutoTaskById === 'function') {
|
||||
return window.openAutoTaskById(req.target_id);
|
||||
}
|
||||
return req.action === 'open';
|
||||
}),
|
||||
},
|
||||
apps: { nameKey: 'sidebar.apps', fallback: 'My Apps', actions: ['open'], open: () => { setView('apps'); return true; } },
|
||||
marketplace: {
|
||||
nameKey: 'marketplace.title',
|
||||
fallback: 'Marketplace',
|
||||
actions: ['open'],
|
||||
open: () => _appNavLoadFeature('marketplace', () => {
|
||||
if (typeof openMarketplace !== 'function') return false;
|
||||
openMarketplace('agent', { returnView: 'conversation' });
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
function _appNavLoadFeature(feature, afterLoad) {
|
||||
const load = typeof loadRendererFeature === 'function'
|
||||
? loadRendererFeature
|
||||
: window.loadRendererFeature;
|
||||
const ready = typeof load === 'function' ? Promise.resolve(load(feature)) : Promise.resolve();
|
||||
return ready.then(async () => {
|
||||
const result = await afterLoad?.();
|
||||
if (result === false) throw new Error(`app navigation target unavailable: ${feature}`);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function _appNavOpenFeatureView(view, feature, afterLoad) {
|
||||
setView(view);
|
||||
return _appNavLoadFeature(feature, afterLoad);
|
||||
}
|
||||
|
||||
function _appNavOpenSettingsTab(tab) {
|
||||
setView('settings');
|
||||
// Settings initializes lazily with the default Account tab; wait for the
|
||||
// owning feature when necessary, then repeat on the next turn to win its
|
||||
// initial-render race (same shape as model-guard's credentials CTA).
|
||||
const activate = () => {
|
||||
if (typeof window.activateSettingsTab !== 'function') return false;
|
||||
window.activateSettingsTab(tab);
|
||||
return true;
|
||||
};
|
||||
return _appNavLoadFeature('settings', async () => {
|
||||
if (!activate()) return false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return activate();
|
||||
});
|
||||
}
|
||||
|
||||
function _appNavReportFailure(surfaceId, action, error) {
|
||||
try { _convLog.warn('app navigation failed', { surface_id: surfaceId, action, error: String(error?.message || error || '') }); } catch (_) {}
|
||||
if (typeof uiToast !== 'function') return;
|
||||
const key = 'chat.app_nav_failed';
|
||||
const localized = t(key);
|
||||
uiToast(localized && localized !== key
|
||||
? localized
|
||||
: 'Could not open this page. Try again or open it from the sidebar.', { variant: 'error' });
|
||||
}
|
||||
|
||||
function _mountAppNavRequests(host, message) {
|
||||
if (!host || !message) return;
|
||||
const requests = Array.isArray(message.app_nav_requests) ? message.app_nav_requests : [];
|
||||
for (const req of requests) {
|
||||
const surfaceId = req && typeof req.surface_id === 'string' ? req.surface_id : '';
|
||||
const surface = _APP_NAV_SURFACES[surfaceId];
|
||||
if (!surface) continue;
|
||||
const action = req && typeof req.action === 'string' ? req.action : 'open';
|
||||
const targetId = req && typeof req.target_id === 'string' ? req.target_id.trim() : '';
|
||||
if (!surface.actions.includes(action)) continue;
|
||||
if (action === 'configure' && !targetId) continue;
|
||||
if (action !== 'configure' && targetId) continue;
|
||||
if (targetId.length > 160 || /[\u0000-\u001f\u007f]/.test(targetId)) continue;
|
||||
const requestKey = JSON.stringify([surfaceId, action, targetId]);
|
||||
if (host.querySelector(`[data-app-nav="${CSS.escape(requestKey)}"]`)) continue;
|
||||
let row = host.querySelector('.chat-app-nav-row');
|
||||
if (!row) {
|
||||
row = document.createElement('div');
|
||||
row.className = 'chat-app-nav-row';
|
||||
host.appendChild(row);
|
||||
}
|
||||
const name = _quickStartText(surface.nameKey, surface.fallback);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'btn btn-sm chat-app-nav-btn';
|
||||
btn.dataset.appNav = requestKey;
|
||||
const labelKey = action === 'create'
|
||||
? 'chat.app_nav_create'
|
||||
: action === 'add_custom'
|
||||
? 'chat.app_nav_add_custom'
|
||||
: action === 'configure'
|
||||
? 'chat.app_nav_configure'
|
||||
: 'chat.app_nav_open';
|
||||
const label = t(labelKey, { name });
|
||||
btn.textContent = label && label !== labelKey ? label : `Open ${name}`;
|
||||
if (typeof window.uiIconHtml === 'function') {
|
||||
const pageIcon = document.createElement('span');
|
||||
pageIcon.className = 'chat-app-nav-btn-page-icon';
|
||||
pageIcon.innerHTML = window.uiIconHtml('panel-list', 'ui-icon chat-app-nav-btn-page-icon-svg');
|
||||
btn.appendChild(pageIcon);
|
||||
}
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const opened = await surface.open({ surface_id: surfaceId, action, target_id: targetId });
|
||||
if (opened === false) throw new Error('app navigation target unavailable');
|
||||
} catch (error) {
|
||||
_appNavReportFailure(surfaceId, action, error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
row.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function _mountMarketplaceInstallRequests(host, msgDiv, message, opts) {
|
||||
const cid = opts.cid || currentCid;
|
||||
if (!cid || !host || !message) return;
|
||||
@@ -13093,6 +13380,10 @@ function _finalizeActorPlaceholder(ph, gm, cid, archive) {
|
||||
_mountMarketplaceInstallRequests(bubble, ph, reqMessage, { cid });
|
||||
}
|
||||
}
|
||||
if (Array.isArray(gm.app_nav_requests) && gm.app_nav_requests.length) {
|
||||
const bubble = ph.querySelector('.chat-bubble');
|
||||
if (bubble) _mountAppNavRequests(bubble, { app_nav_requests: gm.app_nav_requests });
|
||||
}
|
||||
|
||||
// Interactive web-app artifacts (chat-app:// iframe). Idempotent — skips
|
||||
// ids already mounted in this bubble.
|
||||
@@ -13441,16 +13732,11 @@ function _handleGroupBusEvent(cid, streamingMsg, evData, { archive = false } = {
|
||||
// handed-off agent's reply.
|
||||
_serverFloorByCid.set(cid, typeof st.active_recipient === 'string' ? st.active_recipient : '');
|
||||
setGroupConversationBusy(cid, st.status === 'running' || inFlight.length > 0 || activeTurns.length > 0);
|
||||
if (hasActiveTurnsField) {
|
||||
for (const turn of activeTurns) {
|
||||
_ensureActorPlaceholder(cid, turn.actor, streamingMsg, turn.turn_id, turn.msg_id, turn.started_at_ms);
|
||||
}
|
||||
} else if (inFlight.length) {
|
||||
for (const actorId of inFlight) {
|
||||
if (!actorId) continue;
|
||||
_ensureActorPlaceholder(cid, actorId, streamingMsg);
|
||||
}
|
||||
}
|
||||
const primary = _ensureRuntimeActorPlaceholders(
|
||||
cid, streamingMsg, activeTurns, inFlight, hasActiveTurnsField,
|
||||
);
|
||||
const pendingState = pendingConvs.get(cid);
|
||||
if (primary && pendingState && !pendingState.aborted) pendingState.loadingEl = primary;
|
||||
// Reconcile against the snapshot. `state` is level-triggered — the relay
|
||||
// Server stores it last-write-wins and replays it on every WS reconnect —
|
||||
// so an actor that has dropped out of `in_flight` is no longer running.
|
||||
@@ -14228,10 +14514,12 @@ function _projectProcessRow(evt, displayContext, fallbackText = '') {
|
||||
const text = (event ? _formatEventLine(event, displayContext) : '')
|
||||
|| String(fallbackText || '');
|
||||
if (!text) return null;
|
||||
// Hidden diagnostics must not split one visible wait into multiple rows.
|
||||
const idleLifecycle = event ? _processCliIdleLifecycle(event, displayContext) : null;
|
||||
const kind = event ? _eventProcessKind(event, text) : _processKindOf(text);
|
||||
const lifecycle = kind === 'plan'
|
||||
? null
|
||||
: (_processToolLifecycle(event) || _processCliRetryLifecycle(event, displayContext));
|
||||
: (idleLifecycle || _processToolLifecycle(event) || _processCliRetryLifecycle(event, displayContext));
|
||||
const data = event?.data && typeof event.data === 'object' ? event.data : {};
|
||||
const cliResult = event?.stream === 'cli'
|
||||
&& String(data.type || '').toLowerCase() === 'tool-event'
|
||||
|
||||
@@ -180,9 +180,26 @@ async function loadProjectDetail(pid) {
|
||||
_stopProjectKbEventSubscription();
|
||||
}
|
||||
try {
|
||||
// The project composer's recipient chip is one ephemeral value shared by
|
||||
// every project, so a pick made in another project — or an agent unbound
|
||||
// while this page stayed open — can leave it aimed at an agent this project
|
||||
// does not have. The send path turns that chip into an `@name` prefix and
|
||||
// the bus resolves mentions against the global agent registry, so the stale
|
||||
// pick would really dispatch an out-of-project agent into the new
|
||||
// conversation. Same rule the auto-task dialog applies when its project
|
||||
// scope changes (`auto.js::_autoClearRecipientIfOutsideProject`). Decided
|
||||
// off the bindings response alone rather than the whole batch below:
|
||||
// waiting for a large library's file tree would leave the stale target
|
||||
// sendable for that long.
|
||||
const bindingsCall = window.orkas.invoke('projects.bindings.list', { projectId: pid });
|
||||
bindingsCall.then((res) => {
|
||||
if (loadSeq !== _projectDetailLoadSeq || pid !== _projectDetailPid) return;
|
||||
if (!res?.ok || typeof validateRecipientAgainstProject !== 'function') return;
|
||||
validateRecipientAgainstProject('project', pid, (res.bindings && res.bindings.agents) || []);
|
||||
}).catch(() => { /* the batch below owns the load failure */ });
|
||||
const [getRes, listRes, filesRes, kbRes, instrRes, autoRes] = await Promise.all([
|
||||
window.orkas.invoke('projects.get', { projectId: pid }),
|
||||
window.orkas.invoke('projects.bindings.list', { projectId: pid }),
|
||||
bindingsCall,
|
||||
window.orkas.invoke('projects.files.tree', { projectId: pid }),
|
||||
window.orkas.invoke('projects.files.status', { projectId: pid, skipReconcile: true }).catch((err) => ({ ok: false, error: err?.message || String(err) })),
|
||||
window.orkas.invoke('projects.instructions.get', { projectId: pid }).catch((err) => ({ ok: false, error: err?.message || String(err) })),
|
||||
|
||||
@@ -993,6 +993,38 @@ function getCommanderProjectIdName(pid) {
|
||||
return (p && p.name) || '';
|
||||
}
|
||||
|
||||
// Commander navigation uses the same sidebar/create/detail workflows as a
|
||||
// manual click. The helper resolves business ids before opening a detail so a
|
||||
// stale conversation card cannot leave the user on an empty Project shell.
|
||||
window.openProjectsSurface = async function openProjectsSurface(request) {
|
||||
request = request || {};
|
||||
const action = String(request.action || 'open');
|
||||
const toggle = document.getElementById('projects-section-toggle');
|
||||
if (!toggle) return false;
|
||||
if (toggle.getAttribute('aria-expanded') === 'false') toggle.click();
|
||||
|
||||
if (action === 'open') {
|
||||
toggle.scrollIntoView?.({ block: 'nearest' });
|
||||
toggle.focus?.();
|
||||
return true;
|
||||
}
|
||||
if (action === 'create') {
|
||||
_startProjectInlineCreate();
|
||||
return _projectsInlineCreate === true;
|
||||
}
|
||||
if (action === 'configure') {
|
||||
const projectId = String(request.target_id || '').trim();
|
||||
if (!projectId) return false;
|
||||
const projects = await loadProjects(true);
|
||||
const exists = Array.isArray(projects)
|
||||
&& projects.some((project) => project && project.project_id === projectId);
|
||||
if (!exists || typeof setView !== 'function') return false;
|
||||
setView('project', projectId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -324,6 +324,9 @@ function _showWorkspaceDropdown(anchor, target) {
|
||||
else old.remove();
|
||||
return;
|
||||
}
|
||||
if (typeof _closeOtherComposerPopovers === 'function') {
|
||||
_closeOtherComposerPopovers('workspace');
|
||||
}
|
||||
|
||||
const info = _wsInfoByTarget[target] || _wsInfoByTarget['conversation'];
|
||||
|
||||
|
||||
+74
-3
@@ -7482,6 +7482,75 @@ body.is-sidebar-resizing iframe { pointer-events: none; }
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Commander navigation card (open_app_view): a distinct action footer at the
|
||||
bottom of the bubble; navigation still happens only on the user's click. */
|
||||
.chat-app-nav-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
|
||||
}
|
||||
.chat-app-nav-row .chat-app-nav-btn {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
|
||||
}
|
||||
.chat-app-nav-row .chat-app-nav-btn:hover {
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, var(--border));
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
box-shadow: 0 7px 18px rgba(15, 23, 42, 0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.chat-app-nav-row .chat-app-nav-btn:active {
|
||||
box-shadow: 0 2px 7px rgba(15, 23, 42, 0.05);
|
||||
transform: translateY(0);
|
||||
}
|
||||
.chat-app-nav-row .chat-app-nav-btn:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--primary) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.chat-app-nav-btn-page-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.chat-app-nav-btn-page-icon {
|
||||
order: -1;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
flex: 0 0 16px;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
}
|
||||
.chat-app-nav-btn-page-icon-svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chat-app-nav-row .chat-app-nav-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Marketplace install request card. Same inline-panel scale as input forms:
|
||||
the user explicitly approves before any marketplace resource is installed. */
|
||||
.chat-marketplace-request {
|
||||
@@ -9227,9 +9296,11 @@ body.is-sidebar-resizing iframe { pointer-events: none; }
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chat-recipient-chip:hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
.chat-recipient-chip:hover,
|
||||
.chat-recipient-chip.is-open {
|
||||
background: var(--primary-soft);
|
||||
border-color: rgba(91, 87, 214, 0.18);
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
.chat-recipient-chip .chat-recipient-prefix {
|
||||
|
||||
@@ -842,6 +842,7 @@ test.describe('real chat pipeline with a local model', () => {
|
||||
const page = await sendNewChat(modelOrkas, 'E2E allow one command, then ask me again.');
|
||||
const dialog = page.locator('.bash-permission-dialog');
|
||||
await expect(dialog).toContainText('first.txt', { timeout: 20_000 });
|
||||
await expect(dialog.locator('[data-id="allow_once"]')).toBeFocused();
|
||||
await dialog.locator('[data-id="allow_once"]').click();
|
||||
await expect.poll(() => existsSync(firstPath)).toBe(false);
|
||||
|
||||
|
||||
@@ -68,6 +68,22 @@ test.describe('Commander orchestration', () => {
|
||||
const runningConversationId = await page.locator('#conversation-list .conv-item').first()
|
||||
.getAttribute('data-cid');
|
||||
expect(runningConversationId).toBeTruthy();
|
||||
// Switch away and back WITHOUT recreating the renderer. Unlike reload,
|
||||
// this preserves pendingConvs.loadingEl — exactly the field report where
|
||||
// the settled Commander row used to be reattached beside its canonical
|
||||
// history row while ContentWriter was still the active turn.
|
||||
await page.locator('#new-chat-btn').click();
|
||||
await page.locator(`.conv-item[data-cid="${runningConversationId}"]`).click();
|
||||
await expect(page.locator(
|
||||
'#chat-history .chat-message.assistant[data-from-actor="commander"]',
|
||||
)).toHaveCount(1);
|
||||
await expect(page.locator(
|
||||
'#chat-history .chat-message.assistant[data-from-actor="commander"]',
|
||||
)).toContainText(commanderNarration);
|
||||
await expect(page.locator(
|
||||
`#chat-history .chat-message.assistant[data-from-actor="${CONTENT_WRITER_ID}"]`,
|
||||
)).toHaveCount(1);
|
||||
|
||||
// Recreate the renderer while the delegated Agent is still running.
|
||||
// This exercises active-turn recovery plus history rendering together,
|
||||
// the path that a terminal-only assertion completely misses.
|
||||
@@ -151,6 +167,16 @@ test.describe('Commander orchestration', () => {
|
||||
`#chat-history .chat-message.assistant[data-from-actor="${CONTENT_WRITER_ID}"]`,
|
||||
)).toHaveCount(1);
|
||||
await expect(commanderBubbles).toHaveCount(0);
|
||||
|
||||
const runningConversationId = await page.locator('#conversation-list .conv-item').first()
|
||||
.getAttribute('data-cid');
|
||||
expect(runningConversationId).toBeTruthy();
|
||||
await page.locator('#new-chat-btn').click();
|
||||
await page.locator(`.conv-item[data-cid="${runningConversationId}"]`).click();
|
||||
await expect(page.locator(
|
||||
`#chat-history .chat-message.assistant[data-from-actor="${CONTENT_WRITER_ID}"]`,
|
||||
)).toHaveCount(1);
|
||||
await expect(commanderBubbles).toHaveCount(0);
|
||||
} finally {
|
||||
modelOrkas.releaseAgentHandoffReply();
|
||||
}
|
||||
|
||||
@@ -370,9 +370,9 @@ test.describe('shipped professional agents', () => {
|
||||
expect(request).toContain('image-router');
|
||||
expect(request).toContain('image-manifest.json');
|
||||
expect(request).toContain('project.export');
|
||||
expect(request).toContain('User UI language: **Chinese (简体中文)**');
|
||||
expect(request).toContain('Write all human-readable prose in Chinese (简体中文)');
|
||||
expect(request.lastIndexOf('User UI language: **Chinese (简体中文)**'))
|
||||
expect(request).toContain('Fallback UI language: **Chinese (简体中文)**');
|
||||
expect(request).toContain('Write all human-readable prose in the chosen language');
|
||||
expect(request.lastIndexOf('Fallback UI language: **Chinese (简体中文)**'))
|
||||
.toBeGreaterThan(request.lastIndexOf('chat prose follows the current UI language'));
|
||||
});
|
||||
|
||||
|
||||
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user