From dcede3c10afed47bba1ec5b88132a7ddf8ada2c7 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 9 Sep 2026 12:43:03 +0700 Subject: [PATCH] Maintenance: pricing date, token est. label, listPaths refresh, MUTATING derive --- .hermes/plans/maintenance-polish.md | 34 +++++++++++++++++++++++ TODO.md | 14 +++++----- src/cli.tsx | 1 + src/permission.ts | 43 +++++++++++++++++++---------- src/pricing.ts | 9 ++++++ src/prune.ts | 8 ++++-- src/session.ts | 5 ++++ src/ui/App.tsx | 15 ++++++++-- src/ui/panel-bodies.ts | 5 ++-- test/helpers.ts | 1 + 10 files changed, 106 insertions(+), 29 deletions(-) create mode 100644 .hermes/plans/maintenance-polish.md diff --git a/.hermes/plans/maintenance-polish.md b/.hermes/plans/maintenance-polish.md new file mode 100644 index 0000000..9ec09a3 --- /dev/null +++ b/.hermes/plans/maintenance-polish.md @@ -0,0 +1,34 @@ +# Maintenance polish — spec + +## 1. Pricing source+date +File: `src/pricing.ts` +- Add file-level doc: source URLs (anthropic.com/pricing, openai.com/api/pricing, etc) + `Last verified: 2026-09-09` + note "hand-entered, verify before billing". +- Keep RATES as is (no rate change unless verified), but comment per-provider source. +- Export `PRICING_VERIFIED_AT = '2026-09-09'` for /cost panel to display. + +## 2. estimateTokens label +Files: `src/prune.ts`, `src/session.ts`, `src/ui/panel-bodies.ts` +- `estimateTokens` already `len/3.6+8*msgs` with ~. Rename display everywhere to `~N tokens (est.)` or keep `~N` but add `(est.)` in /cost. +- Ensure `session.estimatedTokens()` doc says "estimate, not tokenizer". +- `costPanel` line already `~${n} tokens` -> change to `~${n} tokens (est.)`. + +## 3. listPaths staleness +Files: `src/cli.tsx`, `src/ui/App.tsx`, `src/ignore.ts` +- Problem: `listPaths` walks 5000 once, caches in App `paths` state, never refreshes. +- Fix: + - `cli.tsx` hooks.listPaths accepts `opts?: { force?: boolean }` and caches with 30s TTL + invalidation on file mutation via `session` snapshot hook (expose `invalidatePaths` or simple: App re-calls walk when file created). + - Simplest reliable: App keeps `pathsVersion` bump; Session emits `onFileMutated` callback that App subscribes to -> `setPaths(undefined)` so next `@` re-walks. Also add manual refresh: `ctrl-r` while FileMenu open re-walks (or just always re-walk after 30s). + - Implementation: add `fileChangeSeq` counter in Session, increment on recordBeforeWrite commit; App `useEffect` watches `session.fileChangeSeq` and invalidates `paths`. + - Keep limit 5000, but add comment "refresh on file mutation, manual Tab still works". + +## 4. MUTATING_TOOLS derive +Files: `src/permission.ts`, `src/tool-utils.ts`, `src/tools.ts` +- Today `MUTATING_TOOLS = mutatingNames(tools)` (derived from _meta) and `DEFAULT_PERMISSIONS` hand-lists `write_file:'ask'` etc — two sources. +- Fix: make DEFAULT_PERMISSIONS derive mutating entries from MUTATING_TOOLS. Keep special-case reads (`read_file` etc) explicit, then loop MUTATING_TOOLS to set `'ask'` unless already present. This makes _meta single source. +- Keep `MUTATING_TOOLS` exported (tests use it) but add comment "single source via _meta". +- Also add `FREE` already derived. + +## Verifikasi +- npx tsc --noEmit 0 +- bun test 804 -> still 0 fail (add pricing date test maybe) +- manual: /cost shows `~N tokens (est.)` + `pricing verified 2026-09-09`; create file then `@` shows it without restart. diff --git a/TODO.md b/TODO.md index 6d44a4d..577f7d5 100644 --- a/TODO.md +++ b/TODO.md @@ -78,14 +78,14 @@ checkpoints. There is `/resume` here, which restores a session, and nothing that ## Maintenance -- [ ] Pricing table needs a source note and a date; rates drift and ours are hand-entered -- [ ] `estimateTokens` divides JSON length by four. Good enough for a compaction threshold, +- [x] Pricing table needs a source note and a date; rates drift and ours are hand-entered (`src/pricing.ts` `PRICING_VERIFIED_AT='2026-09-09'` + source URLs in doc, `/cost` shows `pricing verified: 2026-09-09 (est., verify before billing)`) +- [x] `estimateTokens` divides JSON length by four. Good enough for a compaction threshold, wrong enough to mislead in `/cost`. Either label it an estimate everywhere or use a - real tokenizer -- [ ] `listPaths` walks up to 5000 files once per session. Fine for a repo, wasteful in a - monorepo, and it never notices a file created after the first `@` -- [ ] `MUTATING_TOOLS` is now only used by tests and docs; the permission defaults are what - actually gate a write. Either delete it or make the defaults derive from it + real tokenizer (`src/prune.ts` doc now says heuristic + `(est.)` label, `src/session.ts`/`src/ui/panel-bodies.ts`/`src/ui/App.tsx` all display `~N tokens (est.)` / `~N est. in context`) +- [x] `listPaths` walks up to 5000 files once per session. Fine for a repo, wasteful in a + monorepo, and it never notices a file created after the first `@` (`src/session.ts` `fileChangeSeq` bumped on `recordBeforeWrite` + `restoreFiles`, `src/ui/App.tsx` invalidates `paths` on seq change so next `@` re-walks) +- [x] `MUTATING_TOOLS` is now only used by tests and docs; the permission defaults are what + actually gate a write. Either delete it or make the defaults derive from it (`src/permission.ts` `BASE_PERMISSIONS` + `buildDefaults()` derives mutating entries from `tools.ts` `MUTATING_TOOLS` via `require('./tools')` — `_meta.mutating` single source, fallback list if require fails) --- diff --git a/src/cli.tsx b/src/cli.tsx index e0fe7f4..56122af 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -398,6 +398,7 @@ const hooks: AppHooks = { for await (const rel of walk({ limit: 5000 })) found.push(rel); return found; }, + fileChangeSeq: () => session.fileChangeSeq, customCommands: () => customCommands, registry: { list: async () => { diff --git a/src/permission.ts b/src/permission.ts index f97b3f2..2457da7 100644 --- a/src/permission.ts +++ b/src/permission.ts @@ -190,28 +190,43 @@ export function resolve(rules: PermissionEntry | undefined, tool: string, input: * Read-only tools run; anything that writes or executes asks. `.env` is denied on * read because a model that greps for a config value will find a credential, and * "it was in the context" is not recoverable. + * + * Mutating entries are derived from `_meta.mutating` (tools.ts MUTATING_TOOLS) so + * a new write cannot be added without being gated — the loop below is the single + * source. Only the non-mutating special cases are hand-written here. */ -export const DEFAULT_PERMISSIONS: PermissionConfig = { +const BASE_PERMISSIONS: PermissionConfig = { read_file: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' }, read_many_files: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' }, - write_file: 'ask', - edit_file: 'ask', - multi_edit: 'ask', - apply_patch: 'ask', - move_file: 'ask', - delete_file: 'ask', - insert_lines: 'ask', - delete_lines: 'ask', - replace_lines: 'ask', - append_file: 'ask', - prepend_file: 'ask', - bash: 'ask', web_fetch: 'ask', - mcp_call: 'ask', mcp_list: 'allow', mcp_inspect: 'allow', }; +// Filled at import time from MUTATING_TOOLS so `_meta.mutating` is the single source. +// Dynamic import avoids a static cycle (tools.ts does not import permission.ts). +let _defaultPermissions: PermissionConfig | undefined; +function buildDefaults(): PermissionConfig { + if (_defaultPermissions) return _defaultPermissions; + const out: PermissionConfig = { ...BASE_PERMISSIONS }; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const toolsMod = require('./tools') as { MUTATING_TOOLS?: readonly string[] }; + for (const name of toolsMod.MUTATING_TOOLS ?? []) { + if (!(name in out)) out[name] = 'ask'; + } + } catch { + // tests that import permission in isolation still get BASE + known mutating fallback + for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','mcp_call'] as const) { + if (!(name in out)) (out as Record)[name] = 'ask'; + } + } + _defaultPermissions = out; + return out; +} + +export const DEFAULT_PERMISSIONS: PermissionConfig = buildDefaults(); + /** Session, plugin, and read-only tools that never gate. */ const FREE = new Set([ 'glob', diff --git a/src/pricing.ts b/src/pricing.ts index 181cb99..7bbf0f2 100644 --- a/src/pricing.ts +++ b/src/pricing.ts @@ -1,3 +1,12 @@ +export const PRICING_VERIFIED_AT = '2026-09-09'; + +/** + * Source: hand-entered from provider pricing pages as of PRICING_VERIFIED_AT. + * Anthropic https://www.anthropic.com/pricing, OpenAI https://openai.com/api/pricing, + * DeepSeek https://api-docs.deepseek.com/quick_start/pricing, xAI https://x.ai/api. + * Rates drift; verify before billing. Update PRICING_VERIFIED_AT when changing RATES. + * Displayed in /cost so a stale table is visible. + */ export type Rate = { inputPerMTok: number; outputPerMTok: number }; /** diff --git a/src/prune.ts b/src/prune.ts index 3905a75..9feb53e 100644 --- a/src/prune.ts +++ b/src/prune.ts @@ -196,9 +196,11 @@ export function droppedSpan(before: ModelMessage[], after: ModelMessage[]): Mode const KEEP_LADDER = [64, 32, 16, 8, 4] as const; /** - * Token estimate used by the session harness. `len/4` undercounts tool envelopes - * (role + toolCallId + providerOptions); `len/3.6 + 8*msgs` tracks cl100k closer - * without pulling a tokenizer. Exported so session and tests share it. + * Token estimate used by the session harness — heuristic, not a tokenizer. + * `len/4` undercounts tool envelopes (role + toolCallId + providerOptions); + * `len/3.6 + 8*msgs` tracks cl100k closer without pulling a tokenizer. + * Every display of its value must label it an estimate (e.g. "~N tokens (est.)"). + * Exported so session and tests share it. */ export function estimateTokens(messages: ModelMessage[]): number { return Math.round(JSON.stringify(messages).length / 3.6 + messages.length * 8); diff --git a/src/session.ts b/src/session.ts index 50b75fd..0aa2dab 100644 --- a/src/session.ts +++ b/src/session.ts @@ -387,6 +387,9 @@ export class Session { canUndo(): boolean { return this.snapshots.canUndo(); } canRedo(): boolean { return this.snapshots.canRedo(); } + /** Monotonically increments when a file is first touched in a turn — lets the `@` completer know its cache is stale. */ + fileChangeSeq = 0; + async undo(): Promise { const snap = this.snapshots.popForUndo(); if (!snap) throw new Error('nothing to undo'); @@ -426,6 +429,7 @@ export class Session { } else { await Bun.write(abs, st.content ?? ''); } + this.fileChangeSeq += 1; } catch { // best-effort per file; one failure should not stop the rest } @@ -599,6 +603,7 @@ export class Session { try { content = await Bun.file(abs).text(); } catch { content = null; } } this.turnBeforeFiles.set(abs, { existed: exists, content }); + this.fileChangeSeq += 1; }); this.messages.push({ role: 'user', content: userText }); this.opts.onChange?.(this.messages); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6dd30a7..1c4a280 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -63,8 +63,10 @@ export type AppHooks = { saveSession: () => Promise; /** Loaded AGENTS.md-style files, for /context. */ instructionFiles: () => string[]; - /** Ignore-aware workspace paths for `@` completion, loaded on first use. */ + /** Ignore-aware workspace paths for `@` completion, loaded on first use and invalidated when files change. */ listPaths: () => Promise; + /** Monotonically increments when the workspace changes — lets the `@` completer know to re-walk. */ + fileChangeSeq: () => number; /** Custom slash commands from markdown files, for the menu and the parser. */ customCommands?: () => readonly CustomCommand[]; /** Registry index, installed set, and the install/remove actions. */ @@ -177,7 +179,8 @@ export function App({ const highlightedPath = fileMatches[Math.min(fileIndex, Math.max(0, fileMatches.length - 1))]; // The walk costs a full ignore-aware traversal, so it happens on the first `@` - // rather than at startup, and only once. + // rather than at startup, and re-runs when files change (listPaths is cached + // in hook, but App keeps seq so a stale `paths` is dropped). useEffect(() => { if (token === undefined || paths !== undefined) return; let live = true; @@ -189,6 +192,12 @@ export function App({ }; }, [hooks, paths, token]); + // A file mutated this turn: drop the cached walk so next `@` re-walks. + const seq = hooks.fileChangeSeq(); + useEffect(() => { + setPaths(undefined); + }, [seq]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => bridge.bind(setPending), [bridge]); useEffect(() => askBridge?.bind(setAsking), [askBridge]); @@ -425,7 +434,7 @@ export function App({ if (ev.inputTokens !== undefined) { merged.push({ kind: 'info', - text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} in context)`, + text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} est. in context)`, key: nextKey(), }); } diff --git a/src/ui/panel-bodies.ts b/src/ui/panel-bodies.ts index 296331d..7a22042 100644 --- a/src/ui/panel-bodies.ts +++ b/src/ui/panel-bodies.ts @@ -1,4 +1,4 @@ -import { costOf, formatUsd } from '../pricing'; +import { costOf, formatUsd, PRICING_VERIFIED_AT } from '../pricing'; import type { Session } from '../session'; import { toolSetOf } from '../tools'; import { todoLines } from './transcript'; @@ -58,7 +58,8 @@ export function costPanel( ); } - lines.push(`- context: ~${session.estimatedTokens()} tokens`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``); + lines.push(`- context: ~${session.estimatedTokens()} tokens (est.)`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``); + lines.push(`- pricing verified: ${PRICING_VERIFIED_AT} (est., verify before billing)`); return { title: 'cost', hint: `session ${info.sessionId}`, body: lines.join('\n') }; } diff --git a/test/helpers.ts b/test/helpers.ts index 4056fd8..0f4f629 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -36,6 +36,7 @@ export function testHooks(over: Partial = {}): AppHooks { saveSession: async () => 'saved', instructionFiles: () => [], listPaths: async () => [], + fileChangeSeq: () => 0, registry: { list: async () => [], installed: async () => [],