talos-code 0.2.0 → 0.2.2
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 +53 -0
- package/dist/runtime/context-archive.js +20 -1
- package/dist/runtime/repo.js +11 -0
- package/dist/runtime/talos-composition.js +2 -1
- package/dist/version.js +1 -1
- package/dist/workspace/checkpoint-store.js +32 -1
- package/dist/workspace/checkpoint.js +84 -51
- package/package.json +2 -1
- package/vendor/harness-ui/src/context-desktop-service.mjs +19 -4
- package/vendor/harness-ui/src/kernel/talosHarness.mjs +160 -2
- package/vendor/harness-ui/src/workspace-info.mjs +7 -2
- package/vendor/manifest.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,59 @@ 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.2 — 2026-09-27
|
|
9
|
+
|
|
10
|
+
`npm i -g talos-code@0.2.2`, or `talos update` to see it.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Faster turns again. The `/undo` snapshot looks at the files of a folder together instead of one by one: a turn in a
|
|
14
|
+
folder of 6,000 files went from 1.8 s to 1.25 s (1.4 s to 1.28 s in a git repository), on par with Pi.
|
|
15
|
+
- Long sessions no longer slow down at every tool result: the context archive is written once per tool exchange instead
|
|
16
|
+
of after every result, and is no longer read back whole at every write. At 1,350 messages, a turn with six tools
|
|
17
|
+
spends 24 ms on it instead of 160 ms.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- A search asked in the same answer AFTER a file write could miss what that write had just changed (0.2.1: the searches
|
|
21
|
+
of one answer started together, before the write). Only the searches before any other tool start together now.
|
|
22
|
+
- Stopping a turn also stops a search that is still walking the project when ripgrep is not available.
|
|
23
|
+
|
|
24
|
+
### Known limits
|
|
25
|
+
- Tested on Windows 11 x64 only; Node.js 24 (24.18 or later in the 24 line).
|
|
26
|
+
- Shell commands run one at a time, each in its sandbox: the safety check of each command depends on what the
|
|
27
|
+
previous ones did.
|
|
28
|
+
|
|
29
|
+
### Verification
|
|
30
|
+
- Hermetic bench (`scripts/bench-harness.ts`, a git repository of 6,000 files, local fake provider): a turn with no
|
|
31
|
+
tool 1.13 s (Pi 1.23, OpenCode 2.69, Codex 0.50); six reads 1.10 s (Pi 1.23, Codex 1.98, OpenCode 3.34); four
|
|
32
|
+
searches 1.32 s (Pi 1.59, Codex 1.71, OpenCode 3.61).
|
|
33
|
+
- CLI unit suite: 1879 tests, 1875 pass, 4 skip, 0 fail. Acceptance on the built CLI in a synthetic terminal: 263/263.
|
|
34
|
+
The package proof (`scripts/prove-standalone.ts`): 16/16 on `talos-code-0.2.2.tgz`.
|
|
35
|
+
|
|
36
|
+
## talos-cli-v0.2.1 — 2026-09-26
|
|
37
|
+
|
|
38
|
+
`npm i -g talos-code@0.2.1`, or `talos update` to see it.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
- Faster turns in large folders. The workspace snapshot that makes `/undo` possible no longer reads, rewrites and syncs
|
|
42
|
+
every file at every turn: a file that has not changed since the last turn reuses what was stored. A turn in a plain
|
|
43
|
+
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
|
|
44
|
+
`/undo` covers is unchanged.
|
|
45
|
+
- Faster and complete project search. `cerca` runs ripgrep, shipped inside the package for your platform (no download),
|
|
46
|
+
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.
|
|
47
|
+
Before, a search stopped after reading 5,000 files and could miss matches in a bigger project. Several searches asked
|
|
48
|
+
in the same answer run together. Without ripgrep, the previous search still works.
|
|
49
|
+
|
|
50
|
+
### Known limits
|
|
51
|
+
- Tested on Windows 11 x64 only; Node.js 24 (24.18 or later in the 24 line).
|
|
52
|
+
- Shell commands still run one at a time, each in its sandbox; a start of about one second remains.
|
|
53
|
+
|
|
54
|
+
### Verification
|
|
55
|
+
- Measured on a hermetic bench (`scripts/bench-harness.ts`): a local fake provider, no network, TALOS against Pi, Codex
|
|
56
|
+
and OpenCode on the same turns.
|
|
57
|
+
- CLI unit suite: 1876 tests, 1872 pass, 4 skip, 0 fail. Acceptance on the built CLI in a synthetic terminal: 263/263.
|
|
58
|
+
- The package proof (`scripts/prove-standalone.ts`), 16/16 on `talos-code-0.2.1.tgz` (1.76 MB); the installed package
|
|
59
|
+
resolves its own ripgrep.
|
|
60
|
+
|
|
8
61
|
## talos-cli-v0.2.0 — 2026-09-26
|
|
9
62
|
|
|
10
63
|
`npm i -g talos-code@0.2.0`, or `talos update` to see it from 0.1.0.
|
|
@@ -61,9 +61,28 @@ export function withArchivableCapture(hooks, { stalePatienceMs = STALE_PATIENCE_
|
|
|
61
61
|
if (!hooks || typeof hooks.capture !== 'function')
|
|
62
62
|
return hooks;
|
|
63
63
|
const capture = hooks.capture, prepare = hooks.prepare;
|
|
64
|
+
/*
|
|
65
|
+
* P18 phase 1 (owner 2026-09-26). The kernel captures at 'start', 'before-request', 'response', after EVERY tool result
|
|
66
|
+
* and at 'finished'; with the rule above most of those carry the prefix already archived (a tool exchange is archived
|
|
67
|
+
* once, when its last result is in), and each still cost the service a full reread of the archive and a sha256 of
|
|
68
|
+
* every message (context-desktop-service.mjs syncOriginals): ~25 ms a capture at 1,351 messages, measured with
|
|
69
|
+
* scripts/bench-tools.ts. A prefix with the same length, first and last message as the last one archived is not sent
|
|
70
|
+
* again. Any other change (longer, shorter, a message replaced) still goes to the service, which decides.
|
|
71
|
+
*/
|
|
72
|
+
let archived = null;
|
|
73
|
+
const sameAsArchived = (rows) => archived !== null && rows.length === archived.length && rows[0] === archived.first && rows[rows.length - 1] === archived.last;
|
|
64
74
|
return {
|
|
65
75
|
...hooks,
|
|
66
|
-
capture: (input) =>
|
|
76
|
+
capture: async (input) => {
|
|
77
|
+
if (!Array.isArray(input?.messages))
|
|
78
|
+
return retryStale(() => capture(input), stalePatienceMs);
|
|
79
|
+
const rows = archivableHistory(input.messages);
|
|
80
|
+
if (sameAsArchived(rows))
|
|
81
|
+
return undefined;
|
|
82
|
+
const result = await retryStale(() => capture({ ...input, messages: rows }), stalePatienceMs);
|
|
83
|
+
archived = { length: rows.length, first: rows[0], last: rows[rows.length - 1] };
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
67
86
|
...(typeof prepare === 'function' ? { prepare: (input) => retryStale(() => prepare(input), stalePatienceMs) } : {}),
|
|
68
87
|
};
|
|
69
88
|
}
|
package/dist/runtime/repo.js
CHANGED
|
@@ -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.
|
|
1
|
+
export const CLI_VERSION = '0.2.2';
|
|
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
|
-
|
|
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,17 @@ 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, CAPTURE_CONCURRENCY = 32;
|
|
9
|
+
/* Runs `fn` over `items` with at most `limit` in flight and returns the results in the items' order. */
|
|
10
|
+
async function inOrder(items, limit, fn) {
|
|
11
|
+
const out = new Array(items.length);
|
|
12
|
+
let next = 0;
|
|
13
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => { while (next < items.length) {
|
|
14
|
+
const i = next++;
|
|
15
|
+
out[i] = await fn(items[i]);
|
|
16
|
+
} }));
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
8
19
|
function coded(code, message = code, details = {}) { return Object.assign(new Error(message === code ? code : `${code}: ${message}`), { code, details }); }
|
|
9
20
|
function sha256(data) { return createHash('sha256').update(data).digest('hex'); }
|
|
10
21
|
function slash(value) { return value.split(path.sep).join('/'); }
|
|
@@ -59,15 +70,15 @@ catch (error) {
|
|
|
59
70
|
if (process.platform !== 'win32')
|
|
60
71
|
throw error;
|
|
61
72
|
} }
|
|
62
|
-
async function storeBlob(blobRoot, data) {
|
|
73
|
+
async function storeBlob(blobRoot, data, budget) {
|
|
63
74
|
const hash = sha256(data), target = path.join(blobRoot, hash);
|
|
64
|
-
|
|
75
|
+
if (budget.blobs.has(hash))
|
|
76
|
+
return hash;
|
|
65
77
|
const temp = path.join(blobRoot, `.${hash}.${process.pid}.tmp`);
|
|
66
78
|
try {
|
|
67
79
|
const handle = await open(temp, 'wx', 0o600);
|
|
68
80
|
try {
|
|
69
81
|
await handle.writeFile(data);
|
|
70
|
-
await handle.sync();
|
|
71
82
|
}
|
|
72
83
|
finally {
|
|
73
84
|
await handle.close();
|
|
@@ -76,14 +87,35 @@ async function storeBlob(blobRoot, data) {
|
|
|
76
87
|
await rm(temp, { force: true });
|
|
77
88
|
return;
|
|
78
89
|
} throw error; });
|
|
79
|
-
await durableDir(blobRoot);
|
|
80
90
|
}
|
|
81
91
|
catch (error) {
|
|
82
92
|
if (error?.code !== 'EEXIST')
|
|
83
93
|
throw error;
|
|
84
94
|
}
|
|
95
|
+
budget.blobs.add(hash);
|
|
96
|
+
budget.pending.push(target);
|
|
85
97
|
return hash;
|
|
86
98
|
}
|
|
99
|
+
/* Every blob written by this capture is durable before the capture returns (and so before its record is written); a sync
|
|
100
|
+
error is never swallowed (gate E8/E9). */
|
|
101
|
+
async function syncNewBlobs(blobRoot, budget) {
|
|
102
|
+
if (budget.pending.length === 0)
|
|
103
|
+
return;
|
|
104
|
+
for (let i = 0; i < budget.pending.length; i += BLOB_SYNC_BATCH)
|
|
105
|
+
await Promise.all(budget.pending.slice(i, i + BLOB_SYNC_BATCH).map(durableFile));
|
|
106
|
+
budget.pending = [];
|
|
107
|
+
await durableDir(blobRoot);
|
|
108
|
+
}
|
|
109
|
+
async function assertFileContained(workspace, absolute, budget) {
|
|
110
|
+
const dir = path.dirname(absolute);
|
|
111
|
+
let canonical = budget.dirs.get(dir);
|
|
112
|
+
if (canonical === undefined) {
|
|
113
|
+
canonical = await realpath(dir);
|
|
114
|
+
budget.dirs.set(dir, canonical);
|
|
115
|
+
}
|
|
116
|
+
if (!inside(workspace.canonicalRoot, canonical))
|
|
117
|
+
throw coded('CHECKPOINT_PATH_ESCAPE', `Checkpoint path escapes workspace: ${absolute}`);
|
|
118
|
+
}
|
|
87
119
|
function consumeFileBudget(budget, size) {
|
|
88
120
|
if (size > budget.limits.maxStoredFileBytes)
|
|
89
121
|
throw coded('CHECKPOINT_FILE_TOO_LARGE');
|
|
@@ -178,10 +210,13 @@ async function captureActualFile(workspace, absolute, relative, blobRoot, budget
|
|
|
178
210
|
}
|
|
179
211
|
if (!stat.isFile())
|
|
180
212
|
throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
|
|
181
|
-
await
|
|
213
|
+
await assertFileContained(workspace, absolute, budget);
|
|
182
214
|
consumeFileBudget(budget, stat.size);
|
|
183
|
-
const
|
|
184
|
-
const
|
|
215
|
+
const seen = budget.cache?.previous.get(relative);
|
|
216
|
+
const unchanged = Boolean(seen && seen.size === stat.size && seen.mtimeMs === stat.mtimeMs && seen.ctimeMs === stat.ctimeMs
|
|
217
|
+
&& stat.mtimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && stat.ctimeMs < seen.verifiedAtMs - RACY_MARGIN_MS && budget.blobs.has(seen.ref));
|
|
218
|
+
const ref = unchanged ? seen.ref : await storeBlob(blobRoot, await readFile(absolute), budget);
|
|
219
|
+
budget.cache?.next.set(relative, { size: stat.size, mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, ref, verifiedAtMs: budget.startedAtMs });
|
|
185
220
|
const executable = process.platform === 'win32' ? indexExecutable : Boolean(stat.mode & 0o111);
|
|
186
221
|
return { kind: 'file', source: 'blob', ref, size: stat.size, mode: stat.mode & 0o777, executable };
|
|
187
222
|
}
|
|
@@ -213,6 +248,9 @@ async function captureGit(workspace, blobRoot, budget) {
|
|
|
213
248
|
}
|
|
214
249
|
const entries = {};
|
|
215
250
|
const seen = new Set();
|
|
251
|
+
/* The index is validated first, in order (unmerged, duplicate, gitlink: the first bad row decides), then the files are
|
|
252
|
+
looked at CAPTURE_CONCURRENCY at a time and their entries stored in index order. */
|
|
253
|
+
const jobs = [];
|
|
216
254
|
for (const row of trackedRows) {
|
|
217
255
|
const tab = row.indexOf('\t');
|
|
218
256
|
if (tab < 0)
|
|
@@ -231,60 +269,50 @@ async function captureGit(workspace, blobRoot, budget) {
|
|
|
231
269
|
if (!relGit)
|
|
232
270
|
continue;
|
|
233
271
|
const relative = safeRelative(relGit);
|
|
234
|
-
|
|
272
|
+
jobs.push({ relative, absolute: absoluteFromRelative(workspace.canonicalRoot, relative), mode, oid, gitPath });
|
|
273
|
+
}
|
|
274
|
+
const tracked = await inOrder(jobs, CAPTURE_CONCURRENCY, async ({ relative, absolute, mode, oid, gitPath }) => {
|
|
235
275
|
let stat;
|
|
236
276
|
try {
|
|
237
277
|
stat = await lstat(absolute);
|
|
238
278
|
}
|
|
239
279
|
catch (error) {
|
|
240
280
|
if (error?.code === 'ENOENT')
|
|
241
|
-
|
|
281
|
+
return null;
|
|
242
282
|
throw error;
|
|
243
283
|
}
|
|
244
284
|
const mustCapture = dirty.has(gitPath) || stat.isSymbolicLink() || !stat.isFile();
|
|
245
|
-
if (mustCapture)
|
|
246
|
-
|
|
247
|
-
if (actual)
|
|
248
|
-
entries[relative] = actual;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
285
|
+
if (mustCapture)
|
|
286
|
+
return captureActualFile(workspace, absolute, relative, blobRoot, budget, mode === '100755');
|
|
251
287
|
consumeEntry(budget);
|
|
252
|
-
await
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
continue;
|
|
262
|
-
const actual = await captureActualFile(workspace, absoluteFromRelative(workspace.canonicalRoot, relative), relative, blobRoot, budget);
|
|
263
|
-
if (actual)
|
|
264
|
-
entries[relative] = actual;
|
|
265
|
-
}
|
|
288
|
+
await assertFileContained(workspace, absolute, budget);
|
|
289
|
+
return { kind: 'file', source: 'git', ref: oid, size: stat.size, mode: stat.mode & 0o777, executable: mode === '100755', gitPath };
|
|
290
|
+
});
|
|
291
|
+
jobs.forEach((job, i) => { const entry = tracked[i]; if (entry)
|
|
292
|
+
entries[job.relative] = entry; });
|
|
293
|
+
const untracked = [...new Set(nulList(untrackedRaw).map(gitPath => prefix ? (gitPath.startsWith(`${prefix}/`) ? gitPath.slice(prefix.length + 1) : null) : gitPath).filter((relGit) => Boolean(relGit)).map(safeRelative))].filter(relative => !entries[relative]);
|
|
294
|
+
const captured = await inOrder(untracked, CAPTURE_CONCURRENCY, relative => captureActualFile(workspace, absoluteFromRelative(workspace.canonicalRoot, relative), relative, blobRoot, budget));
|
|
295
|
+
untracked.forEach((relative, i) => { const entry = captured[i]; if (entry)
|
|
296
|
+
entries[relative] = entry; });
|
|
266
297
|
return { capturedAt: new Date().toISOString(), workspace: { canonicalRoot: workspace.canonicalRoot, identityHash: workspace.identityHash, gitRoot }, entries, git: { root: gitRoot, head, indexHash, worktreeSemanticsHash }, coverage: { tracked: true, untrackedNonIgnored: true, ignoredUntracked: false, gitMetadataRestored: false }, exclusions: ['Untracked files ignored by Git are outside rollback coverage.', 'Empty-directory existence is outside rollback coverage.', 'Git refs, index and object database are observed for conflict evidence but are not restored.'] };
|
|
267
298
|
}
|
|
268
299
|
async function captureNonGit(workspace, blobRoot, budget) {
|
|
269
300
|
const entries = {};
|
|
301
|
+
/* Folders one at a time and in order; the files of a folder CAPTURE_CONCURRENCY at a time. */
|
|
270
302
|
async function preflight(dirname, relativeDir) {
|
|
271
303
|
const rows = await readdir(dirname, { withFileTypes: true });
|
|
272
304
|
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
273
|
-
|
|
305
|
+
const files = rows.filter(row => !row.isDirectory());
|
|
306
|
+
await inOrder(files, CAPTURE_CONCURRENCY, async (row) => {
|
|
274
307
|
const relative = safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name);
|
|
275
308
|
const absolute = path.join(dirname, row.name);
|
|
276
|
-
if (row.isDirectory()) {
|
|
277
|
-
await assertExistingPathContained(workspace, absolute);
|
|
278
|
-
await preflight(absolute, relative);
|
|
279
|
-
continue;
|
|
280
|
-
}
|
|
281
309
|
let stat;
|
|
282
310
|
try {
|
|
283
311
|
stat = await lstat(absolute);
|
|
284
312
|
}
|
|
285
313
|
catch (error) {
|
|
286
314
|
if (error?.code === 'ENOENT')
|
|
287
|
-
|
|
315
|
+
return;
|
|
288
316
|
throw error;
|
|
289
317
|
}
|
|
290
318
|
consumeEntry(budget);
|
|
@@ -298,12 +326,17 @@ async function captureNonGit(workspace, blobRoot, budget) {
|
|
|
298
326
|
}
|
|
299
327
|
if (!inside(workspace.canonicalRoot, canonical))
|
|
300
328
|
throw coded('CHECKPOINT_SYMLINK_ESCAPE', `Checkpoint symlink escapes workspace: ${relative}`);
|
|
301
|
-
|
|
329
|
+
return;
|
|
302
330
|
}
|
|
303
331
|
if (!stat.isFile())
|
|
304
332
|
throw coded('CHECKPOINT_SPECIAL_FILE_UNSUPPORTED', `Unsupported workspace entry: ${relative}`);
|
|
305
|
-
await
|
|
333
|
+
await assertFileContained(workspace, absolute, budget);
|
|
306
334
|
consumeFileBudget(budget, stat.size);
|
|
335
|
+
});
|
|
336
|
+
for (const row of rows.filter(row => row.isDirectory())) {
|
|
337
|
+
const absolute = path.join(dirname, row.name);
|
|
338
|
+
await assertExistingPathContained(workspace, absolute);
|
|
339
|
+
await preflight(absolute, safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
|
|
307
340
|
}
|
|
308
341
|
}
|
|
309
342
|
await preflight(workspace.canonicalRoot, '');
|
|
@@ -312,30 +345,30 @@ async function captureNonGit(workspace, blobRoot, budget) {
|
|
|
312
345
|
async function walk(dirname, relativeDir) {
|
|
313
346
|
const rows = await readdir(dirname, { withFileTypes: true });
|
|
314
347
|
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
315
|
-
|
|
316
|
-
|
|
348
|
+
const files = rows.filter(row => !row.isDirectory()).map(row => safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
|
|
349
|
+
const captured = await inOrder(files, CAPTURE_CONCURRENCY, relative => captureActualFile(workspace, path.join(dirname, path.basename(relative)), relative, blobRoot, budget));
|
|
350
|
+
files.forEach((relative, i) => { const entry = captured[i]; if (entry)
|
|
351
|
+
entries[relative] = entry; });
|
|
352
|
+
for (const row of rows.filter(row => row.isDirectory())) {
|
|
317
353
|
const absolute = path.join(dirname, row.name);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
await walk(absolute, relative);
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
const actual = await captureActualFile(workspace, absolute, relative, blobRoot, budget);
|
|
324
|
-
if (actual)
|
|
325
|
-
entries[relative] = actual;
|
|
354
|
+
await assertExistingPathContained(workspace, absolute);
|
|
355
|
+
await walk(absolute, safeRelative(relativeDir ? `${relativeDir}/${row.name}` : row.name));
|
|
326
356
|
}
|
|
327
357
|
}
|
|
328
358
|
await walk(workspace.canonicalRoot, '');
|
|
329
359
|
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
360
|
}
|
|
331
|
-
export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS }) {
|
|
361
|
+
export async function captureWorkspaceSnapshot({ projectRoot, blobRoot, limits = CHECKPOINT_LIMITS, statCache }) {
|
|
362
|
+
const startedAtMs = Date.now();
|
|
332
363
|
const workspace = await resolveWorkspaceIdentity({ projectRoot });
|
|
333
364
|
await ensurePrivateDir(blobRoot);
|
|
334
365
|
const canonicalBlobRoot = await realpath(blobRoot);
|
|
335
366
|
if (inside(workspace.canonicalRoot, canonicalBlobRoot))
|
|
336
367
|
throw coded('CHECKPOINT_STORE_INSIDE_WORKSPACE');
|
|
337
|
-
const budget = { entries: 0, storedBytes: 0, limits };
|
|
338
|
-
|
|
368
|
+
const budget = { entries: 0, storedBytes: 0, limits, startedAtMs, blobs: new Set(await readdir(canonicalBlobRoot)), pending: [], dirs: new Map(), cache: statCache ?? null };
|
|
369
|
+
const snapshot = workspace.gitRoot ? await captureGit(workspace, canonicalBlobRoot, budget) : await captureNonGit(workspace, canonicalBlobRoot, budget);
|
|
370
|
+
await syncNewBlobs(canonicalBlobRoot, budget);
|
|
371
|
+
return snapshot;
|
|
339
372
|
}
|
|
340
373
|
export function checkpointMutationAllowed(mode) { return mode !== 'plan'; }
|
|
341
374
|
export function extractWorkspaceWriteEvidence(raw) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "talos-code",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -20,6 +20,8 @@ export function createDesktopContextService({ engine, store, loadLegacy, resolve
|
|
|
20
20
|
/* 24/09 — F4: le sessioni per cui questo processo ha già cercato job orfani (basta una volta: in
|
|
21
21
|
questo processo un job attivo è sempre nella mappa `running` del motore). */
|
|
22
22
|
const recuperate = new Set();
|
|
23
|
+
/* P18: per sessione, le impronte dei record gia' verificati e la revisione dell'archivio che le ha prodotte. */
|
|
24
|
+
const impronteVerificate = new Map();
|
|
23
25
|
let closed = false;
|
|
24
26
|
function serial(sessionId, task) {
|
|
25
27
|
const previous = queues.get(sessionId) ?? Promise.resolve();
|
|
@@ -194,15 +196,28 @@ export function createDesktopContextService({ engine, store, loadLegacy, resolve
|
|
|
194
196
|
}
|
|
195
197
|
fail('CTX_ROUTE_NOT_FOUND', 'Operazione del contesto non trovata.');
|
|
196
198
|
},
|
|
199
|
+
/*
|
|
200
|
+
* P18 (26/09/2026, patch approvata dall'owner via la lane CLI): l'archivio non si RILEGGE a ogni capture. Le
|
|
201
|
+
* impronte dei record gia' verificati restano in memoria, legate alla revisione dell'archivio che le ha prodotte;
|
|
202
|
+
* se la revisione e' ancora quella (nessun altro ha scritto: una compattazione la cambia), si usano quelle invece
|
|
203
|
+
* di `allRecords` (misurato: ~25 ms a capture a 1.351 messaggi, cresce con la storia). Il confronto con OGNI
|
|
204
|
+
* messaggio resta: la divergenza si scopre come prima.
|
|
205
|
+
*/
|
|
197
206
|
syncOriginals({ sessionId, messages }) {
|
|
198
207
|
return serial(sessionId, async () => {
|
|
199
208
|
const snapshot = await ensure(sessionId);
|
|
200
209
|
if (!Array.isArray(messages)) fail('CTX_INVALID_INPUT', 'Cronologia non valida.');
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
210
|
+
const noto = impronteVerificate.get(sessionId);
|
|
211
|
+
const salvate = noto && Number.isInteger(snapshot?.revision) && noto.revision === snapshot.revision ? noto.impronte : (await allRecords(sessionId)).map((record) => record.sha256);
|
|
212
|
+
if (messages.length < salvate.length || salvate.some((impronta, index) => impronta !== digest(messages[index]))) {
|
|
213
|
+
impronteVerificate.delete(sessionId);
|
|
214
|
+
fail('CTX_HISTORY_DIVERGED', 'La cronologia attiva differisce dall’archivio. Gli originali sono conservati; occorre recuperare la versione completa.');
|
|
215
|
+
}
|
|
216
|
+
const records = messages.slice(salvate.length).map((message, offset) => ({ id: `message-${salvate.length + offset + 1}`, message, createdAt: clock(), origin: 'desktop-kernel' }));
|
|
204
217
|
if (records.length) await store.appendOriginalBatch({ sessionId, records, expectedRevision: snapshot.revision });
|
|
205
|
-
|
|
218
|
+
const dopo = await store.readContextSnapshot({ sessionId });
|
|
219
|
+
impronteVerificate.set(sessionId, { revision: dopo?.revision, impronte: [...salvate, ...records.map((record) => digest(record.message))] });
|
|
220
|
+
return dopo;
|
|
206
221
|
});
|
|
207
222
|
},
|
|
208
223
|
/*
|
|
@@ -3662,6 +3662,125 @@ 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
|
+
/* Gli attrezzi che non cambiano niente: una partenza anticipata puo' scavalcarli, mai scavalcare altro. */
|
|
3683
|
+
const LETTURE_PURE = new Set(['cerca', 'leggi', 'elenca'])
|
|
3684
|
+
const MAX_RIGHE_RG = 100
|
|
3685
|
+
const MAX_RIGHE_PER_FILE = 5
|
|
3686
|
+
const MAX_COLONNE_RG = 200
|
|
3687
|
+
const MAX_BYTE_RG = 4_000_000
|
|
3688
|
+
const TEMPO_MAX_RG_MS = 20_000
|
|
3689
|
+
let ripgrepRisolto = null
|
|
3690
|
+
function percorsoRipgrep() {
|
|
3691
|
+
const daAmbiente = process.env.TALOS_RG_PATH
|
|
3692
|
+
if (daAmbiente) return Promise.resolve(existsSync(daAmbiente) ? daAmbiente : null)
|
|
3693
|
+
ripgrepRisolto ??= import('@vscode/ripgrep').then(
|
|
3694
|
+
(m) => (typeof m?.rgPath === 'string' && existsSync(m.rgPath) ? m.rgPath : null),
|
|
3695
|
+
() => null)
|
|
3696
|
+
return ripgrepRisolto
|
|
3697
|
+
}
|
|
3698
|
+
function eseguiRipgrep(rg, argomenti, cartella, segnale) {
|
|
3699
|
+
return new Promise((fine) => {
|
|
3700
|
+
let figlio
|
|
3701
|
+
try { figlio = spawn(rg, argomenti, { cwd: cartella, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }) }
|
|
3702
|
+
catch { fine(null); return }
|
|
3703
|
+
const pezzi = []
|
|
3704
|
+
let byte = 0, tagliato = false
|
|
3705
|
+
const orologio = setTimeout(() => { tagliato = true; figlio.kill() }, TEMPO_MAX_RG_MS)
|
|
3706
|
+
/* Lo Stop ferma anche la ricerca IN VOLO (vincolo del desktop, 26/09): si uccide rg e si dice «fermata». */
|
|
3707
|
+
let fermato = false
|
|
3708
|
+
const alloStop = () => { fermato = true; figlio.kill() }
|
|
3709
|
+
if (segnale?.aborted) alloStop(); else segnale?.addEventListener?.('abort', alloStop, { once: true })
|
|
3710
|
+
figlio.stdout.on('data', (pezzo) => {
|
|
3711
|
+
if (tagliato) return
|
|
3712
|
+
byte += pezzo.length
|
|
3713
|
+
pezzi.push(pezzo)
|
|
3714
|
+
if (byte >= MAX_BYTE_RG) { tagliato = true; figlio.kill() }
|
|
3715
|
+
})
|
|
3716
|
+
figlio.on('error', () => { clearTimeout(orologio); fine(null) })
|
|
3717
|
+
figlio.on('close', (codice) => {
|
|
3718
|
+
clearTimeout(orologio)
|
|
3719
|
+
segnale?.removeEventListener?.('abort', alloStop)
|
|
3720
|
+
if (fermato) { fine({ testo: '', tagliato: false, scaduto: false, fermato: true }); return }
|
|
3721
|
+
const testo = Buffer.concat(pezzi).toString('utf8')
|
|
3722
|
+
/* 0 = trovato, 1 = niente; 2 = errori (un file illeggibile) con o senza risultati. Un 2 senza uscita,
|
|
3723
|
+
o un processo ucciso senza niente, non e' «non c'e'»: e' un guasto, e decide il ripiego JS. */
|
|
3724
|
+
if (codice === 0 || codice === 1 || testo) fine({ testo, tagliato: tagliato && byte >= MAX_BYTE_RG, scaduto: tagliato && byte < MAX_BYTE_RG })
|
|
3725
|
+
else fine(null)
|
|
3726
|
+
})
|
|
3727
|
+
})
|
|
3728
|
+
}
|
|
3729
|
+
const percorsoDaRg = (p) => p.replace(/\\/gu, '/').replace(/^\.\//u, '')
|
|
3730
|
+
async function cercaConRipgrep(radice, testo, chiaveNome, segnale) {
|
|
3731
|
+
const rg = await percorsoRipgrep()
|
|
3732
|
+
if (!rg) return null
|
|
3733
|
+
const comuni = ['--no-config', '--hidden', '--no-require-git', '--glob', '!.git', '--glob', '!node_modules']
|
|
3734
|
+
/* Il ripiego delle cartelle potate vale, come prima, solo quando non c'e' un `.gitignore` da cui leggere. */
|
|
3735
|
+
if (!existsSync(join(radice, '.gitignore'))) for (const c of POTATE_SENZA_GITIGNORE) comuni.push('--glob', `!${c}`)
|
|
3736
|
+
const ricerca = await eseguiRipgrep(rg, [...comuni, '--line-number', '--with-filename', '--no-heading', '--color', 'never',
|
|
3737
|
+
'--fixed-strings', '--ignore-case', '--max-count', String(MAX_RIGHE_PER_FILE), '--max-columns', String(MAX_COLONNE_RG),
|
|
3738
|
+
'--max-columns-preview', '--max-filesize', String(MAX_BYTE_FILE), '-e', testo, '--', '.'], radice, segnale)
|
|
3739
|
+
if (ricerca === null) return null
|
|
3740
|
+
if (ricerca.fermato) return 'stopped: the search was interrupted.'
|
|
3741
|
+
/* rg lavora in parallelo e non promette un ordine: si ordina qui, cosi' la stessa domanda da' la stessa risposta. */
|
|
3742
|
+
const perFile = new Map()
|
|
3743
|
+
for (const riga of ricerca.testo.split(/\r?\n/u)) {
|
|
3744
|
+
const m = /^(.+?):(\d+):(.*)$/u.exec(riga)
|
|
3745
|
+
if (!m) continue
|
|
3746
|
+
const percorso = percorsoDaRg(m[1])
|
|
3747
|
+
if (!perFile.has(percorso)) perFile.set(percorso, [])
|
|
3748
|
+
perFile.get(percorso).push(`${percorso}:${m[2]}:${m[3].trim()}`)
|
|
3749
|
+
}
|
|
3750
|
+
let perNome = []
|
|
3751
|
+
if (chiaveNome) {
|
|
3752
|
+
const elenco = await eseguiRipgrep(rg, [...comuni, '--files', '.'], radice, segnale)
|
|
3753
|
+
if (elenco === null) return null
|
|
3754
|
+
if (elenco.fermato) return 'stopped: the search was interrupted.'
|
|
3755
|
+
perNome = elenco.testo.split(/\r?\n/u).filter(Boolean).map(percorsoDaRg).filter((p) => p.toLowerCase().includes(chiaveNome)).sort()
|
|
3756
|
+
}
|
|
3757
|
+
const avvisi = []
|
|
3758
|
+
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.`)
|
|
3759
|
+
if (ricerca.tagliato) avvisi.push('⚠ incomplete scan: too much output — the matches below are a part of them. Narrow the search.')
|
|
3760
|
+
const coda = avvisi.length > 0 ? `\n${avvisi.join('\n')}` : ''
|
|
3761
|
+
const file = [...perFile.keys()].sort()
|
|
3762
|
+
if (file.length === 0 && perNome.length === 0) {
|
|
3763
|
+
return `no file matches "${testo}" (searched with ripgrep; .gitignore respected, binaries skipped). Try a shorter or different "testo".${coda}`
|
|
3764
|
+
}
|
|
3765
|
+
const righe = perNome.slice(0, MAX_RISULTATI)
|
|
3766
|
+
let righeTesto = 0, fileMostrati = 0
|
|
3767
|
+
for (const f of file) {
|
|
3768
|
+
if (fileMostrati >= MAX_RISULTATI || righeTesto >= MAX_RIGHE_RG) break
|
|
3769
|
+
const trovate = perFile.get(f).slice(0, MAX_RIGHE_RG - righeTesto)
|
|
3770
|
+
righe.push(...trovate)
|
|
3771
|
+
righeTesto += trovate.length
|
|
3772
|
+
fileMostrati += 1
|
|
3773
|
+
}
|
|
3774
|
+
const fileTagliati = file.length - fileMostrati
|
|
3775
|
+
const nomiTagliati = perNome.length - Math.min(perNome.length, MAX_RISULTATI)
|
|
3776
|
+
return righe.join('\n')
|
|
3777
|
+
+ (nomiTagliati > 0 ? `\n… and ${nomiTagliati} more paths matching "${chiaveNome}" not shown — narrow the search.` : '')
|
|
3778
|
+
// ⛔ Il taglio si DICHIARA, come nella camminata JS: «40 file» non deve leggersi come «sono 40».
|
|
3779
|
+
+ (fileTagliati > 0 ? `\n… and ${fileTagliati} more files with matches not shown — narrow the search.` : '')
|
|
3780
|
+
+ (file.some((f) => perFile.get(f).length >= MAX_RIGHE_PER_FILE) ? `\n(at most ${MAX_RIGHE_PER_FILE} matching lines per file are shown)` : '')
|
|
3781
|
+
+ coda
|
|
3782
|
+
}
|
|
3783
|
+
|
|
3665
3784
|
/**
|
|
3666
3785
|
* ⭐⭐⭐ CERCA — l'attrezzo che toglie la cecita', senza comprare l'inventario.
|
|
3667
3786
|
*
|
|
@@ -3685,18 +3804,28 @@ async function tuttiIPercorsi(disco, { filtro = null } = {}) {
|
|
|
3685
3804
|
* @param {{radice?: string}} [opzioni] — la cartella VERA, per leggere il `.gitignore`.
|
|
3686
3805
|
* Assente (test, ponte del telefono) ⇒ si usa il ripiego `POTATE_SENZA_GITIGNORE`.
|
|
3687
3806
|
*/
|
|
3688
|
-
export async function cercaNelProgetto(disco, { testo, nome }, { radice } = {}) {
|
|
3807
|
+
export async function cercaNelProgetto(disco, { testo, nome }, { radice, segnale } = {}) {
|
|
3689
3808
|
const chiaveTesto = String(testo ?? '').trim().toLowerCase()
|
|
3690
3809
|
const chiaveNome = String(nome ?? '').trim().toLowerCase()
|
|
3691
3810
|
if (!chiaveTesto && !chiaveNome) return 'give at least one of "testo" or "nome".'
|
|
3811
|
+
/* P18 fase 3: con la cartella vera e un ripgrep disponibile, la ricerca nel contenuto la fa `rg`.
|
|
3812
|
+
`null` = rg assente o fallito: si prosegue con la camminata JS qui sotto, invariata. */
|
|
3813
|
+
if (chiaveTesto && radice) {
|
|
3814
|
+
const conRg = await cercaConRipgrep(radice, String(testo).trim(), chiaveNome, segnale)
|
|
3815
|
+
if (conRg !== null) return conRg
|
|
3816
|
+
}
|
|
3692
3817
|
|
|
3693
3818
|
const filtro = await filtroDelProgetto(radice)
|
|
3694
3819
|
const { percorsi, dimensioni, stato } = await tuttiIPercorsi(disco, { filtro })
|
|
3820
|
+
/* P18 (vincolo desktop a, 26/09): lo Stop ferma anche la camminata JS, non solo rg — fino a 8 `cerca` possono
|
|
3821
|
+
essere partite insieme, e nessuna deve finire in sottofondo dopo «Ferma». */
|
|
3822
|
+
if (segnale?.aborted) return 'stopped: the search was interrupted.'
|
|
3695
3823
|
const perNome = []
|
|
3696
3824
|
const perTesto = []
|
|
3697
3825
|
const conto = { letti: 0, binari: 0, troppoGrandi: 0, illeggibili: 0, tettoLetture: false, tettoRicerca: false }
|
|
3698
3826
|
|
|
3699
3827
|
for (const p of percorsi) {
|
|
3828
|
+
if (segnale?.aborted) return 'stopped: the search was interrupted.'
|
|
3700
3829
|
const basso = p.toLowerCase()
|
|
3701
3830
|
const combaciaNome = chiaveNome && basso.includes(chiaveNome)
|
|
3702
3831
|
if (combaciaNome) { perNome.push(p); continue }
|
|
@@ -8136,6 +8265,35 @@ export async function talosLavora({
|
|
|
8136
8265
|
break
|
|
8137
8266
|
}
|
|
8138
8267
|
|
|
8268
|
+
/*
|
|
8269
|
+
* ⭐⭐ P18 fase 2 (owner 26/09/2026, patch approvata dalla lane desktop) — le `cerca` della STESSA risposta
|
|
8270
|
+
* partono INSIEME, fino a `MAX_CERCA_INSIEME` (Hermes 8, `_MAX_TOOL_WORKERS`; Claude Code 10, i blocchi
|
|
8271
|
+
* «concurrency-safe» dello `StreamingToolExecutor`). Il ciclo qui sotto resta quello di sempre: hook,
|
|
8272
|
+
* approvazioni, eventi `tool-inizio`/`tool-esito` e risultati nella storia, tutto nell'ordine delle chiamate;
|
|
8273
|
+
* il ramo `cerca` aspetta la SUA ricerca già partita invece di avviarla.
|
|
8274
|
+
* ⛔ Solo `cerca`: e' pura (legge dentro la cartella, non chiede approvazioni) e un rifiuto del pre-hook su una
|
|
8275
|
+
* lettura e' gia' ignorato (vedi FASE A qui sotto). `leggi` no: un percorso fuori dal progetto chiede
|
|
8276
|
+
* l'approvazione, e quella va chiesta in ordine, prima di leggere.
|
|
8277
|
+
* ⛔ Lo Stop ferma anche quelle in volo: il segnale arriva fino a rg, che viene ucciso.
|
|
8278
|
+
* Misura (banco ermetico, 4 `cerca` su 6.000 file): 8.648 ms prima di P18, 377 con ripgrep in fila.
|
|
8279
|
+
*/
|
|
8280
|
+
/* ⛔⛔ Solo il PREFISSO di sole letture della risposta (`cerca`, `leggi`, `elenca`): una `cerca` che viene DOPO
|
|
8281
|
+
una scrittura o un comando deve vedere il loro effetto, e partita prima non lo vedrebbe (misurato: `scrivi`
|
|
8282
|
+
poi `cerca` nella stessa risposta dava la ricerca SENZA il file appena scritto — p18-cerca-insieme). */
|
|
8283
|
+
const ricercheAvviate = new Map()
|
|
8284
|
+
const primaNonLettura = chiamate.findIndex((c) => !LETTURE_PURE.has(c.function?.name))
|
|
8285
|
+
const prefissoDiLettura = primaNonLettura === -1 ? chiamate : chiamate.slice(0, primaNonLettura)
|
|
8286
|
+
const daCercare = prefissoDiLettura.filter((c) => c.function?.name === 'cerca')
|
|
8287
|
+
if (daCercare.length > 1 && !segnaleStop?.aborted) {
|
|
8288
|
+
for (const c of daCercare.slice(0, MAX_CERCA_INSIEME)) {
|
|
8289
|
+
let argomentiCerca = {}
|
|
8290
|
+
try { argomentiCerca = JSON.parse(c.function?.arguments || '{}') } catch { /* vuoto: il ramo dira' cosa manca */ }
|
|
8291
|
+
const avviata = cercaNelProgetto(disco, argomentiCerca, { radice: cartella, segnale: segnaleStop })
|
|
8292
|
+
avviata.catch(() => {})
|
|
8293
|
+
ricercheAvviate.set(c, avviata)
|
|
8294
|
+
}
|
|
8295
|
+
}
|
|
8296
|
+
|
|
8139
8297
|
let fermatoDentroIlGiro = false
|
|
8140
8298
|
for (const c of chiamate) {
|
|
8141
8299
|
/*
|
|
@@ -8232,7 +8390,7 @@ export async function talosLavora({
|
|
|
8232
8390
|
* potare. Senza, `cerca` ricade sulla lista fissa — che e' il ripiego per il ponte
|
|
8233
8391
|
* del telefono e per i test, non il caso normale.
|
|
8234
8392
|
*/
|
|
8235
|
-
esito = await cercaNelProgetto(disco, argomenti, { radice: cartella })
|
|
8393
|
+
esito = await (ricercheAvviate.get(c) ?? cercaNelProgetto(disco, argomenti, { radice: cartella, segnale: segnaleStop }))
|
|
8236
8394
|
}
|
|
8237
8395
|
else if (nome === 'leggi') {
|
|
8238
8396
|
/*
|
|
@@ -105,8 +105,13 @@ function git(argomenti, cwd) {
|
|
|
105
105
|
export async function statoGit(percorso, { profondita = PROFONDITA_REPO_ANNIDATI, eseguiGit = git } = {}) {
|
|
106
106
|
const dentro = await eseguiGit(['rev-parse', '--is-inside-work-tree'], percorso);
|
|
107
107
|
if (!dentro || dentro.trim() !== 'true') return null;
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
/* P18 (26/09/2026): ramo e stato sono indipendenti, si chiedono INSIEME (misurato all'avvio di un turno: ~55 ms
|
|
109
|
+
in meno, due processi git in fila diventano uno accanto all'altro). */
|
|
110
|
+
const [ramoGrezzo, stato] = await Promise.all([
|
|
111
|
+
eseguiGit(['rev-parse', '--abbrev-ref', 'HEAD'], percorso),
|
|
112
|
+
eseguiGit(['status', '--porcelain'], percorso),
|
|
113
|
+
]);
|
|
114
|
+
const ramo = (ramoGrezzo || '').trim() || null;
|
|
110
115
|
const nonSalvate = stato === null ? null : stato.split('\n').filter((r) => r.trim()).length;
|
|
111
116
|
const annidati = await repoAnnidati(percorso, profondita);
|
|
112
117
|
return { ramo, nonSalvate, repoAnnidati: annidati };
|
package/vendor/manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": "talos.cli.vendor.v1",
|
|
3
|
-
"sourceCommit": "
|
|
3
|
+
"sourceCommit": "e553a0cd2616df27b399505afc3fa0eeb8b504fa",
|
|
4
4
|
"files": 163,
|
|
5
5
|
"sha256": {
|
|
6
6
|
"context-engine/package.json": "cd416c2ef02241365eca721ce1bed7a702cff34bb804d79e974f9f36731ced8a",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"harness-ui/src/config.mjs": "ab482ac221f4b5ad3e1b46052d87549ebf89fd2ee30ce113ceda8132b028e1ef",
|
|
37
37
|
"harness-ui/src/contesto-del-progetto.mjs": "5aec0670f0d81fc545112ae4f2a0a36a5359e76e76f39bf63d403a6cb615d030",
|
|
38
38
|
"harness-ui/src/context-asset-adapter.mjs": "5a5feccad64c3b427031dcc8dfc47a2ffa5e0db4eec2959a8d3b70e3c87a55fc",
|
|
39
|
-
"harness-ui/src/context-desktop-service.mjs": "
|
|
39
|
+
"harness-ui/src/context-desktop-service.mjs": "0ac2625b63512909c689d81370047a7e54892fbf0c58622936b5c9a72aff9fc0",
|
|
40
40
|
"harness-ui/src/context-embedding-runtime.mjs": "de494d5d1bf08e2a9b685dbf78f8e9a16631700e0c4e5d045c91f65a6a4c6ec2",
|
|
41
41
|
"harness-ui/src/context-inference-scheduler.mjs": "26dcccde934993226d4adb0b239b465a45640701d377c5851fc58209552e7d39",
|
|
42
42
|
"harness-ui/src/context-native-compaction.mjs": "55069d9a80283d10be924bd1309bb271a234608660424deb48aac82f8e273125",
|
|
@@ -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": "
|
|
76
|
+
"harness-ui/src/kernel/talosHarness.mjs": "0ee781ebbb507389a0ac109eebb599d45760dedf9d368d6f921e02b2a8e97d1e",
|
|
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",
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
"harness-ui/src/workspace-context.mjs": "75bb99b0e5624b2257a73c76997bb8670206758cbabe4613683503c6c785930a",
|
|
163
163
|
"harness-ui/src/workspace-disk.mjs": "b90a666880079a72bca7512bb936324f2abe3ed1075aec828cbcbeba3965ec7a",
|
|
164
164
|
"harness-ui/src/workspace-files.mjs": "998f04da2959d8404da801d4cf981e1e42fcab221d2b96456acb19688980c4a4",
|
|
165
|
-
"harness-ui/src/workspace-info.mjs": "
|
|
165
|
+
"harness-ui/src/workspace-info.mjs": "cd3cb40401139e06a5ddc23683822578913c180744bea233d3b5e2c29b417042",
|
|
166
166
|
"harness-ui/src/workspace-launch-store.mjs": "06e14d1c740377d788609d6cd930575a1180dab32640a1692605a0225cadf9a9",
|
|
167
167
|
"harness-ui/src/workspace-tree.mjs": "c5723383cdff663ea0537e384fef8593f574893e63b9645c395a076910ac1f8b",
|
|
168
168
|
"harness-ui/src/workspace-watcher.mjs": "ab1e1a03ab61b98f2a7a5bd559b56e1e6dfc0b3ec201a41edc96d70512f8053f"
|