@piagent/platform 1.27.0 → 1.28.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +33 -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/helper-run.mjs +2 -2
  58. package/packages/piagent-core/managed/memory-lessons.mjs +63 -0
  59. package/packages/piagent-core/managed/memory-outbox.mjs +93 -0
  60. package/packages/piagent-core/managed/memory.mjs +258 -89
  61. package/packages/piagent-core/managed/session.mjs +7 -7
  62. package/packages/piagent-core/package.json +1 -1
@@ -1,21 +1,24 @@
1
- // Team memory for company conversations (Studio docs/team-memory/PLAN.md):
2
- // Studio keeps one memory per team and repository. On a conversation's first
3
- // round the agent gets a brief of what the team's earlier conversations and
4
- // commits say about the member's request; every round is sent back to grow
5
- // it; new commits are sent when HEAD moved. Everything goes through Studio on
6
- // the round's run token, and nothing here may fail or slow a turn: every call
7
- // is bounded, a failed write waits in a small outbox for the next round, and
8
- // Studio's 204 (memory off for the team) turns memory off for a while.
1
+ // Team/repository memory for managed conversations. Briefs are scoped to the
2
+ // current branch and refreshed after successful compaction. Each member turn
3
+ // has a durable event identity; failed writes wait in a scoped local outbox.
4
+ // Optional network work uses bounded deadlines and the shared run-token owner.
9
5
  import fs from 'node:fs';
10
6
  import path from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+ import { MemoryOutbox, memoryScope, scopeID } from './memory-outbox.mjs';
11
9
  import { execFile } from 'node:child_process';
10
+ import { CheckLessons } from './memory-lessons.mjs';
12
11
  import { CLIENT_AGENT } from './client-agent.mjs';
13
12
  import { redactedText } from '../runtime/managed-failure.mjs';
13
+ import { shareGrant, releaseGrant } from './grant-share.mjs';
14
14
 
15
15
  export const MEMORY_ENTRY = 'agent-watch-memory';
16
16
  const BRIEF_MS = 5_000, WRITE_MS = 4_000, GIT_MS = 6_000, TOOL_MS = 10_000;
17
17
  const OFF_MS = 30 * 60_000, KNOWN_MS = 7 * 24 * 3600_000;
18
- const MAX_TURN = 30 * 1024, MAX_OUTBOX = 2 * 1024 * 1024, MAX_COMMITS = 300, MAX_GIT_BODY = 60 * 1024, MAX_COMMIT_BODY = 2_000;
18
+ // A brief that came back empty, or failed, is not asked again every round of
19
+ // the same context; closing a round (retain + telemetry) stays within CLOSE_MS.
20
+ const EMPTY_BRIEF_MS = 30 * 60_000, FAILED_BRIEF_MS = 5 * 60_000, CLOSE_MS = 4_500;
21
+ const MAX_TURN = 30 * 1024, MAX_COMMITS = 300, MAX_GIT_BODY = 60 * 1024, MAX_COMMIT_BODY = 2_000;
19
22
 
20
23
  // The repository's identity: its origin remote without scheme, user, port
21
24
  // or ".git", lower case ("github.com/acme/shop"). null: no memory here.
@@ -32,44 +35,74 @@ export function repoKey(remote) {
32
35
  return /^[a-z0-9][a-z0-9._-]*\/[a-z0-9._/-]+$/.test(s) && s.length <= 200 && !s.includes('..') ? s : null;
33
36
  }
34
37
 
