@turri-dev/cli 0.1.8-next.bfcb24e → 0.1.8

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
@@ -152,7 +152,7 @@ It asks you about the problem, the stack and the product, writes \*\*\*plain, an
152
152
 
153
153
  - the **panel** above the input shows every build - its stage, what its agents are doing, its model and cost - and the queue behind it;
154
154
  - ask to try it at any time - "let me try it" - and **`release`** puts what is built so far in `dist/<module>` and **`activate`** starts it, installing what it needs - nothing to approve;
155
- - the **side panel** (`ctrl+h`) takes the screen and opens on **regen** - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`;
155
+ - the **side panel** (`ctrl+h`) takes the screen and opens on **regen** - \*\*\*plain, every item marked planned, working, regenerated, validating, conform or needs you - and `p` turns it to the **pulses** (`/pulses`) - every pulse, running, queued and finished, and what each regenerates; what its agents did is in each pulse's log in `.turri/jobs/`, kept with `--debug` (`debug: true`);
156
156
  - **`/halt`** halts a pulse, **`/lineage`** shows every generation of a module, and the **`drift`** tool says what is left to regenerate;
157
157
  - **`/btw <question>`** asks turri something on the side - answered at once, even in the middle of a skill's interview (typed as the answer, under "Something else…"), and left out of the conversation;
158
158
  - **`/turri-config`** changes any setting of the project's `config.turri.yaml` - the models, review, conformance tests, how many pulses at once - and says when it takes effect: now, at your next message, or at the next pulse. No restart: `/turri-config review false`, or `/turri-config` alone to pick from the list;
package/dist/cli.js CHANGED
@@ -97,6 +97,7 @@ const USAGE = [
97
97
  "",
98
98
  ` ${"options".padEnd(12)} --harness-model <m> the model you chat with --coding-model <m> the model renders run on`,
99
99
  ` ${"".padEnd(12)} --stack <name> build with config.turri.<name>.yaml over config.turri.yaml - here and in every command`,
100
+ ` ${"".padEnd(12)} --debug keep what turri writes for debugging it - agents' logs and sessions - as debug: true does`,
100
101
  ` ${"".padEnd(12)} --use-theme <t> another theme than turri-dark - and every option of pi's own`,
101
102
  "",
102
103
  ...Object.entries(COMMANDS).map(([name, { blurb }]) => ` ${name.padEnd(12)} ${blurb}`),
@@ -110,7 +111,7 @@ const USAGE = [
110
111
  ].join("\n");
111
112
  dropNoise();
112
113
  // --stack <name>, for any command and for the conversation: the stack everything is built with (stacks.ts), taken out
113
- // before the rest is read - a command never sees it.
114
+ // before the rest is read - a command never sees it. --debug too: TURRI_DEBUG for this run, and the pi it starts.
114
115
  const argv = await withoutStack(process.argv.slice(2));
115
116
  const [command, ...rest] = argv;
