letagents 0.12.18 → 0.12.20

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.
Files changed (32) hide show
  1. package/README.md +37 -0
  2. package/dist/mcp/local-state/local-chat.js +2 -0
  3. package/dist/mcp/server/register-tools.js +5 -0
  4. package/dist/mcp/server/runtime/workspace-capture.js +267 -0
  5. package/dist/mcp/server/supervised-tool-facade.js +1 -0
  6. package/dist/mcp/server/tools/agent-sessions.js +2 -0
  7. package/dist/mcp/server/tools/knowledge.js +76 -0
  8. package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -0
  9. package/dist/mcp/server/tools/workspace.js +11 -0
  10. package/dist/mcp/server.js +3 -2
  11. package/dist/shared/room-agent-prompts.js +1 -1
  12. package/package.json +2 -2
  13. package/shared/contribution-text.d.mts +1 -0
  14. package/shared/contribution-text.mjs +34 -0
  15. package/shared/local-room-knowledge.d.mts +7 -0
  16. package/shared/local-room-knowledge.mjs +53 -0
  17. package/shared/mcp-workspace-capture-worker.mjs +41 -0
  18. package/shared/room-agent-work.d.mts +4 -1
  19. package/shared/room-agent-work.mjs +23 -6
  20. package/shared/room-knowledge.d.mts +25 -0
  21. package/shared/room-knowledge.mjs +74 -0
  22. package/shared/workspace-change-capture.d.mts +2 -0
  23. package/shared/workspace-change-capture.mjs +147 -0
  24. package/shared/workspace-change-summary.d.mts +24 -0
  25. package/shared/workspace-change-summary.mjs +44 -0
  26. package/shared/workspace-diff.d.mts +9 -0
  27. package/shared/workspace-diff.mjs +105 -0
  28. package/shared/workspace-review-worker.mjs +47 -0
  29. package/shared/workspace-review.d.mts +10 -0
  30. package/shared/workspace-review.mjs +45 -0
  31. package/shared/workspace-turn-capture.d.mts +10 -0
  32. package/shared/workspace-turn-capture.mjs +118 -0
