2 Commits
Author SHA1 Message Date
HomerandClaude Opus 5.5 aaa3e3a44f LLeMbas CLI 1.4.0
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:17:36 +00:00
HomerandClaude Opus 5.5 539896494f Harness spec 1.2.0: the library's tools fit both projects; the CLI implements it
The CLI's facts in memory, notes_search, note_manage, skills_list,
skill_view and skill_manage are `variables` now, filled with the CLI's own
values (src/tool/spec-vars.ts): every description and parameter list the CLI
sends is word for word what 1.1.0 said, except note and document ids, which
are strings on both sides (a number is still taken). Descriptions are
filled by the prompt texts' rules, parameter descriptions too. `narrow`
says what a project offers of a shared tool's parameters. Memory's changes
are one pure function (applyOps). Conformance: fill, narrow, memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 11:45:14 +00:00
25 changed files with 1424 additions and 89 deletions

No files matched your search

+25
View File
@@ -6,6 +6,31 @@ All notable changes to LLeMbas CLI. The format follows
## [Unreleased] ## [Unreleased]
## [1.4.0] — 2026-10-20
Harness spec 1.2.0: the library's tools are the same tools in LLeMbas.
### Changed
- The harness spec's memory, notes and skills tools now fit both projects. The CLI's facts in
their descriptions — memory files and targets, project notes, supporting files, project skills,
`session_search` — are the spec's `variables`, filled with the CLI's own values, so what a model
is sent here is word for word what it was; LLeMbas sends the same tools with its own facts
(LLeMbas 1.5.0). A CLI linked to an instance (`library: lembas`) therefore sees `memory`,
`note_view`, `note_manage`, `skills_list`, `skill_view` and `skill_manage` whether the library is
its own or the instance's, where it used to see the instance's `memory_add`, `notes_create` and
the rest.
- Note and knowledge-document ids are strings in the tools' parameters, as they are on an instance;
the number is still accepted.
- Tool descriptions are filled by the prompt texts' rules, and the descriptions inside a tool's
parameters are filled too.
### Added
- `narrow` in the harness spec: what a project offers of a shared tool's parameters where it offers
less than all. Three conformance areas, run by both projects: `fill`, `narrow`, and `memory` —
one change or a batch, applied together, against the size limit, messages included.
## [1.3.0] — 2026-10-18 ## [1.3.0] — 2026-10-18
Harness spec 1.1.0: planning as one structure shared with LLeMbas. Harness spec 1.1.0: planning as one structure shared with LLeMbas.
+30 -7
View File
@@ -15,7 +15,7 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
|---|---| |---|---|
| `VERSION` | The spec's own version, apart from LLeMbas CLI's | | `VERSION` | The spec's own version, apart from LLeMbas CLI's |
| `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for | | `purpose.json` | The `purpose` argument added to a tool whose calls say what they are for |
| `tools/<name>.json` | One tool each: name, `scope`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, the description and the JSON Schema of its parameters — exactly what a model is sent | | `tools/<name>.json` | One tool each: name, `scope`, `risk`, `exclusive`, `block` (how a call is shown: `shell`, `edit`, `read`, `search`, `web`, `task`, `plan` — not drawn, the plan card refreshes — `hidden`, `tool`), `purpose`, former names (`aliases`, per project), renamed arguments, `variables`, what a project offers of the parameters (`narrow`, per project), the description and the JSON Schema of its parameters — exactly what a model is sent |
| `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer | | `prompts/` | The prompt texts; `prompts/index.json` says which are shared, which LLeMbas fragment each one is, and what each `{{variable}}` in a text means. `prompts/personality/` holds the personality presets both offer |
| `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again | | `permission/hardline.json` | The floor: commands refused in every mode, protected paths, and how a command is spelled plainly before it is checked again |
| `permission/arity.json` | How much of a command an "always allow" covers | | `permission/arity.json` | How much of a command an "always allow" covers |
@@ -46,6 +46,18 @@ page [Harness-parity](https://git.houmeres.sk/LLeMbas/LLeMbas-CLI/wiki/Harness-p
every mode; `plan_submit` only in plan mode, where it is the approval. Their permission key is every mode; `plan_submit` only in plan mode, where it is the approval. Their permission key is
`plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches. `plan` (`permission/defaults.json`), which a rule written for the former `todo` key still reaches.
## 1.2.0
The library's tools fit both projects: `memory`, `notes_search`, `note_view`, `note_manage`,
`skills_list`, `skill_view` and `skill_manage` say the CLI's facts (files, projects, supporting files,
sessions) through `variables`, so LLeMbas sends the same tools with its own facts; filled with the
CLI's values, every description is word for word what 1.1.0 said. Tool descriptions are filled by
the prompt texts' rules, including the descriptions inside the parameters. A tool's `narrow` says
what a project offers of its parameters where it offers less (LLeMbas: one memory target, no note
scope, no skill categories, supporting files or project skills). Note and knowledge-document ids are
strings on both sides. Three conformance areas: `fill`, `narrow`, `memory` (one change or a batch,
applied together, against the size limit).
## 1.1.0 ## 1.1.0
Planning as one shared structure: `plan_write` and `plan_update` (new, shared, in every mode) and Planning as one shared structure: `plan_write` and `plan_update` (new, shared, in every mode) and
@@ -75,13 +87,24 @@ feature beyond protocol 1 gated on its capability string).
## Variables in a description, and in a prompt text ## Variables in a description, and in a prompt text
A description may hold `{{name}}` where the two projects differ in a fact, not in design — a A description may hold `{{name}}` where the two projects differ in a fact, not in design — a
timeout's default, whether commands may run in the background, which subagents exist. The tool's timeout's default, whether commands may run in the background, which subagents exist, where a
`variables` says what each one means; each project fills them before sending the description, and skill's supporting files are. The tool's `variables` says what each one means; each project fills
none may reach a model unfilled. them before sending the description — and the `description` of every parameter, which may hold
them too — and none may reach a model unfilled.
A prompt text may hold them too, declared in `prompts/index.json` under its entry. The same rule, A prompt text may hold them too, declared in `prompts/index.json` under its entry. The same rules
and one more: **a line that is nothing but a bullet and a variable (`- {{name}}`) disappears when the for both, and one more: **a line that is nothing but a bullet and a variable (`- {{name}}`)
variable is empty**, so one text serves a project that has the fact and one that does not. disappears when the variable is empty**, so one text serves a project that has the fact and one that
does not (`conformance/fill.json`).
## Offering less of a shared tool
A project that does not have part of a shared tool says so in the tool's `narrow.<project>` rather
than in a different tool: `drop` leaves out optional properties, `enum` keeps some of a property's
values (`a.b` is b inside a; `a[].b` is b inside the items of the array a). It offers a subset and
nothing else — never a property the spec does not have, never another type — so a call valid for the
narrower tool is valid for the whole one (`conformance/narrow.json`). The description says the
narrower tool's facts through its `variables`.
## Changing it ## Changing it
+1 -1
View File
@@ -1 +1 @@
1.1.0 1.2.0
+88
View File
@@ -0,0 +1,88 @@
{
"about": "A tool description's or a prompt text's `{{variables}}` filled in (`vars`): a value replaces its variable where it stands; a line that is nothing but a bullet and a variable (`- {{name}}`) disappears when the value is empty or blank; a variable with no value at all is an error naming every one missing (`missing`, sorted). Applied to a tool's description and to every `description` inside its parameters. LLeMbas: services/harness_spec.py fill; CLI: prompt/fill.ts fillPrompt.",
"cases": [
{
"text": "Read a page{{files}}",
"vars": {
"files": "."
},
"expect": {
"text": "Read a page."
}
},
{
"text": "Kept across sessions{{kept}}.",
"vars": {
"kept": ""
},
"expect": {
"text": "Kept across sessions."
}
},
{
"text": "- create: one.\n- {{files}}\n- delete: two.",
"vars": {
"files": ""
},
"expect": {
"text": "- create: one.\n- delete: two."
}
},
{
"text": "- create: one.\n- {{files}}\n- delete: two.",
"vars": {
"files": " "
},
"expect": {
"text": "- create: one.\n- delete: two."
}
},
{
"text": "- create: one.\n * {{files}}\n- delete: two.",
"vars": {
"files": "write_file."
},
"expect": {
"text": "- create: one.\n * write_file.\n- delete: two."
}
},
{
"text": "A {{a}} and a {{b}} and {{a}} again",
"vars": {
"a": "x",
"b": "y"
},
"expect": {
"text": "A x and a y and x again"
}
},
{
"text": "Only {{a}} and {{c}} and {{b}}",
"vars": {
"a": "x"
},
"expect": {
"missing": [
"b",
"c"
]
}
},
{
"text": "No variables at all.",
"vars": {},
"expect": {
"text": "No variables at all."
}
},
{
"text": "- not only {{a}} on this line",
"vars": {
"a": ""
},
"expect": {
"text": "- not only on this line"
}
}
]
}
+499
View File
@@ -0,0 +1,499 @@
{
"about": "The memory tool's changes to one target's entries (`entries`, in order) with a size limit (`limit`, characters of the entries joined by \"\\n§\\n\"): one change or a list (`ops`, each `action` add/replace/remove with `content` — or `new_text` — and `old_text`), applied together or not at all; the limit is checked on the result only, and a change that does not grow the entries always goes through. An entry is one fact, at most 400 characters; `old_text` finds an exact entry first, else the one entry containing it. `ok`, the entries after, `message`, `usage`, and `shown` — whether the entries as they were are shown with a refusal, so the model can consolidate. LLeMbas: services/library/memories.py apply_ops; CLI: memory/store.ts applyOps.",
"cases": [
{
"target": "user",
"entries": [],
"limit": 1375,
"ops": [
{
"action": "add",
"content": "Prefers tea."
}
],
"expect": {
"ok": true,
"entries": [
"Prefers tea."
],
"message": "Entry added.",
"usage": "0% — 12/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "add",
"content": " Prefers tea. "
}
],
"expect": {
"ok": true,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "That entry already exists; nothing added.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "memory",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 2200,
"ops": [
{
"action": "add",
"new_text": "Uses Debian."
}
],
"expect": {
"ok": true,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín.",
"Uses Debian."
],
"message": "Entry added.",
"usage": "3% — 75/2,200 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "add",
"content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "an entry is one fact, under 400 characters; this one is 401. Split it into facts, or keep only what will still matter.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "add",
"content": "one\n§\ntwo"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "a line holding only \"§\" separates entries; it cannot be inside one.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "add",
"content": " "
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "add needs content.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "replace",
"old_text": "Rust",
"content": "Works in Rust, Python and Go."
}
],
"expect": {
"ok": true,
"entries": [
"Prefers tea.",
"Works in Rust, Python and Go.",
"Lives in Trenčín."
],
"message": "Entry replaced.",
"usage": "4% — 64/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "replace",
"old_text": "in",
"content": "x"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "\"in\" matches more than one entry — use a longer, unique part.",
"usage": "4% — 60/1,375 chars",
"shown": true
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "remove",
"old_text": "coffee"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "no entry contains \"coffee\".",
"usage": "4% — 60/1,375 chars",
"shown": true
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "remove"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "remove needs old_text: a short unique part of the entry to remove.",
"usage": "4% — 60/1,375 chars",
"shown": true
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "replace",
"old_text": "tea",
"content": ""
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "replace needs content — the complete new entry.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"coffee",
"Drinks coffee black."
],
"limit": 1375,
"ops": [
{
"action": "remove",
"old_text": "coffee"
}
],
"expect": {
"ok": true,
"entries": [
"Drinks coffee black."
],
"message": "Entry removed.",
"usage": "1% — 20/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 60,
"ops": [
{
"action": "add",
"content": "Has two cats."
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "The user profile would be 76/60 chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.",
"usage": "100% — 60/60 chars",
"shown": true
}
},
{
"target": "memory",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 60,
"ops": [
{
"action": "add",
"content": "Has two cats."
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "Memory would be 76/60 chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.",
"usage": "100% — 60/60 chars",
"shown": true
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 60,
"ops": [
{
"action": "remove",
"old_text": "Rust"
},
{
"action": "add",
"content": "Has two cats."
}
],
"expect": {
"ok": true,
"entries": [
"Prefers tea.",
"Lives in Trenčín.",
"Has two cats."
],
"message": "Applied 2 operations.",
"usage": "80% — 48/60 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 30,
"ops": [
{
"action": "remove",
"old_text": "tea"
}
],
"expect": {
"ok": true,
"entries": [
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "Entry removed.",
"usage": "100% — 45/30 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "add",
"content": "Has two cats."
},
{
"action": "remove",
"old_text": "coffee"
}
],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "operation 2: no entry contains \"coffee\". Nothing was changed (the operations go together or not at all).",
"usage": "4% — 60/1,375 chars",
"shown": true
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [],
"expect": {
"ok": false,
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"message": "Nothing to do: give action (with content and/or old_text), or operations.",
"usage": "4% — 60/1,375 chars",
"shown": false
}
},
{
"target": "user",
"entries": [
"Prefers tea.",
"Works in Rust and Python.",
"Lives in Trenčín."
],
"limit": 1375,
"ops": [
{
"action": "remove",
"old_text": "Prefers tea."
},
{
"action": "add",
"content": "Prefers green tea."
}
],
"expect": {
"ok": true,
"entries": [
"Works in Rust and Python.",
"Lives in Trenčín.",
"Prefers green tea."
],
"message": "Applied 2 operations.",
"usage": "4% — 66/1,375 chars",
"shown": false
}
}
]
}
+455
View File
@@ -0,0 +1,455 @@
{
"about": "A shared tool's parameters as one project offers them (`narrow`, the tool's `narrow.<project>`): a project may offer a subset and nothing else — `drop` removes optional properties, `enum` keeps some of a property's values. A path names a property; `a.b` is b inside a, `a[].b` is b inside a's array items. Never adds, never retypes. Refused, with `error`: dropping a required property (`required`), a path that is not there (`unknown`), values that are not all among the property's own (`not_subset`). LLeMbas: services/harness_spec.py narrow; CLI: harness.ts narrowParameters.",
"cases": [
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {},
"expect": {
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
}
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"drop": [
"scope"
]
},
"expect": {
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
}
},
"required": [
"action"
]
}
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"enum": {
"action": [
"create",
"delete"
]
}
},
"expect": {
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
}
}
},
{
"parameters": {
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"patch",
"write_file",
"delete"
]
},
"name": {
"type": "string"
},
"file_path": {
"type": "string"
}
},
"required": [
"action",
"name"
]
}
}
},
"required": [
"operations"
]
},
"narrow": {
"drop": [
"operations[].file_path"
],
"enum": {
"operations[].action": [
"create",
"patch",
"delete"
]
}
},
"expect": {
"parameters": {
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"patch",
"delete"
]
},
"name": {
"type": "string"
}
},
"required": [
"action",
"name"
]
}
}
},
"required": [
"operations"
]
}
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"drop": [
"action"
]
},
"expect": {
"error": "required"
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"drop": [
"nope"
]
},
"expect": {
"error": "unknown"
}
},
{
"parameters": {
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"patch",
"write_file",
"delete"
]
},
"name": {
"type": "string"
},
"file_path": {
"type": "string"
}
},
"required": [
"action",
"name"
]
}
}
},
"required": [
"operations"
]
},
"narrow": {
"drop": [
"operations[].nope"
]
},
"expect": {
"error": "unknown"
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"enum": {
"action": [
"create",
"rename"
]
}
},
"expect": {
"error": "not_subset"
}
},
{
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"create",
"edit",
"delete"
]
},
"id": {
"type": "string"
},
"scope": {
"type": "string",
"enum": [
"project",
"global"
]
}
},
"required": [
"action"
]
},
"narrow": {
"enum": {
"id": [
"a"
]
}
},
"expect": {
"error": "not_subset"
}
}
]
}
+1 -1
View File
@@ -14,7 +14,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
"type": "integer" "type": "string"
}, },
"offset": { "offset": {
"description": "Characters to skip: to read past what the first call returned", "description": "Characters to skip: to read past what the first call returned",
+18 -2
View File
@@ -12,7 +12,14 @@
"memory_forget" "memory_forget"
] ]
}, },
"description": "Save durable facts to persistent memory that survives across sessions. Memory goes into the system prompt of every future session, so keep entries short and worth their place.\n\nHOW: make all your changes in ONE call with an `operations` list ({action, content?, old_text?}). The list is applied together and the size limit is checked only on the result, so one call can remove or shorten stale entries AND add new ones. One call finishes the update; do not repeat it. Use the single action/content/old_text fields only for one lone change.\n\nWHEN: only for facts that hold in EVERY session, whatever the task: who the user is, stable facts about their machines and environment, standing conventions with no better home. How to do a kind of task — procedures, pitfalls, the user's corrections about that work — belongs in a skill (skill_manage), which loads only when relevant. Anything about this project belongs in its AGENTS.md or the project's decision log, not here.\n\nTARGETS: 'user' — who the user is: name, role, preferences, how they like to work. 'memory' — your own notes: environment, conventions, tool quirks, lessons.\n\nIF FULL: an add is refused and the current entries are shown. Send one call that removes or shortens enough stale entries and adds the new one.\n\nSKIP: the obvious, what is easy to find again, raw data, task progress, logs of finished work, temporary state (session_search finds past work).\n\nWhat you save now is in the files at once but appears in your system prompt only from the next session.", "variables": {
"project_home": "where facts about the current project belong instead, as a sentence after the WHEN paragraph's last one (\" Anything about this project belongs in its AGENTS.md or the project's decision log, not here.\"), or empty",
"targets": "the targets this project offers and what each is for",
"past_work": "how past work is found again, in parentheses after the SKIP list (\" (session_search finds past work)\"), or empty",
"when_seen": "when a saved entry reaches the model's instructions",
"target_values": "the `target` parameter's values and what each holds"
},
"description": "Save durable facts to persistent memory that survives across sessions. Memory goes into the system prompt of every future session, so keep entries short and worth their place.\n\nHOW: make all your changes in ONE call with an `operations` list ({action, content?, old_text?}). The list is applied together and the size limit is checked only on the result, so one call can remove or shorten stale entries AND add new ones. One call finishes the update; do not repeat it. Use the single action/content/old_text fields only for one lone change.\n\nWHEN: only for facts that hold in EVERY session, whatever the task: who the user is, stable facts about their machines and environment, standing conventions with no better home. How to do a kind of task — procedures, pitfalls, the user's corrections about that work — belongs in a skill (skill_manage), which loads only when relevant.{{project_home}}\n\nTARGETS: {{targets}}\n\nIF FULL: an add is refused and the current entries are shown. Send one call that removes or shortens enough stale entries and adds the new one.\n\nSKIP: the obvious, what is easy to find again, raw data, task progress, logs of finished work, temporary state{{past_work}}.\n\n{{when_seen}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -23,7 +30,7 @@
"user", "user",
"project" "project"
], ],
"description": "'memory' for your notes, 'user' for the user profile, 'project' for facts about this project only (kept in its .agent/local, never committed)." "description": "{{target_values}}"
}, },
"action": { "action": {
"description": "A single change. Omit when using operations.", "description": "A single change. Omit when using operations.",
@@ -82,5 +89,14 @@
"required": [ "required": [
"target" "target"
] ]
},
"narrow": {
"llembas": {
"enum": {
"target": [
"user"
]
}
}
} }
} }
+14 -2
View File
@@ -15,7 +15,12 @@
"notes_delete" "notes_delete"
] ]
}, },
"description": "Write your notes: things worth keeping that are longer than a memory entry and not a skill — findings, a design worked out with the user, a list to come back to.\n\n- create (title, body, scope); edit (id, title and/or body — body replaces the whole text); delete (id).\n- scope: \"project\" (default, when there is a project) or \"global\".\n- Not for facts that hold in every session (memory), procedures (skills), or the project's own records (tasks, decisions).", "variables": {
"create_scope": "\", scope\" where notes have a scope, or empty",
"scope": "what `scope` means (\"scope: \\\"project\\\" (default, when there is a project) or \\\"global\\\".\"), or empty — the line goes",
"records": "other records a note is not for (\", or the project's own records (tasks, decisions)\"), or empty"
},
"description": "Write your notes: things worth keeping that are longer than a memory entry and not a skill — findings, a design worked out with the user, a list to come back to.\n\n- create (title, body{{create_scope}}); edit (id, title and/or body — body replaces the whole text); delete (id).\n- {{scope}}\n- Not for facts that hold in every session (memory), procedures (skills){{records}}.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -28,7 +33,7 @@
] ]
}, },
"id": { "id": {
"type": "integer" "type": "string"
}, },
"title": { "title": {
"type": "string", "type": "string",
@@ -48,5 +53,12 @@
"required": [ "required": [
"action" "action"
] ]
},
"narrow": {
"llembas": {
"drop": [
"scope"
]
}
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
"type": "integer" "type": "string"
} }
}, },
"required": [ "required": [
+4 -1
View File
@@ -11,7 +11,10 @@
], ],
"llembas": [] "llembas": []
}, },
"description": "Search your notes: things worth keeping that are longer than a memory entry and not a skill — findings, a design worked out with the user, a list to come back to. Kept across sessions, global or this project's; both are searched. An empty query lists the newest. Read one with note_view; write with note_manage.", "variables": {
"kept": "where notes are kept and which are searched (\", global or this project's; both are searched\"), or empty"
},
"description": "Search your notes: things worth keeping that are longer than a memory entry and not a skill — findings, a design worked out with the user, a list to come back to. Kept across sessions{{kept}}. An empty query lists the newest. Read one with note_view; write with note_manage.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
+25 -1
View File
@@ -12,7 +12,13 @@
"skill_edit" "skill_edit"
] ]
}, },
"description": "Create, change or delete skills — your procedural memory for kinds of task that come back. Send a list of operations; they apply together, and if one fails none is kept.\n\n- create: a new skill from description + instructions (or content: a whole SKILL.md). Global unless where: project.\n- patch: fix part of a skill with old_string/new_string (read it with skill_view first) — preferred over rewriting.\n- write_file / remove_file: supporting files under references/, templates/, scripts/ or assets/.\n- delete: remove a skill (alone in its call).\n- revert: put back the skill as it was before its last change (a deleted one too); the version it replaces is kept, so a second revert undoes the first. Alone in its call.\n\nWrite the description as its trigger, in about 60 characters — only that much shows in the skills list: \"Use when <situation>. <what it does>.\" Write lessons, not logs: an instruction and why, no dates or incident stories.", "variables": {
"create_where": "where a new skill goes (\" Global unless where: project.\"), or empty",
"files": "the supporting-file actions (\"write_file / remove_file: supporting files under references/, templates/, scripts/ or assets/.\"), or empty — the line goes",
"revert_deleted": "\" (a deleted one too)\" where a deleted skill can be reverted, or empty",
"index_cut": "why the description is kept short (\" — only that much shows in the skills list\"), or empty"
},
"description": "Create, change or delete skills — your procedural memory for kinds of task that come back. Send a list of operations; they apply together, and if one fails none is kept.\n\n- create: a new skill from description + instructions (or content: a whole SKILL.md).{{create_where}}\n- patch: fix part of a skill with old_string/new_string (read it with skill_view first) — preferred over rewriting.\n- {{files}}\n- delete: remove a skill (alone in its call).\n- revert: put back the skill as it was before its last change{{revert_deleted}}; the version it replaces is kept, so a second revert undoes the first. Alone in its call.\n\nWrite the description as its trigger, in about 60 characters{{index_cut}}: \"Use when <situation>. <what it does>.\" Write lessons, not logs: an instruction and why, no dates or incident stories.",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -93,5 +99,23 @@
"required": [ "required": [
"operations" "operations"
] ]
},
"narrow": {
"llembas": {
"drop": [
"operations[].category",
"operations[].where",
"operations[].file_path",
"operations[].file_content"
],
"enum": {
"operations[].action": [
"create",
"patch",
"delete",
"revert"
]
}
}
} }
} }
+11 -1
View File
@@ -11,7 +11,10 @@
"skill_get" "skill_get"
] ]
}, },
"description": "Load a skill: its full instructions, and the list of its supporting files (references, templates, scripts, assets). To read one of those, call again with file_path.", "variables": {
"files": "what else a skill carries and how to read it (\", and the list of its supporting files (references, templates, scripts, assets). To read one of those, call again with file_path.\"), or \".\""
},
"description": "Load a skill: its full instructions{{files}}",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -27,5 +30,12 @@
"required": [ "required": [
"name" "name"
] ]
},
"narrow": {
"llembas": {
"drop": [
"file_path"
]
}
} }
} }
+11 -1
View File
@@ -9,7 +9,10 @@
"cli": [], "cli": [],
"llembas": [] "llembas": []
}, },
"description": "List the available skills (name, category, description). Load one with skill_view(name).", "variables": {
"category": "\", category\" where skills have categories, or empty"
},
"description": "List the available skills (name{{category}}, description). Load one with skill_view(name).",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -18,5 +21,12 @@
"type": "string" "type": "string"
} }
} }
},
"narrow": {
"llembas": {
"drop": [
"category"
]
}
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lembas-cli", "name": "lembas-cli",
"version": "1.3.0", "version": "1.4.0",
"description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.", "description": "LLeMbas CLI: a terminal coding agent and project manager — OpenTUI, any LLM API, memory, skills, MCP, git-native.",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
+8 -1
View File
@@ -371,7 +371,14 @@ export function createApp(o: AppOptions): App {
background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id; read its output with bash_output, list jobs with bash_list, stop one with bash_kill.", background: " For a server, a watcher or anything long, set `background: true`: it returns at once with a job id; read its output with bash_output, list jobs with bash_list, stop one with bash_kill.",
}, },
} }
const tools: Tool[] = offered.map((t) => (TOOL_SPECS[t.name]?.variables ? { ...t, description: fillDescription(t.description, vars[t.name] ?? {}) } : t)) // A tool whose values are its own (the library's, set where it is defined) is filled already;
// any other with variables is filled here, and one with none given fails rather than reach the
// model with a literal `{{name}}`.
const tools: Tool[] = offered.map((t) => {
if (!TOOL_SPECS[t.name]?.variables || (t.vars && !vars[t.name])) return t
const v = vars[t.name] ?? {}
return { ...t, description: fillDescription(t.description, v), vars: { ...t.vars, ...v } }
})
const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => { const spawn: NonNullable<EngineOptions["spawn"]> = async (name, prompt, signal, callId, opts = {}) => {
const spec = agentList.find((a) => a.name === name) const spec = agentList.find((a) => a.name === name)
if (!spec) throw new ToolError(`There is no agent "${name}". The agents are: ${agentList.map((a) => a.name).join(", ")}.`) if (!spec) throw new ToolError(`There is no agent "${name}". The agents are: ${agentList.map((a) => a.name).join(", ")}.`)
+74 -9
View File
@@ -37,6 +37,7 @@ import t_view_image from "../harness/tools/view_image.json"
import t_web_fetch from "../harness/tools/web_fetch.json" import t_web_fetch from "../harness/tools/web_fetch.json"
import t_web_search from "../harness/tools/web_search.json" import t_web_search from "../harness/tools/web_search.json"
import t_write from "../harness/tools/write.json" import t_write from "../harness/tools/write.json"
import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef { export interface ToolSpecDef {
name: string name: string
@@ -57,6 +58,8 @@ export interface ToolSpecDef {
argument_aliases?: Record<string, string> argument_aliases?: Record<string, string>
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */ /** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string> variables?: Record<string, string>
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
narrow?: Partial<Record<"cli" | "llembas", Narrow>>
description: string description: string
parameters: Record<string, unknown> parameters: Record<string, unknown>
} }
@@ -77,22 +80,84 @@ export const LOOP: {
/** The `purpose` argument (harness/purpose.json). */ /** The `purpose` argument (harness/purpose.json). */
export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE export const PURPOSE_DEF: { name: string; schema: Record<string, unknown> } = PURPOSE
/** A description with its `{{variables}}` filled in. A variable left unfilled is an error: it /** A description with its `{{variables}}` filled in, by the prompt texts' rules (fillPrompt): a
* would reach the model as a literal `{{name}}`. */ * line that is only a bullet and a variable goes when the value is empty, and a variable left
* unfilled is an error — it would reach the model as a literal `{{name}}` (conformance: fill). */
export function fillDescription(description: string, vars: Record<string, string>): string { export function fillDescription(description: string, vars: Record<string, string>): string {
return description.replace(/\{\{(\w+)\}\}/g, (m, k: string) => { try {
if (!Object.hasOwn(vars, k)) throw new Error(`harness: no value for {{${k}}} in a tool description`) return fillPrompt(description, vars)
return vars[k]! } catch (e) {
}) throw new Error(`harness: ${(e as Error).message.replace(/^prompt: /, "")} in a tool description`)
}
}
/** A tool's parameters with every `description` in them filled the same way. */
export function fillParameters(parameters: Record<string, unknown>, vars: Record<string, string>): Record<string, unknown> {
const walk = (o: unknown): unknown => {
if (Array.isArray(o)) return o.map(walk)
if (!o || typeof o !== "object") return o
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(o)) out[k] = k === "description" && typeof v === "string" ? fillDescription(v, vars) : walk(v)
return out
}
return walk(parameters) as Record<string, unknown>
}
/** What one project offers of a shared tool's parameters: optional properties left out (`drop`),
* a property's values narrowed (`enum`). `a.b` is b inside a, `a[].b` b inside a's items. */
export interface Narrow {
drop?: string[]
enum?: Record<string, string[]>
}
export class NarrowError extends Error {
constructor(readonly code: "required" | "unknown" | "not_subset", path: string) {
super(`harness: cannot narrow ${path}: ${code}`)
}
}
/** The parameters as a project offers them: a subset, never more and never retyped
* (conformance: narrow). The spec's own object is not changed. */
export function narrowParameters(parameters: Record<string, unknown>, narrow: Narrow = {}): Record<string, unknown> {
const out = structuredClone(parameters)
type Obj = { properties?: Record<string, Record<string, unknown>>; required?: string[]; items?: Obj }
const locate = (path: string): { parent: Obj; key: string } => {
let node = out as Obj
const steps = path.split(".")
for (const [i, raw] of steps.entries()) {
const items = raw.endsWith("[]")
const key = items ? raw.slice(0, -2) : raw
const props = node.properties
if (!props || !Object.hasOwn(props, key)) throw new NarrowError("unknown", path)
if (i === steps.length - 1 && !items) return { parent: node, key }
const next = props[key] as Obj
node = items ? (next.items as Obj) : next
if (!node) throw new NarrowError("unknown", path)
}
throw new NarrowError("unknown", path)
}
for (const path of narrow.drop ?? []) {
const { parent, key } = locate(path)
if (parent.required?.includes(key)) throw new NarrowError("required", path)
delete parent.properties![key]
}
for (const [path, values] of Object.entries(narrow.enum ?? {})) {
const { parent, key } = locate(path)
const own = parent.properties![key]!.enum as unknown[] | undefined
if (!own || !values.every((v) => own.includes(v))) throw new NarrowError("not_subset", path)
parent.properties![key]!.enum = own.filter((v) => values.includes(v as string))
}
return out
} }
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries( export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), ([t_apply_patch, t_ask_user, t_bash, t_bash_kill, t_bash_list, t_bash_output, t_decisions, t_edit, t_glob, t_grep, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
) )
/** A tool's name and description as the spec has them, for its definition: `...described("read")`. */ /** A tool's name and description as the spec has them, for its definition: `...described("read")`.
export function described(name: string): { name: string; description: string } { * With `vars`, the description filled and the values kept for its parameters (toSpec). */
export function described(name: string, vars?: Record<string, string>): { name: string; description: string; vars?: Record<string, string> } {
const t = TOOL_SPECS[name] const t = TOOL_SPECS[name]
if (!t) throw new Error(`harness: no tool ${name} in the spec`) if (!t) throw new Error(`harness: no tool ${name} in the spec`)
return { name: t.name, description: t.description } return vars ? { name: t.name, description: fillDescription(t.description, vars), vars } : { name: t.name, description: t.description }
} }
+59 -40
View File
@@ -20,6 +20,8 @@ const HEADERS: Record<Target, string> = { memory: "MEMORY (your personal notes)"
export interface Op { export interface Op {
action: "add" | "replace" | "remove" action: "add" | "replace" | "remove"
content?: string content?: string
/** Another name for content. */
new_text?: string
old_text?: string old_text?: string
} }
@@ -29,6 +31,59 @@ export interface Outcome {
/** On a failure the model can fix by consolidating: what is there now. */ /** On a failure the model can fix by consolidating: what is there now. */
entries?: string[] entries?: string[]
usage: string usage: string
/** The entries after the change (applyOps): to be saved when ok. */
after?: string[]
}
const size = (entries: string[]) => entries.join(DELIMITER).length
/** How full a target is: "12% — 164/1,375 chars". */
export function usageOf(entries: string[], limit: number): string {
const n = size(entries)
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
}
/** One target's entries changed by `ops`, all together or not at all; the size limit is checked
* on the result only, so one call can make room and add, and a change that does not grow the
* entries always goes through. Pure: the harness spec's conformance area `memory`, which LLeMbas
* runs through its own implementation. `check` refuses an entry's text (threats, here). */
export function applyOps(t: Target, before: string[], ops: Op[], limit: number, check?: (content: string) => string | undefined): Outcome {
const work = [...before]
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: usageOf(before, limit), ...(show ? { entries: before } : {}) })
if (!ops.length) return fail("Nothing to do: give action (with content and/or old_text), or operations.")
for (const [i, op] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = (op.content ?? op.new_text)?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = check?.(content)
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (size(work) > limit && size(work) > size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
const first = ops[0]!
const message = ops.length > 1 ? `Applied ${ops.length} operations.` : first.action === "add" && before.includes((first.content ?? first.new_text)!.trim()) ? "That entry already exists; nothing added." : `Entry ${first.action === "add" ? "added" : first.action === "replace" ? "replaced" : "removed"}.`
return { ok: true, message, usage: usageOf(unique, limit), after: unique }
} }
export const memoryDir = () => join(paths.config, "memory") export const memoryDir = () => join(paths.config, "memory")
@@ -69,12 +124,8 @@ export class MemoryStore {
return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : [] return existsSync(f) ? parseEntries(readFileSync(f, "utf8")) : []
} }
private size = (entries: string[]) => entries.join(DELIMITER).length
usage(t: Target, entries = this.entries(t)): string { usage(t: Target, entries = this.entries(t)): string {
const n = this.size(entries) return usageOf(entries, this.limits[t])
const limit = this.limits[t]
return `${limit > 0 ? Math.min(100, Math.floor((n / limit) * 100)) : 0}% — ${n.toLocaleString("en")}/${limit.toLocaleString("en")} chars`
} }
private save(t: Target, entries: string[]) { private save(t: Target, entries: string[]) {
@@ -120,41 +171,9 @@ export class MemoryStore {
private applyNow(t: Target, ops: Op[]): Outcome { private applyNow(t: Target, ops: Op[]): Outcome {
const before = this.entries(t) const before = this.entries(t)
const work = [...before] const r = applyOps(t, before, ops, this.limits[t], (content) => threatMessage(content, "The entry"))
const fail = (message: string, show = false): Outcome => ({ ok: false, message: ops.length > 1 ? `${message} Nothing was changed (the operations go together or not at all).` : message, usage: this.usage(t, before), ...(show ? { entries: before } : {}) }) if (r.ok && r.after!.join("\u0000") !== before.join("\u0000")) this.save(t, r.after!)
for (const [i, op] of ops.entries()) { return r
const at = ops.length > 1 ? `operation ${i + 1}: ` : ""
const content = op.content?.trim() ?? ""
if (op.action !== "remove") {
if (!content) return fail(`${at}${op.action} needs content${op.action === "replace" ? " — the complete new entry" : ""}.`)
const threat = threatMessage(content, "The entry")
if (threat) return fail(`${at}${threat}`)
if (/(^|\n)[ \t]*§[ \t]*(\n|$)/.test(content)) return fail(`${at}a line holding only "§" separates entries; it cannot be inside one.`)
if (content.length > MAX_ENTRY) return fail(`${at}an entry is one fact, under ${MAX_ENTRY} characters; this one is ${content.length}. Split it into facts, or keep only what will still matter.`)
}
if (op.action === "add") {
if (!work.includes(content)) work.push(content)
continue
}
if (!op.old_text?.trim())
return fail(`${at}${op.action} needs old_text: a short unique part of the entry to ${op.action}.${op.action === "replace" ? " content is the COMPLETE new entry; the whole matched entry is overwritten." : ""}`, true)
const hit = findEntry(work, op.old_text.trim())
if (hit.ambiguous) return fail(`${at}"${op.old_text}" matches more than one entry — use a longer, unique part.`, true)
if (hit.index === undefined) return fail(`${at}no entry contains "${op.old_text}".`, true)
if (op.action === "remove") work.splice(hit.index, 1)
else work[hit.index] = content
}
const limit = this.limits[t]
// Over the limit already (it was lowered, or the file edited)? Anything that does not grow it
// still goes through, so "forget X" always works.
if (this.size(work) > limit && this.size(work) > this.size(before))
return fail(
`${t === "user" ? "The user profile" : "Memory"} would be ${this.size(work).toLocaleString("en")}/${limit.toLocaleString("en")} chars. Consolidate in the same call: replace overlapping entries with one shorter entry, or remove stale ones, together with the add.`,
true,
)
const unique = [...new Set(work)]
if (unique.join("\u0000") !== before.join("\u0000")) this.save(t, unique)
return { ok: true, message: ops.length > 1 ? `Applied ${ops.length} operations.` : ops[0]!.action === "add" && before.includes(ops[0]!.content!.trim()) ? "That entry already exists; nothing added." : `Entry ${ops[0]!.action === "add" ? "added" : ops[0]!.action === "replace" ? "replaced" : "removed"}.`, usage: this.usage(t, unique) }
} }
/** The system-prompt block for one target, or "" when it is empty. */ /** The system-prompt block for one target, or "" when it is empty. */
+17 -7
View File
@@ -5,6 +5,7 @@ import { z } from "zod"
import { resolveBase, SNIPPET_CHARS } from "../library/store.ts" import { resolveBase, SNIPPET_CHARS } from "../library/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts" import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts" import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const MAX_GET = 40_000 const MAX_GET = 40_000
const MAX_NOTE = 20_000 const MAX_NOTE = 20_000
@@ -22,7 +23,16 @@ function noteScopes(ctx: ToolContext) {
} }
const scopeLabel = (scope: string) => (scope === "global" ? "global" : "project") const scopeLabel = (scope: string) => (scope === "global" ? "global" : "project")
function ownNote(ctx: ToolContext, id: number) { /** An id as the spec sends it (a string, since 1.2.0: LLeMbas's ids are not numbers), as this
* library's number. A model that sends the number itself is taken at its word too. */
const Id = z.preprocess((v) => (typeof v === "number" ? String(v) : typeof v === "string" ? v.trim() : v), z.string())
function numeric(id: string, what: string): number {
if (!/^\d+$/.test(id)) throw new ToolError(`"${id}" is not ${what} id here: the ids are the numbers in [brackets].`)
return Number(id)
}
function ownNote(ctx: ToolContext, raw: string) {
const id = numeric(raw, "a note")
const { L, scopes } = noteScopes(ctx) const { L, scopes } = noteScopes(ctx)
const n = L.lib.note(id) const n = L.lib.note(id)
if (!n || !scopes.includes(n.scope)) throw new ToolError(`There is no note ${id} here.`) if (!n || !scopes.includes(n.scope)) throw new ToolError(`There is no note ${id} here.`)
@@ -30,7 +40,7 @@ function ownNote(ctx: ToolContext, id: number) {
} }
export const notesSearchTool = defineTool({ export const notesSearchTool = defineTool({
...described("notes_search"), ...described("notes_search", CLI_VARS.notes_search),
access: "notes", access: "notes",
purpose: false, purpose: false,
schema: z.object({ schema: z.object({
@@ -50,7 +60,7 @@ export const noteViewTool = defineTool({
access: "notes", access: "notes",
purpose: false, purpose: false,
schema: z.object({ schema: z.object({
id: z.number().int(), id: Id,
}), }),
permission: (a) => ({ permission: "notes", class: "read", patterns: [String(a.id)] }), permission: (a) => ({ permission: "notes", class: "read", patterns: [String(a.id)] }),
async run(a, ctx) { async run(a, ctx) {
@@ -60,12 +70,12 @@ export const noteViewTool = defineTool({
}) })
export const noteManageTool = defineTool({ export const noteManageTool = defineTool({
...described("note_manage"), ...described("note_manage", CLI_VARS.note_manage),
access: "notes", access: "notes",
purpose: false, purpose: false,
schema: z.object({ schema: z.object({
action: z.enum(["create", "edit", "delete"]), action: z.enum(["create", "edit", "delete"]),
id: z.number().int().optional(), id: Id.optional(),
title: z.string().max(200).optional(), title: z.string().max(200).optional(),
body: z.string().optional(), body: z.string().optional(),
scope: z.enum(["project", "global"]).optional(), scope: z.enum(["project", "global"]).optional(),
@@ -130,11 +140,11 @@ export const knowledgeGetTool = defineTool({
access: "knowledge", access: "knowledge",
purpose: false, purpose: false,
requires: "knowledge", requires: "knowledge",
schema: z.object({ id: z.number().int(), offset: z.number().int().min(0).optional() }), schema: z.object({ id: Id, offset: z.number().int().min(0).optional() }),
permission: () => ({ permission: "knowledge", class: "read", patterns: ["*"] }), permission: () => ({ permission: "knowledge", class: "read", patterns: ["*"] }),
async run(a, ctx) { async run(a, ctx) {
const L = lib(ctx) const L = lib(ctx)
const d = L.lib.document(a.id) const d = L.lib.document(numeric(a.id, "a document"))
if (!d || (L.bases && !L.bases.includes(d.base))) throw new ToolError(`There is no document ${a.id} in this project's knowledge bases.`) if (!d || (L.bases && !L.bases.includes(d.base))) throw new ToolError(`There is no document ${a.id} in this project's knowledge bases.`)
if (d.error && !d.text) return { output: `[${d.id}] ${d.title}: no text — ${d.error}`, title: d.title, isError: true } if (d.error && !d.text) return { output: `[${d.id}] ${d.title}: no text — ${d.error}`, title: d.title, isError: true }
const from = a.offset ?? 0 const from = a.offset ?? 0
+3 -3
View File
@@ -4,6 +4,7 @@ import { z } from "zod"
import { MemoryStore, type Op } from "../memory/store.ts" import { MemoryStore, type Op } from "../memory/store.ts"
import { defineTool, ToolError } from "./tool.ts" import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts" import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const OpSchema = z.object({ const OpSchema = z.object({
action: z.enum(["add", "replace", "remove"]), action: z.enum(["add", "replace", "remove"]),
@@ -13,7 +14,7 @@ const OpSchema = z.object({
}) })
export const memoryTool = defineTool({ export const memoryTool = defineTool({
...described("memory"), ...described("memory", CLI_VARS.memory),
access: "memory", access: "memory",
schema: z.object({ schema: z.object({
target: z.enum(["memory", "user", "project"]), target: z.enum(["memory", "user", "project"]),
@@ -27,8 +28,7 @@ export const memoryTool = defineTool({
async run(a, ctx) { async run(a, ctx) {
if (a.target === "project" && !ctx.projectMemory) throw new ToolError("This project has no project memory: it is not trusted, or has no .agent directory. Use 'memory' for facts that hold everywhere.") if (a.target === "project" && !ctx.projectMemory) throw new ToolError("This project has no project memory: it is not trusted, or has no .agent directory. Use 'memory' for facts that hold everywhere.")
const store = a.target === "project" ? ctx.projectMemory! : (ctx.memory ?? new MemoryStore()) const store = a.target === "project" ? ctx.projectMemory! : (ctx.memory ?? new MemoryStore())
const norm = (o: z.infer<typeof OpSchema>): Op => ({ action: o.action, content: o.content ?? o.new_text, old_text: o.old_text }) const ops: Op[] = a.operations?.length ? a.operations : a.action ? [{ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text }] : []
const ops: Op[] = a.operations?.length ? a.operations.map(norm) : a.action ? [norm({ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text })] : []
if (!ops.length) throw new ToolError("Nothing to do: give action (with content and/or old_text), or operations.") if (!ops.length) throw new ToolError("Nothing to do: give action (with content and/or old_text), or operations.")
const r = store.apply(a.target === "project" ? "memory" : a.target, ops) const r = store.apply(a.target === "project" ? "memory" : a.target, ops)
const label = a.target === "user" ? "user profile" : a.target === "project" ? "project memory" : "memory" const label = a.target === "user" ? "user profile" : a.target === "project" ? "project memory" : "memory"
+4 -3
View File
@@ -9,11 +9,12 @@ import { within, findSkill, globalSkillsDir, INDEX_DESCRIPTION, linkedFiles, loa
import { replace } from "./replace-text.ts" import { replace } from "./replace-text.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts" import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts" import { described } from "../harness.ts"
import { CLI_VARS } from "./spec-vars.ts"
const skillsOf = (ctx: ToolContext): Skill[] => ctx.skills?.list() ?? loadSkills() const skillsOf = (ctx: ToolContext): Skill[] => ctx.skills?.list() ?? loadSkills()
export const skillsListTool = defineTool({ export const skillsListTool = defineTool({
...described("skills_list"), ...described("skills_list", CLI_VARS.skills_list),
purpose: false, purpose: false,
access: "skills", access: "skills",
schema: z.object({ category: z.string().optional() }), schema: z.object({ category: z.string().optional() }),
@@ -28,7 +29,7 @@ export const skillsListTool = defineTool({
}) })
export const skillViewTool = defineTool({ export const skillViewTool = defineTool({
...described("skill_view"), ...described("skill_view", CLI_VARS.skill_view),
purpose: false, purpose: false,
access: "skills", access: "skills",
schema: z.object({ schema: z.object({
@@ -135,7 +136,7 @@ function supportPath(s: { dir: string }, rel?: string): string {
} }
export const skillManageTool = defineTool({ export const skillManageTool = defineTool({
...described("skill_manage"), ...described("skill_manage", CLI_VARS.skill_manage),
access: "skill_manage", access: "skill_manage",
schema: z.object({ operations: z.array(Op).min(1).max(20) }), schema: z.object({ operations: z.array(Op).min(1).max(20) }),
permission: (a) => ({ permission: "skill_manage", class: "write", patterns: a.operations.map((o) => `${o.action} ${o.name}`) }), permission: (a) => ({ permission: "skill_manage", class: "write", patterns: a.operations.map((o) => `${o.action} ${o.name}`) }),
+27
View File
@@ -0,0 +1,27 @@
// The CLI's values for the harness spec's `{{variables}}` in the library's tool descriptions
// (harness/tools/<name>.json `variables`): the facts of this machine's library — files, projects,
// supporting files, sessions — where LLeMbas fills in its own. Filled where each tool is defined
// (described); the per-session ones (task, bash) are filled in src/app.ts.
export const CLI_VARS: Record<string, Record<string, string>> = {
memory: {
project_home: " Anything about this project belongs in its AGENTS.md or the project's decision log, not here.",
targets: "'user' — who the user is: name, role, preferences, how they like to work. 'memory' — your own notes: environment, conventions, tool quirks, lessons.",
past_work: " (session_search finds past work)",
when_seen: "What you save now is in the files at once but appears in your system prompt only from the next session.",
target_values: "'memory' for your notes, 'user' for the user profile, 'project' for facts about this project only (kept in its .agent/local, never committed).",
},
notes_search: { kept: ", global or this project's; both are searched" },
note_manage: {
create_scope: ", scope",
scope: 'scope: "project" (default, when there is a project) or "global".',
records: ", or the project's own records (tasks, decisions)",
},
skills_list: { category: ", category" },
skill_view: { files: ", and the list of its supporting files (references, templates, scripts, assets). To read one of those, call again with file_path." },
skill_manage: {
create_where: " Global unless where: project.",
files: "write_file / remove_file: supporting files under references/, templates/, scripts/ or assets/.",
revert_deleted: " (a deleted one too)",
index_cut: " — only that much shows in the skills list",
},
}
+5 -2
View File
@@ -4,7 +4,7 @@ import { z } from "zod"
import type { PermissionRequest } from "../permission/evaluate.ts" import type { PermissionRequest } from "../permission/evaluate.ts"
import { expandHome } from "../config/paths.ts" import { expandHome } from "../config/paths.ts"
import type { ToolSpec } from "../provider/types.ts" import type { ToolSpec } from "../provider/types.ts"
import { PURPOSE_DEF, TOOL_SPECS } from "../harness.ts" import { fillParameters, PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
export interface ToolContext { export interface ToolContext {
/** Absolute project root. */ /** Absolute project root. */
@@ -91,6 +91,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
schema: S schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */ /** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
jsonSchema?: Record<string, unknown> jsonSchema?: Record<string, unknown>
/** The values of the spec's `{{variables}}` for this tool: its description is filled with them
* already (described), its parameters' descriptions when it is sent (toSpec). */
vars?: Record<string, string>
/** Where the model says what a call is for, shown when the user is asked to allow it: the /** Where the model says what a call is for, shown when the user is asked to allow it: the
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's * `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */ * `description`), or false for a tool that never asks. */
@@ -134,7 +137,7 @@ export function toSpec(t: Tool): ToolSpec {
if (t.jsonSchema) schema = t.jsonSchema if (t.jsonSchema) schema = t.jsonSchema
// A built-in tool's parameters are the harness spec's, as LLeMbas sends them too; its zod schema // A built-in tool's parameters are the harness spec's, as LLeMbas sends them too; its zod schema
// validates the call, and tests/harness.test.ts keeps the two the same shape. // validates the call, and tests/harness.test.ts keeps the two the same shape.
else if (TOOL_SPECS[t.name]) schema = structuredClone(TOOL_SPECS[t.name]!.parameters) else if (TOOL_SPECS[t.name]) schema = t.vars ? fillParameters(TOOL_SPECS[t.name]!.parameters, t.vars) : structuredClone(TOOL_SPECS[t.name]!.parameters)
else { else {
schema = z.toJSONSchema(t.schema, { target: "draft-7", io: "input" }) as Record<string, unknown> schema = z.toJSONSchema(t.schema, { target: "draft-7", io: "input" }) as Record<string, unknown>
delete schema.$schema delete schema.$schema
+23
View File
@@ -16,6 +16,9 @@ import { apply, fromTodos, normalise, type Plan } from "../src/plan/plan.ts"
import { ToolCallAccumulator } from "../src/provider/openai-chat.ts" import { ToolCallAccumulator } from "../src/provider/openai-chat.ts"
import { parseInfo, titleFor } from "../src/prototype/fence.ts" import { parseInfo, titleFor } from "../src/prototype/fence.ts"
import { resolveBase } from "../src/library/store.ts" import { resolveBase } from "../src/library/store.ts"
import { NarrowError, narrowParameters, type Narrow } from "../src/harness.ts"
import { fillPrompt } from "../src/prompt/fill.ts"
import { applyOps, type Op, type Target } from "../src/memory/store.ts"
type Case = Record<string, unknown> type Case = Record<string, unknown>
@@ -71,6 +74,26 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
const info = parseInfo(c.info as string) const info = parseInfo(c.info as string)
return info ? { kind: info.kind, title: titleFor(c.code as string, info.title) } : null return info ? { kind: info.kind, title: titleFor(c.code as string, info.title) } : null
}, },
fill: (c) => {
try {
return { text: fillPrompt(c.text as string, c.vars as Record<string, string>) }
} catch (e) {
// "prompt: no value for {{a}}, {{c}}": the names, sorted.
return { missing: [...(e as Error).message.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]!).sort() }
}
},
narrow: (c) => {
try {
return { parameters: narrowParameters(c.parameters as Record<string, unknown>, c.narrow as Narrow) }
} catch (e) {
if (e instanceof NarrowError) return { error: e.code }
throw e
}
},
memory: (c) => {
const r = applyOps(c.target as Target, c.entries as string[], c.ops as Op[], c.limit as number)
return { ok: r.ok, entries: r.ok ? r.after : c.entries, message: r.message, usage: r.usage, shown: Boolean(r.entries) }
},
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null), library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => { toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`. // The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
+20 -5
View File
@@ -5,7 +5,7 @@ import { expect, test } from "bun:test"
import { readdirSync, readFileSync } from "node:fs" import { readdirSync, readFileSync } from "node:fs"
import { join, relative } from "node:path" import { join, relative } from "node:path"
import { z } from "zod" import { z } from "zod"
import { HARNESS_VERSION, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts" import { fillDescription, HARNESS_VERSION, narrowParameters, PURPOSE_DEF, TOOL_SPECS } from "../src/harness.ts"
import { createApp, stricterMode } from "../src/app.ts" import { createApp, stricterMode } from "../src/app.ts"
import { paths } from "../src/config/paths.ts" import { paths } from "../src/config/paths.ts"
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs" import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
@@ -15,7 +15,7 @@ import { promptText } from "../src/prompt/assemble.ts"
import { normalise, type Plan } from "../src/plan/plan.ts" import { normalise, type Plan } from "../src/plan/plan.ts"
import { canonicalToolNames, resolveCall } from "../src/tool/names.ts" import { canonicalToolNames, resolveCall } from "../src/tool/names.ts"
import { BUILTIN_TOOLS } from "../src/tool/registry.ts" import { BUILTIN_TOOLS } from "../src/tool/registry.ts"
import { purposeKey, type Tool, type ToolContext } from "../src/tool/tool.ts" import { purposeKey, toSpec, type Tool, type ToolContext } from "../src/tool/tool.ts"
const HARNESS = join(import.meta.dir, "..", "harness") const HARNESS = join(import.meta.dir, "..", "harness")
const ours: Tool[] = [...BUILTIN_TOOLS] const ours: Tool[] = [...BUILTIN_TOOLS]
@@ -74,7 +74,7 @@ test("every tool the spec gives the CLI is built in, and every built-in tool is
for (const t of ours) { for (const t of ours) {
test(`${t.name}: the spec's parameters, description, flags and risk are this tool's`, () => { test(`${t.name}: the spec's parameters, description, flags and risk are this tool's`, () => {
const s = TOOL_SPECS[t.name]! const s = TOOL_SPECS[t.name]!
expect(t.description).toBe(s.description) expect(t.description).toBe(t.vars ? fillDescription(s.description, t.vars) : s.description)
expect(zodShape(t)).toEqual(shape(s.parameters)) expect(zodShape(t)).toEqual(shape(s.parameters))
expect(t.exclusive === true).toBe(s.exclusive) expect(t.exclusive === true).toBe(s.exclusive)
expect(purposeKey(t) ?? false).toBe(s.purpose) expect(purposeKey(t) ?? false).toBe(s.purpose)
@@ -147,6 +147,16 @@ test("the personality presets are shared prompt texts, one paragraph each", () =
} }
}) })
test("every narrow in the spec is a subset its project can offer, of a shared tool", () => {
for (const s of Object.values(TOOL_SPECS)) {
for (const [project, n] of Object.entries(s.narrow ?? {})) {
expect(s.scope).toBe("shared")
expect(["cli", "llembas"]).toContain(project)
expect(() => narrowParameters(s.parameters, n)).not.toThrow()
}
}
})
test("modes.json is the modes the CLI has, loosest first, with the same former names", () => { test("modes.json is the modes the CLI has, loosest first, with the same former names", () => {
const modes = JSON.parse(readFileSync(join(HARNESS, "permission", "modes.json"), "utf8")) as { order: string[]; aliases: Record<string, string>; modes: Record<string, unknown> } const modes = JSON.parse(readFileSync(join(HARNESS, "permission", "modes.json"), "utf8")) as { order: string[]; aliases: Record<string, string>; modes: Record<string, unknown> }
expect([...MODES].sort() as string[]).toEqual(Object.keys(modes.modes).sort()) expect([...MODES].sort() as string[]).toEqual(Object.keys(modes.modes).sort())
@@ -156,14 +166,19 @@ test("modes.json is the modes the CLI has, loosest first, with the same former n
test("every {{variable}} in a description is declared, and none reaches a model", () => { test("every {{variable}} in a description is declared, and none reaches a model", () => {
for (const s of Object.values(TOOL_SPECS)) { for (const s of Object.values(TOOL_SPECS)) {
const used = [...s.description.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]!) // In the description, and in any description inside the parameters.
const text = s.description + JSON.stringify(s.parameters)
const used = [...new Set([...text.matchAll(/\{\{(\w+)\}\}/g)].map((m) => m[1]!))]
expect(used.sort()).toEqual(Object.keys(s.variables ?? {}).sort()) expect(used.sort()).toEqual(Object.keys(s.variables ?? {}).sort())
} }
mkdirSync(paths.config, { recursive: true }) mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n dialect: openai-chat\n base_url: http://127.0.0.1:9/v1\n models: { m: {} }\n", { mode: 0o600 }) writeFileSync(join(paths.config, "connections.yaml"), "connections:\n f:\n dialect: openai-chat\n base_url: http://127.0.0.1:9/v1\n models: { m: {} }\n", { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlimits: { bash_timeout: 90 }\n") writeFileSync(join(paths.config, "config.yaml"), "model: f/m\nlimits: { bash_timeout: 90 }\n")
const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-vars-")), store: false, snapshots: false, asker: { ask: async () => ({ kind: "once" }) } }) const app = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-vars-")), store: false, snapshots: false, asker: { ask: async () => ({ kind: "once" }) } })
for (const t of app.engine.o.tools) expect(t.description).not.toContain("{{") for (const t of app.engine.o.tools) {
expect(t.description).not.toContain("{{")
expect(JSON.stringify(toSpec(t).parameters)).not.toContain("{{")
}
const bash = app.engine.o.tools.find((t) => t.name === "bash")! const bash = app.engine.o.tools.find((t) => t.name === "bash")!
expect(bash.description).toContain("default 90 seconds, `timeout` up to 10 minutes). For a server") expect(bash.description).toContain("default 90 seconds, `timeout` up to 10 minutes). For a server")
expect(PURPOSE_DEF.schema).toEqual({ type: "string", description: expect.stringContaining("what this call is for") }) expect(PURPOSE_DEF.schema).toEqual({ type: "string", description: expect.stringContaining("what this call is for") })