35
- const git = (cwd, args, timeout = 3_000) => new Promise(resolve => execFile('git', ['-C', cwd, ...args], { timeout, maxBuffer: 4 << 20, encoding: 'utf8' },
38
+ const git = (cwd, args, timeout = 3_000, signal) => new Promise(resolve => execFile('git', ['-C', cwd, ...args], { timeout, signal, maxBuffer: 4 << 20, encoding: 'utf8' },
36
39
  (error, stdout) => resolve(error ? null : stdout)));
37
40
 
38
41
  const textOf = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(p => p?.type === 'text').map(p => p.text).join('\n') : '';
39
42
 
40
43
  // One round as memory keeps it: what the member asked, the agent's final
41
- // answer and the files the conversation changed. Tool calls and their output
44
+ // answer, changed files and bounded check-recovery evidence. Raw tool logs
42
45
  // are left out.
43
- export function turnRecord(messages, changedFiles = []) {
46
+ export function turnRecord(messages, changedFiles = [], lessons = []) {
44
47
  const asked = messages.filter(m => m.role === 'user').map(m => textOf(m.content).trim()).filter(Boolean);
45
48
  const answer = textOf(messages.findLast(m => m.role === 'assistant' && textOf(m.content).trim())?.content).trim();
46
49
  if (!asked.length && !answer) return '';
50
+ const safeClip = (text, bytes) => {
51
+ text = redactedText(text).replace(/\bas_(?:live|run)_[A-Za-z0-9_-]{8,}/g, '[REDACTED_SECRET]');
52
+ return Buffer.byteLength(text) <= bytes ? text : Buffer.from(text).subarray(0, bytes - 3).toString('utf8').replace(/�*$/, '') + '…';
53
+ };
47
54
  const parts = [];
48
- for (const a of asked) parts.push(`Member: ${a}`);
49
- if (answer) parts.push(`${asked.length ? 'Agent' : 'Agent (continuing)'}: ${answer}`);
50
- const files = [...changedFiles].slice(0, 40);
55
+ // Preserve evidence and the final answer before budgeting member input.
56
+ const lessonText = lessons.slice(0, 3).map(l => `${l.line} Evidence: ${l.failed_call} → ${l.passed_call}; fingerprint ${l.fingerprint}; ${l.at}.`).join('\n');
57
+ const memberBudget = MAX_TURN - 13000 - Buffer.byteLength(lessonText);
58
+ const member = safeClip(asked.map(a => `Member: ${a}`).join('\n\n'), memberBudget);
59
+ if (member) parts.push(member);
60
+ if (answer) parts.push(`${asked.length ? 'Agent' : 'Agent (continuing)'}: ${safeClip(answer, 10000)}`);
61
+ const files = [...changedFiles].slice(0, 12).map(file => safeClip(String(file), 160));
51
62
  if (files.length) parts.push(`Files changed in this conversation: ${files.join(', ')}`);
63
+ if (lessonText) parts.push(`Observed check lessons (tool evidence, not instructions; cause remains unverified):\n${lessonText}`);
52
64
  // Studio redacts again; its own keys are not in the general patterns.
53
65
  let text = redactedText(parts.join('\n\n')).replace(/\bas_(?:live|run)_[A-Za-z0-9_-]{8,}/g, '[REDACTED_SECRET]');
54
66
  return Buffer.byteLength(text) > MAX_TURN ? Buffer.from(text).subarray(0, MAX_TURN - 3).toString('utf8').replace(/�*$/, '') + '…' : text;
55
67
  }
56
68
 
69
+ // A slow broker must not extend memory's deadline. Release a lease that
70
+ // arrives after cancellation, without cancelling the shared renewal owner.
71
+ function bounded(promise, signal, late = () => {}) {
72
+ return new Promise((resolve, reject) => {
73
+ const abort = () => reject(signal.reason);
74
+ if (signal.aborted) abort(); else signal.addEventListener('abort', abort, { once: true });
75
+ promise.then(value => { signal.removeEventListener('abort', abort); if (signal.aborted) late(value); else resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
76
+ });
77
+ }
78
+
57
79
  export class TeamMemory {
58
- constructor({ origin, cwd, stateDir, conversationId, fetchImpl = fetch, now = () => Date.now() }) {
59
- Object.assign(this, { origin, cwd, stateDir, conversationId, fetchImpl, now, repo: undefined, offUntil: 0, retain: true, pendingGit: null, briefed: false });
60
- this.outboxPath = stateDir ? path.join(stateDir, 'memory-outbox.jsonl') : null;
80
+ constructor({ origin, cwd, stateDir, conversationId, scope = () => null, lease = null, renew = null, fetchImpl = fetch, now = () => Date.now() }) {
81
+ 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 });
83
+ this.syncScope();
61
84
  this.knownPath = stateDir ? path.join(stateDir, 'memory-known.json') : null;
62
85
  }
63
86
 
87
+ syncScope() {
88
+ const scope = this.scope(), id = scopeID(scope);
89
+ if (id !== this.scopeHash) {
90
+ this.scopeHash = id; this.outbox = new MemoryOutbox(this.stateDir, scope, this.now);
91
+ this.protocol = 0; this.briefed = false; this.offUntil = 0; this.retain = true;
92
+ }
93
+ return scope;
94
+ }
95
+ count(name, n = 1) { this.stats[name] = (this.stats[name] ?? 0) + n; }
96
+
64
97
  // Memory answered for this Studio recently: the conversation gets the
65
98
  // memory tools (their schema is fixed when it opens).
66
- static known(stateDir, origin, now = Date.now()) {
67
- try { const k = JSON.parse(fs.readFileSync(path.join(stateDir, 'memory-known.json'), 'utf8')); return k.origin === origin && now - k.at < KNOWN_MS; }
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; }
68
101
  catch { return false; }
69
102
  }
70
103
  #remember(on) {
71
104
  if (!this.knownPath) return;
72
- try { if (on) fs.writeFileSync(this.knownPath, JSON.stringify({ origin: this.origin, at: this.now() })); else fs.rmSync(this.knownPath, { force: true }); } catch { /* best effort */ }
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 */ }
73
106
  }
74
107
 
75
108
  async repository() {
@@ -78,97 +111,162 @@ export class TeamMemory {
78
111
  }
79
112
  get active() { return this.now() >= this.offUntil; }
80
113
 
81
- async #post(grant, op, body, timeout) {
82
- const response = await this.fetchImpl(`${this.origin}/v1/memory/${op}`, { method: 'POST', signal: AbortSignal.timeout(timeout),
83
- headers: { Authorization: `Bearer ${grant.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT, 'X-Session-Id': grant.role_id },
84
- body: JSON.stringify(body) });
85
- // 204: memory is off for the team; 404: a Studio without team memory.
86
- if (response.status === 204 || response.status === 404) { this.offUntil = this.now() + OFF_MS; this.#remember(false); return null; }
87
- if (!response.ok) throw Error(`memory-${op}-${response.status}`);
88
- this.#remember(true);
89
- return response.json();
114
+ async #post(grant, op, body, timeout, renew = this.renew, signal) {
115
+ const expectedScope = this.scopeHash;
116
+ const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeout)]) : AbortSignal.timeout(timeout);
117
+ const send = async g => {
118
+ if (scopeID(this.scope()) !== expectedScope) throw Error('memory-scope-changed');
119
+ return this.fetchImpl(`${this.origin}/v1/memory/${op}`, { method: 'POST',
120
+ signal: deadline,
121
+ headers: { Authorization: `Bearer ${g.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT, 'X-Session-Id': g.role_id }, body: JSON.stringify(body) });
122
+ };
123
+ let held;
124
+ try {
125
+ // Share the same token owner as model requests, holding the lease until
126
+ // the request has been admitted. Helpers supply their own renewal path.
127
+ const lease = renew?.lease ?? (renew === this.renew ? this.lease : null);
128
+ if (lease) { held = await bounded(lease(), deadline, value => value.release()); grant = held.grant; }
129
+ let response = await send(grant);
130
+ if (response.status === 401 && renew) {
131
+ this.count('auth_retries');
132
+ held?.release(); held = null;
133
+ const fresh = await bounded(renew(grant), deadline).catch(() => null);
134
+ if (fresh?.token && fresh.token !== grant.token) response = await send(fresh);
135
+ }
136
+ if (response.status === 204 || response.status === 404) {
137
+ // Old servers use 204 for retain opt-out as well as global off.
138
+ if (op !== 'retain' || this.protocol >= 2) { this.offUntil = this.now() + OFF_MS; this.#remember(false); }
139
+ else this.retain = false;
140
+ return null;
141
+ }
142
+ if (!response.ok) {
143
+ const error = Error(`memory-${op}-${response.status}`); error.status = response.status; throw error;
144
+ }
145
+ this.#remember(true);
146
+ return response.json();
147
+ } finally { held?.release(); }
90
148
  }
91
149
 
92
- // The round's start: send what waited, and on a conversation's first
93
- // round the brief (returned for the caller to add to the conversation).
94
150
  async beforeRound(grant, prompt, hasBrief) {
151
+ this.syncScope();
95
152
  if (!grant || !this.active || !(await this.repository())) return null;
96
- await this.#drain(grant);
97
- if (hasBrief || this.briefed || !this.active) return null;
98
- this.briefed = true;
153
+ if ((hasBrief || this.briefed) && this.protocol) { this.startBackground(grant); return null; }
154
+ this.count('brief_attempts');
99
155
  let brief;
156
+ this.lastBrief = 'error';
100
157
  try { brief = await this.#post(grant, 'brief', { repo_key: this.repo, prompt: String(prompt ?? '').slice(0, 2000) }, BRIEF_MS); }
101
- catch { return null; }
158
+ catch { this.count('brief_errors'); return null; }
159
+ this.lastBrief = !brief ? 'off' : brief.text?.trim() ? 'ok' : 'empty';
102
160
  if (!brief) return null;
161
+ this.briefed = true; this.protocol = brief.protocol_version ?? 1;
103
162
  this.retain = brief.retain_sessions !== false;
104
- this.pendingGit = this.#sendGit(grant, brief.git_head ?? null).catch(() => {});
163
+ this.count('brief_successes'); if (brief.text?.trim()) this.count('brief_nonempty');
164
+ this.startBackground(grant, brief.git_head ?? null);
105
165
  return { text: typeof brief.text === 'string' ? brief.text : '', facts: brief.facts ?? 0, pages: Array.isArray(brief.pages) ? brief.pages : [] };
106
166
  }
107
167
 
108
- async #sendGit(grant, known) {
109
- const head = (await git(this.cwd, ['rev-parse', 'HEAD']))?.trim();
168
+ startBackground(grant, gitHead = undefined) {
169
+ if (this.background || !this.active) return;
170
+ // One deadline for the whole backlog, not one deadline per old event.
171
+ 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; });
176
+ }
177
+
178
+ async #sendGit(grant, known, signal) {
179
+ const head = (await git(this.cwd, ['rev-parse', 'HEAD'], 3000, signal))?.trim();
110
180
  if (!/^[0-9a-f]{40}$/.test(head ?? '') || head === known) return;
111
- const range = known && (await git(this.cwd, ['merge-base', '--is-ancestor', known, head]).then(out => out !== null)) ? [`${known}..${head}`] : [head];
112
- const log = await git(this.cwd, ['log', `-n${MAX_COMMITS}`, '--no-merges', '--format=%H%x1f%cI%x1f%s%x1f%b%x1e', ...range], 5_000);
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);
113
183
  if (!log) return;
114
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) }; })
115
185
  .filter(c => /^[0-9a-f]{40}$/.test(c.sha) && c.subject?.trim() && c.date);
116
186
  if (!commits.length) return;
117
187
  // Oldest first, in calls under the body cap; the head is recorded with the last.
118
188
  commits.reverse();
119
- for (let i = 0; i < commits.length;) {
189
+ for (let i = 0; i < commits.length && !signal?.aborted;) {
120
190
  const batch = []; let bytes = 0;
121
191
  while (i < commits.length && (bytes += Buffer.byteLength(JSON.stringify(commits[i]))) < MAX_GIT_BODY) batch.push(commits[i++]);
122
192
  if (!batch.length) batch.push(commits[i++]);
123
193
  const last = i >= commits.length;
124
- if (!(await this.#post(grant, 'git', { repo_key: this.repo, head: last ? head : batch.at(-1).sha, commits: batch }, GIT_MS))) return;
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;
125
195
  }
126
196
  }
127
197
 
128
- // The round's end, before its run closes: the round goes to memory, or to
129
- // the outbox when Studio did not take it.
130
- async afterRound(grant, messages, changedFiles) {
131
- if (this.pendingGit) { await this.pendingGit; this.pendingGit = null; }
198
+ // Persist before sending. The same event ID and source identity survive
199
+ // crashes, lost acknowledgements and concurrent sessions.
200
+ async afterRound(grant, messages, changedFiles, event = {}, signal) {
201
+ // The backlog started with the round may still run; wait for it only within the close budget.
202
+ if (this.background) await (signal ? Promise.race([this.background, new Promise(done => signal.addEventListener('abort', done, { once: true }))]) : this.background);
132
203
  if (!grant || !this.active || !this.retain || !(await this.repository())) return;
133
- const text = turnRecord(messages, changedFiles);
204
+ const text = turnRecord(messages, changedFiles, event.lessons ?? []);
134
205
  if (!text) return;
135
- const item = { repo_key: this.repo, conversation_id: this.conversationId, text };
136
- try { await this.#post(grant, 'retain', item, WRITE_MS); }
137
- catch { this.#queue(item); }
206
+ const deadline = signal ? AbortSignal.any([signal, AbortSignal.timeout(WRITE_MS)]) : AbortSignal.timeout(WRITE_MS);
207
+ // A v1 Studio (0.27.x) takes the round in its old body, best effort and
208
+ // without the outbox: it refuses unknown fields and has no event identity.
209
+ 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 */ }
212
+ return;
213
+ }
214
+ const source = event.scope ?? this.scope();
215
+ 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');
221
+ // A rebind cannot send the old round using the new key.
222
+ if (scopeID(source) !== scopeID(this.scope())) return;
223
+ await this.#drain(grant, deadline);
138
224
  }
139
225
 
140
- #queue(item) {
141
- if (!this.outboxPath) return;
142
- try {
143
- const size = fs.existsSync(this.outboxPath) ? fs.statSync(this.outboxPath).size : 0;
144
- if (size + Buffer.byteLength(JSON.stringify(item)) > MAX_OUTBOX) return;
145
- fs.mkdirSync(path.dirname(this.outboxPath), { recursive: true });
146
- fs.appendFileSync(this.outboxPath, JSON.stringify(item) + '\n', { mode: 0o600 });
147
- } catch { /* memory is optional */ }
148
- }
149
-
150
- async #drain(grant) {
151
- if (!this.outboxPath || !fs.existsSync(this.outboxPath)) return;
152
- let items;
153
- try { items = fs.readFileSync(this.outboxPath, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); fs.rmSync(this.outboxPath, { force: true }); }
154
- catch { return; }
155
- const left = [];
156
- for (const [i, item] of items.entries()) {
157
- if (i >= 5 || !this.active) { left.push(item); continue; }
158
- try { await this.#post(grant, 'retain', item, WRITE_MS); } catch { left.push(item); }
226
+ async #drain(grant, signal) {
227
+ if (this.protocol < 2 || !this.outbox?.directory) return;
228
+ const box = this.outbox;
229
+ let sent = 0;
230
+ for (const name of box.files()) {
231
+ if (sent >= 5 || signal?.aborted || !this.active || box.scope !== scopeID(this.scope())) break;
232
+ const before = box.quarantined;
233
+ const claim = box.claim(name); if (box.quarantined > before) this.count('quarantined', box.quarantined - before);
234
+ if (!claim) continue;
235
+ sent++;
236
+ let outcome = 'retry';
237
+ try {
238
+ if (claim.record.attempts) this.count('write_retries');
239
+ const ack = await this.#post(grant, 'retain', claim.record.item, WRITE_MS, this.renew, signal);
240
+ if (!ack || ack.status === 'retain_disabled') { this.retain = false; outcome = 'discard'; }
241
+ else if (ack.event_id === claim.record.item.event_id && ack.operation_id && ['pending','processing','completed','failed','cancelled','unknown'].includes(ack.status)) {
242
+ outcome = 'ack'; this.count('accepted'); if (ack.duplicate) this.count('duplicates');
243
+ }
244
+ } catch (error) { if ([400,409].includes(error.status)) { outcome = 'quarantine'; this.count('quarantined'); } }
245
+ finally { box.finish(claim, outcome); }
159
246
  }
160
- for (const item of left) if (this.active) this.#queue(item);
161
247
  }
162
248
 
163
- async search(grant, query) {
249
+ 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;
251
+ // Counters only. Missing delivery stays unknown; it is never counted as
252
+ // 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,
254
+ conversation_id: this.conversationId, counters: { ...this.stats }, outcome,
255
+ duration_ms: Math.min(604800000, Math.max(0, Date.now() - Date.parse(turn.at))) };
256
+ try { await this.#post(grant, 'telemetry', body, timeout); } catch { /* best effort, no prompt data */ }
257
+ }
258
+
259
+ async search(grant, query, renew) {
164
260
  if (!grant || !(await this.repository())) return 'Team memory is not available for this folder (no origin remote).';
165
- try { return (await this.#post(grant, 'search', { repo_key: this.repo, query }, TOOL_MS))?.text ?? 'Team memory is off for this team.'; }
261
+ 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.'; }
166
263
  catch { return 'Team memory did not answer; go on without it.'; }
167
264
  }
168
- async page(grant, name) {
265
+ async page(grant, name, renew) {
169
266
  if (!grant || !(await this.repository())) return 'Team memory is not available for this folder (no origin remote).';
267
+ this.count('page_calls');
170
268
  try {
171
- const page = await this.#post(grant, 'page', { repo_key: this.repo, name }, TOOL_MS);
269
+ const page = await this.#post(grant, 'page', { repo_key: this.repo, name }, TOOL_MS, renew ?? this.renew);
172
270
  return page ? (page.markdown?.trim() || `The page "${name}" has no content yet.`) : 'Team memory is off for this team.';
173
271
  } catch { return `No knowledge page named "${name}", or team memory did not answer.`; }
174
272
  }
@@ -177,15 +275,26 @@ export class TeamMemory {
177
275
  export const PAGE_NAMES = ['Component map', 'Core concepts', 'Conventions and patterns', 'Key decisions and rationale', 'Initiatives and work in progress'];
178
276
 
179
277
  // The agent's memory tools; helpers get only the search, on their own run.
180
- export function memoryTools(self, { pages = true, grant = () => self.grant } = {}) {
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 } = {}) {
181
290
  const tools = [{ name: 'memory_search', label: 'Team memory',
182
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.',
183
292
  parameters: { type: 'object', properties: { query: { type: 'string', minLength: 1, maxLength: 2000 } }, required: ['query'], additionalProperties: false },
184
- execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.search(grant(), args.query) }], details: {} }) }];
293
+ execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.search(grant(), args.query, renew) }], details: {} }) }];
185
294
  if (pages) tools.push({ name: 'memory_page', label: 'Team knowledge page',
186
295
  description: `Read one knowledge page the team memory keeps for this repository: ${PAGE_NAMES.join(', ')}.`,
187
296
  parameters: { type: 'object', properties: { name: { type: 'string', enum: PAGE_NAMES } }, required: ['name'], additionalProperties: false },
188
- execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.page(grant(), args.name) }], details: {} }) });
297
+ execute: async (_id, args) => ({ content: [{ type: 'text', text: await self.memory.page(grant(), args.name, renew) }], details: {} }) });
189
298
  return tools;
190
299
  }
191
300
 
@@ -195,27 +304,87 @@ export function briefMessage(brief) {
195
304
  const pages = brief.pages.length ? `\n\nKnowledge pages (read with memory_page): ${brief.pages.join(', ')}.` : '';
196
305
  return { customType: MEMORY_ENTRY, content: brief.text + pages, display: false, details: { facts: brief.facts } };
197
306
  }
198
- export const hasBrief = manager => manager.getEntries().some(e => (e.type === 'custom_message' || e.type === 'custom') && e.customType === MEMORY_ENTRY);
307
+ export const hasBrief = manager => manager.buildSessionContext().messages.some(m => m.role === 'custom' && m.customType === MEMORY_ENTRY);
308
+ const generationOf = manager => manager.getBranch().findLast(e => e.type === 'compaction')?.id ?? 'initial';
309
+ const scopedBrief = (messages, scope, generation) => messages.findLast(m => m.role === 'custom' && m.customType === MEMORY_ENTRY && m.details?.scope === scope && (generation === undefined || m.details?.generation === generation));
310
+
311
+ // Called from the awaited context transform, so a successful compaction can
312
+ // never race its next model call. Failed/aborted compactions add no generation.
313
+ export async function prepareMemoryContext(self, messages, signal, exposure = true) {
314
+ if (!self.memory || !self.grant || self.preflight || self.compaction || signal?.aborted) return messages;
315
+ const memory = self.memory, manager = self.session.sessionManager;
316
+ memory.syncScope();
317
+ const generation = generationOf(manager), scope = memory.scopeHash;
318
+ const active = scopedBrief(messages, scope, generation);
319
+ const cached = self.memoryBrief?.generation === generation && self.memoryBrief.scope === scope && manager.getBranch().some(e => e.id === self.memoryBrief.branchEntry) ? self.memoryBrief.message : null;
320
+ let selected = active ?? cached;
321
+ // Until Studio has answered a brief once (protocol unknown) every round asks: the outbox can only drain after that handshake.
322
+ const pause = self.memoryBriefPause, paused = memory.protocol > 0 && pause?.scope === scope && pause.generation === generation && Date.now() < pause.until;
323
+ if (!selected && !paused && self.memoryAttempt !== `${self.memoryTurn?.id}:${generation}`) {
324
+ self.memoryAttempt = `${self.memoryTurn?.id}:${generation}`; memory.briefed = false;
325
+ const previous = manager.getBranch().filter(e => e.type === 'message' && e.message.role === 'user').map(e => textOf(e.message.content)).findLast(t => t.trim().length > 40);
326
+ const prompt = [self.memoryTurn?.request ?? '', previous ? `Recent task context: ${previous}` : ''].filter(Boolean).join('\n').slice(0, 2000);
327
+ const brief = await memory.beforeRound(self.grant, prompt, false);
328
+ if (['empty', 'error'].includes(memory.lastBrief)) self.memoryBriefPause = { scope, generation, until: Date.now() + (memory.lastBrief === 'empty' ? EMPTY_BRIEF_MS : FAILED_BRIEF_MS) };
329
+ if (signal?.aborted || scope !== scopeID(memory.scope())) return messages.filter(m => !(m.role === 'custom' && m.customType === MEMORY_ENTRY));
330
+ if (brief?.text) {
331
+ selected = { role: 'custom', ...briefMessage(brief), timestamp: Date.now(), details: { facts: brief.facts, scope, generation } };
332
+ self.memoryBrief = { generation, scope, branchEntry: self.memoryTurn?.entry, message: selected };
333
+ memory.count('brief_injected'); if (generation !== 'initial') memory.count('brief_refreshes');
334
+ }
335
+ }
336
+ // A single scoped brief, including after branch changes or a key rebind.
337
+ const clean = messages.filter(m => !(m.role === 'custom' && m.customType === MEMORY_ENTRY));
338
+ if (selected && memory.active) { if (exposure) memory.count('brief_exposed_requests'); return [selected, ...clean]; }
339
+ return clean;
340
+ }
199
341
 
200
342
  // The managed session's three hooks (session.mjs): the memory and its tools
201
343
  // when the conversation opens, a round's start once its run is granted, and
202
344
  // a round's end while its run token is still valid. None can fail a turn.
203
- export function attachMemory(self, manager, agentDir) {
204
- self.memory = new TeamMemory({ origin: self.origin, cwd: self.cwd, stateDir: agentDir ?? null, conversationId: manager.getSessionId() });
345
+ 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) });
205
347
  return self.memory;
206
348
  }
207
- export const memoryToolsFor = (self, agentDir) => agentDir && TeamMemory.known(agentDir, self.origin) ? memoryTools(self) : [];
349
+ export function trackMemoryLessons(self) {
350
+ self.session.subscribe(event => { try { self.memoryTurn?.checks?.event(event); } catch { /* optional evidence */ } });
351
+ }
352
+ export const memoryToolsFor = (self, agentDir) => agentDir && TeamMemory.known(agentDir, self.origin, memoryScope(self.origin, self.manifest)) ? memoryTools(self) : [];
208
353
  // A helper (scout, research, verify, review) reads the team memory with its own run token; it never writes.
209
- export const helperMemoryTools = (managed, grant) => managed.memory?.stateDir && TeamMemory.known(managed.memory.stateDir, managed.origin)
210
- ? memoryTools(managed, { pages: false, grant }) : [];
354
+ export const helperMemoryTools = (managed, grant, renew) => managed.memory?.stateDir && TeamMemory.known(managed.memory.stateDir, managed.origin, memoryScope(managed.origin, managed.manifest))
355
+ ? memoryTools(managed, { pages: false, grant, renew }) : [];
211
356
  export async function openRound(self, manager, text) {
212
- self.roundStart = self.session.messages.length;
357
+ if (!self.memory) return;
358
+ self.memory.stats = {};
359
+ const turn = self.memoryTurn = { id: randomUUID(), at: new Date().toISOString(), scope: memoryScope(self.origin, self.manifest), request: text, users: [text], checks: new CheckLessons(self.checks?.commands ?? []) };
360
+ turn.entry = manager.appendCustomEntry('agent-watch-memory-turn', { id: turn.id, at: turn.at, scope: turn.scope });
213
361
  try {
214
- const brief = await self.memory?.beforeRound(self.grant, text, hasBrief(manager));
215
- if (brief?.text) await self.session.sendCustomMessage(briefMessage(brief), { triggerTurn: false });
362
+ // Preserve the first brief in history; later refreshes are injected by the
363
+ // context transform without appending a message while the agent streams.
364
+ const messages = self.session.messages;
365
+ const prepared = await prepareMemoryContext(self, messages, undefined, false);
366
+ const fresh = scopedBrief(prepared, self.memory.scopeHash);
367
+ if (fresh && !scopedBrief(messages, self.memory.scopeHash)) {
368
+ await self.session.sendCustomMessage({ customType: MEMORY_ENTRY, content: fresh.content, details: fresh.details, display: false }, { triggerTurn: false });
369
+ }
370
+ self.memory.startBackground(self.grant);
216
371
  } catch { /* memory is optional */ }
217
372
  }
218
373
  export async function closeRound(self) {
219
- if (!self.grant || self.preflight || !self.memory) return;
220
- try { await self.memory.afterRound(self.grant, self.session.messages.slice(self.roundStart ?? 0), self.ownPaths); } catch { /* memory is optional */ }
374
+ if (!self.grant || self.preflight || !self.memory || !self.memoryTurn) return;
375
+ const turn = self.memoryTurn, closing = AbortSignal.timeout(CLOSE_MS), started = Date.now();
376
+ let outcome = 'unknown';
377
+ try {
378
+ // Entry identities survive context compaction. Read only this branch and
379
+ // this turn; harness-generated prompts are not mistaken for member words.
380
+ const entries = self.session.sessionManager.getBranch(), at = entries.findIndex(e => e.id === turn.entry);
381
+ if (at < 0) return;
382
+ const answer = entries.slice(at + 1).findLast(e => e.type === 'message' && e.message.role === 'assistant' && textOf(e.message.content).trim())?.message;
383
+ outcome = answer?.stopReason === 'stop' ? 'answered' : ['aborted','error'].includes(answer?.stopReason) ? answer.stopReason : 'unknown';
384
+ const messages = [...turn.users.map(content => ({ role: 'user', content })), ...(answer ? [answer] : [])];
385
+ turn.lessons = turn.checks.lessons;
386
+ self.memory.stats.lessons = turn.lessons.length; self.memory.stats.checks_failed = turn.checks.failed; self.memory.stats.checks_passed = turn.checks.passed;
387
+ await self.memory.afterRound(self.grant, messages, self.ownPaths, turn, closing);
388
+ } catch { /* memory is optional */ }
389
+ finally { await self.memory.reportTurn(self.grant, turn, outcome, Math.min(2000, CLOSE_MS - (Date.now() - started))); }
221
390
  }
@@ -27,7 +27,7 @@ import { beforeDelegate } from './objections.mjs';
27
27
  import { HELPER_CALLS, HELPER_ROLES, helperRoles, helperPrompt, webPrompt, delegateDescription } from './helper-roles.mjs';
28
28
  import { loadAgentResources } from './agent-skills.mjs';
29
29
  import { restorePermission, permissionSetter } from './permission.mjs';
30
- import { commandTools, servicesPrompt } from './command-tools.mjs'; import { attachMemory, memoryToolsFor, openRound, closeRound } from './memory.mjs';
30
+ import { commandTools, servicesPrompt } from './command-tools.mjs'; import { attachMemory, memoryToolsFor, openRound, closeRound, prepareMemoryContext, trackMemoryLessons } from './memory.mjs';
31
31
 
32
32
  const PROVIDER = 'agent_watch_managed';
33
33
  export const HELPER_BEAT_MS = 60_000;
@@ -139,7 +139,7 @@ export class ManagedSession {
139
139
  const loader = managedResourceLoader(api, { systemPrompt: () => BASE_PROMPT + referencePrompt(self.boundary.userHome) + TEST_PROMPT + servicesPrompt(self.boundary) + webPrompt(helperRoles(manifest)) + helperPrompt(helperRoles(self.manifest)) + workflowPrompt(workflowPolicy(self.manifest), self.checks), agentsFiles,
140
140
  skills: self.resources.skills, prompts: self.resources.prompts, ...await inlineExtensions(api, sdk, self.cwd, terminalExtensions) });
141
141
  const settings = api.SettingsManager.inMemory({ retry: { enabled: false, provider: { maxRetries: 0 } }, cacheWarming: 'off', enableInstallTelemetry: false, enableAnalytics: false, enableSkillCommands: true });
142
- const manager = sessionManager ?? api.SessionManager.inMemory(self.cwd); attachMemory(self, manager, agentDir);
142
+ const manager = sessionManager ?? api.SessionManager.inMemory(self.cwd); attachMemory(self, manager, agentDir, g => verifyGrant(g, self.manifest, 'main'));
143
143
  const scope = scopeOf(self.origin, manifest);
144
144
  const prior = manager.getEntries().filter(e => e.type === 'custom' && e.customType === 'agent-watch-scope').at(-1)?.data;
145
145
  // Under another member or another Studio a conversation still opens and
@@ -177,16 +177,16 @@ export class ManagedSession {
177
177
  // eyes: the agent has the helper's answer as the tool result, and a
178
178
  // receipt in its context reads as a message from the member.
179
179
  const context = self.session.agent.transformContext;
180
- self.session.agent.transformContext = async (messages, signal) => (context ? await context(messages, signal) : messages)
180
+ self.session.agent.transformContext = async (messages, signal) => (await prepareMemoryContext(self,
181
+ context ? await context(messages, signal) : messages, signal))
181
182
  .filter(m => !(m.role === 'custom' && ['agent-watch-helper-receipt', 'agent-watch-fast-notice'].includes(m.customType)));
182
- // A call to a tool that does not exist (a model inventing a name) is
183
- // answered by Pi before any hook; the run counts them for its report.
183
+ // Pi answers unknown tools before hooks; count those failures in the run report.
184
184
  self.session.subscribe(event => {
185
185
  if (event?.type === 'tool_execution_end' && event.isError && textContent(event.result ?? {}) === `Tool ${event.toolName} not found`) self.run && (self.run.unknownTools += 1);
186
186
  });
187
187
  await stageCompaction(self.session, self.api, self.sdk);
188
188
  compactEarly(settings); backOffFailedCompaction(self.session); trackCompaction(self, self.session);
189
- trackToolFailures(self, self.session);
189
+ trackToolFailures(self, self.session); trackMemoryLessons(self);
190
190
  self.publishHelpers();
191
191
  const setThinking=self.session.setThinkingLevel.bind(self.session);
192
192
  self.runThinking = setThinking;
@@ -315,7 +315,7 @@ export class ManagedSession {
315
315
  if (self.starting) await self.starting;
316
316
  // Between two rounds no model runs: the message waits for the next one.
317
317
  if (self.between) return new Promise((resolve, reject) => self.queued.push({ text, options, resolve, reject }));
318
- if (self.activePrompt) return prompt(text, options); // steering/follow-up keep the root
318
+ if (self.activePrompt) { self.memoryTurn?.users.push(text); return prompt(text, options); } // steering/follow-up keep the root
319
319
  await self.refreshReview();
320
320
  return memberTurn(self, { manager, prompt, runRound }, text, () => prompt(text, { ...options, expandPromptTemplates: false }));
321
321
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.27.0",
3
+ "version": "1.28.0",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",