@@ -0,0 +1,53 @@
1
+ import { assertKnowledgeReplay, RoomKnowledgeError } from './room-knowledge.mjs';
2
+
3
+ export function initializeRoomKnowledge(db) {
4
+ db.exec(`CREATE TABLE IF NOT EXISTS local_room_knowledge (
5
+ room_id TEXT NOT NULL, id TEXT NOT NULL, type TEXT NOT NULL, version INTEGER NOT NULL,
6
+ value TEXT NOT NULL, PRIMARY KEY(room_id, id));
7
+ CREATE TABLE IF NOT EXISTS local_room_knowledge_revisions (
8
+ room_id TEXT NOT NULL, id TEXT NOT NULL, version INTEGER NOT NULL, value TEXT NOT NULL,
9
+ PRIMARY KEY(room_id, id, version));
10
+ CREATE TABLE IF NOT EXISTS local_room_knowledge_outbox (room_id TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(room_id, id));`);
11
+ }
12
+ export function listLocalKnowledge(db, roomId, type) {
13
+ initializeRoomKnowledge(db);
14
+ const rows = db.prepare('SELECT value FROM local_room_knowledge WHERE room_id = ? AND type = ? ORDER BY json_extract(value, \'$.archived\') ASC, (json_extract(value, \'$.response\') IS NULL) DESC, json_extract(value, \'$.updated_at\') DESC LIMIT 201').all(roomId, type);
15
+ return { records: rows.slice(0, 200).map(row => JSON.parse(row.value)), truncated: rows.length > 200 };
16
+ }
17
+ export function getLocalKnowledge(db, roomId, id) {
18
+ initializeRoomKnowledge(db);
19
+ const row = db.prepare('SELECT value FROM local_room_knowledge WHERE room_id = ? AND id = ?').get(roomId, id);
20
+ return row ? JSON.parse(row.value) : null;
21
+ }
22
+ export function localKnowledgeHistory(db, roomId, id) {
23
+ initializeRoomKnowledge(db);
24
+ return db.prepare('SELECT value FROM local_room_knowledge_revisions WHERE room_id = ? AND id = ? ORDER BY version DESC LIMIT 100').all(roomId, id).map(row => JSON.parse(row.value));
25
+ }
26
+ export function saveLocalKnowledge(db, record, expectedVersion) {
27
+ initializeRoomKnowledge(db);
28
+ db.exec('BEGIN IMMEDIATE');
29
+ try {
30
+ const existing = getLocalKnowledge(db, record.room_id, record.id);
31
+ if (record.source_message_id) assertLocalKnowledgeSource(db, record);
32
+ if (expectedVersion === undefined && existing) {
33
+ const row = db.prepare('SELECT value FROM local_room_knowledge_revisions WHERE room_id = ? AND id = ? AND version = 1').get(record.room_id, record.id);
34
+ if (!row) throw new RoomKnowledgeError('The original version is unavailable. Please refresh.', 409);
35
+ const initial = JSON.parse(row.value);
36
+ assertKnowledgeReplay(initial, record);
37
+ db.exec('COMMIT');
38
+ return existing;
39
+ }
40
+ if (expectedVersion !== undefined && existing?.version !== expectedVersion) throw new RoomKnowledgeError('This changed since you opened it. Refresh to see the latest version.', 409);
41
+ db.prepare('INSERT INTO local_room_knowledge VALUES (?, ?, ?, ?, ?) ON CONFLICT(room_id, id) DO UPDATE SET version = excluded.version, value = excluded.value').run(record.room_id, record.id, record.type, record.version, JSON.stringify(record));
42
+ db.prepare('INSERT INTO local_room_knowledge_revisions VALUES (?, ?, ?, ?)').run(record.room_id, record.id, record.version, JSON.stringify(record));
43
+ if (record.type === 'attention' && record.response) db.prepare('INSERT OR IGNORE INTO local_room_knowledge_outbox VALUES (?, ?, ?)').run(record.room_id, record.id, JSON.stringify(record));
44
+ db.exec('COMMIT');
45
+ return record;
46
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
47
+ }
48
+
49
+ function assertLocalKnowledgeSource(db, record) {
50
+ const table = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'local_chat_messages'").get();
51
+ const row = table && db.prepare("SELECT number FROM local_chat_messages WHERE room_id = ? AND number = ? AND NOT (COALESCE(agent_prompt_kind, '') = 'auto' AND TRIM(text) = '')").get(record.room_id, Number(record.source_message_id.slice(4)));
52
+ if (!row) throw new RoomKnowledgeError('The source message does not exist or is not visible in this local room.');
53
+ }
@@ -0,0 +1,41 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { mkdir, writeFile, rename } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { captureWorkspaceTree, captureWorkspacePair } from './workspace-turn-capture.mjs';
5
+ import { encodeWorkspaceReview, REVIEW_PAGE_SIZE } from './workspace-review.mjs';
6
+
7
+ // Git inspection and archive encoding never occupy the MCP transport's thread.
8
+ // A process runs at most one capture worker; only small metadata crosses IPC.
9
+ try {
10
+ const { capture, directory, text } = workerData;
11
+ const identity = `mcp-workspace:${capture.capture_id}`;
12
+ if (workerData.operation === 'begin') {
13
+ parentPort.postMessage({ baseline: await captureWorkspaceTree(capture.workspace, identity) });
14
+ } else {
15
+ const pair = await captureWorkspacePair(capture.workspace, capture.base_revision, capture.baseline, `${identity}:${capture.preparation_id}`);
16
+ pair.contribution.summary = text;
17
+ const { review, ...preview } = pair;
18
+ const summary = { version: 3, recorded_state: 'completed', evidence_incomplete: true, elapsed_ms: null,
19
+ operation_counts: { unresolved: 0, succeeded: 0, failed: 0, denied_before_start: 0,
20
+ cancelled_before_start: 0, interrupted_after_start: 0, lost_after_start: 0 }, ...preview };
21
+ let encoded = null;
22
+ let warning = capture.baseline ? null : 'The starting snapshot is unavailable; no exact change attribution is available.';
23
+ try { encoded = encodeWorkspaceReview(review); }
24
+ catch (error) {
25
+ if (!(error instanceof RangeError)) throw error;
26
+ warning = 'This capture exceeds the full-review size limit. Only its bounded preview is available.';
27
+ }
28
+ await mkdir(directory, { recursive: true, mode: 0o700 });
29
+ if (encoded) {
30
+ await writeFile(join(directory, 'review.tmp'), encoded.data, { mode: 0o600 });
31
+ await rename(join(directory, 'review.tmp'), join(directory, 'review'));
32
+ }
33
+ const prepared = { summary, text, warning,
34
+ archive: encoded ? { digest: encoded.digest, length: encoded.data.length, total: Math.ceil(encoded.data.length / REVIEW_PAGE_SIZE) } : null };
35
+ await writeFile(join(directory, 'prepared.tmp'), JSON.stringify(prepared), { mode: 0o600 });
36
+ await rename(join(directory, 'prepared.tmp'), join(directory, 'prepared.json'));
37
+ parentPort.postMessage({ prepared: true });
38
+ }
39
+ } catch {
40
+ parentPort.postMessage({ error: 'Workspace capture could not finish. Retry with the same capture_id.' });
41
+ }
@@ -1,7 +1,10 @@
1
+ import type { WorkspaceChangeSummary } from "./workspace-change-summary.mjs";
1
2
  export const ROOM_WORK_STATES: readonly ["active", "completed", "completed_no_reply", "failed", "interrupted", "lost", "unknown"];
2
3
  export const ROOM_WORK_OPERATION_OUTCOMES: readonly ["unresolved", "succeeded", "failed", "denied_before_start", "cancelled_before_start", "interrupted_after_start", "lost_after_start"];
3
4
  export type RoomAgentWorkSummary = {
4
- version: 1;
5
+ version: 1 | 2 | 3;
6
+ contribution?: { changes: WorkspaceChangeSummary; summary: string | null };
7
+ workspace?: WorkspaceChangeSummary;
5
8
  recorded_state: typeof ROOM_WORK_STATES[number];
6
9
  evidence_incomplete: boolean;
7
10
  elapsed_ms: number | null;
@@ -1,6 +1,7 @@
1
- // Room-public, host-reported evidence; never execution or approval authority.
2
- // No free-form strings, native handles, paths, command text, or output belong
3
- // in this version. Both the publisher and server use this strict boundary.
1
+ import { parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ // v1 is numeric execution evidence. v2 additionally carries a bounded,
3
+ // deliberately room-visible workspace review snapshot. v3 separates changes
4
+ // during one turn from the cumulative workspace, with an optional public summary.
4
5
  export const ROOM_WORK_STATES = [
5
6
  "active", "completed", "completed_no_reply", "failed", "interrupted", "lost", "unknown",
6
7
  ];
@@ -21,8 +22,22 @@ export function isClearedRoomAgentWorkSummary(value) {
21
22
 
22
23
  /** Return a canonical allowlisted copy, or reject without echoing private input. */
23
24
  export function parseRoomAgentWorkSummary(value) {
24
- if (!exactKeys(value, ["version", "recorded_state", "evidence_incomplete", "elapsed_ms", "operation_counts"])
25
- || value.version !== 1 || !ROOM_WORK_STATES.includes(value.recorded_state)
25
+ const workspace = [2, 3].includes(value?.version) ? parseWorkspaceChangeSummary(value.workspace) : null;
26
+ const keys = ["version", "recorded_state", "evidence_incomplete", "elapsed_ms", "operation_counts"];
27
+ if ([2, 3].includes(value?.version)) keys.push("workspace");
28
+ let contribution = null;
29
+ if (value?.version === 3) {
30
+ keys.push("contribution");
31
+ const candidate = value.contribution;
32
+ if (!exactKeys(candidate, ["changes", "summary"]) || !(candidate.summary === null
33
+ || typeof candidate.summary === "string" && candidate.summary.length <= 400)) return null;
34
+ const changes = parseWorkspaceChangeSummary(candidate.changes);
35
+ if (!changes) return null;
36
+ contribution = { changes, summary: candidate.summary };
37
+ if (new TextEncoder().encode(JSON.stringify(value)).length > 500 * 1024) return null;
38
+ }
39
+ if (!exactKeys(value, keys)
40
+ || ![1, 2, 3].includes(value.version) || (value.version >= 2 && !workspace) || !ROOM_WORK_STATES.includes(value.recorded_state)
26
41
  || typeof value.evidence_incomplete !== "boolean"
27
42
  || (value.elapsed_ms !== null && (!Number.isSafeInteger(value.elapsed_ms) || Number(value.elapsed_ms) < 0))
28
43
  || !exactKeys(value.operation_counts, ROOM_WORK_OPERATION_OUTCOMES)) return null;
@@ -37,7 +52,9 @@ export function parseRoomAgentWorkSummary(value) {
37
52
  // A bounded evidence snapshot, not an unbounded lifetime counter.
38
53
  if (total > 10_000) return null;
39
54
  return {
40
- version: 1, recorded_state: value.recorded_state,
55
+ version: value.version, recorded_state: value.recorded_state,
56
+ ...(workspace ? { workspace } : {}),
57
+ ...(contribution ? { contribution } : {}),
41
58
  evidence_incomplete: value.evidence_incomplete, elapsed_ms: value.elapsed_ms,
42
59
  operation_counts: counts,
43
60
  };
@@ -0,0 +1,25 @@
1
+ export type KnowledgeType = 'memory' | 'attention';
2
+ export type MemoryCategory = 'goal' | 'decision' | 'constraint' | 'term' | 'reference';
3
+ export type AttentionCategory = 'question' | 'decision' | 'approval' | 'review';
4
+ export interface KnowledgeActor { id: string; label: string; kind: 'human' | 'agent' }
5
+ export interface KnowledgeInput {
6
+ category: MemoryCategory | AttentionCategory; title: string; body: string;
7
+ recommendation?: string; unblocks?: string; source_url?: string; source_message_id?: string;
8
+ }
9
+ export interface KnowledgeRecord extends Required<KnowledgeInput> {
10
+ id: string; room_id: string; type: KnowledgeType; version: number;
11
+ author: KnowledgeActor; updated_by: KnowledgeActor; created_at: string; updated_at: string; archived: boolean;
12
+ response: { body: string; actor: KnowledgeActor; at: string } | null;
13
+ }
14
+ export interface KnowledgePage { records: KnowledgeRecord[]; truncated: boolean }
15
+ export interface KnowledgeRevisionInput extends Partial<KnowledgeInput> { expected_version: number; response?: string; archived?: boolean }
16
+ export const MEMORY_CATEGORIES: MemoryCategory[];
17
+ export const ATTENTION_CATEGORIES: AttentionCategory[];
18
+ export class RoomKnowledgeError extends Error { status: number; constructor(message: string, status?: number) }
19
+ export function parseKnowledgeInput(type: KnowledgeType, value: unknown): Required<KnowledgeInput>;
20
+ export function knowledgeId(value: unknown): string;
21
+ export function createKnowledgeRecord(room_id: string, type: KnowledgeType, input: KnowledgeInput & { client_id: string }, author: KnowledgeActor, now?: string): KnowledgeRecord;
22
+ export function reviseKnowledgeRecord(record: KnowledgeRecord, input: KnowledgeRevisionInput, actor: KnowledgeActor, now?: string): KnowledgeRecord;
23
+ export function assertKnowledgeReplay(existing: KnowledgeRecord, candidate: KnowledgeRecord): void;
24
+
25
+ export function formatAttentionResponse(record: KnowledgeRecord): string;
@@ -0,0 +1,74 @@
1
+ export const MEMORY_CATEGORIES = ['goal', 'decision', 'constraint', 'term', 'reference'];
2
+ export const ATTENTION_CATEGORIES = ['question', 'decision', 'approval', 'review'];
3
+
4
+ export class RoomKnowledgeError extends Error {
5
+ constructor(message, status = 400) { super(message); this.status = status; }
6
+ }
7
+
8
+ function text(value, name, max, required = false) {
9
+ if (value == null && !required) return '';
10
+ if (typeof value !== 'string' || value.length > max || (required && !value.trim())) {
11
+ throw new RoomKnowledgeError(`${name} must be ${required ? 'nonempty text' : 'text'} of at most ${max} characters.`);
12
+ }
13
+ return value.trim();
14
+ }
15
+
16
+ export function parseKnowledgeInput(type, value) {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RoomKnowledgeError('A record is required.');
18
+ const categories = type === 'memory' ? MEMORY_CATEGORIES : type === 'attention' ? ATTENTION_CATEGORIES : [];
19
+ if (!categories.includes(value.category)) throw new RoomKnowledgeError('Choose a valid category.');
20
+ const source_url = text(value.source_url, 'Source URL', 2048);
21
+ if (source_url) {
22
+ let url;
23
+ try { url = new URL(source_url); } catch { throw new RoomKnowledgeError('Source URL must be an HTTP or HTTPS link.'); }
24
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw new RoomKnowledgeError('Source URL must be an HTTP or HTTPS link without credentials.');
25
+ }
26
+ const source_message_id = text(value.source_message_id, 'Source message', 40);
27
+ if (source_message_id && (!/^msg_[1-9]\d{0,9}$/.test(source_message_id) || Number(source_message_id.slice(4)) > 2147483647)) throw new RoomKnowledgeError('Invalid source message.');
28
+ return {
29
+ category: value.category,
30
+ title: text(value.title, 'Title', 160, true),
31
+ body: text(value.body, 'Context', 8000, true),
32
+ recommendation: type === 'attention' ? text(value.recommendation, 'Recommendation', 2000) : '',
33
+ unblocks: type === 'attention' ? text(value.unblocks, 'What this unblocks', 1000) : '',
34
+ source_url,
35
+ source_message_id,
36
+ };
37
+ }
38
+
39
+ export function knowledgeId(value) {
40
+ if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{8,80}$/.test(value)) throw new RoomKnowledgeError('Use a stable client ID of 8–80 letters, numbers, underscores or hyphens.');
41
+ return value;
42
+ }
43
+
44
+ export function createKnowledgeRecord(room_id, type, input, author, now = new Date().toISOString()) {
45
+ return { ...parseKnowledgeInput(type, input), id: knowledgeId(input.client_id), room_id, type,
46
+ version: 1, author, updated_by: author, created_at: now, updated_at: now,
47
+ archived: false, response: null };
48
+ }
49
+
50
+ export function reviseKnowledgeRecord(record, input, actor, now = new Date().toISOString()) {
51
+ if (input.expected_version !== record.version) throw new RoomKnowledgeError('This changed since you opened it. Refresh to see the latest version.', 409);
52
+ if (actor.kind !== 'human') throw new RoomKnowledgeError('Only a human can revise shared memory or answer a request.', 403);
53
+ if (record.type === 'attention') {
54
+ if (record.response) throw new RoomKnowledgeError('This request has already been answered.', 409);
55
+ return { ...record, version: record.version + 1, updated_at: now, updated_by: actor,
56
+ response: { body: text(input.response, 'Response', 8000, true), actor, at: now } };
57
+ }
58
+ if (input.archived !== undefined && typeof input.archived !== 'boolean') throw new RoomKnowledgeError('Archived must be true or false.');
59
+ const content = input.archived !== undefined ? {} : parseKnowledgeInput('memory', input);
60
+ return { ...record, ...content, archived: input.archived ?? record.archived,
61
+ version: record.version + 1, updated_at: now, updated_by: actor };
62
+ }
63
+
64
+ export function assertKnowledgeReplay(existing, candidate) {
65
+ const keys = ['type', 'category', 'title', 'body', 'recommendation', 'unblocks', 'source_url', 'source_message_id'];
66
+ if (existing.author.id !== candidate.author.id || existing.author.kind !== candidate.author.kind || keys.some(key => existing[key] !== candidate[key])) {
67
+ throw new RoomKnowledgeError('This client ID was already used for different content. Use a new ID for a new record.', 409);
68
+ }
69
+ }
70
+
71
+ export function formatAttentionResponse(record) {
72
+ const handle = record.author.kind === 'agent' ? record.author.id.trim().replace(/[A-Z]/g, c => c.toLowerCase()).replace(/[^a-z0-9_.:/-]+/g, '') : '';
73
+ return `${handle ? `@agent:${handle}\n\n` : ''}Human response (${record.id}):\n\n${record.response.body}`;
74
+ }
@@ -0,0 +1,2 @@
1
+ import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
2
+ export function captureWorkspaceChanges(workspace: string, startingRevision: string | null, settledTree?: string, fullReview?: boolean): Promise<WorkspaceChangeSummary>;
@@ -0,0 +1,147 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { constants } from 'node:fs';
3
+ import { open, readlink, lstat } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { WORKSPACE_FILE_LIMIT, WORKSPACE_PATCH_LIMIT, parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
7
+ const execute = promisify(execFile);
8
+ async function git(cwd, args, limit = 4 * 1024 * 1024) {
9
+ const result = await execute('git', ['--no-optional-locks', ...args], {
10
+ cwd, encoding: 'utf8', timeout: 5_000, maxBuffer: limit,
11
+ env: { ...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_'))), GIT_TERMINAL_PROMPT: '0' },
12
+ });
13
+ return result.stdout;
14
+ }
15
+ /** Reads the actual provider workspace; never stages, commits, or changes its index. */
16
+ export async function captureWorkspaceChanges(workspace, startingRevision, settledTree, fullReview = false) {
17
+ const patchLimit = fullReview ? 128 * 1024 * 1024 : WORKSPACE_PATCH_LIMIT;
18
+ const fileLimit = fullReview ? 10_000 : WORKSPACE_FILE_LIMIT;
19
+ const empty = (state) => ({
20
+ captured_at: new Date().toISOString(), branch: null, base_revision: null, state,
21
+ files: [], additions: 0, deletions: 0, hidden_files: 0, patch: '', patch_truncated: false,
22
+ });
23
+ try {
24
+ if ((await git(workspace, ['rev-parse', '--is-inside-work-tree'])).trim() !== 'true')
25
+ return empty('not_git');
26
+ }
27
+ catch (error) {
28
+ return empty(/not a git repository/i.test(String(error.stderr ?? '')) ? 'not_git' : 'unavailable');
29
+ }
30
+ try {
31
+ if (startingRevision !== null && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(startingRevision))
32
+ return empty('unavailable');
33
+ const branch = await git(workspace, ['symbolic-ref', '--quiet', '--short', 'HEAD']).then(value => value.trim(), () => null);
34
+ if (settledTree && !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(settledTree))
35
+ return empty('unavailable');
36
+ const base = await git(workspace, ['rev-parse', '--verify', `${startingRevision ?? 'HEAD'}^{tree}`])
37
+ .then(value => value.trim(), error => { if (startingRevision)
38
+ throw error; return null; });
39
+ const diffArgs = ['--no-ext-diff', '--no-textconv', '--no-color', '-M'];
40
+ const [names, stats, untracked] = await Promise.all([
41
+ base ? git(workspace, ['diff', ...diffArgs, '--name-status', '-z', base, ...(settledTree ? [settledTree] : []), '--']) : Promise.resolve(''),
42
+ base ? git(workspace, ['diff', ...diffArgs, '--numstat', '-z', base, ...(settledTree ? [settledTree] : []), '--']) : Promise.resolve(''),
43
+ settledTree ? Promise.resolve('') : git(workspace, ['ls-files', '--others', '--exclude-standard', ...(base ? [] : ['--cached']), '-z']),
44
+ ]);
45
+ const files = new Map();
46
+ const nameParts = names.split('\0');
47
+ for (let i = 0; i < nameParts.length - 1;) {
48
+ const code = nameParts[i++];
49
+ const first = nameParts[i++];
50
+ const renamed = code.startsWith('R') || code.startsWith('C');
51
+ const path = renamed ? nameParts[i++] : first;
52
+ const status = { A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'copied', T: 'typechange' }[code[0]] ?? 'unknown';
53
+ files.set(path, { path, previous_path: renamed ? first : null, status, additions: 0, deletions: 0, binary: false });
54
+ }
55
+ const statParts = stats.split('\0');
56
+ for (let i = 0; i < statParts.length; i++) {
57
+ const match = /^([^\t]+)\t([^\t]+)\t([\s\S]*)$/.exec(statParts[i]);
58
+ if (!match)
59
+ continue;
60
+ let path = match[3];
61
+ if (!path) {
62
+ path = statParts[i + 2];
63
+ i += 2;
64
+ }
65
+ const file = files.get(path);
66
+ if (!file)
67
+ continue;
68
+ file.binary = match[1] === '-' || match[2] === '-';
69
+ file.additions = file.binary ? 0 : Number(match[1]);
70
+ file.deletions = file.binary ? 0 : Number(match[2]);
71
+ }
72
+ let patch = '';
73
+ let truncated = false;
74
+ if (base) {
75
+ try {
76
+ patch = await git(workspace, ['diff', ...diffArgs, '--unified=3', base, ...(settledTree ? [settledTree] : []), '--'], patchLimit);
77
+ }
78
+ catch (error) {
79
+ const failure = error;
80
+ if (failure.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
81
+ throw error;
82
+ patch = String(failure.stdout ?? '').slice(0, patchLimit);
83
+ truncated = true;
84
+ }
85
+ }
86
+ const newPaths = [...new Set(untracked.split('\0').filter(Boolean))];
87
+ let inspectedFiles = 0;
88
+ for (const path of newPaths) {
89
+ if (files.has(path))
90
+ continue;
91
+ const file = { path, previous_path: null, status: base ? 'untracked' : 'added', additions: 0, deletions: 0, binary: false };
92
+ files.set(path, file);
93
+ if (++inspectedFiles > fileLimit) {
94
+ truncated = true;
95
+ continue;
96
+ }
97
+ // Do not follow symlinks into files outside the workspace.
98
+ const absolute = join(workspace, path);
99
+ const metadata = await lstat(absolute);
100
+ let bytes;
101
+ if (metadata.isSymbolicLink())
102
+ bytes = Buffer.from(await readlink(absolute));
103
+ else if (metadata.isFile() && metadata.size <= 1024 * 1024) {
104
+ const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
105
+ try {
106
+ const buffer = Buffer.alloc(1024 * 1024 + 1);
107
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
108
+ bytes = buffer.subarray(0, bytesRead);
109
+ if (bytesRead > 1024 * 1024) {
110
+ truncated = true;
111
+ continue;
112
+ }
113
+ }
114
+ finally {
115
+ await handle.close();
116
+ }
117
+ }
118
+ else {
119
+ truncated = true;
120
+ continue;
121
+ }
122
+ file.binary = bytes.includes(0);
123
+ if (file.binary)
124
+ continue;
125
+ const text = bytes.toString('utf8');
126
+ const lines = text ? text.replace(/\n$/, '').split('\n') : [];
127
+ file.additions = lines.length;
128
+ if (patch.length < patchLimit) {
129
+ const label = JSON.stringify(`b/${path}`);
130
+ const addition = `diff --git ${JSON.stringify(`a/${path}`)} ${label}\nnew file mode ${metadata.isSymbolicLink() ? '120000' : '100644'}\n--- /dev/null\n+++ ${label}\n@@ -0,0 +1,${lines.length} @@\n${lines.map(line => `+${line}\n`).join('')}`;
131
+ patch += addition;
132
+ }
133
+ else
134
+ truncated = true;
135
+ }
136
+ const all = [...files.values()];
137
+ const result = parseWorkspaceChangeSummary({ ...empty('ready'), branch, base_revision: startingRevision ?? base,
138
+ files: all.slice(0, fileLimit), additions: all.reduce((n, file) => n + file.additions, 0),
139
+ deletions: all.reduce((n, file) => n + file.deletions, 0), hidden_files: Math.max(0, all.length - fileLimit),
140
+ patch: patch.slice(0, patchLimit), patch_truncated: truncated || patch.length > patchLimit,
141
+ }, fullReview);
142
+ return result ?? empty('unavailable');
143
+ }
144
+ catch {
145
+ return empty('unavailable');
146
+ }
147
+ }
@@ -0,0 +1,24 @@
1
+ export const WORKSPACE_PATCH_LIMIT: number;
2
+ export const WORKSPACE_FILE_LIMIT: number;
3
+ export type WorkspaceChangedFile = {
4
+ path: string;
5
+ previous_path: string | null;
6
+ status: 'added' | 'modified' | 'deleted' | 'renamed' | 'copied' | 'typechange' | 'untracked' | 'unknown';
7
+ additions: number;
8
+ deletions: number;
9
+ binary: boolean;
10
+ };
11
+ export type WorkspaceChangeSummary = {
12
+ captured_at: string;
13
+ branch: string | null;
14
+ /** Immutable workspace starting revision; includes committed work since creation. */
15
+ base_revision: string | null;
16
+ state: 'ready' | 'unavailable' | 'not_git';
17
+ files: WorkspaceChangedFile[];
18
+ additions: number;
19
+ deletions: number;
20
+ hidden_files: number;
21
+ patch: string;
22
+ patch_truncated: boolean;
23
+ };
24
+ export function parseWorkspaceChangeSummary(value: unknown, fullReview?: boolean): WorkspaceChangeSummary | null;
@@ -0,0 +1,44 @@
1
+ // Bounded room-visible source changes. Absolute host paths and arbitrary metadata
2
+ // are excluded; patch text is intentional review content, never execution input.
3
+ export const WORKSPACE_PATCH_LIMIT = 128 * 1024;
4
+ export const WORKSPACE_FILE_LIMIT = 200;
5
+ const states = ['added', 'modified', 'deleted', 'renamed', 'copied', 'typechange', 'untracked', 'unknown'];
6
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
7
+ && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
8
+ const count = value => Number.isSafeInteger(value) && value >= 0;
9
+ const path = value => typeof value === 'string' && value.length > 0 && value.length <= 2048
10
+ && !value.startsWith('/') && !/^[A-Za-z]:/.test(value) && !value.split(/[\\/]/).includes('..')
11
+ && !/[\x00-\x1f\x7f]/.test(value);
12
+
13
+ export function parseWorkspaceChangeSummary(value, fullReview = false) {
14
+ if (!exact(value, ['captured_at', 'branch', 'base_revision', 'state', 'files', 'additions', 'deletions', 'hidden_files', 'patch', 'patch_truncated'])
15
+ || typeof value.captured_at !== 'string' || !Number.isFinite(Date.parse(value.captured_at))
16
+ || !(value.branch === null || typeof value.branch === 'string' && value.branch.length <= 256 && !/[\x00-\x1f\x7f]/.test(value.branch))
17
+ || !(value.base_revision === null || typeof value.base_revision === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value.base_revision))
18
+ || !['ready', 'unavailable', 'not_git'].includes(value.state)
19
+ || !Array.isArray(value.files) || value.files.length > (fullReview ? 10_000 : WORKSPACE_FILE_LIMIT)
20
+ || !count(value.additions) || !count(value.deletions) || !count(value.hidden_files)
21
+ || typeof value.patch !== 'string' || value.patch.length > (fullReview ? 128 * 1024 * 1024 : WORKSPACE_PATCH_LIMIT)
22
+ || typeof value.patch_truncated !== 'boolean') return null;
23
+ // Full reviews are byte-bounded by their encoded envelope. Re-serializing each
24
+ // patch here creates several full-size copies while decoding a large review.
25
+ if (!fullReview && new TextEncoder().encode(JSON.stringify(value)).length > 480 * 1024) return null;
26
+ const files = [];
27
+ const paths = new Set();
28
+ for (const file of value.files) {
29
+ if (!exact(file, ['path', 'previous_path', 'status', 'additions', 'deletions', 'binary'])
30
+ || !path(file.path) || paths.has(file.path)
31
+ || !(file.previous_path === null || path(file.previous_path)) || !states.includes(file.status)
32
+ || !count(file.additions) || !count(file.deletions) || typeof file.binary !== 'boolean') return null;
33
+ paths.add(file.path);
34
+ files.push({ path: file.path, previous_path: file.previous_path, status: file.status,
35
+ additions: file.additions, deletions: file.deletions, binary: file.binary });
36
+ }
37
+ if (value.state !== 'ready' && (files.length || value.additions || value.deletions || value.hidden_files || value.patch || value.patch_truncated)) return null;
38
+ if (files.reduce((n, file) => n + file.additions, 0) > value.additions
39
+ || files.reduce((n, file) => n + file.deletions, 0) > value.deletions) return null;
40
+ return { captured_at: new Date(value.captured_at).toISOString(), branch: value.branch,
41
+ base_revision: value.base_revision, state: value.state, files, additions: value.additions,
42
+ deletions: value.deletions, hidden_files: value.hidden_files, patch: value.patch,
43
+ patch_truncated: value.patch_truncated };
44
+ }
@@ -0,0 +1,9 @@
1
+ import type { WorkspaceChangedFile } from './workspace-change-summary.mjs';
2
+ export type WorkspaceDiffLine = { text: string; kind: 'context' | 'added' | 'deleted' | 'hunk' | 'metadata'; before: number | null; after: number | null };
3
+ export type WorkspaceDiffPageLine = WorkspaceDiffLine & { textLength: number; textOffset: number; nextTextOffset: number | null };
4
+ export type WorkspaceDiffPage = { lines: WorkspaceDiffPageLine[]; nextOffset: number | null; included: boolean };
5
+ export type WorkspaceDiffPageOptions = { offset?: number; textOffset?: number; singleLine?: boolean };
6
+ export type WorkspaceDiffIndex = { patch: string; files: Map<string, { end: number; checkpoints: { position: number; before: number; after: number }[] }> };
7
+ export function createWorkspaceDiffIndex(patch: string, files: WorkspaceChangedFile[]): WorkspaceDiffIndex;
8
+ export function readWorkspaceDiffPage(index: WorkspaceDiffIndex, path: string, options?: WorkspaceDiffPageOptions): WorkspaceDiffPage;
9
+ export function workspaceFilePatches(patch: string, files: WorkspaceChangedFile[], window?: { offset: number; limit: number }): Map<string, WorkspaceDiffLine[]>;
@@ -0,0 +1,105 @@
1
+ function unquotePath(path) {
2
+ if (!path.startsWith('"') || !path.endsWith('"')) return path;
3
+ const bytes = [];
4
+ const source = path.slice(1, -1);
5
+ const encoder = new TextEncoder();
6
+ for (let i = 0; i < source.length;) {
7
+ const octal = source[i] === '\\' ? /^\\([0-7]{1,3})/.exec(source.slice(i)) : null;
8
+ if (octal) { bytes.push(parseInt(octal[1], 8)); i += octal[0].length; continue; }
9
+ if (source[i] === '\\' && i + 1 < source.length) {
10
+ const char = source[++i];
11
+ bytes.push(...encoder.encode(({ t: '\t', n: '\n', r: '\r', b: '\b', f: '\f', v: '\v' })[char] ?? char));
12
+ i++; continue;
13
+ }
14
+ const char = String.fromCodePoint(source.codePointAt(i));
15
+ bytes.push(...encoder.encode(char)); i += char.length;
16
+ }
17
+ return new TextDecoder().decode(new Uint8Array(bytes));
18
+ }
19
+
20
+ // File boundaries and sparse line checkpoints are retained only by an open review.
21
+ export function createWorkspaceDiffIndex(patch, files) {
22
+ const byPath = new Map(files.map(file => [file.path, file]));
23
+ const byHeader = new Map(files.flatMap(file => [
24
+ [`diff --git a/${file.previous_path ?? file.path} b/${file.path}`, file],
25
+ [`diff --git ${JSON.stringify(`a/${file.previous_path ?? file.path}`)} ${JSON.stringify(`b/${file.path}`)}`, file],
26
+ ]));
27
+ const result = { patch, files: new Map() };
28
+ const boundary = /^diff --git /gm;
29
+ let current = boundary.exec(patch);
30
+ while (current) {
31
+ const next = boundary.exec(patch), end = next?.index ?? patch.length;
32
+ const headers = [];
33
+ let position = current.index;
34
+ while (position < end) {
35
+ const newline = patch.indexOf('\n', position);
36
+ const stop = newline < 0 || newline >= end ? end : newline;
37
+ const line = patch.slice(position, stop);
38
+ if (line.startsWith('@@ ')) break;
39
+ headers.push(line); position = stop + 1;
40
+ }
41
+ const target = headers.find(line => line.startsWith('+++ '));
42
+ const source = headers.find(line => line.startsWith('--- '));
43
+ const path = target && target !== '+++ /dev/null' ? unquotePath(target.slice(4)).replace(/^b\//, '')
44
+ : source ? unquotePath(source.slice(4)).replace(/^a\//, '') : null;
45
+ const renamed = headers.find(line => line.startsWith('rename to '));
46
+ const file = byPath.get(path) ?? (renamed ? byPath.get(unquotePath(renamed.slice(10))) : undefined) ?? byHeader.get(headers[0]);
47
+ if (file) result.files.set(file.path, { end, checkpoints: [{ position, before: 0, after: 0 }] });
48
+ current = next;
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function readLines(index, path, offset, limit, textOffset, characterLimit, budget) {
54
+ const file = index.files.get(path);
55
+ if (!file) return { lines: [], nextOffset: null, included: false };
56
+ const checkpointIndex = Math.min(Math.floor(offset / 500), file.checkpoints.length - 1);
57
+ let { position, before, after } = file.checkpoints[checkpointIndex];
58
+ let row = checkpointIndex * 500;
59
+ const lines = [];
60
+ while (position < file.end) {
61
+ const newline = index.patch.indexOf('\n', position);
62
+ const stop = newline < 0 || newline >= file.end ? file.end : newline;
63
+ const start = position;
64
+ position = stop + 1;
65
+ // Only hunk headers need a regular expression; never slice a giant code line.
66
+ const first = index.patch[start];
67
+ const hunk = first === '@' ? /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(index.patch.slice(start, Math.min(stop, start + 256))) : null;
68
+ let kind, oldLine = null, newLine = null, contentStart = start;
69
+ if (hunk) { before = Number(hunk[1]); after = Number(hunk[2]); kind = 'hunk'; }
70
+ else if (first === '+') { kind = 'added'; contentStart++; newLine = after++; }
71
+ else if (first === '-') { kind = 'deleted'; contentStart++; oldLine = before++; }
72
+ else if (first === ' ') { kind = 'context'; contentStart++; oldLine = before++; newLine = after++; }
73
+ else if (stop > start) kind = 'metadata';
74
+ else continue;
75
+ if (row >= offset) {
76
+ const textLength = stop - contentStart;
77
+ let from = Math.min(textOffset, textLength), to = Math.min(textLength, from + characterLimit, from + budget);
78
+ // Keep UTF-16 surrogate pairs intact at display-chunk boundaries.
79
+ if (from && /[\uDC00-\uDFFF]/.test(index.patch[contentStart + from]) && /[\uD800-\uDBFF]/.test(index.patch[contentStart + from - 1])) from--;
80
+ if (to < textLength && /[\uD800-\uDBFF]/.test(index.patch[contentStart + to - 1])) to--;
81
+ const text = index.patch.slice(contentStart + from, contentStart + to);
82
+ lines.push({ text, kind, before: oldLine, after: newLine, textLength, textOffset: from, nextTextOffset: to < textLength ? to : null });
83
+ budget -= text.length;
84
+ }
85
+ row++;
86
+ if (row % 500 === 0 && !file.checkpoints[row / 500]) file.checkpoints[row / 500] = { position, before, after };
87
+ if (lines.length >= limit || budget <= 0) break;
88
+ }
89
+ return { lines, nextOffset: position < file.end ? row : null, included: true };
90
+ }
91
+
92
+ export function readWorkspaceDiffPage(index, path, options = {}) {
93
+ const { offset = 0, textOffset = 0, singleLine = false } = options;
94
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > index.patch.length
95
+ || !Number.isSafeInteger(textOffset) || textOffset < 0 || textOffset > index.patch.length) throw new Error('Invalid diff page.');
96
+ return readLines(index, path, offset, singleLine ? 1 : 500, textOffset, 4096, 128 * 1024);
97
+ }
98
+
99
+ // Existing callers can still inspect a small preview without a review session.
100
+ export function workspaceFilePatches(patch, files, window) {
101
+ const index = createWorkspaceDiffIndex(patch, files);
102
+ return new Map([...index.files.keys()].map(path => [path,
103
+ readLines(index, path, window?.offset ?? 0, window?.limit ?? Infinity, 0, Infinity, Infinity).lines
104
+ .map(({ text, kind, before, after }) => ({ text, kind, before, after }))]));
105
+ }