@xynogen/pix-runtime 0.13.2 → 0.15.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 CHANGED
@@ -10,7 +10,7 @@ See `DESIGN.md` for the full contract.
10
10
  ## What it does
11
11
 
12
12
  - Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
13
- - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `toolbox`.
13
+ - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `edit`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `toolbox`.
14
14
  - `/pix` has a Footer tab for `pretty.footer` visibility.
15
15
  - Startup imports legacy service config files and archives them only after a successful save.
16
16
  - Atomic writes behind a serialized in-process queue and a short-lived
@@ -65,6 +65,17 @@ The default floor is 100K and the hard minimum is 25K (values below clamp up);
65
65
  `/pix` offers 25K, 50K, 100K, 150K, 200K, 300K, 400K, 600K, 800K, and 1M.
66
66
  pix-core consumes both settings.
67
67
 
68
+ Over that threshold, pix-core masks older tool output before it summarizes.
69
+ `compaction.maskKeep` (default `10`) is the number of newest tool results kept as
70
+ is. Older successful `bash`/`grep`/`find`/`ls`/`read` output becomes a one-line
71
+ stub; errors stay. `compaction.maskMinTokens` (default `5000`) skips a pass that
72
+ would free fewer tokens, because each pass breaks the prompt cache. Both are
73
+ `pix.json` calibration knobs with no `/pix` picker.
74
+
75
+ `edit.rejectNewSyntaxErrors` (default `false`, `/pix` → **Edit → syntax guard**)
76
+ makes pix-diagnostics restore a file and fail the edit when LSP reports a syntax
77
+ error that was not there before. Type errors never trigger it.
78
+
68
79
  Collapse policy helpers:
69
80
 
70
81
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,8 @@
38
38
  "./os": "./src/os.ts",
39
39
  "./audio": "./src/audio.ts",
40
40
  "./safe-path": "./src/safe-path.ts",
41
- "./which": "./src/which.ts"
41
+ "./which": "./src/which.ts",
42
+ "./ast": "./src/ast/index.ts"
42
43
  },
43
44
  "keywords": [
44
45
  "pi",
@@ -63,9 +64,18 @@
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@earendil-works/pi-coding-agent": "*",
66
- "@earendil-works/pi-tui": "*"
67
+ "@earendil-works/pi-tui": "*",
68
+ "@ast-grep/napi": "^0.45.0"
67
69
  },
68
70
  "dependencies": {
69
71
  "xxhash-wasm": "1.1.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@ast-grep/napi": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@ast-grep/napi": "^0.45.0"
70
80
  }
