Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,100 @@ All notable changes to HirayaCoder are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.8.0] — unreleased

0.7.0 gave the agent a way to notice it was stuck and a way to ask. Running it against a
real build showed that noticing is not the problem — **being told is.**

Two models were given the same brief on Machine B, a React + Vite + Tailwind TODO app.
`qwen3.5:4b` finished it in 88 minutes. `qwen3.5:0.8b` never wrote a single file. Both
failures trace to the same missing thing, and the counted version of what follows is in
[`doc/SESSION-ANALYSIS-0.7.0.md`](doc/SESSION-ANALYSIS-0.7.0.md).

### Added — the agent keeps a record of what it already has

Every anti-repetition device in the loop was a *sentence*: "You now know what is in the
project", "Do NOT do it again". They all fired, correctly, and both models did it again
anyway — because the sentence describes something the model can no longer see. It is
asked to take the loop's word for what it is holding, and reaching for the tool is the
cheaper way to be sure.

`agent/workingSet` keeps the record instead of asserting it — paths read, written,
listed and deleted, commands run, and what last went wrong — and renders it back on
every turn. It runs off the step trace, so a 0.8B model gets the same footing
`stepBrief` gives a 4B one without the 2B checklist threshold that excluded it.

- On Tier B it is part of the prompt the loop rebuilds each turn.
- On Tier A it is advisory and *moved* rather than appended, so exactly one copy exists
and it is always the current one, always adjacent to the decision.
- A file the agent wrote counts as a file it has. The "write `App.jsx`, immediately read
`App.jsx` back" pair was a measurable share of the 4B session's 73 reads.

### Changed — a repeated listing no longer ends the run

Five of the 0.8B model's seven sessions died on the repeat guard, four at exactly two
steps: `list_files`, `list_files`, `list_files`, session over. That is a read-only call
costing five milliseconds, answered by ending the user's whole run — while a genuinely
expensive mistake, a wrong `npm install`, gets a diagnosis and another go.

A repeated **read-only** action now gets one substitution: the result it already had,
handed back with the working set and an instruction naming the next move. Repeat after
that and the guard ends the run exactly as before, because a model ignoring the content
and the instruction together is stuck rather than disoriented. `write_file`,
`run_script` and the rest are untouched.

### Added — the step trace is a live panel that says why

At 42 seconds a step the panel used to sit silent for minutes, and the user's first
sight of a run going wrong was the summary at the end.

- It opens on the first step and folds away when the turn ends — unless the user has
clicked it, after which we stop deciding for them.
- Each row carries the model's own stated reason for the step, which both loops already
captured as `thought` and nothing rendered. Without it, eight reads of one file look
exactly like eight reads of eight.
- `read_file` is gone from the panel in favour of *Reading*. The identifier belongs to
the tool protocol, not to the surface whose job is to explain the run.

### Fixed — a compliment no longer restarts finished work

The last message of the 4B session was *"It all works now, thank you"*. The agent
answered it by building a checklist and starting to re-fix bugs it had already fixed,
carried over from two turns earlier. The user cancelled the run.

The gap was a category, not two words: **the user reporting that the work succeeded.**
Adding `works` to the social vocabulary would be wrong, because "the delete button no
longer works" is a bug report. So a success report is matched as a phrase, and any sign
the sentence goes on to say something is still wrong — `but`, `still`, a negation,
`almost` — hands it back to the agent. It is checked *after* the mutating-verb rule, so
"it works now, can you also add a dark mode" stays a task.

### Fixed — four kinds of bug report were being answered as greetings

Found while fixing the above, and live on `main` until now. `isGreetingWithName` tested
the first word against the whole of `SOCIAL_WORDS`, which is mostly filler — `it`,
`the`, `got`, `all` — admitted there on the strength of a rule that only holds for whole
messages. Read one word at a time, it made any message of three words or fewer a
greeting:

| Message | Was | Now |
|---|---|---|
| `it doesn't work` | chat | task |
| `the tests fail` | chat | task |
| `got an error` | chat | task |
| `all buttons broken` | chat | task |

