talos-code 0.2.0 → 0.2.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ Format: [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/). Versions
5
5
  `src/version.ts`, and in the `talos-cli-vX.Y.Z` tag. The package is published on npm as
6
6
  [`talos-code`](https://www.npmjs.com/package/talos-code); the command is `talos`.
7
7
 
8
+ ## talos-cli-v0.2.1 — 2026-09-26
9
+
10
+ `npm i -g talos-code@0.2.1`, or `talos update` to see it.
11
+
12
+ ### Changed
13
+ - Faster turns in large folders. The workspace snapshot that makes `/undo` possible no longer reads, rewrites and syncs
14
+ every file at every turn: a file that has not changed since the last turn reuses what was stored. A turn in a plain
15
+ folder of 6,000 files went from 39 s to 1.8 s, and from 2.6 s to 1.4 s in a git repository of the same size. What
16
+ `/undo` covers is unchanged.
17
+ - Faster and complete project search. `cerca` runs ripgrep, shipped inside the package for your platform (no download),
18
+ and each match now comes with its line: `path:line:text`. Four searches over 6,000 files took 9.3 s; now about 0.2 s.
19
+ Before, a search stopped after reading 5,000 files and could miss matches in a bigger project. Several searches asked
20
+ in the same answer run together. Without ripgrep, the previous search still works.
21
+
22
+ ### Known limits
23
+ - Tested on Windows 11 x64 only; Node.js 24 (24.18 or later in the 24 line).
24
+ - Shell commands still run one at a time, each in its sandbox; a start of about one second remains.
25
+
26
+ ### Verification
27
+ - Measured on a hermetic bench (`scripts/bench-harness.ts`): a local fake provider, no network, TALOS against Pi, Codex
28
+ and OpenCode on the same turns.
29
+ - CLI unit suite: 1876 tests, 1872 pass, 4 skip, 0 fail. Acceptance on the built CLI in a synthetic terminal: 263/263.
30
+ - The package proof (`scripts/prove-standalone.ts`), 16/16 on `talos-code-0.2.1.tgz` (1.76 MB); the installed package
31
+ resolves its own ripgrep.
32
+
8
33
  ## talos-cli-v0.2.0 — 2026-09-26
9
34
 
10
35
  `npm i -g talos-code@0.2.0`, or `talos update` to see it from 0.1.0.
@@ -89,7 +89,18 @@ export function findTalosRepoRoot(start = process.cwd(), deps = {}) {
89
89
  return cwd;
90
90
  throw new Error('TALOS_RUNTIME_NOT_FOUND');
91
91
  }
92
+ /* P18 phase 3 (owner 2026-09-26: ripgrep inside the npm package, no network): the kernel's `cerca` runs ripgrep from
93
+ TALOS_RG_PATH. `@vscode/ripgrep` ships the binary for this platform as an optional dependency of the package (no
94
+ install script, no download); a path already set, or a package without it, is left alone and `cerca` keeps its JS walk. */
95
+ let bundledRipgrep = null;
96
+ export function provideBundledRipgrep(env = process.env) {
97
+ if (env.TALOS_RG_PATH)
98
+ return Promise.resolve();
99
+ return bundledRipgrep ??= import('@vscode/ripgrep').then((m) => { if (typeof m?.rgPath === 'string' && existsSync(m.rgPath))
100
+ env.TALOS_RG_PATH = m.rgPath; }, () => { });
101
+ }
92
102
  export async function importTalosModule(repoRoot, relativeFromHarnessSrc) {
103
+ await provideBundledRipgrep();
93
104
  const file = join(repoRoot, 'harness-ui', 'src', relativeFromHarnessSrc);
94
105
  return import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
95
106
  }
@@ -12,6 +12,7 @@ import { secretValuesFromEnvironment } from "../diagnostics/redact.js";
12
12
  import { isTestProfile } from "../provider/store.js";
13
13
  import { compactionEvent, createCliEventHub, createRunObserver, RETRY_AFTER_MAX_MS, summaryRequestedEvent } from "./provider-attempts.js";
14
14
  import { createToolOutputStore, pruneToolOutput } from "./output-store.js";
15
+ import { provideBundledRipgrep } from "./repo.js";
15
16
  import { withArchivableCapture } from "./context-archive.js";
16
17
  import { CONTEXT_ENGINE_MIN_WINDOW, configureModelProfiles, createModelsDevLoader, modelProfile, modelProfileEvidence, ollamaWindow, rememberOllamaWindow, setModelOverrides } from "./model-profile.js";
17
18
  import { AsyncLocalStorage } from 'node:async_hooks';
@@ -246,7 +247,7 @@ export function createCliContextBridge({ repoRoot, paths, registry, providerRegi
246
247
  export function scopeCliKeyring(raw) { if (!raw)
247
248
  return null; return { get: (s, a) => raw.get(remapService(s), a), set: (s, a, v) => raw.set(remapService(s), a, v), remove: (s, a) => raw.remove(remapService(s), a) }; }
248
249
  /** The kernel modules the product composes with. Exported so a test can observe one of them while every other stays the production one. */
249
- export async function loadModules(repoRoot) { const src = join(repoRoot, 'harness-ui', 'src'); const [cred, owner, agent, sessions, plugins, search, duck, providerRegistry, probe, destination, readiness, chatImages] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-credential-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'runtime-owner-adapter.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'agent-service.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'session-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'search-source-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'duckduckgo-search.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-probe.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'model-destination.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'sessione-pronta.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'chat-image-attachments.mjs')).href))]); return { createProviderCredentialStore: cred.createProviderCredentialStore, createOwnerRuntimeAdapter: owner.createOwnerRuntimeAdapter, creaFetchMultiProvider: owner.creaFetchMultiProvider, compattaSessione: agent.compattaSessione, chiediAlModelloUnaVolta: agent.chiediAlModelloUnaVolta, avviaSessione: agent.avviaSessione, eseguiComandoDiretto: agent.eseguiComandoDiretto, createSessionRegistry: sessions.createSessionRegistry, verificaTrustPlugin: plugins.verificaTrustPlugin, createSearchSourceStore: search.createSearchSourceStore, creaTrasportoSenzaChiave: duck.creaTrasportoSenzaChiave, ENDPOINT_SENTINELLA_DUCKDUCKGO: duck.ENDPOINT_SENTINELLA_DUCKDUCKGO, REGISTRO_FORNITORI: providerRegistry.REGISTRO_FORNITORI, createProviderProbe: probe.createProviderProbe, separaFonteModello: destination.separaFonteModello, creaProntoFn: readiness.creaProntoFn, createChatImageStore: chatImages.createChatImageStore }; }
250
+ export async function loadModules(repoRoot) { await provideBundledRipgrep(); const src = join(repoRoot, 'harness-ui', 'src'); const [cred, owner, agent, sessions, plugins, search, duck, providerRegistry, probe, destination, readiness, chatImages] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-credential-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'runtime-owner-adapter.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'agent-service.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'session-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'search-source-store.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'duckduckgo-search.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'provider-probe.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'model-destination.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'sessione-pronta.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'chat-image-attachments.mjs')).href))]); return { createProviderCredentialStore: cred.createProviderCredentialStore, createOwnerRuntimeAdapter: owner.createOwnerRuntimeAdapter, creaFetchMultiProvider: owner.creaFetchMultiProvider, compattaSessione: agent.compattaSessione, chiediAlModelloUnaVolta: agent.chiediAlModelloUnaVolta, avviaSessione: agent.avviaSessione, eseguiComandoDiretto: agent.eseguiComandoDiretto, createSessionRegistry: sessions.createSessionRegistry, verificaTrustPlugin: plugins.verificaTrustPlugin, createSearchSourceStore: search.createSearchSourceStore, creaTrasportoSenzaChiave: duck.creaTrasportoSenzaChiave, ENDPOINT_SENTINELLA_DUCKDUCKGO: duck.ENDPOINT_SENTINELLA_DUCKDUCKGO, REGISTRO_FORNITORI: providerRegistry.REGISTRO_FORNITORI, createProviderProbe: probe.createProviderProbe, separaFonteModello: destination.separaFonteModello, creaProntoFn: readiness.creaProntoFn, createChatImageStore: chatImages.createChatImageStore }; }
250
251
  async function loadTrustSupport(repoRoot) {
251
252
  const src = join(repoRoot, 'harness-ui', 'src');
252
253
  const [plugins, hooks, mcp, mcpSession, pluginSession] = await Promise.all([import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'hook-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'mcp-registry.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'mcp-session.mjs')).href)), import(__rewriteRelativeImportExtension(pathToFileURL(join(src, 'plugin-session.mjs')).href))]);
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const CLI_VERSION = '0.2.0';
1
+ export const CLI_VERSION = '0.2.1';
2
2
  export const TALOS_BASE_COMMIT = '0c432153a288f64237e98403869d02d20d8fabc7';
