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
5 changes: 5 additions & 0 deletions .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,8 @@ docs/assets/**
# The activity bar glyph. Referenced by `contributes.viewsContainers`, so leaving it out
# ships an extension whose sidebar icon is a blank square.
!docs/assets/activity-bar.svg

# The marketing images and the HTML they are rendered from. README references them by
# absolute raw.githubusercontent URL, exactly as it does the icon, so the Marketplace
# listing renders them without a megabyte of PNG riding along in every install.
docs/images/**
108 changes: 108 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,114 @@ 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.6.1] — unreleased

A macOS run of the same React TODO spec on the 0.6.0 build, `ornith:9b`, high thinking
capacity, step sessions on. Eight steps, four of them refused, and the run stopped at
item one of six reporting *"nothing was written"* — for a step whose two commands had
both exited 0 with the scaffolded project sitting on disk. Four separate faults stacked
up to produce that, and none of them were the model being small.

### Added — the agent is told what machine it is on

Three of the four refusals were the model reaching for shell syntax it had no way to know
was unavailable: `mkdir -p todo-glass-app && cd todo-glass-app && npm create vite@latest .`,
then `mkdir -p todo-glass-app`, then `cd todo-glass-app && npm install`. `mkdir -p` is
POSIX; the same model on Windows proposes `md`. Both are wrong here for the same reason —
there is no shell, and `mkdir` is not on the allow-list — but nothing in the prompt said
which OS this was, and nothing said the shell was absent either. The model was guessing at
the platform *and* at the execution model, and a guess wrong on both is refused three
times before the step dies.

`core/environmentProfile` detects the machine — OS, release, architecture, Node version —
and states it in the system prompt as fact, alongside the sentence that matters more:
there is no shell on any platform, `cwd` is how you run somewhere else, and `create_folder`
/ `list_files` / `read_file` / `write_file` are what replace the shell utilities. Detected
per session from `os.platform()`, never read back from disk, so a workspace synced between
two machines never reports the other one's OS.

- Carried on the tool-using routes only. Every line of it is about performing an action,
and Ask mode has no tools; a Plan prompt gets the platform facts without the lines
naming tools Plan mode structurally does not have.
- Positioned by a `{environment}` placeholder in both prompt files, and appended when the
placeholder is absent — a customised prompt file cannot leave the model guessing at the
platform by not knowing about it.
- Written to `.hirayacoder/environment.json` for the user to read. Nothing in the prompt
path reads it back; it is there so a session's records say which machine produced them.
Every benchmark folder in this repo had its OS reconstructed from the shape of the
commands the model tried.

### Added — `.hirayacoder/` is ignored by git from the first session

`.hirayacoder/` holds the audit log, the outcome ledger, full session transcripts, and
per-session memory. It has never been added to a workspace's `.gitignore`, and in every
benchmark workspace it sits untracked beside a scaffolded project whose `.gitignore`
lists `node_modules` and nothing else. A user who stages everything commits their whole
conversation history.

`core/workspaceBootstrap` now runs at activation: an existing `.gitignore` is **appended
to**, never rewritten; nothing is appended if any line already covers `.hirayacoder` in
any form, including a `!.hirayacoder` negation, which is a decision to respect rather than
overrule; a missing one is created holding that single entry and nothing else, since
guessing at `node_modules` and `dist` for a project whose language nobody has looked at is
how a helpful default becomes a wrong one. Idempotent, and every failure is logged and
swallowed — a session must still start on a read-only checkout.

### Fixed — a scaffolder was refused for the absence of the project it creates

`npm create vite@latest todo-glass-app -- --template react` was refused with
`NO_PACKAGE_JSON`: *"there is no package.json in the workspace root or any folder above
it."* That guard is right about `npm install`, which climbs out of the workspace and
installs into whatever manifest it finds above — that is how a dependency once landed in
this extension's own `package.json`. It is exactly wrong about `npm create`, `npm init`,
and `npm exec`, where the missing manifest is the point.

This was the first failure of the run and it cascaded: step one was "scaffold the
project", the only command that does it was refused, and the model spent its remaining
attempts on `mkdir -p` and `cd … &&` trying to satisfy a precondition that was never
real. `npm create` / `init` / `exec` / `x`, `yarn create` / `init` / `dlx`, and `pnpm
create` / `init` / `dlx` are now exempt. Everything that acts on an existing project is
guarded exactly as before.

### Fixed — `cd folder && command` is now taken, not refused for a fourth time

0.6.0 added `cwd`, documented it in both system prompts, and made the refusal name it
outright: *"If you were chaining `cd folder && …`, drop the `cd` and pass the folder as
'cwd' instead."* The model sent `cd todo-glass-app && npm install`, was told exactly
that, and sent the identical line again. Two of the eight steps in the run went on it.

That is the third wording of the same instruction, and a small model reaching for `cd x
&& y` is not failing to understand the rule — it is producing the only phrasing it has
for "run this over there". The folder and the command are both in the string,
unambiguously, and the extension was throwing them away to ask for them back. `run_script`
now rewrites that one shape into the two arguments it already takes.

Nothing is relaxed. Exactly one shape is accepted — a single leading `cd` into one
relative folder, one `&&`, and a remainder with no operator left in it — and a second
`&&`, a pipe, a redirect, `cd ..`, an absolute path, or a quoted path is left untouched
and refused as before. The rewritten command goes through pre-flight, the permission
gate, the allow-list, and the tokenizer; the folder goes through `pathGuard` like every
other `cwd`; the confirmation prompt and the audit log show what will actually run. The
model is told about the rewrite in the observation every time, against its own input,
which teaches better than the sentence in the system prompt did.

### Fixed — a step that ran a command was scored as having done nothing

The step that ended the run had already succeeded. `npx create-vite` exit 0, `npm
install` exit 0, twenty seconds of it, eleven files and a `node_modules` on disk. The
guard failed it anyway, stopped the run, and skipped the five remaining items, because a
scaffolder's output never passes through `write_file`: `ChangeSet.recordCommand` appends
to `commands`, `ChangeSet.since` reads `files`, and the two never met. A step whose entire
job is "scaffold the project" could not pass.

`ChangeSet` now stamps commands with the same revision counter it stamps file changes
with, `commandsSince` is its counterpart to `since`, and `stepGuard.verify` accepts a
command that succeeded as evidence the step did work — reported as such, so a later step
knows the agent wrote nothing itself before it assumes a file is there. The principle is
unchanged: judged from evidence the extension holds, never from the model's account. A
process it spawned and whose exit code it read is exactly that; it was being read from
the wrong half of the change set.

## [0.6.0] — unreleased

Machine B, a full model round on the 0.5.3 build: every model was given the same
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/assets/icon-128.png" width="96" height="96" alt="HirayaCoder icon" />
</p>

<p align="center">
<img src="https://raw.githubusercontent.com/jaymar921/HirayaCoder/main/docs/images/hero-offline-agent.png" width="900" alt="HirayaCoder v0.6.1 — 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.*

**HirayaCoder is a free AI coding assistant that runs entirely on your own computer.**
Expand Down Expand Up @@ -129,6 +133,10 @@ There is a longer, friendlier walkthrough in

## Using it day to day

<p align="center">
<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>

### The three modes

There is a row of buttons at the top of the chat. You can ignore them at first —
Expand Down
38 changes: 36 additions & 2 deletions app/agent/agentSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const promptRouter = require('../core/promptRouter');
const intentRouter = require('../core/intentRouter');
const toolRegistry = require('./toolRegistry');
const contextBuilder = require('../core/contextBuilder');
const environmentProfile = require('../core/environmentProfile');
const projectOverview = require('../core/projectOverview');
const reactLoop = require('./reactLoop');
const nativeToolLoop = require('./nativeToolLoop');
Expand Down Expand Up @@ -70,7 +71,7 @@ class ChangeSet {
constructor() {
/** @type {Map<string, FileChange>} */
this.files = new Map();
/** @type {Array<{command: string, exitCode: number | null, ok: boolean}>} */
/** @type {Array<{command: string, exitCode: number | null, ok: boolean, revision?: number}>} */
this.commands = [];
/**
* How many records this set has taken, ever.
Expand Down Expand Up @@ -118,7 +119,23 @@ class ChangeSet {
*/
recordCommand(entry) {
this.revision += 1;
this.commands.push(entry);
// Stamped like a file change, and for the same question: `stepGuard` asks what *this
// step* did, and until 0.6.1 the commands half of the set could not answer it.
this.commands.push({ ...entry, revision: this.revision });
}

