@piagent/platform 1.30.0 → 1.31.0
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 +30 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +13 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +13 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +14 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +14 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/managed/memory-lessons.mjs +23 -9
- package/packages/piagent-core/managed/memory-repos.mjs +104 -0
- package/packages/piagent-core/managed/memory-tools.mjs +35 -0
- package/packages/piagent-core/managed/memory.mjs +207 -105
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-3vQTaeOO.js → MarkdownMessage-ZFXG_br8.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/{index-Dve4GNQ7.js → index-B3ODBFGj.js} +3 -3
- package/packages/piagent-webui/dist/client/index.html +1 -1
- package/packages/piagent-webui/shared/company-copy.ts +1 -0
- package/scripts/company-terminal.mjs +23 -12
- package/scripts/piagent-studio.mjs +3 -1
|
@@ -6,11 +6,13 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { MemoryOutbox, memoryScope, scopeID } from './memory-outbox.mjs';
|
|
9
|
-
import { execFile } from 'node:child_process';
|
|
10
9
|
import { CheckLessons } from './memory-lessons.mjs';
|
|
11
10
|
import { CLIENT_AGENT } from './client-agent.mjs';
|
|
12
11
|
import { redactedText } from '../runtime/managed-failure.mjs';
|
|
13
|
-
import {
|
|
12
|
+
import { git, repoKey, repositoryAt, findRepositories, topDirs, eventFor, commitChanges } from './memory-repos.mjs';
|
|
13
|
+
import { PAGE_NAMES, renewer, memoryTools } from './memory-tools.mjs';
|
|
14
|
+
|
|
15
|
+
export { git, repoKey, repositoryAt, findRepositories, PAGE_NAMES, renewer, memoryTools };
|
|
14
16
|
|
|
15
17
|
export const MEMORY_ENTRY = 'agent-watch-memory';
|
|
16
18
|
const BRIEF_MS = 5_000, WRITE_MS = 4_000, GIT_MS = 6_000, TOOL_MS = 10_000;
|
|
@@ -19,24 +21,12 @@ const OFF_MS = 30 * 60_000, KNOWN_MS = 7 * 24 * 3600_000;
|
|
|
19
21
|
// the same context; closing a round (retain + telemetry) stays within CLOSE_MS.
|
|
20
22
|
const EMPTY_BRIEF_MS = 30 * 60_000, FAILED_BRIEF_MS = 5 * 60_000, CLOSE_MS = 4_500;
|
|
21
23
|
const MAX_TURN = 30 * 1024, MAX_COMMITS = 300, MAX_GIT_BODY = 60 * 1024, MAX_COMMIT_BODY = 2_000;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const scp = /^[\w.-]+@([^:/]+):(.+)$/.exec(s); // git@github.com:acme/shop.git
|
|
29
|
-
if (scp) s = `${scp[1]}/${scp[2]}`;
|
|
30
|
-
else {
|
|
31
|
-
try { const u = new URL(s); if (!['http:', 'https:', 'ssh:', 'git:'].includes(u.protocol)) return null; s = `${u.hostname}${u.pathname}`; }
|
|
32
|
-
catch { return null; }
|
|
33
|
-
}
|
|
34
|
-
s = s.toLowerCase().replace(/\.git\/?$/, '').replace(/\/+$/, '').replace(/\/{2,}/g, '/');
|
|
35
|
-
return /^[a-z0-9][a-z0-9._-]*\/[a-z0-9._/-]+$/.test(s) && s.length <= 200 && !s.includes('..') ? s : null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const git = (cwd, args, timeout = 3_000, signal) => new Promise(resolve => execFile('git', ['-C', cwd, ...args], { timeout, signal, maxBuffer: 4 << 20, encoding: 'utf8' },
|
|
39
|
-
(error, stdout) => resolve(error ? null : stdout)));
|
|
24
|
+
// A round is written to at most three of a folder's repositories (memory-repos.mjs).
|
|
25
|
+
const MAX_ROUND_REPOS = 3, ASK_MS = 50_000;
|
|
26
|
+
// Studio protocol 3 (Agent Studio 0.30): rounds and commits carry where the
|
|
27
|
+
// work happened and how its checks ended; memory_ask. Older Studios refuse
|
|
28
|
+
// unknown fields, so these are sent only once Studio has said it speaks 3.
|
|
29
|
+
export const PROTOCOL = 3;
|
|
40
30
|
|
|
41
31
|
const textOf = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(p => p?.type === 'text').map(p => p.text).join('\n') : '';
|
|
42
32
|
|
|
@@ -77,9 +67,9 @@ function bounded(promise, signal, late = () => {}) {
|
|
|
77
67
|
}
|
|
78
68
|
|
|
79
69
|
export class TeamMemory {
|
|
80
|
-
constructor({ origin, cwd, stateDir, conversationId, scope = () => null, lease = null, renew = null, fetchImpl = fetch, now = () => Date.now() }) {
|
|
70
|
+
constructor({ origin, cwd, stateDir, conversationId, scope = () => null, lease = null, renew = null, fetchImpl = fetch, now = () => Date.now(), changed = () => [] }) {
|
|
81
71
|
this.renew = renew; this.scope = scope; this.lease = lease; this.protocol = 0; this.background = null; this.stats = {};
|
|
82
|
-
Object.assign(this, { origin, cwd, stateDir, conversationId, fetchImpl, now, repo: undefined, offUntil: 0, retain: true, briefed: false });
|
|
72
|
+
Object.assign(this, { origin, cwd, stateDir, conversationId, fetchImpl, now, changed, repo: undefined, repos: undefined, folder: null, heads: {}, offUntil: 0, retain: true, briefed: false });
|
|
83
73
|
this.syncScope();
|
|
84
74
|
this.knownPath = stateDir ? path.join(stateDir, 'memory-known.json') : null;
|
|
85
75
|
}
|
|
@@ -96,22 +86,32 @@ export class TeamMemory {
|
|
|
96
86
|
|
|
97
87
|
// Memory answered for this Studio recently: the conversation gets the
|
|
98
88
|
// memory tools (their schema is fixed when it opens).
|
|
99
|
-
static known(stateDir, origin, scope, now
|
|
100
|
-
try { const k = JSON.parse(fs.readFileSync(path.join(stateDir, 'memory-known.json'), 'utf8')); return k.origin === origin && k.scope === scopeID(scope) && !!k.scope && now - k.at < KNOWN_MS; }
|
|
101
|
-
catch { return
|
|
89
|
+
static #known(stateDir, origin, scope, now) {
|
|
90
|
+
try { const k = JSON.parse(fs.readFileSync(path.join(stateDir, 'memory-known.json'), 'utf8')); return k.origin === origin && k.scope === scopeID(scope) && !!k.scope && now - k.at < KNOWN_MS ? k : null; }
|
|
91
|
+
catch { return null; }
|
|
102
92
|
}
|
|
93
|
+
static known(stateDir, origin, scope, now = Date.now()) { return !!TeamMemory.#known(stateDir, origin, scope, now); }
|
|
94
|
+
// The memory protocol that Studio last answered with on this Mac (0: unknown).
|
|
95
|
+
static knownProtocol(stateDir, origin, scope, now = Date.now()) { return stateDir ? TeamMemory.#known(stateDir, origin, scope, now)?.protocol ?? 0 : 0; }
|
|
103
96
|
#remember(on) {
|
|
104
97
|
if (!this.knownPath) return;
|
|
105
|
-
try { if (on) fs.writeFileSync(this.knownPath, JSON.stringify({ origin: this.origin, scope: this.scopeHash, at: this.now() })); else fs.rmSync(this.knownPath, { force: true }); } catch { /* best effort */ }
|
|
98
|
+
try { if (on) fs.writeFileSync(this.knownPath, JSON.stringify({ origin: this.origin, scope: this.scopeHash, at: this.now(), protocol: this.protocol || undefined })); else fs.rmSync(this.knownPath, { force: true }); } catch { /* best effort */ }
|
|
106
99
|
}
|
|
100
|
+
// What this Studio speaks: from this conversation's brief, else from the last one on this Mac.
|
|
101
|
+
get speaks() { return this.protocol || TeamMemory.knownProtocol(this.stateDir, this.origin, this.scope(), this.now()); }
|
|
107
102
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return this.
|
|
103
|
+
// The repositories memory uses here; this.repo is the first (the folder's own).
|
|
104
|
+
async repositories() {
|
|
105
|
+
if (this.repos) return this.repos;
|
|
106
|
+
if (typeof this.repo === 'string') return (this.repos = [{ key: this.repo, dir: this.cwd, name: '' }]);
|
|
107
|
+
const found = await findRepositories(this.cwd);
|
|
108
|
+
this.repos = found.repos; this.folder = found.folder; this.repo = found.repos[0]?.key ?? null;
|
|
109
|
+
return this.repos;
|
|
111
110
|
}
|
|
111
|
+
async repository() { await this.repositories(); return this.repo; }
|
|
112
112
|
get active() { return this.now() >= this.offUntil; }
|
|
113
113
|
|
|
114
|
-
async #post(grant, op, body, timeout, renew = this.renew, signal) {
|
|
114
|
+
async #post(grant, op, body, timeout, renew = this.renew, signal, { remember = true, offOnMissing = true } = {}) {
|
|
115
115
|
const expectedScope = this.scopeHash;
|
|
116
116
|
const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
|
|
117
117
|
const send = async g => {
|
|
@@ -133,6 +133,8 @@ export class TeamMemory {
|
|
|
133
133
|
const fresh = await bounded(renew(grant), deadline).catch(() => null);
|
|
134
134
|
if (fresh?.token && fresh.token !== grant.token) response = await send(fresh);
|
|
135
135
|
}
|
|
136
|
+
// An operation an older Studio does not have (404) is not memory being off.
|
|
137
|
+
if (response.status === 404 && !offOnMissing) return null;
|
|
136
138
|
if (response.status === 204 || response.status === 404) {
|
|
137
139
|
// Old servers use 204 for retain opt-out as well as global off.
|
|
138
140
|
if (op !== 'retain' || this.protocol >= 2) { this.offUntil = this.now() + OFF_MS; this.#remember(false); }
|
|
@@ -142,47 +144,111 @@ export class TeamMemory {
|
|
|
142
144
|
if (!response.ok) {
|
|
143
145
|
const error = Error(`memory-${op}-${response.status}`); error.status = response.status; throw error;
|
|
144
146
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
const answer = await response.json();
|
|
148
|
+
if (op === 'brief' && Number.isInteger(answer?.protocol_version)) this.protocol = answer.protocol_version;
|
|
149
|
+
if (remember) this.#remember(true);
|
|
150
|
+
return answer;
|
|
147
151
|
} finally { held?.release(); }
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Where the member works in a repository now: its branch and the top
|
|
155
|
+
// folders of the files this conversation changed or the working tree holds.
|
|
156
|
+
async #context(repo, signal) {
|
|
157
|
+
const branch = (await git(repo.dir, ['rev-parse', '--abbrev-ref', 'HEAD'], 2_000, signal))?.trim();
|
|
158
|
+
const own = this.#repoFiles(repo, [...(this.changed() ?? [])]);
|
|
159
|
+
const status = (await git(repo.dir, ['status', '--porcelain', '--untracked-files=no'], 2_000, signal)) ?? '';
|
|
160
|
+
const pending = status.split('\n').map(l => l.slice(3).split(' -> ').at(-1)?.trim()).filter(Boolean);
|
|
161
|
+
const paths = topDirs([...own, ...pending]);
|
|
162
|
+
return { ...(branch && branch !== 'HEAD' ? { branch: branch.slice(0, 100) } : {}), ...(paths.length ? { paths } : {}) };
|
|
163
|
+
}
|
|
164
|
+
// The conversation's changed files (relative to cwd) that lie in repo, relative to its top.
|
|
165
|
+
#repoFiles(repo, files) {
|
|
166
|
+
return files.map(f => path.relative(repo.dir, path.resolve(this.cwd, String(f)))).filter(f => f && !f.startsWith('..') && !path.isAbsolute(f)).map(f => f.split(path.sep).join('/'));
|
|
167
|
+
}
|
|
168
|
+
// A brief, search or ask body for one repository; context only for protocol 3.
|
|
169
|
+
async #body(repo, fields, signal) {
|
|
170
|
+
const body = { repo_key: repo.key, ...fields };
|
|
171
|
+
if (this.speaks >= PROTOCOL) { const context = await this.#context(repo, signal).catch(() => ({})); if (Object.keys(context).length) body.context = context; }
|
|
172
|
+
return body;
|
|
173
|
+
}
|
|
174
|
+
// The repositories a question or a round is about: those it names (by folder
|
|
175
|
+
// or repository name) when the folder holds several, else all of them.
|
|
176
|
+
#about(text, max = MAX_ROUND_REPOS) {
|
|
177
|
+
const repos = this.repos ?? [];
|
|
178
|
+
if (repos.length <= 1) return repos;
|
|
179
|
+
const t = String(text ?? '').toLowerCase();
|
|
180
|
+
const named = repos.filter(r => t.includes(r.name.toLowerCase()) || t.includes(r.key.split('/').at(-1)));
|
|
181
|
+
return (named.length ? named : repos).slice(0, max);
|
|
182
|
+
}
|
|
183
|
+
#pick(repository) {
|
|
184
|
+
const repos = this.repos ?? [];
|
|
185
|
+
if (!repository) return null;
|
|
186
|
+
const want = String(repository).toLowerCase().replace(/\/+$/, '');
|
|
187
|
+
return repos.find(r => r.name.toLowerCase() === want || r.key === want || r.key.split('/').at(-1) === want) ?? null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A folder memory cannot use is told to Studio once, so its admins can see
|
|
191
|
+
// it; only where Studio has answered memory on this Mac (a member of a team
|
|
192
|
+
// without memory sends nothing it does not need).
|
|
193
|
+
async #reportFolder(grant) {
|
|
194
|
+
if (this.folderReported || !this.folder || !this.stateDir || !TeamMemory.known(this.stateDir, this.origin, this.scope(), this.now())) return;
|
|
195
|
+
this.folderReported = true;
|
|
196
|
+
try { await this.#post(grant, 'brief', { repo_key: '', prompt: '', folder: this.folder }, BRIEF_MS, this.renew, undefined, { remember: false }); } catch { /* older Studio: 400 */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
150
199
|
async beforeRound(grant, prompt, hasBrief) {
|
|
151
200
|
this.syncScope();
|
|
152
|
-
if (!grant || !this.active
|
|
201
|
+
if (!grant || !this.active) return null;
|
|
202
|
+
const repos = await this.repositories();
|
|
203
|
+
if (!repos.length) { await this.#reportFolder(grant); return null; }
|
|
153
204
|
if ((hasBrief || this.briefed) && this.protocol) { this.startBackground(grant); return null; }
|
|
154
205
|
this.count('brief_attempts');
|
|
155
|
-
let brief;
|
|
156
206
|
this.lastBrief = 'error';
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
this.
|
|
165
|
-
|
|
207
|
+
const ask = String(prompt ?? '').slice(0, 2000);
|
|
208
|
+
const answers = await Promise.allSettled(repos.map(async r => [r, await this.#post(grant, 'brief', await this.#body(r, { prompt: ask }), BRIEF_MS)]));
|
|
209
|
+
const ok = answers.filter(a => a.status === 'fulfilled').map(a => a.value);
|
|
210
|
+
if (!ok.length) { this.count('brief_errors'); return null; }
|
|
211
|
+
const answered = ok.filter(([, b]) => b);
|
|
212
|
+
this.lastBrief = !answered.length ? 'off' : answered.some(([, b]) => b.text?.trim()) ? 'ok' : 'empty';
|
|
213
|
+
if (!answered.length) return null;
|
|
214
|
+
this.briefed = true; this.protocol = Math.min(...answered.map(([, b]) => b.protocol_version ?? 1));
|
|
215
|
+
this.retain = answered.every(([, b]) => b.retain_sessions !== false);
|
|
216
|
+
this.count('brief_successes'); if (this.lastBrief === 'ok') this.count('brief_nonempty');
|
|
217
|
+
const heads = {};
|
|
218
|
+
for (const [r, b] of answered) heads[r.key] = b.git_head ?? null;
|
|
219
|
+
this.startBackground(grant, heads);
|
|
220
|
+
const several = repos.length > 1, pages = new Set(), parts = [];
|
|
221
|
+
let facts = 0;
|
|
222
|
+
for (const [r, b] of answered) {
|
|
223
|
+
facts += b.facts ?? 0; (Array.isArray(b.pages) ? b.pages : []).forEach(p => pages.add(p));
|
|
224
|
+
const text = typeof b.text === 'string' ? b.text.trim() : '';
|
|
225
|
+
if (text) parts.push(several ? `Repository ${r.name}/ (${r.key}):\n${text}` : text);
|
|
226
|
+
}
|
|
227
|
+
return { text: parts.join('\n\n'), facts, pages: [...pages] };
|
|
166
228
|
}
|
|
167
229
|
|
|
168
|
-
startBackground(grant,
|
|
230
|
+
startBackground(grant, heads = undefined) {
|
|
169
231
|
if (this.background || !this.active) return;
|
|
170
232
|
// One deadline for the whole backlog, not one deadline per old event.
|
|
171
233
|
const signal = AbortSignal.timeout(WRITE_MS);
|
|
172
|
-
this.
|
|
173
|
-
|
|
174
|
-
...(gitHead !== undefined ? [this.#sendGit(grant, gitHead, AbortSignal.timeout(GIT_MS))] : []),
|
|
175
|
-
]).finally(() => { this.background = null; });
|
|
234
|
+
const git = heads === undefined ? [] : (this.repos ?? []).filter(r => r.key in heads).map(r => this.#sendGit(grant, r, heads[r.key], AbortSignal.timeout(GIT_MS)));
|
|
235
|
+
this.background = Promise.allSettled([this.#drain(grant, signal), ...git]).finally(() => { this.background = null; });
|
|
176
236
|
}
|
|
177
237
|
|
|
178
|
-
async #sendGit(grant, known, signal) {
|
|
179
|
-
const head = (await git(
|
|
238
|
+
async #sendGit(grant, repo, known, signal) {
|
|
239
|
+
const head = (await git(repo.dir, ['rev-parse', 'HEAD'], 3000, signal))?.trim();
|
|
180
240
|
if (!/^[0-9a-f]{40}$/.test(head ?? '') || head === known) return;
|
|
181
|
-
const range = known && (await git(
|
|
182
|
-
const
|
|
241
|
+
const range = known && (await git(repo.dir, ['merge-base', '--is-ancestor', known, head], 3000, signal).then(out => out !== null)) ? [`${known}..${head}`] : [head];
|
|
242
|
+
const v3 = this.protocol >= PROTOCOL;
|
|
243
|
+
// Protocol 3 adds the files each commit touched: folders for tags, deletions/renames/rewrites for staleness.
|
|
244
|
+
const log = await git(repo.dir, ['log', `-n${MAX_COMMITS}`, '--no-merges', ...(v3 ? ['-M', '--numstat', '--summary'] : []), '--format=%x1e%H%x1f%cI%x1f%s%x1f%b%x1d', ...range], 5_000, signal);
|
|
183
245
|
if (!log) return;
|
|
184
|
-
const commits = log.split('\x1e').map(r => r.trim()).filter(Boolean).map(r => {
|
|
185
|
-
|
|
246
|
+
const commits = log.split('\x1e').map(r => r.trim()).filter(Boolean).map(r => {
|
|
247
|
+
const [meta, files = ''] = r.split('\x1d'), [sha, date, subject, body = ''] = meta.split('\x1f');
|
|
248
|
+
const commit = { sha, date, subject, body: body.trim().slice(0, MAX_COMMIT_BODY) };
|
|
249
|
+
if (v3) { const { dirs, changes } = commitChanges(files.split('\n')); if (dirs.length) commit.dirs = dirs; if (changes.length) commit.changes = changes; }
|
|
250
|
+
return commit;
|
|
251
|
+
}).filter(c => /^[0-9a-f]{40}$/.test(c.sha) && c.subject?.trim() && c.date);
|
|
186
252
|
if (!commits.length) return;
|
|
187
253
|
// Oldest first, in calls under the body cap; the head is recorded with the last.
|
|
188
254
|
commits.reverse();
|
|
@@ -191,35 +257,63 @@ export class TeamMemory {
|
|
|
191
257
|
while (i < commits.length && (bytes += Buffer.byteLength(JSON.stringify(commits[i]))) < MAX_GIT_BODY) batch.push(commits[i++]);
|
|
192
258
|
if (!batch.length) batch.push(commits[i++]);
|
|
193
259
|
const last = i >= commits.length;
|
|
194
|
-
if (!(await this.#post(grant, 'git', { repo_key:
|
|
260
|
+
if (!(await this.#post(grant, 'git', { repo_key: repo.key, head: last ? head : batch.at(-1).sha, commits: batch }, GIT_MS, this.renew, signal))) return;
|
|
195
261
|
}
|
|
196
262
|
}
|
|
197
263
|
|
|
264
|
+
// The repositories a round goes to: those its changed files are in, else
|
|
265
|
+
// those it names, else (a folder with several) all of them, at most three.
|
|
266
|
+
#targets(changedFiles, messages) {
|
|
267
|
+
const repos = this.repos ?? [];
|
|
268
|
+
if (repos.length <= 1) return repos;
|
|
269
|
+
const touched = repos.filter(r => this.#repoFiles(r, [...changedFiles]).length);
|
|
270
|
+
return touched.length ? touched.slice(0, MAX_ROUND_REPOS) : this.#about(messages.map(m => textOf(m.content)).join('\n'));
|
|
271
|
+
}
|
|
272
|
+
|
|
198
273
|
// Persist before sending. The same event ID and source identity survive
|
|
199
274
|
// crashes, lost acknowledgements and concurrent sessions.
|
|
200
275
|
async afterRound(grant, messages, changedFiles, event = {}, signal) {
|
|
201
276
|
// The backlog started with the round may still run; wait for it only within the close budget.
|
|
202
277
|
if (this.background) await (signal ? Promise.race([this.background, new Promise(done => signal.addEventListener('abort', done, { once: true }))]) : this.background);
|
|
203
|
-
if (!grant || !this.active || !this.retain || !(await this.
|
|
204
|
-
const
|
|
205
|
-
|
|
278
|
+
if (!grant || !this.active || !this.retain || !(await this.repositories()).length) return;
|
|
279
|
+
const targets = this.#targets(changedFiles ?? [], messages);
|
|
280
|
+
this.lastTargets = targets;
|
|
206
281
|
const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(WRITE_MS)]) : AbortSignal.timeout(WRITE_MS);
|
|
282
|
+
const several = (this.repos ?? []).length > 1;
|
|
283
|
+
// The record names files as the member sees them; protocol 3 also sends them relative to the repository's top.
|
|
284
|
+
const filesOf = r => several ? this.#repoFiles(r, [...(changedFiles ?? [])]) : [...(changedFiles ?? [])];
|
|
285
|
+
const checks = event.checks?.summary ?? null;
|
|
207
286
|
// A v1 Studio (0.27.x) takes the round in its old body, best effort and
|
|
208
287
|
// without the outbox: it refuses unknown fields and has no event identity.
|
|
209
288
|
if (this.protocol === 1) {
|
|
210
|
-
|
|
211
|
-
|
|
289
|
+
for (const r of targets) {
|
|
290
|
+
const text = turnRecord(messages, filesOf(r), event.lessons ?? []);
|
|
291
|
+
if (!text || deadline.aborted) return;
|
|
292
|
+
try { if (await this.#post(grant, 'retain', { repo_key: r.key, conversation_id: this.conversationId, text }, WRITE_MS, this.renew, deadline)) this.count('accepted'); } catch { /* memory is optional */ }
|
|
293
|
+
}
|
|
212
294
|
return;
|
|
213
295
|
}
|
|
214
296
|
const source = event.scope ?? this.scope();
|
|
215
297
|
if (!source?.key_id) return;
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
298
|
+
const box = new MemoryOutbox(this.stateDir, source, this.now), id = event.id ?? randomUUID();
|
|
299
|
+
let queued = false;
|
|
300
|
+
for (const [i, r] of targets.entries()) {
|
|
301
|
+
const files = filesOf(r), text = turnRecord(messages, files, event.lessons ?? []);
|
|
302
|
+
if (!text) return;
|
|
303
|
+
const item = { repo_key: r.key, conversation_id: this.conversationId, text,
|
|
304
|
+
event_id: eventFor(id, r.key, i), source_key_id: source.key_id, occurred_at: event.at ?? new Date(this.now()).toISOString() };
|
|
305
|
+
if (this.protocol >= PROTOCOL) {
|
|
306
|
+
const context = await this.#context(r, deadline).catch(() => ({}));
|
|
307
|
+
if (Object.keys(context).length) item.context = context;
|
|
308
|
+
const own = this.#repoFiles(r, [...(changedFiles ?? [])]);
|
|
309
|
+
if (own.length) item.files = own.slice(0, 12).map(f => String(f).slice(0, 200));
|
|
310
|
+
if (checks) item.checks = checks;
|
|
311
|
+
}
|
|
312
|
+
if (!box.put(item)) { this.count('queue_rejected'); continue; }
|
|
313
|
+
this.count('queued'); queued = true;
|
|
314
|
+
}
|
|
221
315
|
// A rebind cannot send the old round using the new key.
|
|
222
|
-
if (scopeID(source) !== scopeID(this.scope())) return;
|
|
316
|
+
if (!queued || scopeID(source) !== scopeID(this.scope())) return;
|
|
223
317
|
await this.#drain(grant, deadline);
|
|
224
318
|
}
|
|
225
319
|
|
|
@@ -236,7 +330,9 @@ export class TeamMemory {
|
|
|
236
330
|
let outcome = 'retry';
|
|
237
331
|
try {
|
|
238
332
|
if (claim.record.attempts) this.count('write_retries');
|
|
239
|
-
|
|
333
|
+
// A round queued for protocol 3 meets a Studio that went back to 2: send the fields 2 knows.
|
|
334
|
+
const item = this.protocol >= PROTOCOL ? claim.record.item : (({ context, files, checks, ...v2 }) => v2)(claim.record.item);
|
|
335
|
+
const ack = await this.#post(grant, 'retain', item, WRITE_MS, this.renew, signal);
|
|
240
336
|
if (!ack || ack.status === 'retain_disabled') { this.retain = false; outcome = 'discard'; }
|
|
241
337
|
else if (ack.event_id === claim.record.item.event_id && ack.operation_id && ['pending','processing','completed','failed','cancelled','unknown'].includes(ack.status)) {
|
|
242
338
|
outcome = 'ack'; this.count('accepted'); if (ack.duplicate) this.count('duplicates');
|
|
@@ -247,55 +343,59 @@ export class TeamMemory {
|
|
|
247
343
|
}
|
|
248
344
|
|
|
249
345
|
async reportTurn(grant, turn, outcome, timeout = 2000) {
|
|
250
|
-
|
|
346
|
+
const repo = this.lastTargets?.[0]?.key ?? this.repo;
|
|
347
|
+
if (timeout < 250 || !grant || !this.active || this.protocol < 2 || !repo || scopeID(turn.scope) !== scopeID(this.scope())) return;
|
|
251
348
|
// Counters only. Missing delivery stays unknown; it is never counted as
|
|
252
349
|
// a control conversation. Retain opt-out does not disable measurement.
|
|
253
|
-
const body = { repo_key:
|
|
350
|
+
const body = { repo_key: repo, event_id: turn.id, source_key_id: turn.scope.key_id,
|
|
254
351
|
conversation_id: this.conversationId, counters: { ...this.stats }, outcome,
|
|
255
352
|
duration_ms: Math.min(604800000, Math.max(0, Date.now() - Date.parse(turn.at))) };
|
|
256
353
|
try { await this.#post(grant, 'telemetry', body, timeout); } catch { /* best effort, no prompt data */ }
|
|
257
354
|
}
|
|
258
355
|
|
|
259
|
-
|
|
260
|
-
|
|
356
|
+
// The tools' answers, one per repository when the folder holds several.
|
|
357
|
+
async #each(repos, run) {
|
|
358
|
+
const out = await Promise.all(repos.map(async r => [r, await run(r)]));
|
|
359
|
+
return out.length === 1 ? out[0][1] : out.map(([r, text]) => `## ${r.name}/ (${r.key})\n${text}`).join('\n\n');
|
|
360
|
+
}
|
|
361
|
+
#none(repository) {
|
|
362
|
+
if (!(this.repos ?? []).length) return 'Team memory is not available for this folder (no repository with a remote).';
|
|
363
|
+
return `No repository named "${repository}" here; this folder has: ${this.repos.map(r => r.name || r.key).join(', ')}.`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async search(grant, query, renew, repository) {
|
|
367
|
+
if (!grant || !(await this.repositories()).length) return this.#none();
|
|
368
|
+
const picked = repository ? this.#pick(repository) : null;
|
|
369
|
+
if (repository && !picked && this.repos.length > 1) return this.#none(repository);
|
|
261
370
|
this.count(renew?.role && renew.role !== 'main' ? 'helper_search_calls' : 'search_calls');
|
|
262
|
-
|
|
263
|
-
|
|
371
|
+
return this.#each(picked ? [picked] : this.#about(query), async r => {
|
|
372
|
+
try { return (await this.#post(grant, 'search', await this.#body(r, { query }), TOOL_MS, renew ?? this.renew))?.text ?? 'Team memory is off for this team.'; }
|
|
373
|
+
catch { return 'Team memory did not answer; go on without it.'; }
|
|
374
|
+
});
|
|
264
375
|
}
|
|
265
|
-
async page(grant, name, renew) {
|
|
266
|
-
if (!grant || !(await this.
|
|
376
|
+
async page(grant, name, renew, repository) {
|
|
377
|
+
if (!grant || !(await this.repositories()).length) return this.#none();
|
|
378
|
+
const picked = repository ? this.#pick(repository) : this.repos[0];
|
|
379
|
+
if (!picked) return this.#none(repository);
|
|
267
380
|
this.count('page_calls');
|
|
268
381
|
try {
|
|
269
|
-
const page = await this.#post(grant, 'page', { repo_key:
|
|
382
|
+
const page = await this.#post(grant, 'page', { repo_key: picked.key, name }, TOOL_MS, renew ?? this.renew);
|
|
270
383
|
return page ? (page.markdown?.trim() || `The page "${name}" has no content yet.`) : 'Team memory is off for this team.';
|
|
271
384
|
} catch { return `No knowledge page named "${name}", or team memory did not answer.`; }
|
|
272
385
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const renew = async () => { const held = await lease(); try { return held.grant; } finally { held.release(); } };
|
|
287
|
-
renew.lease = lease; renew.role = role; return renew;
|
|
288
|
-
};
|
|
289
|
-
export function memoryTools(self, { pages = true, grant = () => self.grant, renew } = {}) {
|
|
290
|
-
const tools = [{ name: 'memory_search', label: 'Team memory',
|
|
291
|
-
description: 'Search the team memory of this repository: decisions, rules and conventions from the team\'s earlier conversations and commits, and its knowledge pages. Use it before asking the member something the team may already have settled. Memory is a record of the past: check what it says against the code.',
|
|
292
|
-
parameters: { type: 'object', properties: { query: { type: 'string', minLength: 1, maxLength: 2000 } }, required: ['query'], additionalProperties: false },
|
|
293
|
-
execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.search(grant(), args.query, renew) }], details: {} }) }];
|
|
294
|
-
if (pages) tools.push({ name: 'memory_page', label: 'Team knowledge page',
|
|
295
|
-
description: `Read one knowledge page the team memory keeps for this repository: ${PAGE_NAMES.join(', ')}.`,
|
|
296
|
-
parameters: { type: 'object', properties: { name: { type: 'string', enum: PAGE_NAMES } }, required: ['name'], additionalProperties: false },
|
|
297
|
-
execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.page(grant(), args.name, renew) }], details: {} }) });
|
|
298
|
-
return tools;
|
|
386
|
+
// memory_ask: one synthesized answer with its sources (Studio protocol 3).
|
|
387
|
+
async ask(grant, question, renew, repository) {
|
|
388
|
+
if (!grant || !(await this.repositories()).length) return this.#none();
|
|
389
|
+
const picked = repository ? this.#pick(repository) : null;
|
|
390
|
+
if (repository && !picked && this.repos.length > 1) return this.#none(repository);
|
|
391
|
+
this.count(renew?.role && renew.role !== 'main' ? 'helper_ask_calls' : 'ask_calls');
|
|
392
|
+
return this.#each(picked ? [picked] : this.#about(question, 2), async r => {
|
|
393
|
+
try {
|
|
394
|
+
const answer = await this.#post(grant, 'ask', await this.#body(r, { question }), ASK_MS, renew ?? this.renew, undefined, { offOnMissing: false });
|
|
395
|
+
return answer?.text ?? 'This Studio cannot answer memory_ask yet; use memory_search.';
|
|
396
|
+
} catch { return 'Team memory did not answer; use memory_search or go on without it.'; }
|
|
397
|
+
});
|
|
398
|
+
}
|
|
299
399
|
}
|
|
300
400
|
|
|
301
401
|
// The brief as the conversation keeps it: a custom message the member does
|
|
@@ -343,7 +443,7 @@ export async function prepareMemoryContext(self, messages, signal, exposure = tr
|
|
|
343
443
|
// when the conversation opens, a round's start once its run is granted, and
|
|
344
444
|
// a round's end while its run token is still valid. None can fail a turn.
|
|
345
445
|
export function attachMemory(self, manager, agentDir, verify) {
|
|
346
|
-
self.memory = new TeamMemory({ origin: self.origin, cwd: self.cwd, stateDir: agentDir ?? null, conversationId: manager.getSessionId(), scope: () => memoryScope(self.origin, self.manifest), renew: renewer(self, 'main', verify) });
|
|
446
|
+
self.memory = new TeamMemory({ origin: self.origin, cwd: self.cwd, stateDir: agentDir ?? null, conversationId: manager.getSessionId(), scope: () => memoryScope(self.origin, self.manifest), renew: renewer(self, 'main', verify), changed: () => self.ownPaths ?? [] });
|
|
347
447
|
return self.memory;
|
|
348
448
|
}
|
|
349
449
|
export function trackMemoryLessons(self) {
|
|
@@ -352,10 +452,12 @@ export function trackMemoryLessons(self) {
|
|
|
352
452
|
// Whether this conversation has team memory now: Studio answered memory for
|
|
353
453
|
// this key recently and has not switched it off since (the Terminal's header).
|
|
354
454
|
export const memoryOn = self => !!self?.memory?.stateDir && self.memory.active && TeamMemory.known(self.memory.stateDir, self.origin, memoryScope(self.origin, self.manifest));
|
|
355
|
-
|
|
455
|
+
// memory_ask needs a Studio that answered with protocol 3 (an older one has no /v1/memory/ask).
|
|
456
|
+
const speaksAsk = (dir, origin, manifest) => TeamMemory.knownProtocol(dir, origin, memoryScope(origin, manifest)) >= PROTOCOL;
|
|
457
|
+
export const memoryToolsFor = (self, agentDir) => agentDir && TeamMemory.known(agentDir, self.origin, memoryScope(self.origin, self.manifest)) ? memoryTools(self, { ask: speaksAsk(agentDir, self.origin, self.manifest) }) : [];
|
|
356
458
|
// A helper (scout, research, verify, review) reads the team memory with its own run token; it never writes.
|
|
357
459
|
export const helperMemoryTools = (managed, grant, renew) => managed.memory?.stateDir && TeamMemory.known(managed.memory.stateDir, managed.origin, memoryScope(managed.origin, managed.manifest))
|
|
358
|
-
? memoryTools(managed, { pages: false, grant, renew }) : [];
|
|
460
|
+
? memoryTools(managed, { pages: false, ask: speaksAsk(managed.memory.stateDir, managed.origin, managed.manifest), grant, renew }) : [];
|
|
359
461
|
export async function openRound(self, manager, text) {
|
|
360
462
|
if (!self.memory) return;
|
|
361
463
|
self.memory.stats = {};
|