Maintenance: pricing date, token est. label, listPaths refresh, MUTATING derive
This commit is contained in:
1 parent
4f3a7eef7c
commit
dcede3c10a
10 files changed
+106
-29
No files matched your search
@@ -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.
|
||||||
@@ -78,14 +78,14 @@ checkpoints. There is `/resume` here, which restores a session, and nothing that
|
|||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
- [ ] Pricing table needs a source note and a date; rates drift and ours are hand-entered
|
- [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)`)
|
||||||
- [ ] `estimateTokens` divides JSON length by four. Good enough for a compaction threshold,
|
- [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
|
wrong enough to mislead in `/cost`. Either label it an estimate everywhere or use a
|
||||||
real tokenizer
|
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`)
|
||||||
- [ ] `listPaths` walks up to 5000 files once per session. Fine for a repo, wasteful in a
|
- [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 `@`
|
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)
|
||||||
- [ ] `MUTATING_TOOLS` is now only used by tests and docs; the permission defaults are what
|
- [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
|
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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,7 @@ const hooks: AppHooks = {
|
|||||||
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
for await (const rel of walk({ limit: 5000 })) found.push(rel);
|
||||||
return found;
|
return found;
|
||||||
},
|
},
|
||||||
|
fileChangeSeq: () => session.fileChangeSeq,
|
||||||
customCommands: () => customCommands,
|
customCommands: () => customCommands,
|
||||||
registry: {
|
registry: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
|
|||||||
+29
-14
@@ -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-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
|
* read because a model that greps for a config value will find a credential, and
|
||||||
* "it was in the context" is not recoverable.
|
* "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_file: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' },
|
||||||
read_many_files: { '*': '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',
|
web_fetch: 'ask',
|
||||||
mcp_call: 'ask',
|
|
||||||
mcp_list: 'allow',
|
mcp_list: 'allow',
|
||||||
mcp_inspect: '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<string, PermissionEntry>)[name] = 'ask';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_defaultPermissions = out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PERMISSIONS: PermissionConfig = buildDefaults();
|
||||||
|
|
||||||
/** Session, plugin, and read-only tools that never gate. */
|
/** Session, plugin, and read-only tools that never gate. */
|
||||||
const FREE = new Set([
|
const FREE = new Set([
|
||||||
'glob',
|
'glob',
|
||||||
|
|||||||
@@ -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 };
|
export type Rate = { inputPerMTok: number; outputPerMTok: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+5
-3
@@ -196,9 +196,11 @@ export function droppedSpan(before: ModelMessage[], after: ModelMessage[]): Mode
|
|||||||
const KEEP_LADDER = [64, 32, 16, 8, 4] as const;
|
const KEEP_LADDER = [64, 32, 16, 8, 4] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token estimate used by the session harness. `len/4` undercounts tool envelopes
|
* Token estimate used by the session harness — heuristic, not a tokenizer.
|
||||||
* (role + toolCallId + providerOptions); `len/3.6 + 8*msgs` tracks cl100k closer
|
* `len/4` undercounts tool envelopes (role + toolCallId + providerOptions);
|
||||||
* without pulling a tokenizer. Exported so session and tests share it.
|
* `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 {
|
export function estimateTokens(messages: ModelMessage[]): number {
|
||||||
return Math.round(JSON.stringify(messages).length / 3.6 + messages.length * 8);
|
return Math.round(JSON.stringify(messages).length / 3.6 + messages.length * 8);
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ export class Session {
|
|||||||
canUndo(): boolean { return this.snapshots.canUndo(); }
|
canUndo(): boolean { return this.snapshots.canUndo(); }
|
||||||
canRedo(): boolean { return this.snapshots.canRedo(); }
|
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<string> {
|
async undo(): Promise<string> {
|
||||||
const snap = this.snapshots.popForUndo();
|
const snap = this.snapshots.popForUndo();
|
||||||
if (!snap) throw new Error('nothing to undo');
|
if (!snap) throw new Error('nothing to undo');
|
||||||
@@ -426,6 +429,7 @@ export class Session {
|
|||||||
} else {
|
} else {
|
||||||
await Bun.write(abs, st.content ?? '');
|
await Bun.write(abs, st.content ?? '');
|
||||||
}
|
}
|
||||||
|
this.fileChangeSeq += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort per file; one failure should not stop the rest
|
// 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; }
|
try { content = await Bun.file(abs).text(); } catch { content = null; }
|
||||||
}
|
}
|
||||||
this.turnBeforeFiles.set(abs, { existed: exists, content });
|
this.turnBeforeFiles.set(abs, { existed: exists, content });
|
||||||
|
this.fileChangeSeq += 1;
|
||||||
});
|
});
|
||||||
this.messages.push({ role: 'user', content: userText });
|
this.messages.push({ role: 'user', content: userText });
|
||||||
this.opts.onChange?.(this.messages);
|
this.opts.onChange?.(this.messages);
|
||||||
|
|||||||
+12
-3
@@ -63,8 +63,10 @@ export type AppHooks = {
|
|||||||
saveSession: () => Promise<string>;
|
saveSession: () => Promise<string>;
|
||||||
/** Loaded AGENTS.md-style files, for /context. */
|
/** Loaded AGENTS.md-style files, for /context. */
|
||||||
instructionFiles: () => string[];
|
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<string[]>;
|
listPaths: () => Promise<string[]>;
|
||||||
|
/** 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. */
|
/** Custom slash commands from markdown files, for the menu and the parser. */
|
||||||
customCommands?: () => readonly CustomCommand[];
|
customCommands?: () => readonly CustomCommand[];
|
||||||
/** Registry index, installed set, and the install/remove actions. */
|
/** 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))];
|
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 `@`
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (token === undefined || paths !== undefined) return;
|
if (token === undefined || paths !== undefined) return;
|
||||||
let live = true;
|
let live = true;
|
||||||
@@ -189,6 +192,12 @@ export function App({
|
|||||||
};
|
};
|
||||||
}, [hooks, paths, token]);
|
}, [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(() => bridge.bind(setPending), [bridge]);
|
||||||
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
|
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
|
||||||
|
|
||||||
@@ -425,7 +434,7 @@ export function App({
|
|||||||
if (ev.inputTokens !== undefined) {
|
if (ev.inputTokens !== undefined) {
|
||||||
merged.push({
|
merged.push({
|
||||||
kind: 'info',
|
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(),
|
key: nextKey(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { costOf, formatUsd } from '../pricing';
|
import { costOf, formatUsd, PRICING_VERIFIED_AT } from '../pricing';
|
||||||
import type { Session } from '../session';
|
import type { Session } from '../session';
|
||||||
import { toolSetOf } from '../tools';
|
import { toolSetOf } from '../tools';
|
||||||
import { todoLines } from './transcript';
|
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') };
|
return { title: 'cost', hint: `session ${info.sessionId}`, body: lines.join('\n') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export function testHooks(over: Partial<AppHooks> = {}): AppHooks {
|
|||||||
saveSession: async () => 'saved',
|
saveSession: async () => 'saved',
|
||||||
instructionFiles: () => [],
|
instructionFiles: () => [],
|
||||||
listPaths: async () => [],
|
listPaths: async () => [],
|
||||||
|
fileChangeSeq: () => 0,
|
||||||
registry: {
|
registry: {
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
installed: async () => [],
|
installed: async () => [],
|
||||||
|
|||||||
Reference in new issue
Block a user