/**
* The commands run since a given revision.
*
* The counterpart to `since`, which reads the files half only. A step that scaffolds a
* project writes no files through the agent at all, so this is the only record that it
* did anything — see `stepGuard.ranSomething`.
*
* @param {number} revision
* @returns {Array<{command: string, ok: boolean}>}
*/
commandsSince(revision) {
return this.commands.filter((entry) => (entry.revision || 0) > revision);
}

/** @returns {FileChange[]} */
Expand Down Expand Up @@ -459,6 +476,18 @@ class AgentSession {
*/
this.stepSessions = options.stepSessions === true;

/**
* The machine this session is running on, detected once.
*
* Held on the session rather than detected inside `promptRouter` per call: a
* six-item TODO run in step mode routes once per step, and the answer cannot change
* between them. Injected in tests to assert the Windows and POSIX prompts without
* needing the matching machine.
*
* @type {import('../core/environmentProfile').EnvironmentProfile}
*/
this.environment = options.environment || environmentProfile.detect();

/**
* Earlier turns of this chat, set per `run` call.
*
Expand Down Expand Up @@ -519,6 +548,7 @@ class AgentSession {
thinkingCapacity: this.thinkingCapacity,
memory: await this._renderMemory(),
earnedHints: await this._earnedHints(mode),
environment: this.environment,
intent: intent.intent,
// "Read the README" should not be able to reach `run_script`, whatever the
// planner decides in between. See `intentRouter.isReadOnlyRequest`.
Expand Down Expand Up @@ -857,6 +887,9 @@ class AgentSession {
item: item.text,
stopReason: outcome.stopReason,
changed: changeSet.since(itemRevisionBefore),
// A scaffold step's whole output is a command's side effects, which never pass
// through `write_file` and so never appear above.
commands: changeSet.commandsSince(itemRevisionBefore),
steps: outcome.steps,
});

Expand Down Expand Up @@ -969,6 +1002,7 @@ class AgentSession {
thinkingCapacity: this.thinkingCapacity,
memory: await this._renderMemory({ about }),
earnedHints: await this._earnedHints(activeRoute.mode),
environment: this.environment,
intent: 'task',
});
} catch (err) {
Expand Down
46 changes: 46 additions & 0 deletions app/agent/scriptPreflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,46 @@ const NODE_RUNNERS = new Set(['npm', 'yarn', 'pnpm']);
*/
const IMPLICIT_SCRIPTS = new Set(['test', 'start', 'install', 'stop', 'restart']);

/**
* Subcommands that exist to *create* a project rather than to act on one.
*
* The manifest check below exists because `npm install` with no `package.json` climbs
* out of the workspace and installs into whatever it finds above. These do not: `npm
* create vite@latest todo-glass-app` downloads `create-vite` and runs it against a
* folder it makes itself, `npm init` writes a manifest into the current directory, and
* `npm exec` / `npm x` are `npx` under another name — which was never guarded at all,
* since `npx` is not a package manager by `packageManagerOf`.
*
* Refusing them was the first failure of the macOS 0.6.0 benchmark and it cascaded
* through the whole run: step one was "scaffold the project", the only command that
* does it was refused for the absence of the very thing it was about to produce, and
* the model spent its remaining attempts on `mkdir -p` and `cd … &&` trying to satisfy
* a precondition that was never real.
*
* The rule this keeps: refuse only when being wrong is impossible. "npm cannot install
* into a project that does not exist" is certain. "npm cannot create a project where
* there is no project" is the opposite of certain — it is the one case where the
* missing manifest is the point.
*/
const PROJECT_CREATING_SUBCOMMANDS = new Map([
['npm', new Set(['create', 'init', 'exec', 'x'])],
['yarn', new Set(['create', 'init', 'dlx'])],
['pnpm', new Set(['create', 'init', 'dlx'])],
]);

/**
* Is this command about to create a project rather than act on an existing one?
*
* @param {string} runner
* @param {string} command
* @returns {boolean}
*/
function createsProject(runner, command) {
const subcommand = (String(command || '').trim().split(/\s+/)[1] || '').toLowerCase();
const known = PROJECT_CREATING_SUBCOMMANDS.get(runner);
return Boolean(known && known.has(subcommand));
}

/**
* @typedef {object} PreflightRefusal
* @property {string} code Machine-readable, matching the runner's refusal codes.
Expand Down Expand Up @@ -146,6 +186,10 @@ function preflight({ command, cwd, workspaceRoot }) {
const runner = packageManagerOf(command);
if (!runner || !workspaceRoot) return null;

// A scaffolder makes the project this module would otherwise insist already exists.
// Nothing below has anything useful to say about one, so it is left to run.
if (createsProject(runner, command)) return null;

/** @type {string} */
let directory;
try {
Expand Down Expand Up @@ -219,6 +263,8 @@ module.exports = {
parseRun,
packageManagerOf,
manifestDirectoryWithin,
createsProject,
NODE_RUNNERS,
IMPLICIT_SCRIPTS,
PROJECT_CREATING_SUBCOMMANDS,
};
46 changes: 46 additions & 0 deletions app/agent/stepGuard.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,41 @@ const { namedFiles } = require('./stepBrief');
* @property {Array<{kind: string, path: string}>} changed
* Files this step created, edited, or deleted — this step's, not the session's.
* @property {import('./agentSession').AgentStep[]} steps
* @property {Array<{command: string, ok: boolean}>} [commands]
* Commands this step ran. See `ranSomething` for why a step that only ran commands is
* not a step that did nothing.
*/

/**
* Did this step do its work through a command rather than through `write_file`?
*
* ## The step this was missing
*
* A scaffold step writes no files *through the agent*. `npx create-vite todo-glass-app`
* writes eleven of them and `npm install` writes several thousand, and the extension
* sees none of it: `ChangeSet.recordCommand` appends to `commands`, `ChangeSet.since`
* reads `files`, and the two never meet. So a step whose entire job is "scaffold the
* project" arrived here with an empty `changed` array and was judged to have produced
* "no work at all".
*
* That is what ended the macOS 0.6.0 benchmark run. Both commands the step needed had
* already succeeded — `npx create-vite` exit 0, `npm install` exit 0, twenty seconds of
* it, the project sitting on disk — and the run stopped with *"nothing was written"*
* and skipped the five remaining items. The user's report of the session was a wall of
* text about a scaffold that had in fact worked.
*
* The guard's principle is unchanged: judge from evidence the extension holds itself,
* never from the model's account. A process the extension spawned, whose exit code it
* read, is exactly that kind of evidence — it was simply being read from the wrong half
* of the change set.
*
* @param {StepEvidence} evidence
* @returns {boolean}
*/
function ranSomething(evidence) {
return (evidence.commands || []).some((entry) => entry && entry.ok);
}

/**
* @typedef {object} Verdict
* @property {boolean} ok
Expand Down Expand Up @@ -115,6 +148,19 @@ function verify(evidence) {
const failedSteps = (evidence.steps || []).filter((step) => step.result && step.result.ok === false);

if (changed.length === 0) {
// A command that succeeded is work, and a scaffolder's output never passes through
// `write_file`. Reported rather than passed silently: the step is not failed, but
// "the agent wrote nothing itself" is the one thing a later step needs to know
// before it assumes a file is there.
if (ranSomething(evidence)) {
const ran = evidence.commands.filter((entry) => entry.ok).map((entry) => `\`${entry.command}\``);
return {
ok: true,
reason: 'ran',
detail: `no files were written directly, but ${ran.join(' and ')} ran and succeeded`,
};
}

// The distinction the user sees: a step that tried and was refused reads differently
// from one that never attempted anything, and only the first is worth naming a cause
// for.
Expand Down
Loading
Loading