@@ -60,6 +60,24 @@ function parseRecord(text, expectedRoot) {
60
60
  throw coded('CHECKPOINT_STORE_INVALID');
61
61
  return value;
62
62
  }
63
+ const STAT_INDEX_SCHEMA = 'talos.cli.checkpoint-stat-index.v1';
64
+ async function loadStatIndex(indexPath) {
65
+ const rows = new Map();
66
+ try {
67
+ const value = JSON.parse(await readFile(indexPath, 'utf8'));
68
+ if (value?.schema !== STAT_INDEX_SCHEMA || !Array.isArray(value.rows))
69
+ return rows;
70
+ for (const row of value.rows) {
71
+ if (!Array.isArray(row) || row.length !== 6)
72
+ continue;
73
+ const [p, size, mtimeMs, ctimeMs, ref, verifiedAtMs] = row;
74
+ if (typeof p === 'string' && typeof ref === 'string' && /^[0-9a-f]{64}$/u.test(ref) && [size, mtimeMs, ctimeMs, verifiedAtMs].every(n => typeof n === 'number' && Number.isFinite(n)))
75
+ rows.set(p, { size, mtimeMs, ctimeMs, ref, verifiedAtMs });
76
+ }
77
+ }
78
+ catch { /* missing or torn: an empty index, every file is read again */ }
79
+ return rows;
80
+ }
63
81
  function dedupeEvidence(rows) { const byKey = new Map(); for (const row of rows)
64
82
  byKey.set(`${row.path}\0${row.beforeHash ?? ''}\0${row.afterHash ?? ''}`, row); return [...byKey.values()]; }
