@piagent/platform 1.30.1 → 1.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/README.vi.md +1 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/languages.json +2 -2
  7. package/docs/operator-manual-vietnamese.md +7 -7
  8. package/docs/publishing-for-teams.md +2 -0
  9. package/docs/release-install-policy.md +2 -2
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/benchmark.html +1 -1
  13. package/docs-site/commands.html +1 -1
  14. package/docs-site/content/en/index.html +4 -4
  15. package/docs-site/content/en/whats-new.html +12 -0
  16. package/docs-site/content/index.html +4 -4
  17. package/docs-site/content/whats-new.html +12 -0
  18. package/docs-site/context-engine.html +1 -1
  19. package/docs-site/ecosystem.html +1 -1
  20. package/docs-site/en/architecture.html +1 -1
  21. package/docs-site/en/benchmark.html +1 -1
  22. package/docs-site/en/commands.html +1 -1
  23. package/docs-site/en/context-engine.html +1 -1
  24. package/docs-site/en/ecosystem.html +1 -1
  25. package/docs-site/en/index.html +5 -5
  26. package/docs-site/en/mcp-approval.html +1 -1
  27. package/docs-site/en/mcp-auth.html +1 -1
  28. package/docs-site/en/mcp-servers.html +1 -1
  29. package/docs-site/en/mcp.html +1 -1
  30. package/docs-site/en/permissions.html +1 -1
  31. package/docs-site/en/profiles.html +1 -1
  32. package/docs-site/en/quickstart.html +1 -1
  33. package/docs-site/en/runtime.html +1 -1
  34. package/docs-site/en/scope.html +1 -1
  35. package/docs-site/en/security.html +1 -1
  36. package/docs-site/en/team.html +1 -1
  37. package/docs-site/en/whats-new.html +13 -1
  38. package/docs-site/en/windows.html +1 -1
  39. package/docs-site/en/workflows.html +1 -1
  40. package/docs-site/index.html +5 -5
  41. package/docs-site/mcp-approval.html +1 -1
  42. package/docs-site/mcp-auth.html +1 -1
  43. package/docs-site/mcp-servers.html +1 -1
  44. package/docs-site/mcp.html +1 -1
  45. package/docs-site/permissions.html +1 -1
  46. package/docs-site/profiles.html +1 -1
  47. package/docs-site/quickstart.html +1 -1
  48. package/docs-site/runtime.html +1 -1
  49. package/docs-site/scope.html +1 -1
  50. package/docs-site/security.html +1 -1
  51. package/docs-site/team.html +1 -1
  52. package/docs-site/whats-new.html +13 -1
  53. package/docs-site/windows.html +1 -1
  54. package/docs-site/workflows.html +1 -1
  55. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  56. package/package.json +1 -1
  57. package/packages/piagent-core/managed/memory-lessons.mjs +23 -9
  58. package/packages/piagent-core/managed/memory-repos.mjs +104 -0
  59. package/packages/piagent-core/managed/memory-tools.mjs +35 -0
  60. package/packages/piagent-core/managed/memory.mjs +210 -105
  61. package/packages/piagent-core/package.json +1 -1
  62. package/scripts/company-terminal.mjs +2 -2