Four dropped requests, which is the one outcome `intentRouter`'s header says it must
never produce. Greetings now match a dedicated `GREETING_WORDS` set.

### Documentation

- `doc/SESSION-ANALYSIS-0.7.0.md` — the counted analysis of both evaluation sessions.
- Two new marketing images, `live-session.png` and `knows-what-it-has.png`, with their
HTML sources; the hero and capabilities images are regenerated for 0.8.0.
- The README and the hero image now say plainly that this is a **pre-release** installed
from GitHub Releases, rather than offering a Marketplace button that does not exist.

## [0.7.0] — unreleased

Everything here follows from one observation: a small model that is stuck does not know
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/hero-offline-agent.png" width="900" alt="HirayaCoder v0.7.0 — your AI pair programmer, fully offline. A VS Code chat panel showing the agent reading two files, writing two files, and asking for approval before running npm run build." />
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/hero-offline-agent.png" width="900" alt="HirayaCoder v0.8.0 — your AI pair programmer, fully offline. A VS Code chat panel showing the agent reading two files, writing two files, and asking for approval before running npm run build." />
</p>

*A local Filipino-inspired AI coder that brings imagination and speed to your VS Code workflow.*

> **Pre-release.** HirayaCoder is not on the VS Code Marketplace yet. Releases are
> published as a `.vsix` on the
> [Releases page](https://github.com/jaymar921/HirayaCoder/releases) and installed by
> hand — [Step 4](#step-4--install-hirayacoder) has the one command it takes. Everything
> described below works today; what is missing is the one-click install.

**HirayaCoder is a free AI coding assistant that runs entirely on your own computer.**
You type what you want in plain English, and it writes and edits the files for you — no
account, no subscription, no internet connection, and nothing you write ever leaves your
Expand Down Expand Up @@ -137,6 +143,36 @@ There is a longer, friendlier walkthrough in
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/capabilities.png" width="900" alt="What HirayaCoder does: nothing leaves your machine, you approve every change, agentic on every model, big requests become a checklist, it knows your machine, and it learns your project. Three modes — Agent, Plan, and Ask." />
</p>

### Watching a run happen

<p align="center">
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/live-session.png" width="900" alt="The live Steps panel in HirayaCoder v0.8.0. Six steps of a TODO app build, each showing the action, the file it touched, and the model's own stated reason — reading README.md to extract the project structure, scaffolding the React project, writing the useTodos hook, and running npm run build." />
</p>

A local model can take the better part of a minute per step, so the panel shows you each
one as it happens: what it is doing, which file, and the reason the model gave for it.
It opens when the first step arrives and folds away when the turn ends — and if you open
or close it yourself, it stays how you left it.

That matters most when a run is going wrong. Six steps in, you can see it re-reading the
same file or editing something you never asked about, and stop it — rather than finding
out from the summary ten minutes later.

### Small models that finish

<p align="center">
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/knows-what-it-has.png" width="900" alt="Before and after, measured on qwen3.5:0.8b. Before v0.8.0: three identical list_files calls and the run ended, 5 of 7 sessions this way and zero files written. After: the second repeat is answered with a WHAT YOU ALREADY HAVE block listing the folders already listed, and the third step writes a file." />
</p>

The classic failure of a very small model is not bad code — it is the same correct-looking
action forever. HirayaCoder keeps its own record of every file the agent has read, written
and deleted, every folder it has listed and every command it has run, and puts that record
in front of the model on each turn.

A repeated read is no longer fatal either. Asking twice for a directory listing used to end
the run; now the agent is handed back what it already had, told what to do next, and only
stopped if it asks a third time.

### The three modes

There is a row of buttons at the top of the chat. You can ignore them at first —
Expand Down
37 changes: 37 additions & 0 deletions app/agent/nativeToolLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
const logger = require('../utils/logger');
const { parseToolCalls, REQUIRED_FIELDS } = require('../core/outputParser');
const { truncateToTokens } = require('../utils/tokenBudget');
const { WorkingSet } = require('./workingSet');

/**
* Required arguments a native tool call arrived without.
Expand Down Expand Up @@ -145,6 +146,11 @@ async function run(options) {
/** @type {Map<string, number>} */
const seen = new Map();

/** What the agent is holding — see `agent/workingSet`. */
const workingSet = new WorkingSet();
/** The single working-set message in `messages`, moved to the end each turn. */
let heldMessage = /** @type {{role: string, content: string} | null} */ (null);

let summary = '';
let stopReason = 'budget';
let narratedCalls = 0;
Expand Down Expand Up @@ -309,6 +315,7 @@ async function run(options) {
emit({ type: 'action', step: steps.length + 1, action });
const result = await execute(action);
steps.push({ action, result });
workingSet.record(action, result, steps.length);
emit({ type: 'observation', step: steps.length, action, result });

messages.push({
Expand All @@ -321,6 +328,36 @@ async function run(options) {
}

if (stopped) break;

// What the agent is holding, restated after the tool results and before the next
// decision.
//
// Tier A does not lose file contents the way Tier B does — the whole exchange stays
// in `messages` — and it re-read anyway. On the 0.7.0 benchmark `qwen3.5:4b` spent
// 73 of 126 steps on `read_file` for 25 distinct paths; `App.jsx` was read 28 times
// and written 4, and a binary PNG was read 13 times. 97% of the 88-minute run was
// inference, so each of those redundant turns cost roughly 42 seconds of the user's
// afternoon and told the model nothing it did not already have.
//
// A long transcript is not the same as an accessible one: by turn forty the first
// read of `App.jsx` is thousands of tokens back and competing with everything since.
// This is one short list, adjacent to the decision, saying which paths are already
// in hand. Unlike Tier B's version it is advisory — nothing here refuses a call —
// because a Tier A model re-reading after a write it did not make is sometimes right.
// Moved rather than appended. Pushing a fresh block each turn would leave forty
// stale copies in the transcript by the end of a long run — each one a list of files
// that was accurate when written and is now contradicted by the next copy down. The
// previous block is spliced out so exactly one exists, always the current one, and
// always last.
const held = workingSet.render({ includeStruggles: true });
if (held) {
if (heldMessage) {
const at = messages.indexOf(heldMessage);
if (at !== -1) messages.splice(at, 1);
}
heldMessage = { role: 'user', content: held };
messages.push(heldMessage);
}
}

if (!summary) {
Expand Down
70 changes: 70 additions & 0 deletions app/agent/reactLoop.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,22 @@
const logger = require('../utils/logger');
const { parseAction, actionSchema } = require('../core/outputParser');
const { truncateToTokens } = require('../utils/tokenBudget');
const { WorkingSet, isRecon } = require('./workingSet');

/** How many identical actions before the loop intervenes. */
const REPEAT_LIMIT = 2;

/**
* How many times a repeated read-only action is answered rather than fatal.
*
* One. The substitution's whole claim is that the model repeated itself because it had
* lost the result, so handing the result back should settle it. A model that repeats the
* same recon action *again*, with the content and an explicit instruction both in front
* of it, is not disoriented — it is stuck, and the honest end to that run is the stop
* the guard was already going to produce.
*/
const RECON_SUBSTITUTION_LIMIT = 1;

/** How many consecutive unparseable turns before giving up. */
const PARSE_FAILURE_LIMIT = 3;

Expand Down Expand Up @@ -368,11 +380,15 @@ async function run(options) {
/** Status sentences this loop has shown the model, for the echo check below. */
/** @type {Set<string>} */
const notices = new Set();
/** What the agent is holding, rendered into every turn — see `agent/workingSet`. */
const workingSet = new WorkingSet();

let observation = '';
let summary = '';
let stopReason = 'budget';
let parseFailures = 0;
/** Recon repeats answered with their own result rather than a stop. */
let substitutions = 0;
/** A `done` has already been sent back once for want of evidence. */
let doneChallenged = false;
// Two independent nudges. `hint` is about the task ("you have the file, now edit
Expand Down Expand Up @@ -424,6 +440,11 @@ async function run(options) {
const sections = [
options.context,
renderTrace(steps, traceBudget),
// The trace above says which actions ran; this says what the agent is *holding*
// as a result. They read similarly and do different jobs — a trace line reading
// "3. read_file src/App.jsx → ok" is a history entry, and a model that has lost
// the file itself answers it by reading the file again. See `agent/workingSet`.
workingSet.render({ includeStruggles: budgets.promptTokenTarget >= 1800 }),
observation ? `Result of your last action:\n${observation}` : '',
hint,
parseNudge,
Expand Down Expand Up @@ -539,6 +560,54 @@ async function run(options) {
const repeats = (seen.get(key) || 0) + 1;
seen.set(key, repeats);

// A repeated *reconnaissance* action does not end the session on the first strike.
//
// This is the single most expensive rule of the 0.7.0 round. Five of `qwen3.5:0.8b`'s
// seven sessions ended here, four of them at exactly two steps: list_files,
// list_files, list_files, session over, nothing written, seven times in a row. The
// model was not burning a budget or damaging anything — it was listing a directory,
// a read-only call costing five milliseconds, and the response was to end the user's
// whole run. Meanwhile a genuinely costly mistake, a wrong `npm install`, gets a
// diagnosis and another go.
//
// So a recon repeat gets one intervention first: the result it already had, handed
// back with the working set and an instruction naming the next move. It costs one
// turn, and it is the turn in which the model has both the content and a statement
// that it has the content. If it repeats *again* after that, the guard falls through
// and ends the run as before — a model ignoring the substitution twice is genuinely
// stuck, and the rung above this one is `errorRecovery` asking the user.
//
// Mutating and executing actions are untouched: repeating `run_script` can install
// packages and start servers, and "it was only a repeat" is no comfort there.
if (repeats > REPEAT_LIMIT && isRecon(action.action) && substitutions < RECON_SUBSTITUTION_LIMIT) {
substitutions += 1;
// Not charged against the repeat budget, so the model is not immediately over the
// line again on its next turn — but `substitutions` is capped, so this cannot
// become a way to loop forever.
seen.set(key, repeats - 1);
logger.info(`Substituting for a repeated recon action "${key}" rather than ending the session.`);

const previous = steps.find(
(entry) => entry.action && actionKey(entry.action) === key && entry.result && entry.result.ok
);
hint =
`STOP. You have already done ${action.action}${action.path ? ` on ${action.path}` : ''} and the result is ` +
'above — asking for it again returns the same thing and gets you no further. ' +
(activeRoute.allowedActions.has('write_file')
? 'Your next action must change a file: send write_file with "path" and the COMPLETE file contents in "code". ' +
'If you genuinely cannot write anything yet, reply "done" and say what is blocking you.'
: 'Look at a different file, or reply "done" with what you have found.');
// The content, not just the assertion. The whole reason a hint alone failed is
// that it described something the model could no longer see.
if (previous && previous.result) {
observation = truncateToTokens(previous.result.observation, Math.floor(budgets.promptTokenTarget * 0.45), {
keep: 'both',
}).text;
}
emit({ type: 'repeat-substituted', action, step: steps.length });
continue;
}

if (repeats > REPEAT_LIMIT) {
logger.warn(`ReAct loop repeated "${key}" ${repeats} times; stopping.`);
// Careful not to overclaim failure: the loop can repeat itself *after* doing
Expand Down Expand Up @@ -573,6 +642,7 @@ async function run(options) {
}
: await execute(action);
steps.push({ action, result });
workingSet.record(action, result, steps.length);
emit({ type: 'observation', step: steps.length, action, result });

// A refused write changed nothing, so the corrected retry the hint just asked
Expand Down
Loading
Loading