65
83
  export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOINT_LIMITS }) {
@@ -85,7 +103,20 @@ export function createCheckpointStore({ rootDir, projectRoot, limits = CHECKPOIN
85
103
  } }
86
104
  async function replace(record) { const x = await init(); if (record.canonicalRoot !== x.canonicalRoot)
87
105
  throw coded('CHECKPOINT_WORKSPACE_MISMATCH'); await atomicWrite(await recordPath(record.id), `${JSON.stringify(record)}\n`); }
88
- async function capture() { const x = await init(); return captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits }); }
106
+ /* P18: the stat index of the last capture (checkpoint.ts StatCache), kept in memory and in the workspace's folder so a new
107
+ `talos -p` process starts from it too. It is only a cache: unreadable means empty (every file read again), and a
108
+ failed save costs speed on the next turn, never a checkpoint. */
109
+ let statRows = null;
110
+ async function capture() {
111
+ const x = await init();
112
+ const indexPath = path.join(x.workspaceDir, 'stat-index.json');
113
+ statRows ??= await loadStatIndex(indexPath);
114
+ const next = new Map();
115
+ const snapshot = await captureWorkspaceSnapshot({ projectRoot: x.canonicalRoot, blobRoot, limits, statCache: { previous: statRows, next } });
116
+ statRows = next;
117
+ await atomicWrite(indexPath, `${JSON.stringify({ schema: STAT_INDEX_SCHEMA, rows: [...next].map(([p, r]) => [p, r.size, r.mtimeMs, r.ctimeMs, r.ref, r.verifiedAtMs]) })}\n`).catch(() => { });
118
+ return snapshot;
119
+ }
89
120
  async function list() { const x = await init(); let names = []; try {
90
121
  names = await readdir(x.recordsDir);
91
122
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { resolveWorkspaceIdentity } from "../security/workspace-identity.js";
7
7
  export const CHECKPOINT_LIMITS = { maxEntries: 50_000, maxStoredBytes: 256 * 1024 * 1024, maxStoredFileBytes: 64 * 1024 * 1024 };
8
+ const RACY_MARGIN_MS = 2_000, BLOB_SYNC_BATCH = 16;
8
9
  function coded(code, message = code, details = {}) { return Object.assign(new Error(message === code ? code : `${code}: ${message}`), { code, details }); }
9
10
  function sha256(data) { return createHash('sha256').update(data).digest('hex'); }
10
11
  function slash(value) { return value.split(path.sep).join('/'); }
@@ -59,15 +60,15 @@ catch (error) {
59
60
  if (process.platform !== 'win32')
60
61
  throw error;
61
62
  } }
62
- async function storeBlob(blobRoot, data) {
63
+ async function storeBlob(blobRoot, data, budget) {
63
64
  const hash = sha256(data), target = path.join(blobRoot, hash);
64
- await ensurePrivateDir(blobRoot);
65
+ if (budget.blobs.has(hash))
66
+ return hash;
65
67
  const temp = path.join(blobRoot, `.${hash}.${process.pid}.tmp`);
66
68
  try {
67
69
  const handle = await open(temp, 'wx', 0o600);
68
70
  try {
69
71
  await handle.writeFile(data);
70
- await handle.sync();
71
72
  }
72
73
  finally {
73
74
  await handle.close();
@@ -76,14 +77,35 @@ async function storeBlob(blobRoot, data) {
76
77
  await rm(temp, { force: true });
77
78
  return;
78
79
  } throw error; });
79
- await durableDir(blobRoot);
80
80
  }
81
81
  catch (error) {
82
82
  if (error?.code !== 'EEXIST')
83
83
  throw error;
84
84
  }
85
+ budget.blobs.add(hash);
86
+ budget.pending.push(target);
85
87
  return hash;
86
88
  }
89
+ /* Every blob written by this capture is durable before the capture returns (and so before its record is written); a sync
90
+ error is never swallowed (gate E8/E9). */
91
+ async function syncNewBlobs(blobRoot, budget) {
92
+ if (budget.pending.length === 0)
93
+ return;
94
+ for (let i = 0; i < budget.pending.length; i += BLOB_SYNC_BATCH)
95
+ await Promise.all(budget.pending.slice(i, i + BLOB_SYNC_BATCH).map(durableFile));
96
+ budget.pending = [];
97
+ await durableDir(blobRoot);
98
+ }
99
+ async function assertFileContained(workspace, absolute, budget) {
100
+ const dir = path.dirname(absolute);
101
+ let canonical = budget.dirs.get(dir);
102
+ if (canonical === undefined) {
103
+ canonical = await realpath(dir);
104
+ budget.dirs.set(dir, canonical);
105
+ }
106
+ if (!inside(workspace.canonicalRoot, canonical))
107
+ throw coded('CHECKPOINT_PATH_ESCAPE', `Checkpoint path escapes workspace: ${absolute}`);
108
+ }
87
109
  function consumeFileBudget(budget, size) {
88
110
  if (size > budget.limits.maxStoredFileBytes)
89
111
  throw coded('CHECKPOINT_FILE_TOO_LARGE');
@@ -178,10 +200,13 @@ async function captureActualFile(workspace, absolute, relative, blobRoot, budget
178
200
  }
179
201
  if (!stat.isFile())
180
202
  throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
181
- await assertExistingPathContained(workspace, absolute);
203
+ await assertFileContained(workspace, absolute, budget);
182
204
  consumeFileBudget(budget, stat.size);
183
- const data = await readFile(absolute);
184
- const ref = await storeBlob(blobRoot, data);
205
+ const seen = budget.cache?.previous.get(relative);
206
+ const unchanged = Boolean(seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs && seen.ctimeMs === stat.ctimeMs
207
+ && stat.mtimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && stat.ctimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && budget.blobs.has(seen.ref));
208
+ const ref = unchanged ? seen.ref : await storeBlob(blobRoot, await readFile(absolute), budget);
209
+ budget.cache?.next.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ref, verifiedAtMs: budget.startedAtMs });
185
210
  const executable = process.platform === 'win32' ? indexExecutable : Boolean(stat.mode & 0o111);