@@ -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 { shareGrant, releaseGrant } from './grant-share.mjs';
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
- // The repository's identity: its origin remote without scheme, user, port
24
- // or ".git", lower case ("github.com/acme/shop"). null: no memory here.
25
- export function repoKey(remote) {
26
- let s = String(remote ?? '').trim();
27
- if (!s) return null;
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 = Date.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 false; }
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
- async repository() {
109
- if (this.repo === undefined) this.repo = repoKey(await git(this.cwd, ['remote', 'get-url', 'origin']));
110
- return this.repo;
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
- this.#remember(true);
146
- return response.json();
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 || !(await this.repository())) return null;
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
- try { brief = await this.#post(grant, 'brief', { repo_key: this.repo, prompt: String(prompt ?? '').slice(0, 2000) }, BRIEF_MS); }
158
- catch { this.count('brief_errors'); return null; }
159
- this.lastBrief = !brief ? 'off' : brief.text?.trim() ? 'ok' : 'empty';
160
- if (!brief) return null;
161
- this.briefed = true; this.protocol = brief.protocol_version ?? 1;
162
- this.retain = brief.retain_sessions !== false;
163
- this.count('brief_successes'); if (brief.text?.trim()) this.count('brief_nonempty');
164
- this.startBackground(grant, brief.git_head ?? null);
165
- return { text: typeof brief.text === 'string' ? brief.text : '', facts: brief.facts ?? 0, pages: Array.isArray(brief.pages) ? brief.pages : [] };
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, gitHead = undefined) {
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.background = Promise.allSettled([
173
- this.#drain(grant, signal),
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(this.cwd, ['rev-parse', 'HEAD'], 3000, signal))?.trim();
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(this.cwd, ['merge-base', '--is-ancestor', known, head], 3000, signal).then(out => out !== null)) ? [`${known}..${head}`] : [head];
182
- const log = await git(this.cwd, ['log', `-n${MAX_COMMITS}`, '--no-merges', '--format=%H%x1f%cI%x1f%s%x1f%b%x1e', ...range], 5_000, signal);
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 => { const [sha, date, subject, body = ''] = r.split('\x1f'); return { sha, date, subject, body: body.trim().slice(0, MAX_COMMIT_BODY) }; })
185
- .filter(c => /^[0-9a-f]{40}$/.test(c.sha) && c.subject?.trim() && c.date);
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: this.repo, head: last ? head : batch.at(-1).sha, commits: batch }, GIT_MS, this.renew, signal))) return;
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.repository())) return;
204
- const text = turnRecord(messages, changedFiles, event.lessons ?? []);
205
- if (!text) return;
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
- if (deadline.aborted) return;
211
- try { if (await this.#post(grant, 'retain', { repo_key: this.repo, conversation_id: this.conversationId, text }, WRITE_MS, this.renew, deadline)) this.count('accepted'); } catch { /* memory is optional */ }
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 item = { repo_key: this.repo, conversation_id: this.conversationId, text,
217
- event_id: event.id ?? randomUUID(), source_key_id: source.key_id, occurred_at: event.at ?? new Date(this.now()).toISOString() };
218
- const box = new MemoryOutbox(this.stateDir, source, this.now);
219
- if (!box.put(item)) { this.count('queue_rejected'); return; }
220
- this.count('queued');
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
- const ack = await this.#post(grant, 'retain', claim.record.item, WRITE_MS, this.renew, signal);
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
- if (timeout < 250 || !grant || !this.active || this.protocol < 2 || !this.repo || scopeID(turn.scope) !== scopeID(this.scope())) return;
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: this.repo, event_id: turn.id, source_key_id: turn.scope.key_id,
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
- async search(grant, query, renew) {
260
- if (!grant || !(await this.repository())) return 'Team memory is not available for this folder (no origin remote).';
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
- try { return (await this.#post(grant, 'search', { repo_key: this.repo, query }, TOOL_MS, renew ?? this.renew))?.text ?? 'Team memory is off for this team.'; }
263
- catch { return 'Team memory did not answer; go on without it.'; }
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.repository())) return 'Team memory is not available for this folder (no origin remote).';
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: this.repo, name }, TOOL_MS, renew ?? this.renew);
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
- export const PAGE_NAMES = ['Component map', 'Core concepts', 'Conventions and patterns', 'Key decisions and rationale', 'Initiatives and work in progress'];
276
-
277
- // The agent's memory tools; helpers get only the search, on their own run.
278
- // A fresh token for a role, shared with that role's requests running beside it.
279
- export const renewer = (owner, role, verify) => {
280
- const lease = async () => {
281
- const share = shareGrant(owner, role);
282
- try { const grant = await share.grant; verify?.(grant); if (role === 'main') owner.grant = grant;
283
- return { grant, release: () => releaseGrant(owner, role, share) };
284
- } catch (error) { releaseGrant(owner, role, share); throw error; }
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,15 @@ 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
- export const memoryToolsFor = (self, agentDir) => agentDir && TeamMemory.known(agentDir, self.origin, memoryScope(self.origin, self.manifest)) ? memoryTools(self) : [];
455
+ // memory_ask is offered whenever the conversation has team memory. The
456
+ // protocol this Mac last heard was recorded before a Studio upgrade, so it
457
+ // cannot decide; on a Studio without /v1/memory/ask the tool says to use
458
+ // memory_search instead (ask() does not turn memory off on a 404).
459
+ const speaksAsk = () => true;
460
+ export const memoryToolsFor = (self, agentDir) => agentDir && TeamMemory.known(agentDir, self.origin, memoryScope(self.origin, self.manifest)) ? memoryTools(self, { ask: speaksAsk() }) : [];
356
461
  // A helper (scout, research, verify, review) reads the team memory with its own run token; it never writes.
357
462
  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 }) : [];
463
+ ? memoryTools(managed, { pages: false, ask: speaksAsk(), grant, renew }) : [];
359
464
  export async function openRound(self, manager, text) {
360
465
  if (!self.memory) return;
361
466
  self.memory.stats = {};
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.30.1",
3
+ "version": "1.31.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -206,9 +206,9 @@ export async function companyToolRenderers(sdkRoot) {
206
206
  const require = createRequire(path.join(sdkRoot, 'package.json'));
207
207
  const { Text } = await import(pathToFileURL(require.resolve('@earendil-works/pi-tui')).href);
208
208
  const memory = (title) => ({
209
- renderCall: (args, theme) => new Text(theme.fg('toolTitle', theme.bold(title)) + (args?.query || args?.name ? theme.fg('muted', ` ${args.query ?? args.name}`) : ''), 0, 0),
209
+ renderCall: (args, theme) => new Text(theme.fg('toolTitle', theme.bold(title)) + (args?.query || args?.name || args?.question ? theme.fg('muted', ` ${args.query ?? args.name ?? args.question}`) : ''), 0, 0),
210
210
  renderResult: (result, options, theme) => new Text(theme.fg('toolOutput', memoryResultLines((result?.content ?? []).map((c) => c.text ?? '').join('\n'), options?.expanded).join('\n')), 0, 0) });
211
- return { memory_search: memory('Team memory'), memory_page: memory('Trang kiến thức'), update_plan: {
211
+ return { memory_search: memory('Team memory'), memory_page: memory('Trang kiến thức'), memory_ask: memory('Hỏi team memory'), update_plan: {
212
212
  renderCall: (_args, theme) => new Text(theme.fg('toolTitle', theme.bold('Plan')), 0, 0),
213
213
  renderResult: (result, _options, theme) => {
214
214
  const plan = result?.details?.plan;