71
81
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * engine.ts — the lazy @ast-grep/napi loader and language map.
3
+ *
4
+ * The native addon loads on first tool call, never at extension registration.
5
+ * The addon bundles six grammars: ts, tsx, js, jsx, css, html. We map file
6
+ * extensions to language aliases. Non-bundled languages use grammars.ts, which
7
+ * loads cached grammar packages or installs them only after user consent.
8
+ *
9
+ * Loader adapted from pi-lens `clients/deps/ast-grep-napi.ts` (MIT). See
10
+ * packages/pix-diagnostics/LICENSE.pi-lens.
11
+ *
12
+ * `@ast-grep/napi` is an optional peer: pix-runtime never forces the native addon on
13
+ * every install. Packages that parse (pix-astgrep, pix-graph) depend on it; without it
14
+ * `loadEngine()` returns undefined and callers degrade.
15
+ * ponytail: resolution assumes npm hoists napi next to pix-runtime (the flat Pi install).
16
+ */
17
+
18
+ import { createRequire } from "node:module";
19
+ import { extname } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+
22
+ // The typed shape of the addon accessor we use (parse → root → findAll).
23
+ export interface SgEdit {
24
+ startPos: number;
25
+ endPos: number;
26
+ insertedText: string;
27
+ }
28
+ export interface SgNode {
29
+ text(): string;
30
+ kind(): string;
31
+ range(): { start: SgPos; end: SgPos };
32
+ findAll(matcher: string | { rule: Record<string, unknown> }): SgNode[];
33
+ children(): SgNode[];
34
+ isNamed(): boolean;
35
+ /** Child stored under a tree-sitter field name (`name`, `body`, `function`, …). */
36
+ field(name: string): SgNode | null;
37
+ replace(text: string): SgEdit;
38
+ commitEdits(edits: SgEdit[]): string;
39
+ getMatch(name: string): SgNode | null;
40
+ getMultipleMatches(name: string): SgNode[];
41
+ }
42
+ interface SgPos {
43
+ line: number;
44
+ column: number;
45
+ index: number;
46
+ }
47
+ interface SgRoot {
48
+ root(): SgNode;
49
+ }
50
+ interface LangApi {
51
+ parse(source: string): SgRoot;
52
+ }
53
+ interface AstGrepNapi {
54
+ ts: LangApi;
55
+ tsx: LangApi;
56
+ js: LangApi;
57
+ jsx: LangApi;
58
+ css: LangApi;
59
+ html: LangApi;
60
+ /** Top-level parse for a dynamically registered language, keyed by alias. */
61
+ parse(lang: string, source: string): SgRoot;
62
+ registerDynamicLanguage(langs: Record<string, unknown>): void;
63
+ }
64
+
65
+ const _require = createRequire(import.meta.url);
66
+ let cached: AstGrepNapi | undefined;
67
+ let loadPromise: Promise<AstGrepNapi | undefined> | undefined;
68
+
69
+ /** Load the native addon once. Returns `undefined` when it cannot load. */
70
+ export async function loadEngine(): Promise<AstGrepNapi | undefined> {
71
+ if (cached) return cached;
72
+ if (loadPromise) return loadPromise;
73
+ loadPromise = (async () => {
74
+ try {
75
+ const entry = _require.resolve("@ast-grep/napi");
76
+ // SAFETY: the addon has no bundled ESM types; the accessors we use
77
+ // (ts/tsx/js/jsx/css/html .parse) are verified at runtime by tests.
78
+ const mod = (await import(pathToFileURL(entry).href)) as unknown as AstGrepNapi;
79
+ cached = mod;
80
+ return mod;
81
+ } catch {
82
+ return undefined;
83
+ } finally {
84
+ loadPromise = undefined;
85
+ }
86
+ })();
87
+ return loadPromise;
88
+ }
89
+
90
+ /** The six grammars the napi addon parses in-process without any package. */
91
+ export const LANGUAGES = ["typescript", "tsx", "javascript", "jsx", "css", "html"] as const;
92
+ export type Language = (typeof LANGUAGES)[number];
93
+ const BUNDLED = new Set<string>(LANGUAGES);
94
+
95
+ /** True when napi parses this alias in-process (no `@ast-grep/lang-*` needed). */
96
+ export function isBundled(lang: string): lang is Language {
97
+ return BUNDLED.has(lang);
98
+ }
99
+
100
+ /**
101
+ * Supported ast-grep language aliases. The six bundled ones parse in-process;
102
+ * the rest have published `@ast-grep/lang-*` packages (see grammars.ts).
103
+ * This intentionally excludes languages without an installable grammar.
104
+ */
105
+ export const ALL_LANGUAGES = [
106
+ "typescript",
107
+ "tsx",
108
+ "javascript",
109
+ "jsx",
110
+ "css",
111
+ "html",
112
+ "python",
113
+ "go",
114
+ "rust",
115
+ "java",
116
+ "c",
117
+ "cpp",
118
+ "csharp",
119
+ "ruby",
120
+ "php",
121
+ "swift",
122
+ "kotlin",
123
+ "scala",
124
+ "lua",
125
+ "elixir",
126
+ "json",
127
+ "yaml",
128
+ "bash",
129
+ "haskell",
130
+ ] as const;
131
+ export type AnyLanguage = (typeof ALL_LANGUAGES)[number];
132
+
133
+ // File extension → language alias, from the ast-grep language table.
134
+ const EXT_TO_LANG: Record<string, AnyLanguage> = {
135
+ ".ts": "typescript",
136
+ ".mts": "typescript",
137
+ ".cts": "typescript",
138
+ ".tsx": "tsx",
139
+ ".js": "javascript",
140
+ ".mjs": "javascript",
141
+ ".cjs": "javascript",
142
+ ".jsx": "jsx",
143
+ ".css": "css",
144
+ ".html": "html",
145
+ ".htm": "html",
146
+ ".xhtml": "html",
147
+ ".py": "python",
148
+ ".pyi": "python",
149
+ ".go": "go",
150
+ ".rs": "rust",
151
+ ".java": "java",
152
+ ".c": "c",
153
+ ".h": "c",
154
+ ".cc": "cpp",
155
+ ".cpp": "cpp",
156
+ ".cxx": "cpp",
157
+ ".hpp": "cpp",
158
+ ".hh": "cpp",
159
+ ".cu": "cpp",
160
+ ".cs": "csharp",
161
+ ".rb": "ruby",
162
+ ".gemspec": "ruby",
163
+ ".php": "php",
164
+ ".swift": "swift",
165
+ ".kt": "kotlin",
166
+ ".kts": "kotlin",
167
+ ".scala": "scala",
168
+ ".sc": "scala",
169
+ ".sbt": "scala",
170
+ ".lua": "lua",
171
+ ".ex": "elixir",
172
+ ".exs": "elixir",
173
+ ".json": "json",
174
+ ".yml": "yaml",
175
+ ".yaml": "yaml",
176
+ ".sh": "bash",
177
+ ".bash": "bash",
178
+ ".zsh": "bash",
179
+ ".ksh": "bash",
180
+ ".hs": "haskell",
181
+ };
182
+
183
+ /** Map a file path to any ast-grep language alias, or `undefined`. */
184
+ export function languageForFile(path: string): AnyLanguage | undefined {
185
+ return EXT_TO_LANG[extname(path).toLowerCase()];
186
+ }
187
+
188
+ /** Get the addon accessor for a language, or `undefined` when unsupported. */
189
+ export function accessorFor(engine: AstGrepNapi, lang: Language): LangApi {
190
+ switch (lang) {
191
+ case "typescript":
192
+ return engine.ts;
193
+ case "tsx":
194
+ return engine.tsx;
195
+ case "javascript":
196
+ return engine.js;
197
+ case "jsx":
198
+ return engine.jsx;
199
+ case "css":
200
+ return engine.css;
201
+ case "html":
202
+ return engine.html;
203
+ default:
204
+ throw new Error(`unsupported language: ${lang as string}`);
205
+ }
206
+ }
207
+
208
+ /** Parse source into a root node for one bundled language. */
209
+ export function parse(engine: AstGrepNapi, lang: Language, source: string): SgNode {
210
+ return accessorFor(engine, lang).parse(source).root();
211
+ }
212
+
213
+ /**
214
+ * Parse source for any language alias, including a dynamically registered one.
215
+ * The caller must have run `ensureLanguage(lang)` first for non-bundled ones.
216
+ */
217
+ export function parseDynamic(engine: AstGrepNapi, lang: string, source: string): SgNode {
218
+ return engine.parse(lang, source).root();
219
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * grammars.ts — lazy, package-owned multi-language grammar loading.
3
+ *
4
+ * The napi addon bundles six grammars (see engine.ts). Every other ast-grep
5
+ * language ships as its own `@ast-grep/lang-<x>` npm package. This module loads
6
+ * one on demand:
7
+ *
8
+ * 1. If napi already parses it (the six), no grammar is needed.
9
+ * 2. Else try to import `@ast-grep/lang-<x>` from the pix cache, then from
10
+ * the resolvable node_modules.
11
+ * 3. If absent, report `needs-install` with the exact package name. The tool
12
+ * asks the user once, then this module installs it INTO the pix cache
13
+ * (never the agent, never a global write). A later call finds it cached.
14
+ *
15
+ * The cache lives at `<agentDir>/pix-astgrep/grammars`. We install with the
16
+ * user's package manager into that private prefix, so a read-only global pix
17
+ * install still works.
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import { mkdir } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { join } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { runTool } from "../exec.ts";
26
+ import { agentDir } from "../paths.ts";
27
+ import { isBundled, loadEngine, parse, parseDynamic, type SgNode } from "./engine.ts";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
31
+
32
+ /** Canonical language alias → its `@ast-grep/lang-*` package name. */
33
+ const LANG_PACKAGE: Record<string, string> = {
34
+ python: "@ast-grep/lang-python",
35
+ go: "@ast-grep/lang-go",
36
+ rust: "@ast-grep/lang-rust",
37
+ java: "@ast-grep/lang-java",
38
+ c: "@ast-grep/lang-c",
39
+ cpp: "@ast-grep/lang-cpp",
40
+ csharp: "@ast-grep/lang-csharp",
41
+ ruby: "@ast-grep/lang-ruby",
42
+ php: "@ast-grep/lang-php",
43
+ swift: "@ast-grep/lang-swift",
44
+ kotlin: "@ast-grep/lang-kotlin",
45
+ scala: "@ast-grep/lang-scala",
46
+ lua: "@ast-grep/lang-lua",
47
+ elixir: "@ast-grep/lang-elixir",
48
+ json: "@ast-grep/lang-json",
49
+ yaml: "@ast-grep/lang-yaml",
50
+ bash: "@ast-grep/lang-bash",
51
+ haskell: "@ast-grep/lang-haskell",
52
+ };
53
+
54
+ /** Languages that need a `@ast-grep/lang-*` package (not the napi six). */
55
+ export function packageForLanguage(lang: string): string | undefined {
56
+ return LANG_PACKAGE[lang];
57
+ }
58
+
59
+ /**
60
+ * Cache root for installed grammars: `<agentDir>/pix-astgrep/grammars`. The path predates the
61
+ * move into pix-runtime; it stays so grammars users already installed keep working.
62
+ */
63
+ export function grammarCacheDir(): string {
64
+ return join(agentDir(), "pix-astgrep", "grammars");
65
+ }
66
+
67
+ // ponytail: napi registers once per process. Load all cached grammars together;
68
+ // adding a language afterward needs an agent restart, not a silently ignored registration.
69
+ const stateKey = Symbol.for("pix-astgrep.grammars");
70
+ const globals = globalThis as typeof globalThis & {
71
+ [stateKey]?: { registered: Set<string>; declined: Set<string>; registration?: Promise<void> };
72
+ };
73
+ globals[stateKey] ??= { registered: new Set(), declined: new Set() };
74
+ const state = globals[stateKey];
75
+ const { registered, declined } = state;
76
+
77
+ export type GrammarResult =
78
+ | { kind: "ready" }
79
+ | { kind: "napi" }
80
+ | { kind: "needs-install"; pkg: string }
81
+ | { kind: "declined"; pkg: string }
82
+ | { kind: "unsupported" }
83
+ | { kind: "error"; message: string };
84
+
85
+ /** Import a grammar module from the cache prefix, then this package. */
86
+ async function importGrammar(pkg: string): Promise<unknown | undefined> {
87
+ const cached = join(grammarCacheDir(), "node_modules", pkg);
88
+ const candidates = existsSync(cached) ? [cached, pkg] : [pkg];
89
+ for (const spec of candidates) {
90
+ let entry: string;
91
+ try {
92
+ entry = require.resolve(spec);
93
+ } catch (err) {
94
+ if ((err as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") continue;
95
+ throw err;
96
+ }
97
+ const mod = (await import(pathToFileURL(entry).href)) as { default?: unknown };
98
+ return mod.default ?? mod;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Make a language usable. Returns `ready` when registered, `napi` when the
105
+ * addon already covers it, `needs-install` with the package to install, or
106
+ * `unsupported` for an unknown alias.
107
+ */
108
+ export async function ensureLanguage(lang: string): Promise<GrammarResult> {
109
+ const engine = await loadEngine();
110
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
111
+
112
+ // The six bundled grammars never need a package.
113
+ if (isBundled(lang)) {
114
+ return { kind: "napi" };
115
+ }
116
+ const pkg = packageForLanguage(lang);
117
+ if (!pkg) return { kind: "unsupported" };
118
+ if (registered.has(lang)) return { kind: "ready" };
119
+
120
+ try {
121
+ const grammar = await importGrammar(pkg);
122
+ if (!grammar) return { kind: "needs-install", pkg };
123
+ state.registration ??= (async () => {
124
+ const grammars: Record<string, unknown> = {};
125
+ for (const [alias, name] of Object.entries(LANG_PACKAGE)) {
126
+ const loaded = await importGrammar(name);
127
+ if (loaded) grammars[alias] = loaded;
128
+ }
129
+ engine.registerDynamicLanguage(grammars);
130
+ for (const alias of Object.keys(grammars)) registered.add(alias);
131
+ })();
132
+ await state.registration;
133
+ return registered.has(lang)
134
+ ? { kind: "ready" }
135
+ : {
136
+ kind: "error",
137
+ message: `${pkg} is installed, but napi's language registry is already initialized. Restart the agent to load newly installed grammars.`,
138
+ };
139
+ } catch (err) {
140
+ return { kind: "error", message: errorText(err) };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Install a `@ast-grep/lang-*` package into the pix cache prefix. This runs a
146
+ * network install — the caller must get user consent first. It never touches a
147
+ * global path or this package's own node_modules.
148
+ */
149
+ export async function installGrammar(pkg: string): Promise<{ ok: boolean; message?: string }> {
150
+ const dir = grammarCacheDir();
151
+ try {
152
+ await mkdir(dir, { recursive: true });
153
+ // A minimal package.json so npm installs into this prefix cleanly.
154
+ const pkgJson = join(dir, "package.json");
155
+ if (!existsSync(pkgJson)) {
156
+ const { writeFile } = await import("node:fs/promises");
157
+ await writeFile(pkgJson, JSON.stringify({ name: "pix-astgrep-grammars", private: true }));
158
+ }
159
+ const result = await runTool("npm", ["install", "--save", "--prefix", dir, pkg], {
160
+ cwd: dir,
161
+ timeoutMs: 120_000,
162
+ });
163
+ if (result.code !== 0) {
164
+ return {
165
+ ok: false,
166
+ message: result.timedOut
167
+ ? "grammar installation timed out"
168
+ : result.stderr || `npm exited ${result.code}`,
169
+ };
170
+ }
171
+ return { ok: true };
172
+ } catch (err) {
173
+ return { ok: false, message: errorText(err) };
174
+ }
175
+ }
176
+
177
+ export type ParseOutcome = { kind: "ok"; root: SgNode } | GrammarResult;
178
+
179
+ /** A one-time consent callback: return true to install `pkg`. */
180
+ export type ConsentFn = (pkg: string) => Promise<boolean>;
181
+
182
+ /**
183
+ * Parse one file's source for any language alias, installing a missing grammar
184
+ * with consent. The bundled six always parse. A non-bundled language is loaded
185
+ * from the pix cache. When absent, `consent(pkg)` decides whether to install it
186
+ * INTO the cache (once per language). A declined language is remembered so the
187
+ * caller is not asked again this session.
188
+ */
189
+ export async function parseWithGrammar(
190
+ lang: string,
191
+ source: string,
192
+ consent?: ConsentFn,
193
+ ): Promise<ParseOutcome> {
194
+ const engine = await loadEngine();
195
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
196
+
197
+ try {
198
+ if (isBundled(lang)) return { kind: "ok", root: parse(engine, lang, source) };
199
+ if (declined.has(lang)) {
200
+ return { kind: "declined", pkg: packageForLanguage(lang) ?? lang };
201
+ }
202
+
203
+ let ready = await ensureLanguage(lang);
204
+ if (ready.kind === "needs-install") {
205
+ if (!consent) return ready;
206
+ if (!(await consent(ready.pkg))) {
207
+ declined.add(lang);
208
+ return { kind: "declined", pkg: ready.pkg };
209
+ }
210
+ const installed = await installGrammar(ready.pkg);
211
+ if (!installed.ok) {
212
+ return { kind: "error", message: installed.message ?? `failed to install ${ready.pkg}` };
213
+ }
214
+ ready = await ensureLanguage(lang);
215
+ }
216
+ if (ready.kind !== "ready") return ready;
217
+ return { kind: "ok", root: parseDynamic(engine, lang, source) };
218
+ } catch (err) {
219
+ return { kind: "error", message: errorText(err) };
220
+ }
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * pix-runtime/ast — shared ast-grep parsing: the lazy napi loader, the file → language map,
3
+ * and consent-gated `@ast-grep/lang-*` grammar loading. UI-free; the install prompt lives in
4
+ * `@xynogen/pix-pretty/grammar-consent`.
5
+ */
6
+ export {
7
+ ALL_LANGUAGES,
8
+ type AnyLanguage,
9
+ isBundled,
10
+ LANGUAGES,
11
+ type Language,
12
+ languageForFile,
13
+ loadEngine,
14
+ parse,
15
+ parseDynamic,
16
+ type SgEdit,
17
+ type SgNode,
18
+ } from "./engine.ts";
19
+ export {
20
+ type ConsentFn,
21
+ ensureLanguage,
22
+ type GrammarResult,
23
+ grammarCacheDir,
24
+ installGrammar,
25
+ type ParseOutcome,
26
+ packageForLanguage,
27
+ parseWithGrammar,
28
+ } from "./grammars.ts";
@@ -22,6 +22,7 @@ import type { PixRuntime } from "./runtime.ts";
22
22
  import { type DeepPartial, deepMerge, type SectionHandle } from "./schema.ts";
23
23
  import { collapseSection } from "./sections/collapse.ts";
24
24
  import { compactionSection } from "./sections/compaction.ts";
25
+ import { editSection } from "./sections/edit.ts";
25
26
  import { gateSection } from "./sections/gate.ts";
26
27
  import { ioSection } from "./sections/io.ts";
27
28
  import { prettySection, type RenderSize } from "./sections/pretty.ts";
@@ -176,6 +177,14 @@ const SETTINGS: SettingRow<unknown>[] = [
176
177
  read: (v) => formatTokens(v.minimumTokens),
177
178
  patch: (value) => ({ minimumTokens: parseTokens(value) }),
178
179
  }),
180
+ row({
181
+ section: "Edit",
182
+ label: "syntax guard",
183
+ handle: editSection,
184
+ values: ["off", "on"],
185
+ read: (v) => (v.rejectNewSyntaxErrors ? "on" : "off"),
186
+ patch: (value) => ({ rejectNewSyntaxErrors: value === "on" }),
187
+ }),
179
188
  row({
180
189
  section: "Gate",
181
190
  label: "Guardrails",
@@ -12,6 +12,10 @@ export interface CompactionConfig {
12
12
  triggerPercent: number;
13
13
  /** Absolute floor for a percentage-based trigger. */
14
14
  minimumTokens: number;
15
+ /** Over threshold: newest tool results kept as is; older bash/grep/find/ls/read output is masked. */
16
+ maskKeep: number;
17
+ /** Skip a masking pass that would free fewer tokens than this (each pass breaks the prompt cache). */
18
+ maskMinTokens: number;
15
19
  }
16
20
 
17
21
  const MINIMUM_TOKEN_FLOOR = 25_000;
@@ -20,6 +24,8 @@ const MINIMUM_TOKEN_DEFAULT = 100_000;
20
24
  const DEFAULTS: Readonly<CompactionConfig> = {
21
25
  triggerPercent: 60,
22
26
  minimumTokens: MINIMUM_TOKEN_DEFAULT,
27
+ maskKeep: 10,
28
+ maskMinTokens: 5_000,
23
29
  };
24
30
 
25
31
  /** Clamp to [0, 100]; non-numbers fall back. */
@@ -35,6 +41,12 @@ function minimumTokensOr(v: unknown, fallback: number): number {
35
41
  return Math.max(MINIMUM_TOKEN_FLOOR, Math.round(v));
36
42
  }
37
43
 
44
+ /** Non-negative integer; non-numbers fall back. */
45
+ function countOr(v: unknown, fallback: number): number {
46
+ if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
47
+ return Math.max(0, Math.round(v));
48
+ }
49
+
38
50
  function parseTokenString(label: string): number | undefined {
39
51
  const raw = label.trim();
40
52
  if (!raw) return undefined;
@@ -60,6 +72,8 @@ export const compactionSection = defineSection<"compaction", CompactionConfig>({
60
72
  return {
61
73
  triggerPercent: pctOr(raw.triggerPercent, DEFAULTS.triggerPercent),
62
74
  minimumTokens: minimumTokensOr(coercedMin, DEFAULTS.minimumTokens),
75
+ maskKeep: countOr(raw.maskKeep, DEFAULTS.maskKeep),
76
+ maskMinTokens: countOr(raw.maskMinTokens, DEFAULTS.maskMinTokens),
63
77
  };
64
78
  },
65
79
  });
@@ -0,0 +1,21 @@
1
+ import { boolOr, defineSection, isObj } from "../schema.ts";
2
+
3
+ export interface EditConfig {
4
+ /** Restore the file and fail the edit when LSP reports new syntax errors after it. */
5
+ rejectNewSyntaxErrors: boolean;
6
+ }
7
+
8
+ const DEFAULTS: Readonly<EditConfig> = {
9
+ rejectNewSyntaxErrors: false,
10
+ };
11
+
12
+ export const editSection = defineSection<"edit", EditConfig>({
13
+ key: "edit",
14
+ defaults: DEFAULTS,
15
+ parse(raw) {
16
+ if (!isObj(raw)) return { ...DEFAULTS };
17
+ return {
18
+ rejectNewSyntaxErrors: boolOr(raw.rejectNewSyntaxErrors, DEFAULTS.rejectNewSyntaxErrors),
19
+ };
20
+ },
21
+ });
@@ -1,5 +1,6 @@
1
1
  export { type CollapseConfig, collapseSection } from "./collapse.ts";
2
2
  export { type CompactionConfig, compactionSection } from "./compaction.ts";
3
+ export { type EditConfig, editSection } from "./edit.ts";
3
4
  export {
4
5
  type GateConfig,
5
6
  type GateRuleConfig,
@@ -25,6 +26,7 @@ export {
25
26
 
26
27
  import { collapseSection } from "./collapse.ts";
27
28
  import { compactionSection } from "./compaction.ts";
29
+ import { editSection } from "./edit.ts";
28
30
  import { gateSection } from "./gate.ts";
29
31
  import { ioSection } from "./io.ts";
30
32
  import { optimizerSection } from "./optimizer.ts";
@@ -50,6 +52,7 @@ export const builtinSections = [
50
52
  optimizerSection,
51
53
  gateSection,
52
54
  compactionSection,
55
+ editSection,
53
56
  fetchSection,
54
57
  searchSection,
55
58
  voiceSection,