186
211
  return { kind: 'file', source: 'blob', ref, size: stat.size, mode: stat.mode & 0o777, executable };
187
212
  }
@@ -249,7 +274,7 @@ async function captureGit(workspace, blobRoot, budget) {
249
274
  continue;
250
275
  }
251
276
  consumeEntry(budget);
252
- await assertExistingPathContained(workspace, absolute);
277
+ await assertFileContained(workspace, absolute, budget);
253
278
  entries[relative] = { kind: 'file', source: 'git', ref: oid, size: stat.size, mode: stat.mode & 0o777, executable: mode === '100755', gitPath };
254
279
  }
255
280
  for (const gitPath of nulList(untrackedRaw)) {
@@ -302,7 +327,7 @@ async function captureNonGit(workspace, blobRoot, budget) {
302
327
  }
303
328
  if (!stat.isFile())
304
329
  throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
305
- await assertExistingPathContained(workspace, absolute);
330
+ await assertFileContained(workspace, absolute, budget);
306
331
  consumeFileBudget(budget, stat.size);
307
332
  }
308
333
  }
@@ -328,14 +353,17 @@ async function captureNonGit(workspace, blobRoot, budget) {
328
353
  await walk(workspace.canonicalRoot, '');
329
354
  return { capturedAt: new Date().toISOString(), workspace: { canonicalRoot: workspace.canonicalRoot, identityHash: workspace.identityHash, gitRoot: null }, entries, git: null, coverage: { tracked: false, untrackedNonIgnored: true, ignoredUntracked: true, gitMetadataRestored: false }, exclusions: ['Filesystem metadata other than file content, executable bit and symlink target is outside rollback coverage.', 'Empty-directory existence is outside rollback coverage.'] };
330
355
  }