116
117
  async function withoutStack(args) {
@@ -122,6 +123,8 @@ async function withoutStack(args) {
122
123
  stack = args[++at] ?? "";
123
124
  else if (arg.startsWith("--stack="))
124
125
  stack = arg.slice("--stack=".length);
126
+ else if (arg === "--debug")
127
+ process.env.TURRI_DEBUG = "1";
125
128
  else
126
129
  kept.push(arg);
127
130
  }
@@ -76,6 +76,8 @@ export function settings(rules = []) {
76
76
  { key: "agent-env.files", about: "dotenv files whose variables the agents' commands get", kind: "list", unset: "none", effect: "next-pulse", projectOnly: true },
77
77
  { key: "agent-env.vars", about: "variables from turri's environment the agents' commands get, by name", kind: "list", unset: "none", effect: "next-pulse", projectOnly: true },
78
78
  { key: "toolchains", about: "folders of this machine an agent's commands may read beside the usual ones", kind: "list", unset: "none", effect: "next-pulse", projectOnly: true },
79
+ { key: "debug", about: "whether turri keeps what it writes for debugging it - agents' logs and sessions, pulse logs, outputs", kind: "boolean", unset: "false", effect: "next-pulse", projectOnly: true },
80
+ { key: "cache-keep", about: "megabytes of package caches the project keeps; 0 keeps none", kind: "number", options: ["0", "1024", "4096", "16384"], min: 0, unset: "4096", effect: "next-start", projectOnly: true },
79
81
  { key: "ledger-keep", about: "megabytes of the ledger - what turri did, as facts - the project keeps; 0 keeps none", kind: "number", options: ["0", "50", "100", "500"], min: 0, unset: "100", effect: "next-start", projectOnly: true },
80
82
  ...roles,
81
83
  ];
package/dist/config.d.ts CHANGED
@@ -265,6 +265,18 @@ export interface ProjectConfig {
265
265
  * are pruned when a run first writes. 0 keeps none, and writes none. The project's alone. Unset: 100.
266
266
  */
267
267
  ledgerKeep?: number;
268
+ /**
269
+ * Whether turri keeps what it writes for a person debugging it: every agent's whole log and saved session, each
270
+ * pulse's log, the testing scripts' and services' outputs, the agents' temporary files, and the dependencies installed
271
+ * in the modules a chain has left behind. Off, only what turri reads to work is kept. The project's alone. Unset: false.
272
+ */
273
+ debug?: boolean;
274
+ /**
275
+ * Megabytes of package caches - plain_modules/.turri/cache, and the caches in the agents' home folders - the project
276
+ * keeps: past it, the least recently written are removed when a run first starts an agent's command. 0 keeps none.
277
+ * The project's alone. Unset: 4096.
278
+ */
279
+ cacheKeep?: number;
268
280
  /** Each agent role's own model, thinking and turn limit; `default` for every role not named. */
269
281
  models?: Partial<Record<AgentRole | "default", RoleChoice>>;
270
282
  /** Seconds each turn of an agent a render starts has, for every role without its own. Unset: 120. 0: none. */
@@ -294,6 +306,8 @@ export declare const CONFIG_KEYS: {
294
306
  readonly agentEnv: "agent-env";
295
307
  readonly toolchains: "toolchains";
296
308
  readonly ledgerKeep: "ledger-keep";
309
+ readonly debug: "debug";
310
+ readonly cacheKeep: "cache-keep";
297
311
  readonly models: "models";
298
312
  readonly turnLimit: "turn-limit";
299
313
  };
@@ -316,6 +330,13 @@ export declare function readProjectConfig(root: string, stack?: string): Project
316
330
  * edited. Never set in the conversation.
317
331
  */
318
332
  type RunOverrides = Partial<Pick<ProjectConfig, "harnessModel" | "codingModel" | "judgeModel" | "conformanceTests" | "review" | "mutationTesting">>;
333
+ /** Megabytes of package caches a project keeps when `cache-keep:` does not say. */
334
+ export declare const DEFAULT_CACHE_KEEP_MB = 4096;
335
+ /**
336
+ * Whether turri keeps what it writes for a person debugging it, in this project: TURRI_DEBUG - `--debug` sets it, and
337
+ * `0` or `false` turns it off - else the project's `debug:`. Off by default.
338
+ */
339
+ export declare function debugMode(project: string): boolean;
319
340
  /** Read a command's options as the config's keys for the rest of this process. */
320
341
  export declare function overrideConfig(overrides: RunOverrides): void;
321
342
  /** `--conformance-tests <value>`: the config's own values - all (or true), chosen, module, false. Undefined for anything else. */
package/dist/config.js CHANGED
@@ -380,6 +380,8 @@ export const CONFIG_KEYS = {
380
380
  agentEnv: "agent-env",
381
381
  toolchains: "toolchains",
382
382
  ledgerKeep: "ledger-keep",
383
+ debug: "debug",
384
+ cacheKeep: "cache-keep",
383
385
  models: "models",
384
386
  turnLimit: "turn-limit",
385
387
  };
@@ -432,7 +434,7 @@ function readStackedConfig(root, stack) {
432
434
  // The project's alone: one conversation, one module built when none is named.
433
435
  const merged = { ...project };
434
436
  for (const key of Object.keys(CONFIG_KEYS)) {
435
- if (key === "module" || key === "harnessModel" || key === "parallelJobs" || key === "rulesOff" || key === "compactAt" || key === "ledgerKeep" || key === "toolchains" || key === "agentEnv" || own[key] === undefined)
437
+ if (key === "module" || key === "harnessModel" || key === "parallelJobs" || key === "rulesOff" || key === "compactAt" || key === "ledgerKeep" || key === "debug" || key === "cacheKeep" || key === "toolchains" || key === "agentEnv" || own[key] === undefined)
436
438
  continue;
437
439
  merged[key] = own[key];
438
440
  }
@@ -444,6 +446,23 @@ function readStackedConfig(root, stack) {
444
446
  return withoutUndefined(merged);
445
447
  }
446
448
  let runOverrides = {};
449
+ /** Megabytes of package caches a project keeps when `cache-keep:` does not say. */
450
+ export const DEFAULT_CACHE_KEEP_MB = 4096;
451
+ /**
452
+ * Whether turri keeps what it writes for a person debugging it, in this project: TURRI_DEBUG - `--debug` sets it, and
453
+ * `0` or `false` turns it off - else the project's `debug:`. Off by default.
454
+ */
455
+ export function debugMode(project) {
456
+ const said = process.env.TURRI_DEBUG?.trim().toLowerCase();
457
+ if (said !== undefined && said !== "")
458
+ return said !== "0" && said !== "false";
459
+ try {
460
+ return readProjectConfig(project).debug === true;
461
+ }
462
+ catch {
463
+ return false;
464
+ }
465
+ }
447
466
  /** Read a command's options as the config's keys for the rest of this process. */
448
467
  export function overrideConfig(overrides) {
449
468
  runOverrides = { ...overrides };
@@ -546,6 +565,10 @@ function readConfigFile(path) {
546
565
  ledgerKeep: typeof raw[CONFIG_KEYS.ledgerKeep] === "number" && Number.isInteger(raw[CONFIG_KEYS.ledgerKeep]) && raw[CONFIG_KEYS.ledgerKeep] >= 0
547
566
  ? raw[CONFIG_KEYS.ledgerKeep]
548
567
  : undefined,
568
+ debug: typeof raw[CONFIG_KEYS.debug] === "boolean" ? raw[CONFIG_KEYS.debug] : undefined,
569
+ cacheKeep: typeof raw[CONFIG_KEYS.cacheKeep] === "number" && Number.isInteger(raw[CONFIG_KEYS.cacheKeep]) && raw[CONFIG_KEYS.cacheKeep] >= 0
570
+ ? raw[CONFIG_KEYS.cacheKeep]
571
+ : undefined,
549
572
  models: modelsSetting(raw[CONFIG_KEYS.models]),
550
573
  turnLimit: turnLimitSetting(raw[CONFIG_KEYS.turnLimit]),
551
574
  };
package/dist/genome.js CHANGED
@@ -136,6 +136,8 @@ export async function runSkill(opts) {
136
136
  rules: true,
137
137
  system: toolchainsBlock(),
138
138
  resumeFrom: resumed?.sessionFile,
139
+ // Its run picks it back up (resume) and the side questions read it (btw): saved whatever the mode.
140
+ saveSession: true,
139
141
  });
140
142
  const manifest = resumed ?? {
141
143
  skill: skill.name,
package/dist/host.d.ts CHANGED
@@ -8,7 +8,7 @@ export { createTurriExtension, type TurriHost } from "./extension.ts";
8
8
  export type { AskUserIO } from "./ask.ts";
9
9
  /** pi itself, as turri's extension is built against it: a host makes its session with this one, not another copy. */
10
10
  export * as pi from "@earendil-works/pi-coding-agent";
11
- export { configForSpec, hasProjectConfig, modelSpec, projectModels, readProjectConfig, stacksOf, type ProjectConfig } from "./config.ts";
11
+ export { configForSpec, debugMode, hasProjectConfig, modelSpec, projectModels, readProjectConfig, stacksOf, type ProjectConfig } from "./config.ts";
12
12
  export { MODULES_DIR, projectDirOf, resolveTemplateDirs } from "./regenerate/paths.ts";
13
13
  export { BEDROCK_DEFAULT, BEDROCK_REGIONS, bedrockModels, initConfig, VENDORS, type BedrockChoice, type Vendor } from "./init-config.ts";
14
14
  export { checkPlainFile, type CheckResult } from "./plain/check.ts";
package/dist/host.js CHANGED
@@ -9,7 +9,7 @@ export { createTurriExtension } from "./extension.js";
9
9
  /** pi itself, as turri's extension is built against it: a host makes its session with this one, not another copy. */
10
10
  export * as pi from "@earendil-works/pi-coding-agent";
11
11
  // The project and its settings.
12
- export { configForSpec, hasProjectConfig, modelSpec, projectModels, readProjectConfig, stacksOf } from "./config.js";
12
+ export { configForSpec, debugMode, hasProjectConfig, modelSpec, projectModels, readProjectConfig, stacksOf } from "./config.js";
13
13
  export { MODULES_DIR, projectDirOf, resolveTemplateDirs } from "./regenerate/paths.js";
14
14
  // A project's first config.turri.yaml, as `turri init-config` writes it - the vendor whose models it takes chosen by the host.
15
15
  export { BEDROCK_DEFAULT, BEDROCK_REGIONS, bedrockModels, initConfig, VENDORS } from "./init-config.js";
@@ -67,6 +67,8 @@ export declare const DEFAULTS: {
67
67
  readonly maxSpecsPerPulse: 3;
68
68
  readonly maxLinesPerPulse: 120;
69
69
  readonly ledgerKeep: 100;
70
+ readonly debug: false;
71
+ readonly cacheKeep: 4096;
70
72
  };
71
73
  /** How many pulses run at once without a setting: half this machine's cores, one to four - as regenerate/jobs.ts has it. */
72
74
  export declare function defaultParallel(cores?: number): number;
@@ -119,6 +119,8 @@ export const DEFAULTS = {
119
119
  maxSpecsPerPulse: 3,
120
120
  maxLinesPerPulse: 120,
121
121
  ledgerKeep: 100,
122
+ debug: false,
123
+ cacheKeep: 4096,
122
124
  };
123
125
  /** How many pulses run at once without a setting: half this machine's cores, one to four - as regenerate/jobs.ts has it. */
124
126
  export function defaultParallel(cores = availableParallelism()) {
@@ -188,6 +190,14 @@ export function quickStartConfig(vendor = "other", bedrock) {
188
190
  "# the oldest runs' are pruned. 0 keeps none.",
189
191
  `ledger-keep: ${DEFAULTS.ledgerKeep}`,
190
192
  "",
193
+ "# Megabytes of package caches - npm's, pip's, Maven's... in plain_modules/.turri/cache - the project keeps; past it,",
194
+ "# the least recently written are removed. 0 keeps none.",
195
+ `cache-keep: ${DEFAULTS.cacheKeep}`,
196
+ "",
197
+ "# Whether turri keeps what it writes for debugging it: every agent's whole log and session, each pulse's log, the",
198
+ "# test runs' outputs, the agents' temporary files. Off, only what turri reads to work is kept. TURRI_DEBUG=1 for one run.",
199
+ `debug: ${DEFAULTS.debug}`,
200
+ "",
191
201
  "# The testing scripts, by path from the project root - written here by the skill that writes them. One not named is",
192
202
  "# looked for at the root, then in test_scripts/. Each runs the static checks of what it tests - the formatter, the",
193
203
  "# linter, the type checker - with the tests, exiting 80 when the tests pass and only the checks do not. The prepare",
@@ -21,7 +21,7 @@ export function ledgerTool(options) {
21
21
  "how each ended, how long, what it cost. Starts nothing and calls no model.",
22
22
  promptSnippet: "ledger: what turri did - a pulse as facts, the last runs, or events counted by kind - this session and earlier ones",
23
23
  promptGuidelines: [
24
- "Use ledger for what turri recorded of the work - a pulse's rounds, test runs, verdicts, healers and commits, this session's or an earlier one's; how often something happened; what earlier runs did and cost - rather than reading .turri/ledger with the shell. pulses is what is running and planned now; a pulse's whole transcript is its log, which ledger names.",
24
+ "Use ledger for what turri recorded of the work - a pulse's rounds, test runs, verdicts, healers and commits, this session's or an earlier one's; how often something happened; what earlier runs did and cost - rather than reading .turri/ledger with the shell. pulses is what is running and planned now; a pulse's whole transcript is its log, which ledger names when it is kept (debug mode).",
25
25
  ],
26
26
  parameters: Type.Object({
27
27
  pulse: Type.Optional(Type.String({ description: "A pulse's id, or the start of it: that pulse, as the ledger has it" })),
package/dist/pi.d.ts CHANGED
@@ -184,6 +184,8 @@ export declare function createExecutor(opts: {
184
184
  rules?: boolean;
185
185
  /** Continue the session saved in this file, instead of starting a new one. */
186
186
  resumeFrom?: string;
187
+ /** Save its session to disk, debug mode or not: it is picked back up later (a skill's run, resumed). */
188
+ saveSession?: boolean;
187
189
  /** Added to its system prompt: its sandbox, above all - what it can reach (see confine.ts sandboxNote). */
188
190
  system?: string;
189
191
  /**
package/dist/pi.js CHANGED
@@ -2,7 +2,7 @@ import { basename, dirname, join, resolve, sep } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
4
4
  import { loadRules, renderRulesBlock } from "./rules.js";
5
- import { inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, roleTurnLimitMs, splitModelSpec } from "./config.js";
5
+ import { debugMode, inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, roleTurnLimitMs, splitModelSpec } from "./config.js";
6
6
  import { maskToolResults } from "./regenerate/masking.js";
7
7
  import { withListArguments } from "./regenerate/list-arguments.js";
8
8
  import { checkAgainstScript, ENVIRONMENT_ERROR_TOOL, EnvironmentError, environmentErrorTool, sandboxGapOf } from "./regenerate/environment-error.js";
@@ -753,11 +753,14 @@ export function guardRepeats(tools, session, budget) {
753
753
  * start that has forgotten what it just wrote.
754
754
  */
755
755
  export async function createExecutor(opts) {
756
- // Saved to disk, in the project: an agent's session is the record of what it did, and the
757
- // only way to pick a long run back up after the process that ran it is gone.
756
+ // Saved to disk, in the project, for an agent picked back up after the process that ran it is gone - a skill's run -
757
+ // and in debug mode, as the record of what each did. Nothing else reads one back: the rest are kept in memory.
758
+ const project = projectRootOf(opts.cwd);
758
759
  const sessionManager = opts.resumeFrom !== undefined
759
760
  ? SessionManager.open(opts.resumeFrom)
760
- : SessionManager.create(opts.cwd, sessionsDir(projectRootOf(opts.cwd)));
761
+ : opts.saveSession === true || debugMode(project)
762
+ ? SessionManager.create(opts.cwd, sessionsDir(project))
763
+ : SessionManager.inMemory(opts.cwd);
761
764
  // An agent that writes and runs code - given the harness's run its reports are checked against - can say the machine
762
765
  // lacks what the work needs: its turn ends, and the pulse stops with it. Observers, explorers and judges cannot.
763
766
  const hasShell = opts.customTools.some((tool) => tool.name === "bash") && opts.checkEnvironment !== undefined;
@@ -28,6 +28,13 @@ export declare function agentEnvironment(moduleDir: string, options?: {
28
28
  hostEnv?: NodeJS.ProcessEnv;
29
29
  home?: string;
30
30
  }): AgentEnvironment;
31
+ /**
32
+ * Hold the package caches of a scratch area to `keepMb` megabytes: past it, the caches least recently written go whole -
33
+ * a package manager's folder in `cache`, or a tool's in an agent's home - until it is under. A cache is as recent as the
34
+ * newest thing written anywhere in it: a package manager writes deep inside its folder, never beside it. What goes is
35
+ * downloaded again when it is wanted. Never a reason for the work to fail.
36
+ */
37
+ export declare function tidyCaches(scratch: string, keepMb: number): void;
31
38
  /**
32
39
  * On macOS, what a program asks the system for as the per-user temporary folder - not TMPDIR - is that folder with this
33
40
  * suffix added: a folder of the module's own inside it. The JVM keeps its attach sockets there (`.java_pid<pid>`), which
@@ -1,8 +1,8 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
- import { readProjectConfig, stackOf } from "../config.js";
5
+ import { DEFAULT_CACHE_KEEP_MB, readProjectConfig, stackOf } from "../config.js";
6
6
  import { registerSecrets } from "./masking.js";
7
7
  import { projectRootOf, modulesScratchDir } from "./paths.js";
8
8
  /**
@@ -12,9 +12,9 @@ import { projectRootOf, modulesScratchDir } from "./paths.js";
12
12
  * agent, and an agent's command - or the code it runs - could read any of them. So only an allow-list passes: the path,
13
13
  * the locale, the terminal, the user, and the variables that locate the host's toolchains. Then turri's own: a HOME of
14
14
  * the module's own, kept between pulses, so a tool's settings and an agent's dotfiles never land in the person's; a
15
- * TMPDIR; the package caches in the project, so a dependency downloads once; and git stopped at the module, so a
16
- * project that is itself a repository is not found from the code. Last, what the project passes on purpose -
17
- * `agent-env:` in its config - which never replaces turri's own.
15
+ * TMPDIR; the package caches in the project, so a dependency downloads once - held to `cache-keep:`; and git stopped
16
+ * at the module, so a project that is itself a repository is not found from the code. Last, what the project passes on
17
+ * purpose - `agent-env:` in its config - which never replaces turri's own.
18
18
  */
19
19
  /** Passed from the host as they are. */
20
20
  const ALLOWED = ["PATH", "LANG", "TERM", "TZ", "USER", "LOGNAME", "COLORTERM"];
@@ -62,11 +62,18 @@ export function cacheEnv(cache) {
62
62
  GRADLE_USER_HOME: join(cache, "gradle"),
63
63
  CARGO_HOME: join(cache, "cargo"),
64
64
  npm_config_cache: join(cache, "npm"),
65
+ // node-gyp's headers, which it keeps in the home folder - a copy each module's - once for the project.
66
+ npm_config_devdir: join(cache, "node-gyp"),
65
67
  YARN_CACHE_FOLDER: join(cache, "yarn"),
66
68
  PIP_CACHE_DIR: join(cache, "pip"),
67
69
  UV_CACHE_DIR: join(cache, "uv"),
68
70
  GOCACHE: join(cache, "go-build"),
69
71
  GOMODCACHE: join(cache, "go-mod"),
72
+ // The browsers the end-to-end test tools download - hundreds of megabytes each, kept in the home folder: once for
73
+ // the project, not once a module.
74
+ PLAYWRIGHT_BROWSERS_PATH: join(cache, "ms-playwright"),
75
+ PUPPETEER_CACHE_DIR: join(cache, "puppeteer"),
76
+ CYPRESS_CACHE_FOLDER: join(cache, "Cypress"),
70
77
  };
71
78
  }
72
79
  /** The git identity an agent's commits in a scratch repository get - never the person's. */
@@ -101,9 +108,123 @@ export function agentEnvironment(moduleDir, options = {}) {
101
108
  Object.assign(env, turrisOwn(module, name));
102
109
  return { env, passed: Object.keys(values).sort(), values, missing };
103
110
  }
111
+ /** The scratch areas whose caches this process has held to `cache-keep:`: once a run, before its first command. */
112
+ const tidied = new Set();
113
+ /** Where in an agent's home folder its tools keep caches of their own. */
114
+ const HOME_CACHES = [join("Library", "Caches"), ".cache"];
115
+ /**
116
+ * Hold the package caches of a scratch area to `keepMb` megabytes: past it, the caches least recently written go whole -
117
+ * a package manager's folder in `cache`, or a tool's in an agent's home - until it is under. A cache is as recent as the
118
+ * newest thing written anywhere in it: a package manager writes deep inside its folder, never beside it. What goes is
119
+ * downloaded again when it is wanted. Never a reason for the work to fail.
120
+ */
121
+ export function tidyCaches(scratch, keepMb) {
122
+ const caches = [];
123
+ const folders = [join(scratch, "cache")];
124
+ for (const home of entries(join(scratch, "home")))
125
+ for (const inHome of HOME_CACHES)
126
+ folders.push(join(scratch, "home", home, inHome));
127
+ for (const folder of folders) {
128
+ for (const name of entries(folder)) {
129
+ const path = join(folder, name);
130
+ try {
131
+ caches.push({ path, ...measure(path) });
132
+ }
133
+ catch {
134
+ /* gone meanwhile */
135
+ }
136
+ }
137
+ }
138
+ let total = caches.reduce((sum, cache) => sum + cache.bytes, 0);
139
+ for (const cache of caches.sort((a, b) => a.written - b.written)) {
140
+ if (total <= keepMb * 1024 * 1024)
141
+ break;
142
+ total -= cache.bytes;
143
+ // What could not be removed still counts: a cache half gone is not the room it took.
144
+ if (existsSync(cache.path))
145
+ total += remove(cache.path);
146
+ }
147
+ }
148
+ function entries(dir) {
149
+ try {
150
+ return readdirSync(dir);
151
+ }
152
+ catch {
153
+ return [];
154
+ }
155
+ }
156
+ /** A file's or folder's bytes and when anything in it was last written, its links not followed. */
157
+ function measure(path) {
158
+ const stat = lstatSync(path);
159
+ if (!stat.isDirectory())
160
+ return { bytes: stat.size, written: stat.mtimeMs };
161
+ let bytes = 0;
162
+ let written = stat.mtimeMs;
163
+ for (const name of entries(path)) {
164
+ try {
165
+ const inside = measure(join(path, name));
166
+ bytes += inside.bytes;
167
+ written = Math.max(written, inside.written);
168
+ }
169
+ catch {
170
+ /* gone meanwhile */
171
+ }
172
+ }
173
+ return { bytes, written };
174
+ }
175
+ /**
176
+ * Remove a cache whole, and say how many of its bytes are left. Some tools make what they cache read-only - Go's module
177
+ * cache, so it is never edited in place - and a folder that cannot be written cannot be emptied: each is made writable
178
+ * first, as `go clean -modcache` does.
179
+ */
180
+ function remove(path) {
181
+ try {
182
+ rmSync(path, { recursive: true, force: true });
183
+ }
184
+ catch {
185
+ writable(path);
186
+ try {
187
+ rmSync(path, { recursive: true, force: true });
188
+ }
189
+ catch {
190
+ /* what cannot be removed stays */
191
+ }
192
+ }
193
+ try {
194
+ return existsSync(path) ? measure(path).bytes : 0;
195
+ }
196
+ catch {
197
+ return 0;
198
+ }
199
+ }
200
+ /** Every folder under `path` made writable by its owner, links not followed. */
201
+ function writable(path) {
202
+ try {
203
+ const stat = lstatSync(path);
204
+ if (!stat.isDirectory())
205
+ return;
206
+ chmodSync(path, stat.mode | 0o700);
207
+ }
208
+ catch {
209
+ return;
210
+ }
211
+ for (const name of entries(path))
212
+ writable(join(path, name));
213
+ }
104
214
  /** HOME, TMPDIR, the caches and git's ceiling: what no setting of the project's replaces. */
105
215
  function turrisOwn(module, name) {
106
216
  const scratch = modulesScratchDir(module);
217
+ if (!tidied.has(scratch)) {
218
+ tidied.add(scratch);
219
+ let keep = DEFAULT_CACHE_KEEP_MB;
220
+ try {
221
+ keep = readProjectConfig(projectRootOf(module)).cacheKeep ?? DEFAULT_CACHE_KEEP_MB;
222
+ }
223
+ catch {
224
+ /* a config that does not parse is said elsewhere: the default holds */
225
+ }
226
+ tidyCaches(scratch, keep);
227
+ }
107
228
  const fakeHome = join(scratch, "home", name);
108
229
  mkdirSync(fakeHome, { recursive: true });
109
230
  const gitconfig = join(fakeHome, ".gitconfig");
@@ -79,6 +79,11 @@ export declare function stepsOf(input: PlanInput, draft: DraftJob[]): Array<Draf
79
79
  step: SpecStep;
80
80
  }>;
81
81
  export declare const MAX_SPECS_PER_JOB = 3;
82
+ /** The most a pulse's title is - in words, and in characters - however it was made: a host shows it as its heading. */
83
+ export declare const TITLE_WORDS = 10;
84
+ export declare const TITLE_CHARS = 70;
85
+ /** A title held to `TITLE_WORDS` and `TITLE_CHARS` - or less, for room beside it - cut at a word, with an ellipsis. */
86
+ export declare function shortTitle(text: string, chars?: number, words?: number): string;
82
87
  export declare const MAX_LINES_PER_JOB = 120;
83
88
  /**
84
89
  * A functional spec to build, for the grouping: the number the agent answers with, its text, and whether it is built
@@ -71,6 +71,19 @@ export function stepsOf(input, draft) {
71
71
  });
72
72
  }
73
73
  export const MAX_SPECS_PER_JOB = 3;
74
+ /** The most a pulse's title is - in words, and in characters - however it was made: a host shows it as its heading. */
75
+ export const TITLE_WORDS = 10;
76
+ export const TITLE_CHARS = 70;
77
+ /** A title held to `TITLE_WORDS` and `TITLE_CHARS` - or less, for room beside it - cut at a word, with an ellipsis. */
78
+ export function shortTitle(text, chars = TITLE_CHARS, words = TITLE_WORDS) {
79
+ const all = text.trim().split(/\s+/).filter((word) => word !== "");
80
+ let cut = all.slice(0, words).join(" ");
81
+ if (all.length <= words && cut.length <= chars)
82
+ return cut;
83
+ if (cut.length > chars - 1)
84
+ cut = cut.slice(0, chars - 1).replace(/\s+\S*$/, "") || cut.slice(0, chars - 1);
85
+ return `${cut.replace(/[\s,.;:-]+$/, "")}…`;
86
+ }
74
87
  export const MAX_LINES_PER_JOB = 120;
75
88
  /** How often an answer that does not hold goes back to its agent before the plan falls back. */
76
89
  const RETRIES = 2;
@@ -124,6 +137,9 @@ export function groupProblems(specs, answer, maxSpecs = MAX_SPECS_PER_JOB) {
124
137
  }
125
138
  if (group.title.trim() === "")
126
139
  problems.push("Every group needs a title.");
140
+ else if (group.title.trim().split(/\s+/).length > TITLE_WORDS || group.title.trim().length > TITLE_CHARS) {
141
+ problems.push(`"${group.title}" is too long for a title: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.`);
142
+ }
127
143
  }
128
144
  return problems;
129
145
  }
@@ -227,7 +243,8 @@ function draftOf(chunks, net) {
227
243
  })
228
244
  .filter((entry) => entry.changes.length > 0)
229
245
  .map((entry) => ({
230
- title: entry.chunk.title,
246
+ // However the title was made - an agent's, a spec's first line, several joined - it is held to the limit.
247
+ title: shortTitle(entry.chunk.title),
231
248
  reason: entry.chunk.built !== undefined
232
249
  ? "what is built already, changed"
233
250
  : entry.chunk.specs.length > 0 && entry.chunk.specs.every(regeneratesAgain)
@@ -238,7 +255,7 @@ function draftOf(chunks, net) {
238
255
  }
239
256
  /** A spec's first line, as a group's title when no agent gave one. */
240
257
  function titleOf(change) {
241
- return textOf(change).split("\n")[0].replace(/^-\s*/, "").slice(0, 80);
258
+ return shortTitle(textOf(change).split("\n")[0].replace(/^-\s*/, ""));
242
259
  }
243
260
  /**
244
261
  * Cut the queue's work - from `input.base` to `input.current` - into jobs, as the three steps say. Returns the draft
@@ -470,7 +487,10 @@ async function cut(input, agents, notes) {
470
487
  }
471
488
  runs.push(run);
472
489
  say(`"${group.title}" is past ${maxLines} lines with its definitions and reqs - split into ${runs.length} pulses`);
473
- runs.forEach((specs, index) => split.push({ specs, title: specs.length === 1 ? titleOf(bySpec.get(specs[0])) : `${group.title} (${index + 1} of ${runs.length})` }));
490
+ runs.forEach((specs, index) => split.push({
491
+ specs,
492
+ title: specs.length === 1 ? titleOf(bySpec.get(specs[0])) : `${shortTitle(group.title, TITLE_CHARS - 9, TITLE_WORDS - 3)} (${index + 1} of ${runs.length})`,
493
+ }));
474
494
  });
475
495
  const before = placement;
476
496
  // A req placed with a group that split goes with the first functional spec of it.
@@ -520,7 +540,8 @@ export function groupPrompt(specs, steering, maxSpecs = MAX_SPECS_PER_JOB) {
520
540
  "Never group specs only because they are of the same kind.",
521
541
  "",
522
542
  "Only functional specs next to each other in the same module go together, and they stay in the order given: the",
523
- "order is what each is built on. Give each group a short title that says what it builds.",
543
+ `order is what each is built on. Give each group a title that says what it builds - at most ${TITLE_WORDS} words and ${TITLE_CHARS}`,
544
+ "characters: it heads the group's pulse where the person watches the work, and a longer one is cut short there.",
524
545
  "",
525
546
  "The specification is split into modules - a ***plain matter, not how the code is laid out. Each numbers its",
526
547
  "functional specs from 1: each below is given with its module and its id there, and a number of its own to answer with.",
@@ -600,9 +621,9 @@ export function llmChunkAgents(options) {
600
621
  tool: (take) => defineTool({
601
622
  name: "submit_groups",
602
623
  label: "Submit the groups",
603
- description: "The new functional specs in groups, in order: each group's spec numbers and a short title.",
624
+ description: `The new functional specs in groups, in order: each group's spec numbers and a title of at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.`,
604
625
  parameters: Type.Object({
605
- groups: Type.Array(Type.Object({ specs: Type.Array(Type.Number()), title: Type.String() })),
626
+ groups: Type.Array(Type.Object({ specs: Type.Array(Type.Number()), title: Type.String({ description: `What the group builds: at most ${TITLE_WORDS} words and ${TITLE_CHARS} characters.` }) })),
606
627
  notes: Type.Optional(Type.String({ description: NOTES })),
607
628
  }),
608
629
  async execute(_id, params) {
@@ -1,6 +1,8 @@
1
1
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
2
  /**
3
- * Every render agent's whole conversation, streamed to a file of its own as it happens - always.
3
+ * Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
4
+ * long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
5
+ * pulse-files.ts); an agent outside a pulse only in debug mode.
4
6
  *
5
7
  * A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
6
8
  * running. When an agent goes wrong, what it was actually
@@ -2,9 +2,13 @@ import { appendFileSync, existsSync } from "node:fs";
2
2
  import { maskSecrets } from "./masking.js";
3
3
  import { dirname, join, resolve, sep } from "node:path";
4
4
  import { currentJob, reportActivity, reportTranscript } from "./activity.js";
5
+ import { debugMode } from "../config.js";
6
+ import { pulseWrote } from "./pulse-files.js";
5
7
  import { ensureInScratch, MODULES_DIR, projectRootOf, scratchDir } from "./paths.js";
6
8
  /**
7
- * Every render agent's whole conversation, streamed to a file of its own as it happens - always.
9
+ * Every render agent's whole conversation, streamed to a file of its own as it happens: an agent of a pulse's for as
10
+ * long as the pulse runs - what the diagnosis of a failure reads - and kept after it in debug mode alone (`debug:`,
11
+ * pulse-files.ts); an agent outside a pulse only in debug mode.
8
12
  *
9
13
  * A job's transcript keeps a tool call's arguments to 600 characters and its result to 2000, and only while pi is
10
14
  * running. When an agent goes wrong, what it was actually
@@ -47,8 +51,11 @@ export function debugLogOf(session) {
47
51
  */
48
52
  export function startDebugLog(session, options) {
49
53
  let file;
54
+ const dir = debugDirFor(options.cwd);
55
+ // Outside a pulse nothing reads it - no diagnosis, no person but one debugging turri.
56
+ if (currentJob() === undefined && !debugMode(dirname(dirname(dir))))
57
+ return undefined;
50
58
  try {
51
- const dir = debugDirFor(options.cwd);
52
59
  ensureInScratch(dirname(dir), dir);
53
60
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
54
61
  const name = options.label.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "agent";
@@ -56,6 +63,7 @@ export function startDebugLog(session, options) {
56
63
  for (let n = 2; existsSync(file); n += 1)
57
64
  file = join(dir, `${stamp}-${name}-${n}.log`);
58
65
  appendFileSync(file, maskSecrets(header(session, options)), { flag: "wx" });
66
+ pulseWrote(file);
59
67
  }
60
68
  catch (error) {
61
69
  reportActivity(`debug log not started: ${error instanceof Error ? error.message : String(error)}`);
@@ -1,6 +1,7 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import { ask, BEGIN, createExecutor } from "../pi.js";
4
+ import { debugMode } from "../config.js";
4
5
  import { confinedTools } from "./confine.js";
5
6
  /** How many tool calls the diagnosis may make before it is told to decide. */
6
7
  export const DIAGNOSIS_CALLS = 40;
@@ -77,6 +78,13 @@ export function diagnosisPrompt(options) {
77
78
  "Find the root cause, not the last symptom: read the failing run's output and the agents' logs around where it",
78
79
  "failed, and follow it to the spec line, the code, the test, the environment or the harness it comes from. Then",
79
80
  "call submit_diagnosis: what happened, why, whose it is to fix, and exactly what to do.",
81
+ ...(debugMode(options.projectDir)
82
+ ? []
83
+ : [
84
+ "",
85
+ "The logs and the full outputs are removed as the job ends - the project is not in debug mode - so never send",
86
+ "the person to one of them: quote what in them shows the cause.",
87
+ ]),
80
88
  "",
81
89
  ...options.failures.flatMap((failure, index) => [`<attempt ${index + 1}>`, failure.trim().slice(-12_000), `</attempt ${index + 1}>`, ""]),
82
90
  ].join("\n");
@@ -5,7 +5,8 @@ import { describeEnvironmentError, EnvironmentError, missingCredentials } from "
5
5
  import { addAgentReport, describeAgents, JobStoppedError, withActivity } from "./activity.js";
6
6
  import { projectOfLanes, startJobLog } from "./job-log.js";
7
7
  import { firstLine, healing, record } from "./ledger.js";
8
- import { chosenStack, withStack } from "../config.js";
8
+ import { chosenStack, debugMode, withStack } from "../config.js";
9
+ import { pulseEnded, pulseWrote } from "./pulse-files.js";
9
10
  const DEFAULT_TAIL_CHARS = 4000;
10
11
  const ACTIVITY_LINES = 10;
11
12
  /** A long render's full transcript is kept, up to about this much text. */
@@ -261,6 +262,8 @@ export function createJobRegistry(options) {
261
262
  };
262
263
  /** It holds one of the run slots, to give back when it ends. */
263
264
  let holding = false;
265
+ /** It started, holding its lanes: what it wrote, and what its chain installed, are its to clear as it ends. */
266
+ let ran = false;
264
267
  const promise = turn
265
268
  .then(async () => {
266
269
  // Stopped while it waited: it never starts.
@@ -295,9 +298,14 @@ export function createJobRegistry(options) {
295
298
  changed();
296
299
  }
297
300
  log = startJobLog(job, claimed);
298
- if (log !== undefined)
301
+ ran = true;
302
+ if (log !== undefined) {
299
303
  job.logFile = log.file;
300
- record({ kind: "job.started", waitedMs: job.startedAt - job.queuedAt, ...(log !== undefined ? { logFile: log.file } : {}) }, place);
304
+ pulseWrote(log.file, id);
305
+ }
306
+ // Its log is named only where it is kept: otherwise it goes as the pulse ends.
307
+ const named = log !== undefined && project !== undefined && debugMode(project);
308
+ record({ kind: "job.started", waitedMs: job.startedAt - job.queuedAt, ...(named ? { logFile: log.file } : {}) }, place);
301
309
  // What the job's agents start runs until it ends, and no longer (withPulse) - with the stack it was queued
302
310
  // with, whatever is chosen while it waits or runs.
303
311
  return withStack(stack, () => (guarded ? runGuarded() : runOnce()));
@@ -323,6 +331,10 @@ export function createJobRegistry(options) {
323
331
  giveSlot();
324
332
  }
325
333
  finished.finishedAt = Date.now();
334
+ log?.end(finished.status === "succeeded" ? "finished" : finished.status === "stopped" ? "stopped" : "finished - failed", finished.announce ?? finished.report ?? finished.error, describeAgents(finished.agents));
335
+ // What it wrote for a person goes now, outside debug mode - its log too, which nothing names from then on.
336
+ if (ran && project !== undefined && !pulseEnded({ job: id, project, ...(input.module !== undefined ? { module: input.module } : {}), lanes: claimed }))
337
+ delete finished.logFile;
326
338
  const outcome = finished.details;
327
339
  const stopReason = stopReasons.get(id);
328
340
  record({
@@ -337,7 +349,6 @@ export function createJobRegistry(options) {
337
349
  ...(finished.error !== undefined ? { error: firstLine(finished.error, 300) } : {}),
338
350
  ...(finished.logFile !== undefined ? { logFile: finished.logFile } : {}),
339
351
  }, place);
340
- log?.end(finished.status === "succeeded" ? "finished" : finished.status === "stopped" ? "stopped" : "finished - failed", finished.announce ?? finished.report ?? finished.error, describeAgents(finished.agents));
341
352
  // A failing listener must not take the job down with it; the result is
342
353
  // already recorded and can still be collected by asking.
343
354
  try {
@@ -0,0 +1,14 @@
1
+ /** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
2
+ export declare function pulseWrote(file: string, job?: string | undefined): void;
3
+ /**
4
+ * A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
5
+ * temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
6
+ * pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
7
+ * everything was kept.
8
+ */
9
+ export declare function pulseEnded(options: {
10
+ job: string;
11
+ project: string;
12
+ module?: string;
13
+ lanes: string[];
14
+ }): boolean;
@@ -0,0 +1,63 @@
1
+ import { existsSync, rmSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { debugMode } from "../config.js";
4
+ import { currentJob } from "./activity.js";
5
+ import { modulesScratchDir } from "./paths.js";
6
+ /**
7
+ * What a pulse writes only for a person debugging turri, and when it goes.
8
+ *
9
+ * A pulse writes its own log, every one of its agents' whole logs, the testing scripts' and services' full outputs and
10
+ * its agents' temporary files. Nothing of turri reads them once the pulse is over: while it runs, its agents read their
11
+ * outputs (`read_output`, `service_logs`) and the diagnosis of a failure reads the logs - so they are written as before,
12
+ * and removed as it ends. In debug mode (`debug: true`, TURRI_DEBUG, `--debug`) they are kept, as they always were.
13
+ *
14
+ * So are the dependencies installed in the modules below a pulse's own in its chain: the chain is built in its tip, and
15
+ * what was installed in the codebases it left behind is only installed again - from the package cache - if one of them
16
+ * is ever built on again. The testing scripts install what they run with themselves.
17
+ */
18
+ /** The files each pulse wrote for a person to read, by its id. */
19
+ const written = new Map();
20
+ /** A file the running pulse wrote for a person to read - removed as it ends, unless in debug mode. Outside a pulse, nothing. */
21
+ export function pulseWrote(file, job = currentJob()?.id) {
22
+ if (job === undefined)
23
+ return;
24
+ const files = written.get(job) ?? new Set();
25
+ files.add(file);
26
+ written.set(job, files);
27
+ }
28
+ /** Where a codebase's dependencies are installed: what a module its chain has left behind keeps no longer. */
29
+ const INSTALLED = ["node_modules", ".venv", "venv"];
30
+ /**
31
+ * A pulse has ended. Unless the project is in debug mode, what it wrote for a person goes - its files, and its module's
32
+ * temporary folder - and so do the dependencies installed in the modules below its own in its chain. `lanes` are the
33
+ * pulse's (`plain_modules/<module>` of its whole chain), still held: no other pulse works in them. Returns whether
34
+ * everything was kept.
35
+ */
36
+ export function pulseEnded(options) {
37
+ const files = written.get(options.job) ?? new Set();
38
+ written.delete(options.job);
39
+ if (debugMode(options.project))
40
+ return true;
41
+ const gone = (path) => {
42
+ try {
43
+ rmSync(path, { recursive: true, force: true });
44
+ }
45
+ catch {
46
+ /* what cannot be removed stays: never a reason for the pulse to fail */
47
+ }
48
+ };
49
+ for (const file of files)
50
+ gone(file);
51
+ for (const lane of options.lanes) {
52
+ // A codebase of the chain: its folder holds `code` - a lane may also be a spec file, or a build folder.
53
+ if (!existsSync(join(lane, "code")))
54
+ continue;
55
+ const name = basename(lane);
56
+ if (name === options.module)
57
+ gone(join(modulesScratchDir(lane), "tmp", name));
58
+ else
59
+ for (const installed of INSTALLED)
60
+ gone(join(lane, "code", installed));
61
+ }
62
+ return false;
63
+ }
@@ -8,6 +8,7 @@ import { agentShell } from "./agent-shell.js";
8
8
  import { maskSecrets } from "./masking.js";
9
9
  import { modulesScratchDir } from "./paths.js";
10
10
  import { isRunning, startInBackground, stopGroup } from "./shell.js";
11
+ import { pulseWrote } from "./pulse-files.js";
11
12
  /**
12
13
  * What an agent starts and keeps running while it works - a server, a worker - to try the software as someone would,
13
14
  * from outside: with curl, or what the stack's own client is. Whatever the stack: the agent gives the command that
@@ -83,6 +84,8 @@ export function createServiceTools(options) {
83
84
  return say(`Not started: ${checked.reason}`);
84
85
  mkdirSync(logsDir, { recursive: true });
85
86
  const log = join(logsDir, `${name.replace(/[^\w.-]+/g, "_")}-${randomUUID().slice(0, 8)}.log`);
87
+ // Read through service_logs while the pulse runs, and by no one after it (pulse-files.ts).
88
+ pulseWrote(log);
86
89
  const limit = Math.min(Math.max(seconds ?? DEFAULT_READY_SECONDS, 1), MAX_READY_SECONDS);
87
90
  // Started and waited on under the audit: what it writes while it comes up, outside its role, is put back.
88
91
  const { value: outcome, note } = await fenced.audited(async () => {
@@ -9,6 +9,7 @@ import { modulesScratchDir } from "./paths.js";
9
9
  import { RECORD_FILE } from "./records.js";
10
10
  import { missingToolchain, runUnitScript, scriptFailure } from "./test-scripts.js";
11
11
  import { COMMAND_TIMEOUT_MS } from "./shell.js";
12
+ import { pulseWrote } from "./pulse-files.js";
12
13
  /** Up to this much of a run's output is shown: all of it when it fits, otherwise its end. */
13
14
  const MAX_OUTPUT_CHARS = 60_000;
14
15
  /** A test run has the same time limit as any command: see COMMAND_TIMEOUT_MS. */
@@ -92,6 +93,8 @@ export function keepOutput(dir, label, output) {
92
93
  const name = label.replace(/[^\w.-]+/g, "_").slice(0, 60);
93
94
  const file = join(logs, `${stamp}-${name}.log`);
94
95
  writeFileSync(file, typeof output === "string" ? maskSecrets(output) : maskSecrets(output.toString("utf8")));
96
+ // Read through read_output while the pulse runs, and by no one after it (pulse-files.ts).
97
+ pulseWrote(file);
95
98
  const all = readdirSync(logs).sort();
96
99
  for (const old of all.slice(0, Math.max(0, all.length - MAX_LOGS)))
97
100
  rmSync(join(logs, old), { force: true });
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
+ import { debugMode } from "./config.js";
3
4
  import { turriIdentity } from "./environment.js";
4
5
  import { onModelEvent } from "./regenerate/activity.js";
5
6
  import { firstRegeneration } from "./regenerate/jobs.js";
@@ -290,11 +291,14 @@ export function startRunReport(options) {
290
291
  spec_gaps: specGapsOf(jobs),
291
292
  turn_incidents: turnIncidentsOf(),
292
293
  ...(exitParts ?? {}),
293
- logs: {
294
- jobs_dir: fromProject(join(projectRoot, ".turri", "jobs")),
295
- debug_dir: fromProject(join(projectRoot, ".turri", "debug")),
296
- sessions_dir: fromProject(join(projectRoot, ".turri", "sessions")),
297
- },
294
+ // Kept only in debug mode: otherwise each pulse's go as it ends, and the sessions are never written.
295
+ logs: debugMode(projectRoot)
296
+ ? {
297
+ jobs_dir: fromProject(join(projectRoot, ".turri", "jobs")),
298
+ debug_dir: fromProject(join(projectRoot, ".turri", "debug")),
299
+ sessions_dir: fromProject(join(projectRoot, ".turri", "sessions")),
300
+ }
301
+ : null,
298
302
  };
299
303
  };
300
304
  const write = (exitCode) => {
package/docs/config.md CHANGED
@@ -42,12 +42,12 @@ Each change says when it takes effect. None needs turri started again - what tur
42
42
  | --- | --- |
43
43
  | now | `harness-model` and `models.chat` - the conversation switches to it; `parallel-jobs` - a pulse waiting for a slot starts if there is one now; `allow-to-stop-jobs` |
44
44
  | your next message | `rules-off` - for the conversation; a skill's agents from the next skill run |
45
- | the next pulse - one running keeps what it started with | `coding-model`, `judge-model`, every other `models.<role>`, `review`, `mutation-testing`, `turn-limit`, `compact-at`, `agent-env`, `toolchains` |
45
+ | the next pulse - one running keeps what it started with | `coding-model`, `judge-model`, every other `models.<role>`, `review`, `mutation-testing`, `turn-limit`, `compact-at`, `agent-env`, `toolchains`, `debug` |
46
46
  | the next time the work is planned | `conformance-tests`, `conformance-budget`, `max-specs-per-pulse`, `max-lines-per-pulse` |
47
47
  | the next test run - the running pulse's too | the testing scripts, `testing-scripts-run-on` |
48
48
  | the next pulse that lands - the running one too | `release-after-pulse` |
49
49
  | the next regeneration that names no `.plain` file | `module` |
50
- | the next time turri starts here | `ledger-keep` |
50
+ | the next time turri starts here | `ledger-keep`, `cache-keep` |
51
51
 
52
52
  A coding model chosen this session - `--coding-model`, or `engine` - gives way to one set here: the last word wins. It says, too, what wins over the change: `models.chat` over `harness-model` (and so on), and a stack's `config.turri.<stack>.yaml`, which is not changed here, while that stack is chosen. In a project still read from `config.yaml`, the first change copies it to `config.turri.yaml` first - see [the old config.yaml](#the-old-configyaml).
53
53
 
@@ -93,6 +93,8 @@ models: # each agent role's model, thinking and tur
93
93
  | `prepare-environment-script` | optional: run as `<script> <build>` before every run of the conformance testing script, which runs only once it passes - see [the testing scripts](regenerating.md#the-testing-scripts). turri never writes one | the one `prepare_environment*.sh` at the root or in `test_scripts/`, else none |
94
94
  | `compact-at` | how many tokens of work - past its instructions - an agent kept through a job holds before the harness compacts it between turns; at least 1000; the project's alone | 100,000 - see [agent sessions](regenerating.md#agent-sessions) |
95
95
  | `ledger-keep` | how many megabytes of the ledger - what turri did, as facts, in `.turri/ledger` - the project keeps: past it, the oldest runs' are pruned when a run first writes; `0` keeps none, and writes none; the project's alone. See [the ledger](using.md#the-ledger) | 100 |
96
+ | `debug` | `true` or `false`: whether turri keeps what it writes only for a person debugging it - every agent's whole log and saved session, each pulse's log, the whole outputs of its test runs and services, its agents' temporary files, and the dependencies installed in the modules a chain has left behind. `false`: only what turri reads to work is kept. `TURRI_DEBUG=1` - or `turri --debug` - turns it on for one run, and `TURRI_DEBUG=0` off; the project's alone. See [debug mode](#debug-mode) | `false` |
97
+ | `cache-keep` | how many megabytes of package caches the project keeps - `plain_modules/.turri/cache`, and the caches in the agents' home folders: past it, the least recently written - by the newest file in each - are removed when a run starts its first agent command, each downloaded again when it is wanted; `0` keeps none; the project's alone | 4096 |
96
98
  | `max-specs-per-pulse` | the most functional specs the planner groups into one pulse - what builds one feature goes together, up to this many; a stack's file wins | 3 |
97
99
  | `max-lines-per-pulse` | the most lines of spec in one pulse - its functional specs with the definitions they bring; a group past it is split again, a functional spec a pulse; a stack's file wins | 120 |
98
100
  | `parallel-jobs` | how many jobs the chat runs at once - modules that share nothing build side by side up to it; read when the chat starts, from the folder it starts in | half this machine's cores, between one and four - see [how many jobs at once](#how-many-jobs-at-once) |
@@ -180,6 +182,23 @@ The optional prepare script - `prepare-environment-script:`, else the one `prepa
180
182
 
181
183
  A folder holding two of a kind is not guessed between: name the one to use. A script named inside `plain_modules/` is refused - agents write there, so it could be changed by the work it judges. The scripts are never linked from the specs. What they are and how they run is in [regenerating the code](regenerating.md#the-testing-scripts).
182
184
 
185
+ ## Debug mode
186
+
187
+ turri writes much more than it needs to work, for a person working out why it did what it did - each agent's whole conversation, each pulse's whole transcript, every test run's whole output. Most of it nothing of turri ever reads again. So by default it is not kept:
188
+
189
+ | | outside debug mode | in debug mode |
190
+ | --- | --- | --- |
191
+ | each pulse's log (`.turri/jobs/`) and its agents' (`.turri/debug/`) | written while the pulse runs - the diagnosis of a failure reads them - and removed as it ends | kept |
192
+ | the logs of agents outside a pulse - the release agent, a side question | not written | kept |
193
+ | the agents' saved sessions (`.turri/sessions/`) | not written - each is kept in memory while it works; a gated skill's alone is saved, as its run resumes from it | kept |
194
+ | whole outputs of test runs and services (`plain_modules/.turri/logs/`, `services/`) | read by the pulse's agents while it runs, removed as it ends | kept, the last 100 outputs |
195
+ | a module's `TMPDIR` (`plain_modules/.turri/tmp/<module>`) | emptied as each of its pulses ends | kept |
196
+ | dependencies installed in the modules below a pulse's own in its chain (`node_modules`, `.venv`) | removed as the pulse ends: the chain is built in its tip, and a module built on again installs them again, from the cache | kept |
197
+
198
+ Kept either way: the ledger - what each pulse did, how it ended, what it cost (see [the ledger](using.md#the-ledger)) - the code, its tests and their history, a gated skill's run, what a conformance explorer found, what `deploy` started, and the package caches, held to `cache-keep:`. The testing scripts install what they run with themselves, so nothing removed is ever missed by a test run.
199
+
200
+ Turn it on in `config.turri.yaml` (`debug: true`, or `/turri-config debug true`), or for one run with `TURRI_DEBUG=1` or `turri --debug`.
201
+
183
202
  ## Credentials for the agents
184
203
 
185
204
  An agent's commands do not get the environment turri was started in: it holds your providers' API keys, cloud credentials and tokens. They get only the path, the locale, the terminal and the user, the variables that locate your toolchains (`JAVA_HOME`, `NVM_DIR`, `RUSTUP_HOME`, ... - the ones in your home found even when your shell sets none), and turri's own: a home folder of the module's own (`plain_modules/.turri/home/<module>`, kept between pulses, with a git identity of its own), a `TMPDIR`, the package caches in `plain_modules/.turri/cache`, and git stopped at the module folder, so a project that is itself a repository is never found from the code.
@@ -231,7 +250,7 @@ How a stack's config is made:
231
250
  - **Laid over `config.turri.yaml`, key by key.** What the stack's file gives wins; what it leaves out is the project's. A model it names takes what it leaves out from the project's same model - above, with the stack frontend chosen, everything renders on the project's coding model, thinking `high`.
232
251
  - **Pinned to a module when it is asked for.** A module is built with the stack it was asked for with, from its first pulse to its last: the pulses planned after it, its test runs, the scripts found, its plans and its conformance tests, whatever stack is chosen meanwhile. A pulse waiting for a free slot keeps the stack it was queued with. A change to its spec keeps its stack - unless it was built with none and has a stack of its own now; asked for again, its stack is worked out again. What `regenerate` says back names each module's stack, and so does a pulse's report.
233
252
  - **Refused with none, when that cannot build.** In a project of stacks, a module asked for with no stack - none named, none named after it, none chosen - is refused before anything is planned when `config.turri.yaml` alone names no unit testing script: every pulse would stop for want of one. The refusal names the stacks and the script each names. A spec change `write_spec_diff` writes is never refused: it is written, and said not to be built yet - passed the stack, everything written is built. `stack: none` builds with `config.turri.yaml` alone all the same.
234
- - **The project's alone.** `module:`, `harness-model:`, `parallel-jobs:`, `rules-off:`, `compact-at:` and `ledger-keep:` are read only from `config.turri.yaml` - there is one conversation, one module built when none is named, one machine. `agent-env:` and `toolchains:` in a stack's file add to the project's.
253
+ - **The project's alone.** `module:`, `harness-model:`, `parallel-jobs:`, `rules-off:`, `compact-at:`, `ledger-keep:`, `debug:` and `cache-keep:` are read only from `config.turri.yaml` - there is one conversation, one module built when none is named, one machine. `agent-env:` and `toolchains:` in a stack's file add to the project's.
235
254
 
236
255
  The chat is told each stack's config and the one chosen. The skills that write testing scripts put each stack's in its own file once a project has two stacks, each stack named after the module it builds.
237
256
 
@@ -56,7 +56,8 @@ The workflow signs in to npm with npm's trusted publishing (OIDC), not a token:
56
56
  | `src/regenerate/side-panel.ts`, `src/regenerate/panel-dock.ts` | the side panel, and the chat narrowed beside it when it is docked |
57
57
  | `src/screen-padding.ts` | the blank row above and below the whole screen and the two columns either side, in both of pi's ways of drawing it |
58
58
  | `src/regenerate/spec-map.ts`, `src/regenerate/spec-map-view.ts` | regen: each item's mark, from the records, the `.plain` files and the work loop's plan (`mapPlan`), and the map drawn |
59
- | `src/regenerate/debug-log.ts` | each render agent's whole conversation, always streamed to `.turri/debug/` |
59
+ | `src/regenerate/debug-log.ts` | each render agent's whole conversation, streamed to `.turri/debug/` - while its pulse runs, and kept in debug mode |
60
+ | `src/regenerate/pulse-files.ts` | what a pulse writes only for a person - its logs, outputs, temporary files, its chain's installed dependencies - removed as it ends outside debug mode |
60
61
  | `src/regenerate/ledger.ts`, `src/regenerate/ledger-view.ts`, `src/ledger-tools.ts`, `src/ledger-cli.ts` | the ledger: what turri did, as typed facts, in `.turri/ledger/` - `record()`, read back for the `ledger` tool, `pulses`, `diagnose` and `turri ledger` - see [the ledger](using.md#the-ledger) |
61
62
  | `genome/` | what turri knows and can do: `genome/skills/`, the skill library, and `genome/rules/`, the \*\*\*plain authoring rules |
62
63
 
package/docs/headless.md CHANGED
@@ -84,8 +84,9 @@ The command exits `0` only when every module is complete: its code regenerated f
84
84
  | `plain_modules/<module>/code/` | the code and unit tests |
85
85
  | `plain_modules/<module>/tests/` | the conformance tests |
86
86
  | `plain_modules/<module>/code/.turri.json` | the marshalled plain tree the code is built from |
87
- | `.turri/jobs/` | each pulse's log: its agents, its test runs, how it ended |
88
- | `.turri/debug/` | each agent's whole conversation |
87
+ | `.turri/ledger/` | what each pulse did, how it ended and what it cost - `turri ledger` |
88
+ | `.turri/jobs/` | each pulse's log: its agents, its test runs, how it ended - with `--debug` (or `debug: true`) only |
89
+ | `.turri/debug/` | each agent's whole conversation - with `--debug` only |
89
90
 
90
91
  And what happened, step by step, in the module's history:
91
92
 
@@ -142,7 +143,7 @@ A module that `requires` others is built in its own codebase on top of theirs: t
142
143
  "status": "succeeded", "landed": true, "failed_for_good": false,
143
144
  "started_at": "…", "duration_seconds": 512.3, "usage": { },
144
145
  "agents": [{ "label": "app 1 coding agent", "model": "…", "turns": 14, "compactions": 0, "input_tokens": 0, "…": 0 }],
145
- "log_file": ".turri/jobs/….log", "error": null
146
+ "log_file": ".turri/jobs/….log", "error": null // log_file null outside debug mode
146
147
  }]
147
148
  },
148
149
  "functionalities": {
@@ -164,7 +165,7 @@ A module that `requires` others is built in its own codebase on top of theirs: t
164
165
  { "kind": "stopped", "pulse_id": "5c1e0b2f", "role": "conformance explorer", "model": "…", "reason": "10 submit_exploration calls failed in a row" }
165
166
  ],
166
167
  "build_dir_export": { "exported": true, "dir": "/work/out", "module": "app", "commit": "8036b3b", "files": 23 },
167
- "logs": { "jobs_dir": ".turri/jobs", "debug_dir": ".turri/debug", "sessions_dir": ".turri/sessions" }
168
+ "logs": { "jobs_dir": ".turri/jobs", "debug_dir": ".turri/debug", "sessions_dir": ".turri/sessions" } // null outside debug mode
168
169
  }
169
170
  ```
170
171
 
package/docs/hosting.md CHANGED
@@ -11,6 +11,7 @@
11
11
  | `createTurriExtension(host)` | turri's conversation as an extension of pi's: every tool, slash command, rule and skill the terminal has. The host is told of its jobs and asks its questions - see below. |
12
12
  | `pi` | pi itself, as turri's extension is built against it. Make the session with this one - `pi.createAgentSession`, `pi.DefaultResourceLoader` - not another copy. |
13
13
  | `readProjectConfig`, `projectModels`, `modelSpec`, `configForSpec`, `hasProjectConfig`, `stacksOf` | the project's settings, as [config.turri.yaml](config.md) has them. |
14
+ | `debugMode` | whether turri keeps, in a project, what it writes only for debugging it - `debug:`, or `TURRI_DEBUG` over it (see [debug mode](config.md#debug-mode)). |
14
15
  | `initConfig(dir, { vendor, bedrock, force })`, `VENDORS`, `BEDROCK_REGIONS`, `BEDROCK_DEFAULT`, `bedrockModels` | a project's first config.turri.yaml, as `turri init-config` writes it: the host asks which vendor's models - `VENDORS` has each one's label and its two models, `BEDROCK_REGIONS` where Bedrock serves each maker's - and `initConfig` writes it, saying what it did. One already there keeps everything but its two models. |
15
16
  | `checkPlainFile`, `plainFileParser`, `resolveTemplateDirs`, `iterateFunctionalSpecs`, `FUNCTIONAL_REQUIREMENTS` | a spec checked and parsed, its functional specs one by one - as `turri validate` reads it. |
16
17
  | `highlightLines`, `roleOf` | a spec's lines coloured by turri's own \*\*\*plain grammar. |
@@ -33,12 +33,12 @@ The harness's scratch - each ignores itself, and is never part of a module's his
33
33
  | | |
34
34
  | --- | --- |
35
35
  | `plain_modules/.turri/` | what turri keeps beside the code - see [sandbox](sandbox.md#what-turri-keeps-beside-the-code) |
36
- | `plain_modules/.turri/cache/` | the package caches, so a dependency is fetched once |
36
+ | `plain_modules/.turri/cache/` | the package caches, so a dependency is fetched once - held to `cache-keep:` |
37
37
  | `plain_modules/.turri/scripts/` | the copies each testing script run happens in, one folder a run |
38
- | `plain_modules/.turri/logs/` | the whole output of each run or command an agent was shown cut short, by time and what ran - read by agents only through `read_output` |
39
- | `.turri/jobs/` | each job's log: its diff, its stages, its agents and what they cost, and which walls were up under its agents' commands |
40
- | `.turri/debug/` | each agent's log: its prompt, every tool call and every answer |
41
- | `.turri/sessions/` | the agents' saved sessions |
38
+ | `plain_modules/.turri/logs/` | the whole output of each run or command an agent was shown cut short, by time and what ran - read by agents only through `read_output`; removed as the pulse ends, outside [debug mode](config.md#debug-mode) |
39
+ | `.turri/jobs/` | each job's log: its diff, its stages, its agents and what they cost, and which walls were up under its agents' commands - kept in debug mode only |
40
+ | `.turri/debug/` | each agent's log: its prompt, every tool call and every answer - kept in debug mode only |
41
+ | `.turri/sessions/` | the agents' saved sessions - in debug mode, and a gated skill's run's |
42
42
  | `.turri/runs/<skill>/` | a gated skill's run: what an interrupted one resumes from, and its gates' evidence |
43
43
  | `.turri/deploy/` | the output of what `deploy` started |
44
44
  | `dist/<module>/` | what `deploy` puts out: the module's code as its last commit has it, replaced whole each time - see [deploy](deploy.md) |
@@ -411,7 +411,7 @@ The agents that work through a job keep **one session for it**: the coding agent
411
411
 
412
412
  An agent kept through a job re-reads everything it has done on every call. So between turns, once its conversation - what it has done, past its instructions - passes `compact-at` tokens (100,000 unless [the project config](config.md) says otherwise), the harness compacts it, with instructions for its role: the coding agent and the fixer keep what is done, what still fails and why, the decisions and every review problem's fate; a reviewer keeps every problem it raised and where each stands, and what it has already checked. pi's own compaction, when the context window is nearly full, stays on beneath it.
413
413
 
414
- What each agent of a job did - its turns, its compactions, its tokens and cost - is at the end of the job's log in `.turri/jobs/`, and in `pulses`.
414
+ What each agent of a job did - its turns, its compactions, its tokens and cost - is at the end of the job's log in `.turri/jobs/` (kept in [debug mode](config.md#debug-mode)), and in `pulses`.
415
415
 
416
416
  ## Working notes
417
417
 
package/docs/sandbox.md CHANGED
@@ -107,12 +107,12 @@ It lives in `plain_modules/.turri`, which ignores itself in git:
107
107
 
108
108
  | | |
109
109
  | --- | --- |
110
- | `cache/` | package caches - pip, npm, cargo, Maven, Gradle, Go - so a dependency downloads once per project |
111
- | `home/<module>` | each module's home folder for its agents' commands, kept between pulses |
112
- | `tmp/<module>` | each module's `TMPDIR` |
110
+ | `cache/` | package caches - pip, npm, cargo, Maven, Gradle, Go, node-gyp's headers, the browsers Playwright, Puppeteer and Cypress download - so a dependency downloads once per project; held to `cache-keep:` megabytes, the least recently written going first |
111
+ | `home/<module>` | each module's home folder for its agents' commands, kept between pulses; the caches its tools keep there count towards `cache-keep:` |
112
+ | `tmp/<module>` | each module's `TMPDIR`, emptied as each of its pulses ends outside [debug mode](config.md#debug-mode) |
113
113
  | `scripts/` | the throwaway copies test runs happen in, emptied after each run |
114
- | `logs/` | whole outputs cut short for an agent, read through `read_output` |
115
- | `services/` | what the services agents started printed |
114
+ | `logs/` | whole outputs cut short for an agent, read through `read_output` - removed as the pulse ends, outside debug mode |
115
+ | `services/` | what the services agents started printed - removed as the pulse ends, outside debug mode |
116
116
 
117
117
  ## What the machine lacks
118
118
 
package/docs/using.md CHANGED
@@ -162,14 +162,16 @@ git --git-dir=plain_modules/<module>/.plaingit --work-tree=plain_modules/<module
162
162
 
163
163
  ## Agent logs
164
164
 
165
- When an agent does something you cannot explain, its logs are where to look: the chat shows what its pulse is doing, not what its agents said. Every agent a render starts - the coding agent, the conformance agent, the repair agent, the spec-steps planner - also streams its whole conversation to a file of its own, always.
165
+ When an agent does something you cannot explain, its logs are where to look: the chat shows what its pulse is doing, not what its agents said. Every agent a render starts - the coding agent, the conformance agent, the repair agent, the spec-steps planner - also streams its whole conversation to a file of its own.
166
166
 
167
167
  The logs are in `.turri/debug/` in the project, beside `plain_modules`, one per agent, named by when it started and what it is: `2026-09-26T13-45-02-123Z-app-3-coding-agent.log`. Each starts with the pulse it runs for, its model and thinking level, its folder, its saved pi session, its tools and its system prompt, and then has, in order and stamped with the time: every prompt it is sent, whole; its thinking and replies as they stream; every tool call with its arguments, numbered
168
168
  - `→ #3 bash` - and its whole result under the same number, `← #3 bash done`, since calls made together run at once and finish in any order; a tool call a response was cut off in, marked as never run; each response's stop reason, tokens and cost; the provider's retries and errors; and how each turn ended. Every line is on disk as it happens, so `tail -f` follows an agent live and a run that crashed leaves its log up to that moment. The pulse's own log names each agent's log as it starts.
169
169
 
170
170
  Beside them, each pulse has a log of its own in `.turri/jobs/` - `<time>-<pulse id>-<kind>.log`: its whole transcript, written as it happens. The harness's stages, the functional spec or the diff it builds, and every one of its agents in turn - each prompt, its thinking and replies, every tool call and its result, whole where the screen clips them - and how the pulse ended. A finished pulse, opened in the side panel, names it. It is the one to read for a pulse as a whole; an agent's own log has its system prompt, tokens and retries besides.
171
171
 
172
- No agent can read the logs: they are outside `plain_modules` - or, for a `--build-dir` outside it, beside that folder rather than in it. Nothing prunes them either - delete the folder when you are done with it. A log that cannot be written is given up on without failing the render.
172
+ They are kept only in [debug mode](config.md#debug-mode) - `debug: true` in `config.turri.yaml`, or `TURRI_DEBUG=1`, or `turri --debug` for one run. Otherwise a pulse's logs are written while it runs - the diagnosis of a pulse that fails twice reads them, and quotes what it needs, never sending you to a file that is about to go - and removed as it ends, and an agent outside a pulse writes none; the [ledger](#the-ledger) still says what each pulse did, how it ended and what it cost. To read why an agent did something, turn debug mode on and run it again.
173
+
174
+ The coding and conformance agents cannot read the logs: they are outside `plain_modules` - or, for a `--build-dir` outside it, beside that folder rather than in it. The diagnosis of a failed pulse reads them. In debug mode nothing prunes them - delete the folder when you are done with it. A log that cannot be written is given up on without failing the render.
173
175
 
174
176
  ## The ledger
175
177
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turri-dev/cli",
3
- "version": "0.1.8-next.bfcb24e",
3
+ "version": "0.1.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {