Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7baa77922 | ||
|
|
1e8418336d | ||
|
|
33612bf6d4 | ||
|
|
ffecd62b83 | ||
|
|
c77dcf5890 | ||
|
|
9c80f2c857 | ||
|
|
1253430ed4 | ||
|
|
e7d4b9ff76 |
No files matched your search
@@ -3,6 +3,50 @@
|
|||||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||||
until 1.0, minor versions may contain breaking changes.
|
until 1.0, minor versions may contain breaking changes.
|
||||||
|
|
||||||
|
## [v202611-00023] – 2026-11-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Tokens and cost under every answer:** a small line under each answer of the model shows the
|
||||||
|
tokens sent/received and the cost of all requests of that turn.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Total cost of chats stopped growing (opencode Go):** opencode now reports plan requests as
|
||||||
|
"nothing charged" (cost 0, usage in `allowance`), and CodeCraft took the 0. Plan requests are
|
||||||
|
valued at the model's list prices again; earlier ones are corrected once on start.
|
||||||
|
- **Saving opencode Go models failed after fetching prices** ("data did not match any variant of
|
||||||
|
untagged enum ModelEntry"): opencode lists cache prices per duration for some models
|
||||||
|
(`{"30m": …, "12h": …}`); the shortest duration is used now.
|
||||||
|
- The chat title in the top bar no longer shrinks to a single letter when there are many chips.
|
||||||
|
|
||||||
|
## [v202611-00022] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme "Tintenblau"** (dark: ink blue with cream text; light: paper with dark ink), from the
|
||||||
|
Tintenblau desktop theme; also in the terminal client.
|
||||||
|
|
||||||
|
## [v202611-00021] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Commits of agents name the model:** every `git commit` an agent makes gets the trailer
|
||||||
|
`Co-Authored-By: <model> (<provider>) via CodeCraft` (a git wrapper in the agent shell, also in
|
||||||
|
the sandbox; your own commits stay unchanged). The text is set under *Settings → General*
|
||||||
|
(`server.commit_trailer`, `{model}`, `{provider}`, `{model_id}`; empty = off).
|
||||||
|
|
||||||
|
## [v202611-00020] – 2026-11-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reverse engineering outside craft:** *Settings → Agents & commands* can give any mode the
|
||||||
|
`binary_info` and `decompile` (Ghidra) tools; the clean-room rule stays a craft rule.
|
||||||
|
- **Raw firmware in `decompile`:** optional `processor` (Ghidra language id, e.g.
|
||||||
|
`ARM:LE:32:Cortex`) and `base_address` for images without a file format (.bin, converted .hex).
|
||||||
|
- **Skill "reverse-engineering":** how to identify, decompile and explore programs and firmware
|
||||||
|
(processor table for common chips, HEX conversion, notes in `analysis/`).
|
||||||
|
|
||||||
## [v202611-00019] – 2026-11-11
|
## [v202611-00019] – 2026-11-11
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ COPY --from=cli /out /app/cli
|
|||||||
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
# Used with a mounted /var/run/docker.sock (see docs/MOVING.md, --docker-sock).
|
||||||
COPY --from=dockercli /docker /usr/local/bin/docker
|
COPY --from=dockercli /docker /usr/local/bin/docker
|
||||||
COPY server/ghidra /app/ghidra
|
COPY server/ghidra /app/ghidra
|
||||||
|
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
|
||||||
|
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
|
||||||
COPY server/resources /app/resources
|
COPY server/resources /app/resources
|
||||||
# The agent's home lives on the data volume: tools it installs in user space
|
# The agent's home lives on the data volume: tools it installs in user space
|
||||||
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img alt="Version v202611-00019" src="https://img.shields.io/badge/version-v202611--00019-d69e2e?style=flat-square">
|
<img alt="Version v202611-00023" src="https://img.shields.io/badge/version-v202611--00023-d69e2e?style=flat-square">
|
||||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||||
@@ -296,6 +296,7 @@ in turn are based on:
|
|||||||
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
| Coffee | Dark, Latte | [megabyte0x/omarchy-coffee-theme](https://github.com/megabyte0x/omarchy-coffee-theme) | MIT |
|
||||||
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
| Solarized | Dark, Light | [Solarized by Ethan Schoonover](https://ethanschoonover.com/solarized/) | MIT |
|
||||||
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
| Tokyo Night | Night, Day | [folke/tokyonight.nvim](https://github.com/folke/tokyonight.nvim) | Apache-2.0 |
|
||||||
|
| Tintenblau | Dark, Light | original (zink/tintenblau desktop theme) | private |
|
||||||
|
|
||||||
Further standard themes (palettes re-created from the published colour specifications):
|
Further standard themes (palettes re-created from the published colour specifications):
|
||||||
|
|
||||||
|
|||||||
@@ -701,6 +701,60 @@
|
|||||||
"syntax-type": "#b58900",
|
"syntax-type": "#b58900",
|
||||||
"syntax-variable": "#586e75"
|
"syntax-variable": "#586e75"
|
||||||
},
|
},
|
||||||
|
"tintenblau-dark": {
|
||||||
|
"accent": "#7aa0cc",
|
||||||
|
"accent2": "#d4b46a",
|
||||||
|
"aqua": "#96b4bc",
|
||||||
|
"bg0": "#232a44",
|
||||||
|
"bg1": "#2b3352",
|
||||||
|
"bg2": "#353e60",
|
||||||
|
"bg3": "#4a5478",
|
||||||
|
"blue": "#7aa0cc",
|
||||||
|
"fg0": "#f5efe3",
|
||||||
|
"fg1": "#eae4d6",
|
||||||
|
"fg2": "#d6d0c2",
|
||||||
|
"fg3": "#bdcfd4",
|
||||||
|
"green": "#96b4bc",
|
||||||
|
"muted": "#8e96b4",
|
||||||
|
"on-accent": "#232a44",
|
||||||
|
"purple": "#c896b2",
|
||||||
|
"red": "#d47a6a",
|
||||||
|
"syntax-comment": "#8e96b4",
|
||||||
|
"syntax-function": "#7aa0cc",
|
||||||
|
"syntax-keyword": "#c896b2",
|
||||||
|
"syntax-meta": "#baa0c4",
|
||||||
|
"syntax-number": "#96b4bc",
|
||||||
|
"syntax-string": "#d47a6a",
|
||||||
|
"syntax-type": "#d4b46a",
|
||||||
|
"syntax-variable": "#eae4d6"
|
||||||
|
},
|
||||||
|
"tintenblau-light": {
|
||||||
|
"accent": "#2a3f6f",
|
||||||
|
"accent2": "#6a4a2c",
|
||||||
|
"aqua": "#3a5a6a",
|
||||||
|
"bg0": "#f0ead9",
|
||||||
|
"bg1": "#e6dfd0",
|
||||||
|
"bg2": "#dbd3c1",
|
||||||
|
"bg3": "#c9c0ac",
|
||||||
|
"blue": "#3a5a8a",
|
||||||
|
"fg0": "#0f1628",
|
||||||
|
"fg1": "#1e2a4a",
|
||||||
|
"fg2": "#3a3f5a",
|
||||||
|
"fg3": "#5e5a6a",
|
||||||
|
"green": "#4a5a3a",
|
||||||
|
"muted": "#8a8694",
|
||||||
|
"on-accent": "#f0ead9",
|
||||||
|
"purple": "#5a4a72",
|
||||||
|
"red": "#8a3028",
|
||||||
|
"syntax-comment": "#8a8694",
|
||||||
|
"syntax-function": "#3a5a6a",
|
||||||
|
"syntax-keyword": "#2a3f6f",
|
||||||
|
"syntax-meta": "#4a5a3a",
|
||||||
|
"syntax-number": "#5a4a72",
|
||||||
|
"syntax-string": "#8a3028",
|
||||||
|
"syntax-type": "#6a4a2c",
|
||||||
|
"syntax-variable": "#1e2a4a"
|
||||||
|
},
|
||||||
"tokyonight-dark": {
|
"tokyonight-dark": {
|
||||||
"accent": "#7aa2f7",
|
"accent": "#7aa2f7",
|
||||||
"accent2": "#ff9e64",
|
"accent2": "#ff9e64",
|
||||||
|
|||||||
+22
-1
@@ -159,7 +159,13 @@ impl ModelConfig {
|
|||||||
/// Cost of one request now. Cache prices default to the input price.
|
/// Cost of one request now. Cache prices default to the input price.
|
||||||
/// The provider's own figure wins when it reports one (opencode).
|
/// The provider's own figure wins when it reports one (opencode).
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
u.cost.unwrap_or_else(|| self.cost_at(u, chrono::Utc::now()))
|
match u.cost {
|
||||||
|
// A plan request (opencode Go): nothing charged, the usage counts toward the plan –
|
||||||
|
// its value at the model's list prices.
|
||||||
|
Some(c) if c == 0.0 && u.allowance.is_some_and(|a| a > 0.0) => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
Some(c) => c,
|
||||||
|
None => self.cost_at(u, chrono::Utc::now()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cost of one request at a given time: context tiers and time factors applied.
|
/// Cost of one request at a given time: context tiers and time factors applied.
|
||||||
@@ -187,6 +193,21 @@ mod price_tests {
|
|||||||
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
Usage { input_tokens: input, output_tokens: output, cache_read_tokens: read, ..Default::default() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_requests_are_valued_at_list_prices() {
|
||||||
|
let m = ModelConfig { input_price: Some(1.0), output_price: Some(2.0), ..Default::default() };
|
||||||
|
let mut u = usage(1_000_000, 1_000_000, 0);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = Some(0.4);
|
||||||
|
assert_eq!(m.cost(&u), 3.0);
|
||||||
|
// Charged by the provider: that amount; free without a plan: free.
|
||||||
|
u.cost = Some(0.7);
|
||||||
|
assert_eq!(m.cost(&u), 0.7);
|
||||||
|
u.cost = Some(0.0);
|
||||||
|
u.allowance = None;
|
||||||
|
assert_eq!(m.cost(&u), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tiers_and_multipliers() {
|
fn tiers_and_multipliers() {
|
||||||
let m = ModelConfig {
|
let m = ModelConfig {
|
||||||
|
|||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# git for the shells of CodeCraft agents: `git commit` gets the co-author trailer from
|
||||||
|
# $CODECRAFT_COMMIT_TRAILER (model, provider, CodeCraft). Everything else goes to the real
|
||||||
|
# git unchanged. Git itself skips the trailer when the message already ends with it.
|
||||||
|
real=${CODECRAFT_REAL_GIT:-/usr/bin/git}
|
||||||
|
[[ -n $CODECRAFT_COMMIT_TRAILER ]] || exec "$real" "$@"
|
||||||
|
# The subcommand comes after git's global options (-C dir, -c key=value, --git-dir=…).
|
||||||
|
i=1
|
||||||
|
while (( i <= $# )); do
|
||||||
|
case ${!i} in
|
||||||
|
-C | -c | --git-dir | --work-tree | --namespace | --exec-path | --config-env) i=$((i + 2)) ;;
|
||||||
|
--) break ;;
|
||||||
|
-*) i=$((i + 1)) ;;
|
||||||
|
*) break ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if (( i <= $# )) && [[ ${!i} == commit ]]; then
|
||||||
|
# Right after "commit", so it stays an option even before "-- <paths>".
|
||||||
|
exec "$real" "${@:1:i}" --trailer "$CODECRAFT_COMMIT_TRAILER" "${@:i+1}"
|
||||||
|
fi
|
||||||
|
exec "$real" "$@"
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
name: reverse-engineering
|
||||||
|
description: Analyse programs and firmware – identify the format, inspect with binary_info, decompile with Ghidra (decompile tool, also raw firmware with processor/base address), explore the output and write down findings. For file formats, protocols, bugs and interoperability.
|
||||||
|
---
|
||||||
|
# Reverse engineering of programs and firmware
|
||||||
|
|
||||||
|
You analyse a binary to understand how it works – a file format, a protocol, an algorithm,
|
||||||
|
a bug, or how to talk to a device. Work from cheap to expensive and write down what you learn.
|
||||||
|
|
||||||
|
Only analyse binaries the user owns or may analyse (their own builds, their devices, programs
|
||||||
|
for interoperability). Decompiled code is third-party material: never commit it and never paste
|
||||||
|
it into the user's source – describe behaviour in your own words.
|
||||||
|
|
||||||
|
## 1. Identify
|
||||||
|
- `file <path>` – format, architecture, stripped or not.
|
||||||
|
- Executables and libraries (PE .exe/.dll, ELF, Mach-O): `binary_info` – sections, imports,
|
||||||
|
exports, strings. Imports and strings tell a lot (file names, error messages, protocol
|
||||||
|
keywords, library names) before any decompiling.
|
||||||
|
- Archives and update packages: unpack first (`unzip`, `7z x`), then identify the parts.
|
||||||
|
- Intel HEX / S-record firmware: convert to a raw image –
|
||||||
|
`objcopy -I ihex -O binary fw.hex fw.bin` (the lowest address in the HEX file is the base
|
||||||
|
address). An ELF from the firmware's build (e.g. `zephyr.elf`, `firmware.elf`) is far better
|
||||||
|
than a raw image: symbols and addresses are included – look for it first.
|
||||||
|
- Raw images: look for clues with `strings -n 6 fw.bin | head -200` and
|
||||||
|
`python3 -c` one-liners (vector table, magic numbers, embedded file systems).
|
||||||
|
|
||||||
|
## 2. Raw firmware: processor and load address
|
||||||
|
`decompile` needs both for images without a file format:
|
||||||
|
|
||||||
|
| Chip family | `processor` | typical `base_address` |
|
||||||
|
|---|---|---|
|
||||||
|
| ARM Cortex-M (STM32, nRF52, RP2040, SAMD) | `ARM:LE:32:Cortex` | STM32 `0x08000000`, nRF52 `0x00000000`, RP2040 `0x10000000` |
|
||||||
|
| ARM Cortex-A / ARMv7 | `ARM:LE:32:v7` | from the bootloader / datasheet |
|
||||||
|
| AArch64 | `AARCH64:LE:64:v8A` | |
|
||||||
|
| ESP32 (Xtensa) | `Xtensa:LE:32:default` | per segment (use the ELF if possible) |
|
||||||
|
| AVR (Arduino) | `AVR8:LE:16:atmega256` or `AVR8:LE:16:default` | `0x0` |
|
||||||
|
| MIPS (routers) | `MIPS:BE:32:default` or `MIPS:LE:32:default` | e.g. `0x80000000` |
|
||||||
|
|
||||||
|
Cortex-M check: the first word of the image is the initial stack pointer (0x2000xxxx = RAM),
|
||||||
|
the second the reset vector – it points just behind the base address. If the decompiled output
|
||||||
|
is mostly garbage, the processor or base address is wrong: fix it and run again (results are
|
||||||
|
cached per file *and* processor/address).
|
||||||
|
|
||||||
|
## 3. Decompile
|
||||||
|
- `decompile` with the path (plus `processor`/`base_address` for raw images). Large programs:
|
||||||
|
start with `max_functions` (e.g. 2000), widen when needed.
|
||||||
|
- Output in `.codecraft/decompiled/<name>/`: `functions.tsv` (address, name, size, file,
|
||||||
|
signature), `functions/*.c`, `strings.tsv`, `imports.tsv`, `exports.tsv`.
|
||||||
|
|
||||||
|
## 4. Explore
|
||||||
|
- Start from what you know: a string, an import or an export.
|
||||||
|
`grep -l "SetCommState" .codecraft/decompiled/<name>/functions` finds the functions that use it.
|
||||||
|
- Follow the calls by reading the functions; note addresses (`FUN_00401a30`) as you go.
|
||||||
|
- Broad searches through thousands of functions: delegate them to an `explore` subagent with a
|
||||||
|
precise question and let it report file:line.
|
||||||
|
- File formats: compare the reading/writing code with a sample file (`python3` to dump the bytes)
|
||||||
|
until every field is explained.
|
||||||
|
|
||||||
|
## 5. Write it down
|
||||||
|
Keep findings in `analysis/notes.md` (create it; it is not committed): structure of the
|
||||||
|
program, important functions with addresses, data structures, file formats and protocols with
|
||||||
|
field tables, open questions. Make sure `.gitignore` excludes `analysis/`, `original/` and
|
||||||
|
`.codecraft/decompiled/`. At the end, report what you found and how sure you are – mark guesses
|
||||||
|
as guesses.
|
||||||
+15
-2
@@ -843,6 +843,19 @@ impl WindowQ {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cost of each answer for the chat view (usage.cost: charged, or valued at list prices).
|
||||||
|
fn with_costs(app: &App, mut messages: Vec<crate::types::Message>) -> Vec<crate::types::Message> {
|
||||||
|
let registry = app.registry();
|
||||||
|
for m in &mut messages {
|
||||||
|
if let (Some(u), Some(spec)) = (m.usage.as_mut(), m.model.as_deref()) {
|
||||||
|
if let Ok((_, model)) = registry.resolve(spec) {
|
||||||
|
u.cost = Some(model.cost(u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
async fn get_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<WindowQ>) -> ApiResult {
|
||||||
let s = readable(&app, &user, &id)?;
|
let s = readable(&app, &user, &id)?;
|
||||||
let busy = app.is_busy(&id);
|
let busy = app.is_busy(&id);
|
||||||
@@ -853,7 +866,7 @@ async fn get_session(Extension(user): Extension<User>, State(app): State<AppStat
|
|||||||
match q.window() {
|
match q.window() {
|
||||||
Some(w) => {
|
Some(w) => {
|
||||||
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
let (messages, older, newer) = app.store.messages_window(&id, &w)?;
|
||||||
out["messages"] = json!(messages);
|
out["messages"] = json!(with_costs(&app, messages));
|
||||||
out["has_older"] = json!(older);
|
out["has_older"] = json!(older);
|
||||||
out["has_newer"] = json!(newer);
|
out["has_newer"] = json!(newer);
|
||||||
}
|
}
|
||||||
@@ -867,7 +880,7 @@ async fn session_messages(Extension(user): Extension<User>, State(app): State<Ap
|
|||||||
readable(&app, &user, &id)?;
|
readable(&app, &user, &id)?;
|
||||||
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
let w = q.window().ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "before, after, around or limit is required".into()))?;
|
||||||
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
let (messages, older, newer) = app.store.messages_window(&id, &w).map_err(|e| ApiError(StatusCode::NOT_FOUND, format!("{e:#}")))?;
|
||||||
Ok(Json(json!({"messages": messages, "has_older": older, "has_newer": newer})))
|
Ok(Json(json!({"messages": with_costs(&app, messages), "has_older": older, "has_newer": newer})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
/// Images, videos and audio files shown in a chat (all of it, with subagents).
|
||||||
|
|||||||
@@ -144,6 +144,11 @@ impl App {
|
|||||||
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
Ok(n) => tracing::info!("moved {n} images of older chats to the image store"),
|
||||||
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
Err(e) => tracing::warn!("moving images to the image store: {e:#}"),
|
||||||
}
|
}
|
||||||
|
match crate::budget::value_plan_requests(&self) {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!("valued {n} plan requests (opencode Go) at list prices"),
|
||||||
|
Err(e) => tracing::warn!("valuing plan requests: {e:#}"),
|
||||||
|
}
|
||||||
match crate::images::backfill_copies(&self) {
|
match crate::images::backfill_copies(&self) {
|
||||||
Ok(0) => {}
|
Ok(0) => {}
|
||||||
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
Ok(n) => tracing::info!("kept copies of {n} generated images in the image store"),
|
||||||
|
|||||||
+71
-9
@@ -361,6 +361,49 @@ fn opencode_when(w: &Value) -> Option<When> {
|
|||||||
Some((days, hours, from, until))
|
Some((days, hours, from, until))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan requests (opencode Go) that were booked at 0: value them at the model's list
|
||||||
|
/// prices of the time of the request. Idempotent – valued rows no longer match.
|
||||||
|
pub fn value_plan_requests(app: &App) -> Result<usize> {
|
||||||
|
let mut n = 0;
|
||||||
|
for (row, ts, spec, usage) in app.store.plan_requests_without_cost()? {
|
||||||
|
let Ok((_, model)) = app.registry().resolve(&spec) else { continue };
|
||||||
|
let Some(at) = chrono::DateTime::from_timestamp_millis(ts) else { continue };
|
||||||
|
let cost = model.cost_at(&usage, at);
|
||||||
|
if cost > 0.0 {
|
||||||
|
app.store.set_usage_cost(row, cost)?;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A price from opencode's list: a number, or one price per cache duration
|
||||||
|
/// (`{"30m": 0.125, "12h": 0.25}`) – then the shortest duration (the default caching).
|
||||||
|
fn price_of(v: &Value) -> Value {
|
||||||
|
match v {
|
||||||
|
Value::Number(_) => v.clone(),
|
||||||
|
Value::Object(o) => {
|
||||||
|
let secs = |k: &str| -> Option<f64> {
|
||||||
|
let (num, unit) = k.split_at(k.find(|c: char| c.is_ascii_alphabetic())?);
|
||||||
|
let n: f64 = num.parse().ok()?;
|
||||||
|
Some(n * match unit {
|
||||||
|
"s" => 1.0,
|
||||||
|
"m" => 60.0,
|
||||||
|
"h" => 3600.0,
|
||||||
|
"d" => 86400.0,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
o.iter()
|
||||||
|
.filter(|(_, x)| x.is_number())
|
||||||
|
.min_by(|a, b| secs(a.0).unwrap_or(f64::MAX).total_cmp(&secs(b.0).unwrap_or(f64::MAX)))
|
||||||
|
.map(|(_, x)| x.clone())
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
_ => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
/// Model row from opencode's model list (prices per 1M tokens, tiers, time factors).
|
||||||
fn opencode_model(m: &Value) -> Value {
|
fn opencode_model(m: &Value) -> Value {
|
||||||
let cost = &m["cost"]["text"];
|
let cost = &m["cost"]["text"];
|
||||||
@@ -371,13 +414,13 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
for o in side["overrides"].as_array().into_iter().flatten() {
|
for o in side["overrides"].as_array().into_iter().flatten() {
|
||||||
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
let Some(gt) = o["when"]["context"]["gt"].as_u64() else { continue };
|
||||||
let t = tiers.entry(gt).or_default();
|
let t = tiers.entry(gt).or_default();
|
||||||
t.insert(field.into(), o["price"].clone());
|
t.insert(field.into(), price_of(&o["price"]));
|
||||||
if field == "input_price" {
|
if field == "input_price" {
|
||||||
if o["cache"]["read"].is_number() {
|
for (key, k) in [("read", "cache_read_price"), ("write", "cache_write_price")] {
|
||||||
t.insert("cache_read_price".into(), o["cache"]["read"].clone());
|
let p = price_of(&o["cache"][key]);
|
||||||
|
if !p.is_null() {
|
||||||
|
t.insert(k.into(), p);
|
||||||
}
|
}
|
||||||
if o["cache"]["write"].is_number() {
|
|
||||||
t.insert("cache_write_price".into(), o["cache"]["write"].clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,10 +455,10 @@ fn opencode_model(m: &Value) -> Value {
|
|||||||
"max_input": m["limit"]["input"],
|
"max_input": m["limit"]["input"],
|
||||||
"vision": images,
|
"vision": images,
|
||||||
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
"reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false),
|
||||||
"input_price": input["price"],
|
"input_price": price_of(&input["price"]),
|
||||||
"output_price": output["price"],
|
"output_price": price_of(&output["price"]),
|
||||||
"cache_read_price": input["cache"]["read"],
|
"cache_read_price": price_of(&input["cache"]["read"]),
|
||||||
"cache_write_price": input["cache"]["write"],
|
"cache_write_price": price_of(&input["cache"]["write"]),
|
||||||
"price_tiers": price_tiers,
|
"price_tiers": price_tiers,
|
||||||
"price_multipliers": price_multipliers,
|
"price_multipliers": price_multipliers,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
@@ -481,6 +524,25 @@ pub async fn discover_prices(app: &App, provider: &str) -> Result<Value> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
#[test]
|
||||||
|
fn prices_per_cache_duration() {
|
||||||
|
use super::{opencode_model, price_of};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
assert_eq!(price_of(&json!(0.5)), json!(0.5));
|
||||||
|
assert_eq!(price_of(&json!({"12h": 0.25, "30m": 0.125})), json!(0.125));
|
||||||
|
assert_eq!(price_of(&json!({"1h": 2.0, "5m": 1.25})), json!(1.25));
|
||||||
|
assert_eq!(price_of(&Value::Null), Value::Null);
|
||||||
|
// A model of opencode's list with per-duration cache write prices.
|
||||||
|
let m = json!({"id": "gpt-6-luna", "limit": {"context": 1050000, "input": 922000, "output": 128000},
|
||||||
|
"cost": {"text": {"input": {"price": 0.1, "cache": {"read": 0.01, "write": {"30m": 0.125, "12h": 0.25}},
|
||||||
|
"overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.2, "cache": {"read": 0.02, "write": {"30m": 0.25, "12h": 0.5}}}]},
|
||||||
|
"output": {"price": 0.5, "overrides": [{"when": {"context": {"gt": 272000}}, "price": 0.75}]}}}});
|
||||||
|
let row = opencode_model(&m);
|
||||||
|
assert_eq!(row["cache_write_price"], json!(0.125));
|
||||||
|
assert_eq!(row["price_tiers"][0]["cache_write_price"], json!(0.25));
|
||||||
|
assert_eq!(row["max_input"], json!(922000));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn opencode_cost_conversion() {
|
fn opencode_cost_conversion() {
|
||||||
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
let m: serde_json::Value = serde_json::from_str(r#"{"id":"grok","limit":{"context":500000,"output":8000},
|
||||||
|
|||||||
@@ -307,8 +307,13 @@ pub struct ServerConfig {
|
|||||||
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
/// Bearer token for GET /metrics (Prometheus); empty = /metrics disabled.
|
||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
pub metrics_token: String,
|
pub metrics_token: String,
|
||||||
|
/// Trailer added to every git commit an agent makes ({model}, {provider}, {model_id});
|
||||||
|
/// empty = off.
|
||||||
|
pub commit_trailer: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const DEFAULT_COMMIT_TRAILER: &str = "Co-Authored-By: {model} ({provider}) via CodeCraft";
|
||||||
|
|
||||||
impl Default for ServerConfig {
|
impl Default for ServerConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -327,6 +332,7 @@ impl Default for ServerConfig {
|
|||||||
max_upload_mb: 500,
|
max_upload_mb: 500,
|
||||||
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
update_url: "https://git.familie-zink.org/api/v1/repos/zink/codecraft/releases/latest".into(),
|
||||||
metrics_token: String::new(),
|
metrics_token: String::new(),
|
||||||
|
commit_trailer: DEFAULT_COMMIT_TRAILER.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,6 +404,10 @@ pub struct AgentConfig {
|
|||||||
/// Review loop after each turn of this mode.
|
/// Review loop after each turn of this mode.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub refine: Option<RefineConfig>,
|
pub refine: Option<RefineConfig>,
|
||||||
|
/// Reverse engineering tools (binary_info, decompile with Ghidra) in this mode;
|
||||||
|
/// craft has them anyway (deep mode).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reverse_tools: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
/// Refinement loop: after the agent finishes, a reviewer checks the result
|
||||||
|
|||||||
+11
-6
@@ -32,6 +32,8 @@ pub struct Agent {
|
|||||||
pub max_parallel: Option<usize>,
|
pub max_parallel: Option<usize>,
|
||||||
/// Review loop after each turn (None/0 rounds = off).
|
/// Review loop after each turn (None/0 rounds = off).
|
||||||
pub refine: Option<crate::config::RefineConfig>,
|
pub refine: Option<crate::config::RefineConfig>,
|
||||||
|
/// binary_info and decompile available (besides craft).
|
||||||
|
pub reverse: bool,
|
||||||
/// Source file of Markdown agents.
|
/// Source file of Markdown agents.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
@@ -43,12 +45,12 @@ pub use codecraft_core::prompts::*;
|
|||||||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||||||
let craft = craft_prompt(clean_room);
|
let craft = craft_prompt(clean_room);
|
||||||
vec![
|
vec![
|
||||||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, reverse: false, path: None },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +124,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
source: "file".into(),
|
source: "file".into(),
|
||||||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||||||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }),
|
||||||
|
reverse: meta.get("reverse_tools").is_some_and(|v| v == "true"),
|
||||||
path: Some(p.display().to_string()),
|
path: Some(p.display().to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,6 +144,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
|||||||
source: "config".into(),
|
source: "config".into(),
|
||||||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||||||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||||||
|
reverse: a.reverse_tools.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.reverse)),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,6 +177,7 @@ pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) ->
|
|||||||
source: "personal".into(),
|
source: "personal".into(),
|
||||||
max_parallel: None,
|
max_parallel: None,
|
||||||
refine: a.refine,
|
refine: a.refine,
|
||||||
|
reverse: a.reverse_tools.unwrap_or(false),
|
||||||
path: None,
|
path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-4
@@ -142,7 +142,10 @@ impl Tool for Decompile {
|
|||||||
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
"Decompile an executable or library with Ghidra (x86/x64/ARM/MIPS/PowerPC/68k …, PE, ELF, \
|
||||||
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
Mach-O, raw DOS). Writes pseudo-C for every function plus strings, imports and exports \
|
||||||
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
into .codecraft/decompiled/<name>/ and returns an overview. Can take several minutes for \
|
||||||
large programs; results are cached per file."
|
large programs; results are cached per file. Raw firmware images (.bin without a file \
|
||||||
|
format) need `processor` (Ghidra language id, e.g. \"ARM:LE:32:Cortex\" for Cortex-M, \
|
||||||
|
\"ARM:LE:32:v8\", \"AVR8:LE:16:atmega256\", \"Xtensa:LE:32:default\" for ESP32) and usually \
|
||||||
|
`base_address` (load address, e.g. \"0x08000000\" for STM32 flash)."
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
fn schema(&self) -> Value {
|
fn schema(&self) -> Value {
|
||||||
@@ -151,7 +154,9 @@ impl Tool for Decompile {
|
|||||||
"properties": {
|
"properties": {
|
||||||
"path": {"type": "string"},
|
"path": {"type": "string"},
|
||||||
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
"max_functions": {"type": "integer", "description": "Limit for very large binaries (default: all)"},
|
||||||
"force": {"type": "boolean", "description": "Re-run even if cached"}
|
"force": {"type": "boolean", "description": "Re-run even if cached"},
|
||||||
|
"processor": {"type": "string", "description": "Ghidra language id for raw images, e.g. ARM:LE:32:Cortex (default: detected from the file format)"},
|
||||||
|
"base_address": {"type": "string", "description": "Load address of a raw image, e.g. 0x08000000"}
|
||||||
},
|
},
|
||||||
"required": ["path"]
|
"required": ["path"]
|
||||||
})
|
})
|
||||||
@@ -171,7 +176,20 @@ impl Tool for Decompile {
|
|||||||
bail!("{} is not a file", path.display());
|
bail!("{} is not a file", path.display());
|
||||||
}
|
}
|
||||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("binary").replace(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-', "_");
|
||||||
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}", file_hash(&path)?));
|
let processor = input["processor"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||||
|
let base = input["base_address"].as_str().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||||
|
if processor.as_deref().is_some_and(|p| !p.chars().all(|c| c.is_ascii_alphanumeric() || ":_-.".contains(c))) {
|
||||||
|
bail!("invalid processor id (expected e.g. ARM:LE:32:Cortex)");
|
||||||
|
}
|
||||||
|
if base.as_deref().is_some_and(|b| !b.trim_start_matches("0x").chars().all(|c| c.is_ascii_hexdigit())) {
|
||||||
|
bail!("invalid base_address (expected e.g. 0x08000000)");
|
||||||
|
}
|
||||||
|
// Another processor or load address is another analysis.
|
||||||
|
let variant = match (&processor, &base) {
|
||||||
|
(None, None) => String::new(),
|
||||||
|
(p, b) => format!("-{}", crate::blobs::hash(format!("{p:?}{b:?}").as_bytes()).get(..8).unwrap_or("")),
|
||||||
|
};
|
||||||
|
let out_dir = ctx.cwd.join(".codecraft/decompiled").join(format!("{name}-{}{variant}", file_hash(&path)?));
|
||||||
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
if out_dir.join(".done").exists() && !input["force"].as_bool().unwrap_or(false) {
|
||||||
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
return Ok(ToolOutput::text(format!("(cached)\n{}", summarize(&out_dir, &ctx.cwd)?)));
|
||||||
}
|
}
|
||||||
@@ -183,7 +201,15 @@ impl Tool for Decompile {
|
|||||||
let scripts = scripts_dir().display().to_string();
|
let scripts = scripts_dir().display().to_string();
|
||||||
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
let max = input["max_functions"].as_u64().map(|n| n.to_string()).unwrap_or_else(|| "2147483647".into());
|
||||||
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
let (p, proj, out) = (path.display().to_string(), project.display().to_string(), out_dir.display().to_string());
|
||||||
let args = vec![proj.as_str(), "cc", "-import", p.as_str(), "-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"];
|
let mut args = vec![proj.as_str(), "cc", "-import", p.as_str()];
|
||||||
|
// Raw firmware: the binary loader with the given processor and load address.
|
||||||
|
if let Some(proc_id) = processor.as_deref() {
|
||||||
|
args.extend(["-processor", proc_id]);
|
||||||
|
}
|
||||||
|
if let Some(addr) = base.as_deref() {
|
||||||
|
args.extend(["-loader", "BinaryLoader", "-loader-baseAddr", addr]);
|
||||||
|
}
|
||||||
|
args.extend(["-scriptPath", scripts.as_str(), "-postScript", "CodeCraftExport.java", out.as_str(), max.as_str(), "-deleteProject", "-max-cpu", "2"]);
|
||||||
let java = tools.join("jdk").display().to_string();
|
let java = tools.join("jdk").display().to_string();
|
||||||
let mut cmd = match &ctx.sandbox {
|
let mut cmd = match &ctx.sandbox {
|
||||||
Some(spec) => {
|
Some(spec) => {
|
||||||
|
|||||||
+10
-3
@@ -226,7 +226,12 @@ pub fn available_tools(app: &App, agent: &Agent, owner: Option<&crate::store::Us
|
|||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|t| !agent.read_only || t.read_only())
|
.filter(|t| !agent.read_only || t.read_only())
|
||||||
// Craft tools only where they are useful.
|
// Craft tools only where they are useful.
|
||||||
.filter(|t| agent.name == "craft" || !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase") || agent.tools.is_some())
|
.filter(|t| {
|
||||||
|
agent.name == "craft"
|
||||||
|
|| agent.tools.is_some()
|
||||||
|
|| (agent.reverse && matches!(t.name(), "binary_info" | "decompile"))
|
||||||
|
|| !matches!(t.name(), "browser_snapshot" | "image_compare" | "binary_info" | "decompile" | "craft_phase")
|
||||||
|
})
|
||||||
.filter(|t| match &agent.tools {
|
.filter(|t| match &agent.tools {
|
||||||
None => true,
|
None => true,
|
||||||
Some(list) => {
|
Some(list) => {
|
||||||
@@ -456,7 +461,8 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
agent: agent.name.clone(),
|
agent: agent.name.clone(),
|
||||||
roots: roots.clone(),
|
roots: roots.clone(),
|
||||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||||
clean_room,
|
// Clean room is a craft rule; other modes with reverse tools may analyse binaries.
|
||||||
|
clean_room: clean_room && agent.name == "craft",
|
||||||
owner_id: None,
|
owner_id: None,
|
||||||
};
|
};
|
||||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
@@ -1107,7 +1113,8 @@ async fn stream_step(
|
|||||||
let mut message = Message::new(Role::Assistant, parts);
|
let mut message = Message::new(Role::Assistant, parts);
|
||||||
message.id = msg_id;
|
message.id = msg_id;
|
||||||
message.model = Some(model_spec.to_string());
|
message.model = Some(model_spec.to_string());
|
||||||
message.usage = Some(usage.clone());
|
// With its cost (charged, or valued at list prices) for the line under the answer.
|
||||||
|
message.usage = Some(Usage { cost: Some(req.model.cost(&usage)), ..usage.clone() });
|
||||||
if !message.parts.is_empty() {
|
if !message.parts.is_empty() {
|
||||||
app.store.add_message(&session.id, &message)?;
|
app.store.add_message(&session.id, &message)?;
|
||||||
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
app.emit(Event::MessageAdded { session_id: session.id.clone(), message: message.clone() });
|
||||||
|
|||||||
@@ -653,6 +653,39 @@ impl Store {
|
|||||||
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
Ok(json!({"total": total, "by_model": by_model, "by_agent": by_agent, "first": first, "last": last}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan requests booked at 0 (opencode Go reports nothing charged, only `allowance`):
|
||||||
|
/// (row id, ts, "provider/model", usage).
|
||||||
|
pub fn plan_requests_without_cost(&self) -> Result<Vec<(i64, i64, String, crate::types::Usage)>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
"SELECT id, ts, provider || '/' || model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, allowance
|
||||||
|
FROM usage WHERE cost = 0 AND allowance > 0",
|
||||||
|
)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get(0)?,
|
||||||
|
r.get(1)?,
|
||||||
|
r.get(2)?,
|
||||||
|
crate::types::Usage {
|
||||||
|
input_tokens: r.get::<_, i64>(3)? as u64,
|
||||||
|
output_tokens: r.get::<_, i64>(4)? as u64,
|
||||||
|
cache_read_tokens: r.get::<_, i64>(5)? as u64,
|
||||||
|
cache_write_tokens: r.get::<_, i64>(6)? as u64,
|
||||||
|
allowance: r.get(7)?,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_usage_cost(&self, row: i64, cost: f64) -> Result<()> {
|
||||||
|
self.conn.lock().unwrap().execute("UPDATE usage SET cost = ?2 WHERE id = ?1", params![row, cost])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||||
/// into the viewer's local time.
|
/// into the viewer's local time.
|
||||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ impl Tool for Bash {
|
|||||||
/// The bash command for a script, sandboxed for non-admin users.
|
/// The bash command for a script, sandboxed for non-admin users.
|
||||||
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Command, ToolOutput> {
|
||||||
// stderr of every command (not just the last one) goes into the output.
|
// stderr of every command (not just the last one) goes into the output.
|
||||||
let script = format!("exec 2>&1\n{command}");
|
let script = format!("exec 2>&1\n{}{command}", commit_attribution(ctx));
|
||||||
Ok(match &ctx.sandbox {
|
Ok(match &ctx.sandbox {
|
||||||
Some(spec) => {
|
Some(spec) => {
|
||||||
if !crate::sandbox::available() {
|
if !crate::sandbox::available() {
|
||||||
@@ -161,6 +161,36 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Directory with the git wrapper that adds the co-author trailer to commits.
|
||||||
|
fn git_wrapper_dir() -> String {
|
||||||
|
std::env::var("CODECRAFT_GIT_WRAPPER").unwrap_or_else(|_| "/usr/local/lib/codecraft/bin".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell lines that make `git commit` in this command carry the co-author trailer of
|
||||||
|
/// the session's model (see docker/git-coauthor). Set in the script itself, so it
|
||||||
|
/// works inside the sandbox (which clears the environment) too.
|
||||||
|
fn commit_attribution(ctx: &ToolCtx) -> String {
|
||||||
|
let template = ctx.app.config().server.commit_trailer.clone();
|
||||||
|
let dir = git_wrapper_dir();
|
||||||
|
if template.trim().is_empty() || !std::path::Path::new(&dir).join("git").exists() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let Ok(session) = ctx.app.session(&ctx.session_id) else { return String::new() };
|
||||||
|
let (provider, model_id) = session.model.split_once('/').unwrap_or(("", session.model.as_str()));
|
||||||
|
let name = ctx.app.registry().resolve(&session.model).ok().and_then(|(_, m)| m.name).filter(|n| !n.trim().is_empty()).unwrap_or_else(|| model_id.to_string());
|
||||||
|
let trailer = commit_trailer(&template, &name, provider, model_id);
|
||||||
|
format!("export PATH={}:\"$PATH\" CODECRAFT_COMMIT_TRAILER={}\n", shell_quote(&dir), shell_quote(&trailer))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trailer text for a model ("Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft").
|
||||||
|
pub fn commit_trailer(template: &str, model: &str, provider: &str, model_id: &str) -> String {
|
||||||
|
template.replace("{model}", model).replace("{provider}", provider).replace("{model_id}", model_id).replace(['\n', '\r'], " ").trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shell_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', "'\\''"))
|
||||||
|
}
|
||||||
|
|
||||||
fn tail(s: &str, max: usize) -> String {
|
fn tail(s: &str, max: usize) -> String {
|
||||||
if s.len() <= max {
|
if s.len() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
@@ -171,3 +201,18 @@ fn tail(s: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
s[start..].to_string()
|
s[start..].to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod commit_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commit_trailer_text() {
|
||||||
|
assert_eq!(
|
||||||
|
commit_trailer(crate::config::DEFAULT_COMMIT_TRAILER, "DeepSeek V4.1 Flash", "opencode-go", "deepseek-v4.1-flash"),
|
||||||
|
"Co-Authored-By: DeepSeek V4.1 Flash (opencode-go) via CodeCraft"
|
||||||
|
);
|
||||||
|
assert_eq!(commit_trailer("By {model_id}\nx", "M", "p", "m-1"), "By m-1 x");
|
||||||
|
assert_eq!(shell_quote("it's"), "'it'\\''s'");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -245,8 +245,10 @@ const MessageList = memo(function MessageList({
|
|||||||
baseDir,
|
baseDir,
|
||||||
onRewind,
|
onRewind,
|
||||||
highlight,
|
highlight,
|
||||||
|
currency,
|
||||||
}: {
|
}: {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
|
currency: string;
|
||||||
results: Map<string, ToolResult>;
|
results: Map<string, ToolResult>;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
@@ -280,13 +282,52 @@ const MessageList = memo(function MessageList({
|
|||||||
));
|
));
|
||||||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||||
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>;
|
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||||
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
|
if (i === lastWork || steps.length < 2)
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
{items}
|
||||||
|
{usage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<WorkBlock messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>
|
||||||
|
{items}
|
||||||
|
</WorkBlock>
|
||||||
|
{usage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Tokens (in/out) and cost of one answer: all requests of the turn together. */
|
||||||
|
function TurnUsage({ messages, currency }: { messages: Message[]; currency: string }) {
|
||||||
|
const t = useT();
|
||||||
|
let inp = 0;
|
||||||
|
let out = 0;
|
||||||
|
let cost = 0;
|
||||||
|
let requests = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
const u = m.role === "assistant" ? m.usage : undefined;
|
||||||
|
if (!u) continue;
|
||||||
|
requests++;
|
||||||
|
inp += u.input_tokens + u.cache_read_tokens + u.cache_write_tokens;
|
||||||
|
out += u.output_tokens;
|
||||||
|
cost += u.cost ?? 0;
|
||||||
|
}
|
||||||
|
if (!requests) return null;
|
||||||
|
return (
|
||||||
|
<div className="turn-usage" title={t("chat.turnUsageHint", { n: requests })}>
|
||||||
|
{fmtCompact(inp)}
|
||||||
|
<Icon name="arrow-up" className="tiny" /> {fmtCompact(out)}
|
||||||
|
<Icon name="arrow-down" className="tiny" /> · {fmtCost(cost, currency)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||||
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -825,6 +866,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
|||||||
baseDir={session.cwd}
|
baseDir={session.cwd}
|
||||||
onRewind={onRewind}
|
onRewind={onRewind}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
|
currency={stats?.currency ?? "USD"}
|
||||||
/>
|
/>
|
||||||
{win?.newer && (
|
{win?.newer && (
|
||||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
small_model: s.small_model ?? "",
|
small_model: s.small_model ?? "",
|
||||||
currency: s.currency ?? "USD",
|
currency: s.currency ?? "USD",
|
||||||
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
auto_compact_threshold: s.auto_compact_threshold ?? 0.85,
|
||||||
|
commit_trailer: ((s as any).commit_trailer ?? "Co-Authored-By: {model} ({provider}) via CodeCraft") as string,
|
||||||
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
max_parallel_subagents: s.max_parallel_subagents ?? 4,
|
||||||
monthly_budget: s.monthly_budget ?? 0,
|
monthly_budget: s.monthly_budget ?? 0,
|
||||||
budget_warning: s.budget_warning ?? 0.8,
|
budget_warning: s.budget_warning ?? 0.8,
|
||||||
@@ -271,6 +272,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
{ path: ["server", "small_model"], value: form.small_model },
|
{ path: ["server", "small_model"], value: form.small_model },
|
||||||
{ path: ["server", "currency"], value: form.currency },
|
{ path: ["server", "currency"], value: form.currency },
|
||||||
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
{ path: ["server", "auto_compact_threshold"], value: Number(form.auto_compact_threshold) },
|
||||||
|
{ path: ["server", "commit_trailer"], value: form.commit_trailer.trim() },
|
||||||
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
|
||||||
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
|
||||||
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
|
||||||
@@ -305,6 +307,9 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
|||||||
<label>{t("settings.autoCompact")}</label>
|
<label>{t("settings.autoCompact")}</label>
|
||||||
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
<input type="number" min={0} max={1} step={0.05} value={form.auto_compact_threshold} onChange={(e) => setForm({ ...form, auto_compact_threshold: e.target.value })} />
|
||||||
<div className="hint">{t("settings.autoCompactHint")}</div>
|
<div className="hint">{t("settings.autoCompactHint")}</div>
|
||||||
|
<label>{t("settings.commitTrailer")}</label>
|
||||||
|
<input className="mono" value={form.commit_trailer} placeholder={t("settings.commitTrailerOff")} onChange={(e) => setForm({ ...form, commit_trailer: e.target.value })} />
|
||||||
|
<div className="hint">{t("settings.commitTrailerHint")}</div>
|
||||||
<label>{t("settings.monthlyBudgetTotal")}</label>
|
<label>{t("settings.monthlyBudgetTotal")}</label>
|
||||||
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
<input type="number" min={0} value={form.monthly_budget} onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||||
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
refine_model: raw?.refine?.model ?? "",
|
refine_model: raw?.refine?.model ?? "",
|
||||||
refine_criteria: raw?.refine?.criteria ?? "",
|
refine_criteria: raw?.refine?.criteria ?? "",
|
||||||
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean,
|
||||||
|
reverse_tools: !!(raw?.reverse_tools ?? a.reverse),
|
||||||
});
|
});
|
||||||
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name));
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (a.name !== "craft") patches.push({ path: [...base, "reverse_tools"], value: form.reverse_tools || null });
|
||||||
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null });
|
||||||
if (!builtin) {
|
if (!builtin) {
|
||||||
patches.push({ path: [...base, "mode"], value: form.mode });
|
patches.push({ path: [...base, "mode"], value: form.mode });
|
||||||
@@ -166,6 +168,15 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T
|
|||||||
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
<div className="hint">{t("agents.refineAfterTodosHint")}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{a.name !== "craft" && (
|
||||||
|
<>
|
||||||
|
<label className="check">
|
||||||
|
<input type="checkbox" checked={form.reverse_tools} onChange={(e) => setForm({ ...form, reverse_tools: e.target.checked })} />
|
||||||
|
{t("agents.reverseTools")}
|
||||||
|
</label>
|
||||||
|
<div className="hint">{t("agents.reverseToolsHint")}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<label>{t("agents.description")}</label>
|
<label>{t("agents.description")}</label>
|
||||||
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<input value={form.description} placeholder={a.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
{a.mode !== "primary" && <div className="hint">{t("agents.descriptionHint")}</div>}
|
||||||
|
|||||||
+7
-1
@@ -189,7 +189,7 @@ export const de: Messages = {
|
|||||||
"tools.install": "Ghidra installieren",
|
"tools.install": "Ghidra installieren",
|
||||||
"tools.reinstall": "Installation prüfen / reparieren",
|
"tools.reinstall": "Installation prüfen / reparieren",
|
||||||
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
|
||||||
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
|
"tools.ghidraHint": "Mit Ghidra können Agenten Programme und Firmware dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren – im Craft-Deep-Modus und in Modi mit Reverse-Engineering-Werkzeugen (Einstellungen → Agenten & Befehle). Wird samt eigenem JDK ins Daten-Volume installiert.",
|
||||||
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
|
||||||
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
|
||||||
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
|
||||||
@@ -336,6 +336,8 @@ export const de: Messages = {
|
|||||||
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
"activity.reviewing": "Ergebnis wird geprüft (Runde {round})…",
|
||||||
"chat.reviewPassed": "Review bestanden:",
|
"chat.reviewPassed": "Review bestanden:",
|
||||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||||
|
"agents.reverseTools": "Reverse-Engineering-Werkzeuge (binary_info, decompile mit Ghidra)",
|
||||||
|
"agents.reverseToolsHint": "Der Agent kann in diesem Modus Programme und Firmware untersuchen und dekompilieren (Ghidra muss unter Einstellungen → Werkzeuge installiert sein). Craft hat sie im Deep-Modus ohnehin.",
|
||||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||||
"chat.workSteps": "{n} Schritte",
|
"chat.workSteps": "{n} Schritte",
|
||||||
@@ -365,6 +367,7 @@ export const de: Messages = {
|
|||||||
"media.prev": "Vorheriges",
|
"media.prev": "Vorheriges",
|
||||||
"media.next": "Nächstes",
|
"media.next": "Nächstes",
|
||||||
"media.back": "Zurück zur Übersicht",
|
"media.back": "Zurück zur Übersicht",
|
||||||
|
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||||
"agents.refine": "Review-Schleife",
|
"agents.refine": "Review-Schleife",
|
||||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||||
@@ -768,6 +771,9 @@ export const de: Messages = {
|
|||||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||||
"settings.currency": "Währung",
|
"settings.currency": "Währung",
|
||||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||||
|
"settings.commitTrailer": "Commit-Vermerk der Agenten",
|
||||||
|
"settings.commitTrailerOff": "aus",
|
||||||
|
"settings.commitTrailerHint": "Wird als Trailer an jeden Git-Commit eines Agenten angehängt. {model} = Modellname, {provider} = Anbieter, {model_id} = Modell-ID. Leer = aus. Deine eigenen Commits bleiben unverändert.",
|
||||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||||
"settings.craft": "Craft-Modus",
|
"settings.craft": "Craft-Modus",
|
||||||
"settings.cleanRoom": "Clean Room",
|
"settings.cleanRoom": "Clean Room",
|
||||||
|
|||||||
+7
-1
@@ -188,7 +188,7 @@ export const en = {
|
|||||||
"tools.install": "Install Ghidra",
|
"tools.install": "Install Ghidra",
|
||||||
"tools.reinstall": "Check / repair installation",
|
"tools.reinstall": "Check / repair installation",
|
||||||
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
|
||||||
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.",
|
"tools.ghidraHint": "Ghidra lets agents decompile programs and firmware (Windows .exe/.dll, Linux, DOS, …) to study how they work – in craft Deep mode and in modes with reverse engineering tools (Settings → Agents & commands). Installed into the data volume together with its own JDK.",
|
||||||
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
|
||||||
"rewind.noFiles": "The agent did not change files since then.",
|
"rewind.noFiles": "The agent did not change files since then.",
|
||||||
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
"rewind.restore": "Also restore the {n} file(s) the agent changed",
|
||||||
@@ -335,6 +335,8 @@ export const en = {
|
|||||||
"activity.reviewing": "Reviewing the result (round {round})…",
|
"activity.reviewing": "Reviewing the result (round {round})…",
|
||||||
"chat.reviewPassed": "Review passed:",
|
"chat.reviewPassed": "Review passed:",
|
||||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||||
|
"agents.reverseTools": "Reverse engineering tools (binary_info, decompile with Ghidra)",
|
||||||
|
"agents.reverseToolsHint": "The agent can inspect and decompile programs and firmware in this mode (Ghidra must be installed under Settings → Tools). Craft has them anyway in deep mode.",
|
||||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||||
"chat.workSteps": "{n} steps",
|
"chat.workSteps": "{n} steps",
|
||||||
@@ -364,6 +366,7 @@ export const en = {
|
|||||||
"media.prev": "Previous",
|
"media.prev": "Previous",
|
||||||
"media.next": "Next",
|
"media.next": "Next",
|
||||||
"media.back": "Back to the overview",
|
"media.back": "Back to the overview",
|
||||||
|
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||||
"chat.reviewRound": "Review {round}: changes requested",
|
"chat.reviewRound": "Review {round}: changes requested",
|
||||||
"agents.refine": "Review loop",
|
"agents.refine": "Review loop",
|
||||||
"agents.refineRounds": "Review rounds per task",
|
"agents.refineRounds": "Review rounds per task",
|
||||||
@@ -767,6 +770,9 @@ export const en = {
|
|||||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||||
"settings.currency": "Currency",
|
"settings.currency": "Currency",
|
||||||
"settings.autoCompact": "Auto-compact at",
|
"settings.autoCompact": "Auto-compact at",
|
||||||
|
"settings.commitTrailer": "Commit note of agents",
|
||||||
|
"settings.commitTrailerOff": "off",
|
||||||
|
"settings.commitTrailerHint": "Added as a trailer to every git commit an agent makes. {model} = model name, {provider} = provider, {model_id} = model id. Empty = off. Your own commits stay unchanged.",
|
||||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||||
"settings.craft": "Craft mode",
|
"settings.craft": "Craft mode",
|
||||||
"settings.cleanRoom": "Clean room",
|
"settings.cleanRoom": "Clean room",
|
||||||
|
|||||||
+13
-1
@@ -456,7 +456,8 @@ button.session-group:hover {
|
|||||||
}
|
}
|
||||||
.topbar .grow {
|
.topbar .grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
/* The title stays readable: with many chips they wrap into a second row instead. */
|
||||||
|
min-width: min(160px, 40vw);
|
||||||
}
|
}
|
||||||
.topbar h1 {
|
.topbar h1 {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -4398,3 +4399,14 @@ span.chat-tab > button {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tokens and cost under each answer. */
|
||||||
|
.turn-usage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin: -6px 0 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export const THEMES = [
|
|||||||
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
|
||||||
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
|
||||||
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
|
||||||
|
{ id: "tintenblau", name: "Tintenblau", source: "zink/tintenblau", license: "private" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type Theme = (typeof THEMES)[number]["id"];
|
export type Theme = (typeof THEMES)[number]["id"];
|
||||||
|
|||||||
@@ -30,3 +30,5 @@ import "./everforest-dark.css";
|
|||||||
import "./everforest-light.css";
|
import "./everforest-light.css";
|
||||||
import "./kanagawa-dark.css";
|
import "./kanagawa-dark.css";
|
||||||
import "./kanagawa-light.css";
|
import "./kanagawa-light.css";
|
||||||
|
import "./tintenblau-dark.css";
|
||||||
|
import "./tintenblau-light.css";
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Tintenblau Dark
|
||||||
|
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||||
|
*/
|
||||||
|
:root[data-theme="tintenblau-dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--theme-color: #232a44;
|
||||||
|
|
||||||
|
--bg0: #232a44;
|
||||||
|
--bg1: #2b3352;
|
||||||
|
--bg2: #353e60;
|
||||||
|
--bg3: #4a5478;
|
||||||
|
--fg0: #f5efe3;
|
||||||
|
--fg1: #eae4d6;
|
||||||
|
--fg2: #d6d0c2;
|
||||||
|
--fg3: #bdcfd4;
|
||||||
|
--muted: #8e96b4;
|
||||||
|
|
||||||
|
--accent: #7aa0cc;
|
||||||
|
--accent2: #d4b46a;
|
||||||
|
--on-accent: #232a44;
|
||||||
|
--red: #d47a6a;
|
||||||
|
--green: #96b4bc;
|
||||||
|
--aqua: #96b4bc;
|
||||||
|
--blue: #7aa0cc;
|
||||||
|
--purple: #c896b2;
|
||||||
|
|
||||||
|
--diff-add: rgba(150, 180, 188, 0.16);
|
||||||
|
--diff-del: rgba(212, 122, 106, 0.16);
|
||||||
|
--shadow: 0 6px 24px rgba(10, 14, 30, 0.45);
|
||||||
|
|
||||||
|
--syntax-keyword: #c896b2;
|
||||||
|
--syntax-string: #d47a6a;
|
||||||
|
--syntax-number: #96b4bc;
|
||||||
|
--syntax-comment: #8e96b4;
|
||||||
|
--syntax-function: #7aa0cc;
|
||||||
|
--syntax-type: #d4b46a;
|
||||||
|
--syntax-variable: #eae4d6;
|
||||||
|
--syntax-meta: #baa0c4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Tintenblau (light)
|
||||||
|
* Palette: zink/tintenblau (colors.toml, neovim.lua, zed.json of the desktop theme).
|
||||||
|
*/
|
||||||
|
:root[data-theme="tintenblau-light"] {
|
||||||
|
color-scheme: light;
|
||||||
|
--theme-color: #f0ead9;
|
||||||
|
|
||||||
|
--bg0: #f0ead9;
|
||||||
|
--bg1: #e6dfd0;
|
||||||
|
--bg2: #dbd3c1;
|
||||||
|
--bg3: #c9c0ac;
|
||||||
|
--fg0: #0f1628;
|
||||||
|
--fg1: #1e2a4a;
|
||||||
|
--fg2: #3a3f5a;
|
||||||
|
--fg3: #5e5a6a;
|
||||||
|
--muted: #8a8694;
|
||||||
|
|
||||||
|
--accent: #2a3f6f;
|
||||||
|
--accent2: #6a4a2c;
|
||||||
|
--on-accent: #f0ead9;
|
||||||
|
--red: #8a3028;
|
||||||
|
--green: #4a5a3a;
|
||||||
|
--aqua: #3a5a6a;
|
||||||
|
--blue: #3a5a8a;
|
||||||
|
--purple: #5a4a72;
|
||||||
|
|
||||||
|
--diff-add: rgba(74, 90, 58, 0.14);
|
||||||
|
--diff-del: rgba(138, 48, 40, 0.12);
|
||||||
|
--shadow: 0 6px 24px rgba(30, 42, 74, 0.16);
|
||||||
|
|
||||||
|
--syntax-keyword: #2a3f6f;
|
||||||
|
--syntax-string: #8a3028;
|
||||||
|
--syntax-number: #5a4a72;
|
||||||
|
--syntax-comment: #8a8694;
|
||||||
|
--syntax-function: #3a5a6a;
|
||||||
|
--syntax-type: #6a4a2c;
|
||||||
|
--syntax-variable: #1e2a4a;
|
||||||
|
--syntax-meta: #4a5a3a;
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ export interface Usage {
|
|||||||
output_tokens: number;
|
output_tokens: number;
|
||||||
cache_read_tokens: number;
|
cache_read_tokens: number;
|
||||||
cache_write_tokens: number;
|
cache_write_tokens: number;
|
||||||
|
/** Cost of the request (charged, or valued at list prices for plan requests). */
|
||||||
|
cost?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -168,6 +170,8 @@ export interface AgentInfo {
|
|||||||
source: "builtin" | "config" | "file" | "personal";
|
source: "builtin" | "config" | "file" | "personal";
|
||||||
max_parallel?: number | null;
|
max_parallel?: number | null;
|
||||||
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
refine?: { rounds: number; model?: string | null; criteria?: string | null; after_todos?: boolean } | null;
|
||||||
|
/** binary_info and decompile (Ghidra) available in this mode. */
|
||||||
|
reverse?: boolean;
|
||||||
/** Source file of Markdown agents. */
|
/** Source file of Markdown agents. */
|
||||||
path?: string | null;
|
path?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||||
*/
|
*/
|
||||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||||
|
"202611-00023": {
|
||||||
|
en: ["Tokens and cost under every answer; the total cost of chats with opencode Go is shown correctly again"],
|
||||||
|
de: ["Tokens und Kosten unter jeder Antwort; der Gesamtpreis von Chats mit opencode Go stimmt wieder"],
|
||||||
|
},
|
||||||
|
"202611-00022": {
|
||||||
|
en: ["New theme “Tintenblau” (Settings → General → Theme)"],
|
||||||
|
de: ["Neues Theme „Tintenblau“ (Einstellungen → Allgemein → Theme)"],
|
||||||
|
},
|
||||||
|
"202611-00021": {
|
||||||
|
en: ["Git commits of agents name the model: “Co-Authored-By: <model> (<provider>) via CodeCraft” (Settings → General)"],
|
||||||
|
de: ["Git-Commits von Agenten nennen das Modell: „Co-Authored-By: <Modell> (<Anbieter>) via CodeCraft“ (Einstellungen → Allgemein)"],
|
||||||
|
},
|
||||||
|
"202611-00020": {
|
||||||
|
en: ["Reverse engineering: decompile with Ghidra can be enabled for any mode (Settings → Agents & commands), now also for raw firmware"],
|
||||||
|
de: ["Reverse Engineering: Dekompilieren mit Ghidra lässt sich für jeden Modus einschalten (Einstellungen → Agenten & Befehle), jetzt auch für rohe Firmware"],
|
||||||
|
},
|
||||||
"202611-00019": {
|
"202611-00019": {
|
||||||
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
||||||
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
||||||
|
|||||||
Reference in new issue
Block a user