331
- export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS }) {
356
+ export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS, statCache }) {
357
+ const startedAtMs = Date.now();
332
358
  const workspace = await resolveWorkspaceIdentity({ projectRoot });
333
359
  await ensurePrivateDir(blobRoot);
334
360
  const canonicalBlobRoot = await realpath(blobRoot);
335
361
  if (inside(workspace.canonicalRoot, canonicalBlobRoot))
336
362
  throw coded('CHECKPOINT_STORE_INSIDE_WORKSPACE');
337
- const budget = { entries: 0, storedBytes: 0, limits };
338
- return workspace.gitRoot ? captureGit(workspace, canonicalBlobRoot, budget) : captureNonGit(workspace, canonicalBlobRoot, budget);
363
+ const budget = { entries: 0, storedBytes: 0, limits, startedAtMs, blobs: new Set(await readdir(canonicalBlobRoot)), pending: [], dirs: new Map(), cache: statCache ?? null };
364
+ const snapshot = workspace.gitRoot ? await captureGit(workspace, canonicalBlobRoot, budget) : await captureNonGit(workspace, canonicalBlobRoot, budget);
365
+ await syncNewBlobs(canonicalBlobRoot, budget);
366
+ return snapshot;
339
367
  }
340
368
  export function checkpointMutationAllowed(mode) { return mode !== 'plan'; }
