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.
- package/README.md +37 -0
- package/dist/mcp/local-state/local-chat.js +2 -0
- package/dist/mcp/server/register-tools.js +5 -0
- package/dist/mcp/server/runtime/workspace-capture.js +267 -0
- package/dist/mcp/server/supervised-tool-facade.js +1 -0
- package/dist/mcp/server/tools/agent-sessions.js +2 -0
- package/dist/mcp/server/tools/knowledge.js +76 -0
- package/dist/mcp/server/tools/rooms/inspection-tools.js +1 -0
- package/dist/mcp/server/tools/workspace.js +11 -0
- package/dist/mcp/server.js +3 -2
- package/dist/shared/room-agent-prompts.js +1 -1
- package/package.json +2 -2
- package/shared/contribution-text.d.mts +1 -0
- package/shared/contribution-text.mjs +34 -0
- package/shared/local-room-knowledge.d.mts +7 -0
- package/shared/local-room-knowledge.mjs +53 -0
- package/shared/mcp-workspace-capture-worker.mjs +41 -0
- package/shared/room-agent-work.d.mts +4 -1
- package/shared/room-agent-work.mjs +23 -6
- package/shared/room-knowledge.d.mts +25 -0
- package/shared/room-knowledge.mjs +74 -0
- package/shared/workspace-change-capture.d.mts +2 -0
- package/shared/workspace-change-capture.mjs +147 -0
- package/shared/workspace-change-summary.d.mts +24 -0
- package/shared/workspace-change-summary.mjs +44 -0
- package/shared/workspace-diff.d.mts +9 -0
- package/shared/workspace-diff.mjs +105 -0
- package/shared/workspace-review-worker.mjs +47 -0
- package/shared/workspace-review.d.mts +10 -0
- package/shared/workspace-review.mjs +45 -0
- package/shared/workspace-turn-capture.d.mts +10 -0
- package/shared/workspace-turn-capture.mjs +118 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
3
|
+
import { decodeWorkspaceReview, parseWorkspaceReviewPage } from './workspace-review.mjs';
|
|
4
|
+
import { createWorkspaceDiffIndex, readWorkspaceDiffPage } from './workspace-diff.mjs';
|
|
5
|
+
|
|
6
|
+
let review = null;
|
|
7
|
+
let pages = [], digest = '', total = 0;
|
|
8
|
+
const indexes = new Map();
|
|
9
|
+
const describe = () => ({ version: 1, workspace: { ...review.workspace, patch: '' }, contribution: { ...review.contribution, patch: '' } });
|
|
10
|
+
parentPort.on('message', ({ id, method, input }) => {
|
|
11
|
+
try {
|
|
12
|
+
let result;
|
|
13
|
+
if (method === 'local') {
|
|
14
|
+
let db;
|
|
15
|
+
try {
|
|
16
|
+
db = new DatabaseSync(input.databasePath, { readOnly: true });
|
|
17
|
+
const row = db.prepare(`SELECT r.data,r.digest FROM room_workspace_reviews r JOIN room_work_publications p
|
|
18
|
+
USING(agent_id,room_id,source_message_id) WHERE p.room_id=? AND p.agent_key=? AND p.source_message_id=? AND p.api_origin=? AND p.state='open'`)
|
|
19
|
+
.get(input.roomId, input.agentKey, input.sourceMessageId, input.apiOrigin);
|
|
20
|
+
if (row) review = decodeWorkspaceReview(String(row.data), String(row.digest));
|
|
21
|
+
} catch { review = null; } finally { db?.close(); }
|
|
22
|
+
result = review ? describe() : null;
|
|
23
|
+
} else if (method === 'append') {
|
|
24
|
+
if (!Array.isArray(input) || input.length > 4) throw new Error('Invalid review pages.');
|
|
25
|
+
if (!pages.length) { review = null; indexes.clear(); }
|
|
26
|
+
for (const raw of input) {
|
|
27
|
+
const page = parseWorkspaceReviewPage(raw);
|
|
28
|
+
if (!page || page.index !== pages.length || (pages.length && (page.digest !== digest || page.total !== total))) throw new Error('Incomplete workspace review.');
|
|
29
|
+
digest = page.digest; total = page.total; pages.push(page.data);
|
|
30
|
+
}
|
|
31
|
+
result = null;
|
|
32
|
+
} else if (method === 'finish') {
|
|
33
|
+
if (!total || pages.length !== total) throw new Error('Incomplete workspace review.');
|
|
34
|
+
const data = pages.join(''); pages = [];
|
|
35
|
+
review = decodeWorkspaceReview(data, digest);
|
|
36
|
+
result = describe();
|
|
37
|
+
} else if (method === 'page') {
|
|
38
|
+
if (!review || !['workspace', 'contribution'].includes(input.view) || typeof input.path !== 'string') throw new Error('Review is not open.');
|
|
39
|
+
if (!indexes.has(input.view)) {
|
|
40
|
+
const snapshot = review[input.view];
|
|
41
|
+
indexes.set(input.view, createWorkspaceDiffIndex(snapshot.patch, snapshot.files));
|
|
42
|
+
}
|
|
43
|
+
result = readWorkspaceDiffPage(indexes.get(input.view), input.path, input);
|
|
44
|
+
} else throw new Error('Invalid review operation.');
|
|
45
|
+
parentPort.postMessage({ id, result });
|
|
46
|
+
} catch (error) { parentPort.postMessage({ id, error: error.message }); }
|
|
47
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
|
|
2
|
+
export const REVIEW_LIMIT: number;
|
|
3
|
+
export const REVIEW_PAGE_SIZE: number;
|
|
4
|
+
export const REVIEW_MAX_PAGES: number;
|
|
5
|
+
export type WorkspaceReview = { version: 1; workspace: WorkspaceChangeSummary; contribution: WorkspaceChangeSummary };
|
|
6
|
+
export type WorkspaceReviewPage = { digest: string; index: number; total: number; data: string };
|
|
7
|
+
export function parseWorkspaceReview(value: unknown): WorkspaceReview | null;
|
|
8
|
+
export function encodeWorkspaceReview(value: WorkspaceReview): { data: string; digest: string };
|
|
9
|
+
export function decodeWorkspaceReview(data: string, digest: string): WorkspaceReview;
|
|
10
|
+
export function parseWorkspaceReviewPage(value: unknown): WorkspaceReviewPage | null;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
3
|
+
import { parseWorkspaceChangeSummary } from './workspace-change-summary.mjs';
|
|
4
|
+
|
|
5
|
+
// Full review bytes travel separately from the bounded room timeline.
|
|
6
|
+
export const REVIEW_LIMIT = 128 * 1024 * 1024;
|
|
7
|
+
export const REVIEW_PAGE_SIZE = 64 * 1024;
|
|
8
|
+
export const REVIEW_MAX_PAGES = REVIEW_LIMIT / REVIEW_PAGE_SIZE;
|
|
9
|
+
export function parseWorkspaceReview(value) {
|
|
10
|
+
if (!value || value.version !== 1 || Object.keys(value).sort().join(',') !== 'contribution,version,workspace') return null;
|
|
11
|
+
const workspace = parseWorkspaceChangeSummary(value.workspace, true);
|
|
12
|
+
const contribution = parseWorkspaceChangeSummary(value.contribution, true);
|
|
13
|
+
if (!workspace || !contribution) return null;
|
|
14
|
+
return { version: 1, workspace, contribution };
|
|
15
|
+
}
|
|
16
|
+
export function encodeWorkspaceReview(value) {
|
|
17
|
+
const parsed = parseWorkspaceReview(value);
|
|
18
|
+
if (!parsed) throw new Error('Invalid full workspace review.');
|
|
19
|
+
const json = JSON.stringify(parsed);
|
|
20
|
+
if (Buffer.byteLength(json) > REVIEW_LIMIT) throw new RangeError('Workspace review exceeds capture capacity.');
|
|
21
|
+
const data = gzipSync(json).toString('base64');
|
|
22
|
+
if (data.length > REVIEW_LIMIT) throw new RangeError('Workspace review exceeds transfer capacity.');
|
|
23
|
+
return { data, digest: createHash('sha256').update(data).digest('hex') };
|
|
24
|
+
}
|
|
25
|
+
export function decodeWorkspaceReview(data, digest) {
|
|
26
|
+
if (typeof data !== 'string' || data.length > REVIEW_LIMIT || createHash('sha256').update(data).digest('hex') !== digest) throw new Error('Incomplete workspace review.');
|
|
27
|
+
const compressed = Buffer.from(data, 'base64');
|
|
28
|
+
// Our single-member gzip records its output size in the trailer. Use it only
|
|
29
|
+
// as a bounded allocation hint: zlib still verifies CRC/size and enforces the
|
|
30
|
+
// output limit. One spare byte avoids allocating another full output buffer.
|
|
31
|
+
const chunkSize = compressed.length < 4 ? 16 * 1024
|
|
32
|
+
: Math.max(16 * 1024, Math.min(REVIEW_LIMIT + 1, compressed.readUInt32LE(compressed.length - 4) + 1));
|
|
33
|
+
const value = parseWorkspaceReview(JSON.parse(gunzipSync(compressed, { maxOutputLength: REVIEW_LIMIT, chunkSize }).toString('utf8')));
|
|
34
|
+
if (!value) throw new Error('Invalid workspace review.');
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
export function parseWorkspaceReviewPage(value) {
|
|
38
|
+
if (!value || Object.keys(value).sort().join(',') !== 'data,digest,index,total'
|
|
39
|
+
|| typeof value.digest !== 'string' || !/^[a-f0-9]{64}$/.test(value.digest)
|
|
40
|
+
|| !Number.isSafeInteger(value.total) || value.total < 1 || value.total > REVIEW_MAX_PAGES
|
|
41
|
+
|| !Number.isSafeInteger(value.index) || value.index < 0 || value.index >= value.total
|
|
42
|
+
|| typeof value.data !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)
|
|
43
|
+
|| value.data.length > REVIEW_PAGE_SIZE || (value.index < value.total - 1 && value.data.length !== REVIEW_PAGE_SIZE)) return null;
|
|
44
|
+
return { digest: value.digest, index: value.index, total: value.total, data: value.data };
|
|
45
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkspaceChangeSummary } from './workspace-change-summary.mjs';
|
|
2
|
+
import type { WorkspaceReview } from './workspace-review.mjs';
|
|
3
|
+
export function unavailableWorkspace(): WorkspaceChangeSummary;
|
|
4
|
+
export function captureWorkspaceTree(workspace: string, identity: string): Promise<string | null>;
|
|
5
|
+
export function releaseWorkspaceTree(workspace: string, identity: string): Promise<void>;
|
|
6
|
+
export function captureWorkspacePair(workspace: string, startingRevision: string | null, baseline: string | null, identity: string): Promise<{
|
|
7
|
+
workspace: WorkspaceChangeSummary;
|
|
8
|
+
contribution: { changes: WorkspaceChangeSummary; summary: string | null };
|
|
9
|
+
review: WorkspaceReview;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdtemp, open, readlink, realpath, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join, relative } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { captureWorkspaceChanges } from './workspace-change-capture.mjs';
|
|
8
|
+
const environment = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
|
|
9
|
+
function git(cwd, args, signal, index, input) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = execFile('git', ['-c', 'core.hooksPath=/dev/null', '--no-optional-locks', ...args], {
|
|
12
|
+
cwd, signal, timeout: 5_000, maxBuffer: 4 * 8 * 1024 * 1024, encoding: 'utf8',
|
|
13
|
+
env: { ...environment(), GIT_TERMINAL_PROMPT: '0', ...(index ? { GIT_INDEX_FILE: index } : {}) },
|
|
14
|
+
}, (error, stdout) => error ? reject(error) : resolve(stdout));
|
|
15
|
+
child.stdin?.on('error', () => { });
|
|
16
|
+
child.stdin?.end(input);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export const unavailableWorkspace = () => ({ captured_at: new Date().toISOString(), branch: null,
|
|
20
|
+
base_revision: null, state: 'unavailable', files: [], additions: 0, deletions: 0, hidden_files: 0, patch: '', patch_truncated: false });
|
|
21
|
+
const reviewRef = (identity) => `refs/letagents/workspace-review/${createHash('sha256').update(identity).digest('hex')}`;
|
|
22
|
+
/** Retained Git tree, with no changes to the user's index, branch, or working files.
|
|
23
|
+
* Unsupported/oversized observations fail closed; a partial baseline is never a turn diff. */
|
|
24
|
+
export async function captureWorkspaceTree(workspace, identity) {
|
|
25
|
+
const signal = AbortSignal.timeout(20_000);
|
|
26
|
+
const scratch = await mkdtemp(join(tmpdir(), 'letagents-workspace-'));
|
|
27
|
+
const index = join(scratch, 'index');
|
|
28
|
+
try {
|
|
29
|
+
const root = await realpath(workspace);
|
|
30
|
+
const head = (await git(root, ['rev-parse', '--verify', 'HEAD^{tree}'], signal)).trim();
|
|
31
|
+
await git(root, ['read-tree', head], signal, index);
|
|
32
|
+
const original = new Map((await git(root, ['ls-tree', '-r', '-z', head], signal)).split('\0').filter(Boolean).map(entry => {
|
|
33
|
+
const split = entry.indexOf('\t');
|
|
34
|
+
return [entry.slice(split + 1), entry.slice(0, split).split(' ')];
|
|
35
|
+
}));
|
|
36
|
+
const paths = [...new Set([...original.keys(), ...(await git(root, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], signal)).split('\0').filter(Boolean)])];
|
|
37
|
+
if (paths.length > 10_000)
|
|
38
|
+
return null;
|
|
39
|
+
const entries = [];
|
|
40
|
+
let bytesRead = 0;
|
|
41
|
+
for (const path of paths) {
|
|
42
|
+
// Parent symlinks must not redirect an observation outside the managed workspace.
|
|
43
|
+
const parent = await realpath(dirname(join(root, path))).catch(() => null);
|
|
44
|
+
if (parent && (relative(root, parent).startsWith('..') || relative(root, parent).startsWith('/')))
|
|
45
|
+
return null;
|
|
46
|
+
if (signal.aborted)
|
|
47
|
+
return null;
|
|
48
|
+
const absolute = join(root, path);
|
|
49
|
+
const stat = await lstat(absolute).catch(error => { if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
|
|
50
|
+
return null; throw error; });
|
|
51
|
+
if (original.get(path)?.[1] === 'commit')
|
|
52
|
+
return null; // Submodules need their own workspace.
|
|
53
|
+
if (!stat) {
|
|
54
|
+
entries.push(`0 ${'0'.repeat(head.length)}\t${path}\0`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let bytes;
|
|
58
|
+
if (stat.isSymbolicLink())
|
|
59
|
+
bytes = Buffer.from(await readlink(absolute));
|
|
60
|
+
else if (stat.isFile() && stat.size <= 8 * 1024 * 1024) {
|
|
61
|
+
const handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
62
|
+
try {
|
|
63
|
+
const buffer = Buffer.alloc(8 * 1024 * 1024 + 1);
|
|
64
|
+
const read = await handle.read(buffer, 0, buffer.length, 0);
|
|
65
|
+
bytes = buffer.subarray(0, read.bytesRead);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await handle.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
return null; // Includes submodule changes: do not attribute a fabricated text diff.
|
|
73
|
+
bytesRead += bytes.length;
|
|
74
|
+
if (bytes.length > 8 * 1024 * 1024 || bytesRead > 64 * 1024 * 1024)
|
|
75
|
+
return null;
|
|
76
|
+
const mode = stat.isSymbolicLink() ? '120000' : stat.mode & 0o111 ? '100755' : '100644';
|
|
77
|
+
const rawOid = createHash(head.length === 64 ? 'sha256' : 'sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
78
|
+
if (original.get(path)?.[0] === mode && original.get(path)?.[2] === rawOid)
|
|
79
|
+
continue;
|
|
80
|
+
if (entries.length >= 10_000)
|
|
81
|
+
return null;
|
|
82
|
+
const oid = (await git(root, ['hash-object', '-w', '--no-filters', '--stdin'], signal, undefined, bytes)).trim();
|
|
83
|
+
entries.push(`${mode} ${oid}\t${path}\0`);
|
|
84
|
+
}
|
|
85
|
+
if (entries.length)
|
|
86
|
+
await git(root, ['update-index', '-z', '--index-info'], signal, index, entries.join(''));
|
|
87
|
+
const tree = (await git(root, ['write-tree'], signal, index)).trim();
|
|
88
|
+
await git(root, ['update-ref', reviewRef(identity), tree], signal);
|
|
89
|
+
return tree;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
await rm(scratch, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export async function releaseWorkspaceTree(workspace, identity) {
|
|
99
|
+
try {
|
|
100
|
+
await git(workspace, ['update-ref', '-d', reviewRef(identity)], AbortSignal.timeout(5_000));
|
|
101
|
+
}
|
|
102
|
+
catch { /* Retain on failure, never jeopardize provider work. */ }
|
|
103
|
+
}
|
|
104
|
+
export async function captureWorkspacePair(workspace, startingRevision, baseline, identity) {
|
|
105
|
+
const tree = await captureWorkspaceTree(workspace, `${identity}:settled`);
|
|
106
|
+
try {
|
|
107
|
+
const full = tree ? await captureWorkspaceChanges(workspace, startingRevision, tree, true) : unavailableWorkspace();
|
|
108
|
+
const changes = tree && baseline ? await captureWorkspaceChanges(workspace, baseline, tree, true) : unavailableWorkspace();
|
|
109
|
+
const preview = (snapshot) => ({ ...snapshot,
|
|
110
|
+
files: snapshot.files.slice(0, 200), hidden_files: snapshot.hidden_files + Math.max(0, snapshot.files.length - 200),
|
|
111
|
+
patch: snapshot.patch.slice(0, 48 * 1024), patch_truncated: snapshot.patch_truncated || snapshot.patch.length > 48 * 1024 });
|
|
112
|
+
return { workspace: preview(full), contribution: { changes: preview(changes), summary: null },
|
|
113
|
+
review: { version: 1, workspace: full, contribution: changes } };
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await releaseWorkspaceTree(workspace, `${identity}:settled`);
|
|
117
|
+
}
|
|
118
|
+
}
|