341
369
  export function extractWorkspaceWriteEvidence(raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talos-code",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "TALOS in the terminal: a coding agent with per-tool permissions, file checkpoints and resumable sessions, on the model you choose.",
5
5
  "keywords": [
6
6
  "coding-agent",
@@ -34,6 +34,7 @@
34
34
  "@microsoft/mxc-sdk": "0.8.0",
35
35
  "@modelcontextprotocol/client": "2.0.0",
36
36
  "@napi-rs/keyring": "1.3.0",
37
+ "@vscode/ripgrep": "1.18.0",
37
38
  "ai": "7.0.93",
38
39
  "diff": "9.0.0",
39
40
  "docx": "9.5.1",
@@ -3662,6 +3662,123 @@ async function tuttiIPercorsi(disco, { filtro = null } = {}) {
3662
3662
  return { percorsi, dimensioni, stato }
3663
3663
  }
3664
3664
 
3665
+ /**
3666
+ * ⭐⭐⭐ P18 fase 3 (owner 26/09/2026: «TALOS più veloce di tutti»; decisioni: ripgrep DENTRO il pacchetto,
3667
+ * righe trovate con un tetto) — `cerca` nel contenuto con ripgrep.
3668
+ *
3669
+ * Misurato sul banco ermetico (`cli/scripts/bench-harness.ts`, 26/09): quattro `cerca` su un albero di 6.000 file
3670
+ * costavano **9,3 s** (una lettura JS alla volta, fino a `MAX_FILE_LETTI`), e su questo monorepo (6.405 file visibili)
3671
+ * la ricerca si fermava a 5.000 letture: **incompleta**, e una parola assente risultava «non c'e'». Pi fa `rg --json`
3672
+ * (`coding-agent/src/core/tools/grep.ts:119-168`), Claude Code ha ripgrep incluso (`USE_BUILTIN_RIPGREP`), Codex lo usa
3673
+ * dalla shell. ⇒ Stessa semantica di prima (sottostringa, maiuscole indifferenti, `.gitignore` anche fuori da un repo,
3674
+ * `.git`/`node_modules` sempre fuori, i binari saltati), ma ogni risultato porta **riga e testo** (`percorso:riga:testo`):
3675
+ * il modello non deve riaprire il file per sapere dove.
3676
+ *
3677
+ * Il binario: `TALOS_RG_PATH` (la CLI lo mette da `@vscode/ripgrep`, che e' nel suo pacchetto, senza rete), altrimenti
3678
+ * `@vscode/ripgrep` se risolvibile da qui. ⛔ Assente, rotto o in timeout ⇒ `null`, e `cercaNelProgetto` prosegue con la
3679
+ * camminata JS di sempre: nessuna installazione perde la ricerca.
3680
+ */
3681
+ const MAX_CERCA_INSIEME = 8
3682
+ const MAX_RIGHE_RG = 100
3683
+ const MAX_RIGHE_PER_FILE = 5
3684
+ const MAX_COLONNE_RG = 200
3685
+ const MAX_BYTE_RG = 4_000_000
3686
+ const TEMPO_MAX_RG_MS = 20_000
3687
+ let ripgrepRisolto = null
3688
+ function percorsoRipgrep() {
3689
+ const daAmbiente = process.env.TALOS_RG_PATH
3690
+ if (daAmbiente) return Promise.resolve(existsSync(daAmbiente) ? daAmbiente : null)
3691
+ ripgrepRisolto ??= import('@vscode/ripgrep').then(
3692
+ (m) => (typeof m?.rgPath === 'string' && existsSync(m.rgPath) ? m.rgPath : null),
3693
+ () => null)
3694
+ return ripgrepRisolto
3695
+ }
3696
+ function eseguiRipgrep(rg, argomenti, cartella, segnale) {
3697
+ return new Promise((fine) => {
3698
+ let figlio
3699
+ try { figlio = spawn(rg, argomenti, { cwd: cartella, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) }
3700
+ catch { fine(null); return }
3701
+ const pezzi = []
3702
+ let byte = 0, tagliato = false
3703
+ const orologio = setTimeout(() => { tagliato = true; figlio.kill() }, TEMPO_MAX_RG_MS)
3704
+ /* Lo Stop ferma anche la ricerca IN VOLO (vincolo del desktop, 26/09): si uccide rg e si dice «fermata». */
3705
+ let fermato = false
3706
+ const alloStop = () => { fermato = true; figlio.kill() }
3707
+ if (segnale?.aborted) alloStop(); else segnale?.addEventListener?.('abort', alloStop, { once: true })
3708
+ figlio.stdout.on('data', (pezzo) => {
3709
+ if (tagliato) return
3710
+ byte += pezzo.length
3711
+ pezzi.push(pezzo)
3712
+ if (byte >= MAX_BYTE_RG) { tagliato = true; figlio.kill() }
3713
+ })
3714
+ figlio.on('error', () => { clearTimeout(orologio); fine(null) })
3715
+ figlio.on('close', (codice) => {
3716
+ clearTimeout(orologio)
3717
+ segnale?.removeEventListener?.('abort', alloStop)
3718
+ if (fermato) { fine({ testo: '', tagliato: false, scaduto: false, fermato: true }); return }
3719
+ const testo = Buffer.concat(pezzi).toString('utf8')
3720
+ /* 0 = trovato, 1 = niente; 2 = errori (un file illeggibile) con o senza risultati. Un 2 senza uscita,
3721
+ o un processo ucciso senza niente, non e' «non c'e'»: e' un guasto, e decide il ripiego JS. */
3722
+ if (codice === 0 || codice === 1 || testo) fine({ testo, tagliato: tagliato && byte >= MAX_BYTE_RG, scaduto: tagliato && byte < MAX_BYTE_RG })
3723
+ else fine(null)
3724
+ })
3725
+ })
3726
+ }
3727
+ const percorsoDaRg = (p) => p.replace(/\\/gu, '/').replace(/^\.\//u, '')
3728
+ async function cercaConRipgrep(radice, testo, chiaveNome, segnale) {
3729
+ const rg = await percorsoRipgrep()
3730
+ if (!rg) return null
3731
+ const comuni = ['--no-config', '--hidden', '--no-require-git', '--glob', '!.git', '--glob', '!node_modules']
3732
+ /* Il ripiego delle cartelle potate vale, come prima, solo quando non c'e' un `.gitignore` da cui leggere. */
3733
+ if (!existsSync(join(radice, '.gitignore'))) for (const c of POTATE_SENZA_GITIGNORE) comuni.push('--glob', `!${c}`)
3734
+ const ricerca = await eseguiRipgrep(rg, [...comuni, '--line-number', '--with-filename', '--no-heading', '--color', 'never',
3735
+ '--fixed-strings', '--ignore-case', '--max-count', String(MAX_RIGHE_PER_FILE), '--max-columns', String(MAX_COLONNE_RG),
3736
+ '--max-columns-preview', '--max-filesize', String(MAX_BYTE_FILE), '-e', testo, '--', '.'], radice, segnale)
3737
+ if (ricerca === null) return null
3738
+ if (ricerca.fermato) return 'stopped: the search was interrupted.'
3739
+ /* rg lavora in parallelo e non promette un ordine: si ordina qui, cosi' la stessa domanda da' la stessa risposta. */
3740
+ const perFile = new Map()
3741
+ for (const riga of ricerca.testo.split(/\r?\n/u)) {
3742
+ const m = /^(.+?):(\d+):(.*)$/u.exec(riga)
3743
+ if (!m) continue
3744
+ const percorso = percorsoDaRg(m[1])
3745
+ if (!perFile.has(percorso)) perFile.set(percorso, [])
3746
+ perFile.get(percorso).push(`${percorso}:${m[2]}:${m[3].trim()}`)
3747
+ }
3748
+ let perNome = []
3749
+ if (chiaveNome) {
3750
+ const elenco = await eseguiRipgrep(rg, [...comuni, '--files', '.'], radice, segnale)
3751
+ if (elenco === null) return null
3752
+ if (elenco.fermato) return 'stopped: the search was interrupted.'
3753
+ perNome = elenco.testo.split(/\r?\n/u).filter(Boolean).map(percorsoDaRg).filter((p) => p.toLowerCase().includes(chiaveNome)).sort()
3754
+ }
3755
+ const avvisi = []
3756
+ if (ricerca.scaduto) avvisi.push(`⚠ incomplete scan: the search took longer than ${TEMPO_MAX_RG_MS / 1000} s and was stopped. Search inside a subfolder.`)
3757
+ if (ricerca.tagliato) avvisi.push('⚠ incomplete scan: too much output — the matches below are a part of them. Narrow the search.')
3758
+ const coda = avvisi.length > 0 ? `\n${avvisi.join('\n')}` : ''
3759
+ const file = [...perFile.keys()].sort()
3760
+ if (file.length === 0 && perNome.length === 0) {
3761
+ return `no file matches "${testo}" (searched with ripgrep; .gitignore respected, binaries skipped). Try a shorter or different "testo".${coda}`
3762
+ }
3763
+ const righe = perNome.slice(0, MAX_RISULTATI)
3764
+ let righeTesto = 0, fileMostrati = 0
3765
+ for (const f of file) {
3766
+ if (fileMostrati >= MAX_RISULTATI || righeTesto >= MAX_RIGHE_RG) break
3767
+ const trovate = perFile.get(f).slice(0, MAX_RIGHE_RG - righeTesto)
3768
+ righe.push(...trovate)
3769
+ righeTesto += trovate.length
3770
+ fileMostrati += 1
3771
+ }
3772
+ const fileTagliati = file.length - fileMostrati
3773
+ const nomiTagliati = perNome.length - Math.min(perNome.length, MAX_RISULTATI)
3774
+ return righe.join('\n')
3775
+ + (nomiTagliati > 0 ? `\n… and ${nomiTagliati} more paths matching "${chiaveNome}" not shown — narrow the search.` : '')
3776
+ // ⛔ Il taglio si DICHIARA, come nella camminata JS: «40 file» non deve leggersi come «sono 40».
3777
+ + (fileTagliati > 0 ? `\n… and ${fileTagliati} more files with matches not shown — narrow the search.` : '')
3778
+ + (file.some((f) => perFile.get(f).length >= MAX_RIGHE_PER_FILE) ? `\n(at most ${MAX_RIGHE_PER_FILE} matching lines per file are shown)` : '')
3779
+ + coda
3780
+ }
3781
+
3665
3782
  /**
3666
3783
  * ⭐⭐⭐ CERCA — l'attrezzo che toglie la cecita', senza comprare l'inventario.
3667
3784
  *
@@ -3685,10 +3802,16 @@ async function tuttiIPercorsi(disco, { filtro = null } = {}) {
3685
3802
  * @param {{radice?: string}} [opzioni] — la cartella VERA, per leggere il `.gitignore`.
3686
3803
  * Assente (test, ponte del telefono) ⇒ si usa il ripiego `POTATE_SENZA_GITIGNORE`.
3687
3804
  */
3688
- export async function cercaNelProgetto(disco, { testo, nome }, { radice } = {}) {
3805
+ export async function cercaNelProgetto(disco, { testo, nome }, { radice, segnale } = {}) {
3689
3806
  const chiaveTesto = String(testo ?? '').trim().toLowerCase()
3690
3807
  const chiaveNome = String(nome ?? '').trim().toLowerCase()
3691
3808
  if (!chiaveTesto && !chiaveNome) return 'give at least one of "testo" or "nome".'
3809
+ /* P18 fase 3: con la cartella vera e un ripgrep disponibile, la ricerca nel contenuto la fa `rg`.
3810
+ `null` = rg assente o fallito: si prosegue con la camminata JS qui sotto, invariata. */
3811
+ if (chiaveTesto && radice) {
3812
+ const conRg = await cercaConRipgrep(radice, String(testo).trim(), chiaveNome, segnale)
3813
+ if (conRg !== null) return conRg
3814
+ }
3692
3815
 
3693
3816
  const filtro = await filtroDelProgetto(radice)
3694
3817
  const { percorsi, dimensioni, stato } = await tuttiIPercorsi(disco, { filtro })
@@ -8136,6 +8259,30 @@ export async function talosLavora({
8136
8259
  break
8137
8260
  }
8138
8261
 
8262
+ /*
8263
+ * ⭐⭐ P18 fase 2 (owner 26/09/2026, patch approvata dalla lane desktop) — le `cerca` della STESSA risposta
8264
+ * partono INSIEME, fino a `MAX_CERCA_INSIEME` (Hermes 8, `_MAX_TOOL_WORKERS`; Claude Code 10, i blocchi
8265
+ * «concurrency-safe» dello `StreamingToolExecutor`). Il ciclo qui sotto resta quello di sempre: hook,
8266
+ * approvazioni, eventi `tool-inizio`/`tool-esito` e risultati nella storia, tutto nell'ordine delle chiamate;
8267
+ * il ramo `cerca` aspetta la SUA ricerca già partita invece di avviarla.
8268
+ * ⛔ Solo `cerca`: e' pura (legge dentro la cartella, non chiede approvazioni) e un rifiuto del pre-hook su una
8269
+ * lettura e' gia' ignorato (vedi FASE A qui sotto). `leggi` no: un percorso fuori dal progetto chiede
8270
+ * l'approvazione, e quella va chiesta in ordine, prima di leggere.
8271
+ * ⛔ Lo Stop ferma anche quelle in volo: il segnale arriva fino a rg, che viene ucciso.
8272
+ * Misura (banco ermetico, 4 `cerca` su 6.000 file): 8.648 ms prima di P18, 377 con ripgrep in fila.
8273
+ */
8274
+ const ricercheAvviate = new Map()
8275
+ const daCercare = chiamate.filter((c) => c.function?.name === 'cerca')
8276
+ if (daCercare.length > 1 && !segnaleStop?.aborted) {
8277
+ for (const c of daCercare.slice(0, MAX_CERCA_INSIEME)) {
8278
+ let argomentiCerca = {}
8279
+ try { argomentiCerca = JSON.parse(c.function?.arguments || '{}') } catch { /* vuoto: il ramo dira' cosa manca */ }
8280
+ const avviata = cercaNelProgetto(disco, argomentiCerca, { radice: cartella, segnale: segnaleStop })
8281
+ avviata.catch(() => {})
8282
+ ricercheAvviate.set(c, avviata)
8283
+ }
8284
+ }
8285
+
8139
8286
  let fermatoDentroIlGiro = false
8140
8287
  for (const c of chiamate) {
8141
8288
  /*
@@ -8232,7 +8379,7 @@ export async function talosLavora({
8232
8379
  * potare. Senza, `cerca` ricade sulla lista fissa — che e' il ripiego per il ponte
8233
8380
  * del telefono e per i test, non il caso normale.
8234
8381
  */
8235
- esito = await cercaNelProgetto(disco, argomenti, { radice: cartella })
8382
+ esito = await (ricercheAvviate.get(c) ?? cercaNelProgetto(disco, argomenti, { radice: cartella, segnale: segnaleStop }))
8236
8383
  }
8237
8384
  else if (nome === 'leggi') {
8238
8385
  /*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": "talos.cli.vendor.v1",
3
- "sourceCommit": "f54beedcbd8fb3c6c735881e91dab21fbe13bc1b",
3
+ "sourceCommit": "9a10705c95b865e72c431f8d8ad7873bcdd430b5",
4
4
  "files": 163,
5
5
  "sha256": {
6
6
  "context-engine/package.json": "cd416c2ef02241365eca721ce1bed7a702cff34bb804d79e974f9f36731ced8a",
@@ -73,7 +73,7 @@
73
73
  "harness-ui/src/image-generator.mjs": "8762739d578f81d1b881d24338518480e59fcae5b8a3ff6a071947652bec8008",
74
74
  "harness-ui/src/istruzioni-di-progetto.mjs": "a0c2c9dec9d119799e3bc02b1aee99ffa996d2c141ba679345f7309495a29cff",
75
75
  "harness-ui/src/kernel/dist/kernelPerIlBanco.js": "ff75a3e93b6c6d6d838331016c5eb2d1e6acfea85ffc0609d2f531f07b202a54",
76
- "harness-ui/src/kernel/talosHarness.mjs": "cd41c71a874ffaa25ba456ec98a664265704e71dcfd63eeb1a4216a5924ba613",
76
+ "harness-ui/src/kernel/talosHarness.mjs": "c03a8d1e155a8f4f31793e687f7b26e5ee625ff3a3dcfc737dde6f8cb0fca32a",
77
77
  "harness-ui/src/library-policy-store.mjs": "b0295e20d7348cc13cb23a8ad2a0b577fe275b4778b94e102ceeb4fb1bb4c70b",
78
78
  "harness-ui/src/library-store.mjs": "b7a0333da5783361536509f9108e1293b1e8c22bddfb02fd5581a4e1ed4bcd4f",
79
79
  "harness-ui/src/llama-server-supervisor.mjs": "d59cc22a0e7875b4deeb4a4eac1daa55c3f524db7f5f9bd1f5e58f2c4be261e3",