@piagent/platform 1.27.1 → 1.28.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 (70) hide show
  1. package/CHANGELOG.md +39 -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/extensions/company-session-guard.ts +36 -0
  58. package/packages/piagent-core/extensions/piagent-guard.ts +2 -0
  59. package/packages/piagent-core/managed/memory-lessons.mjs +63 -0
  60. package/packages/piagent-core/managed/memory-outbox.mjs +93 -0
  61. package/packages/piagent-core/managed/memory.mjs +246 -93
  62. package/packages/piagent-core/managed/session.mjs +6 -6
  63. package/packages/piagent-core/package.json +1 -1
  64. package/scripts/company-terminal.mjs +12 -0
  65. package/scripts/install-global.sh +11 -0
  66. package/scripts/personal-pi-config.mjs +137 -0
  67. package/scripts/piagent-cli.mjs +8 -0
  68. package/scripts/piagent-studio.mjs +4 -0
  69. package/scripts/team-doctor.sh +4 -0
  70. package/scripts/update-global.mjs +9 -2
@@ -1,14 +1,13 @@
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';
14
13
  import { shareGrant, releaseGrant } from './grant-share.mjs';
@@ -16,7 +15,10 @@ import { shareGrant, releaseGrant } from './grant-share.mjs';
16
15
  export const MEMORY_ENTRY = 'agent-watch-memory';
17
16
  const BRIEF_MS = 5_000, WRITE_MS = 4_000, GIT_MS = 6_000, TOOL_MS = 10_000;
18
17
  const OFF_MS = 30 * 60_000, KNOWN_MS = 7 * 24 * 3600_000;
19
- 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;
20
22
 
21
23
  // The repository's identity: its origin remote without scheme, user, port
22
24
  // or ".git", lower case ("github.com/acme/shop"). null: no memory here.
@@ -33,45 +35,74 @@ export function repoKey(remote) {
33
35
  return /^[a-z0-9][a-z0-9._-]*\/[a-z0-9._/-]+$/.test(s) && s.length <= 200 && !s.includes('..') ? s : null;
34
36
  }
35
37
 
36
- 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' },
37
39
  (error, stdout) => resolve(error ? null : stdout)));
38
40
 
39
41
  const textOf = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(p => p?.type === 'text').map(p => p.text).join('\n') : '';
40
42
 
41
43
  // One round as memory keeps it: what the member asked, the agent's final
42
- // answer and the files the conversation changed. Tool calls and their output
44
+ // answer, changed files and bounded check-recovery evidence. Raw tool logs
43
45
  // are left out.
44
- export function turnRecord(messages, changedFiles = []) {
46
+ export function turnRecord(messages, changedFiles = [], lessons = []) {
45
47
  const asked = messages.filter(m => m.role === 'user').map(m => textOf(m.content).trim()).filter(Boolean);
46
48
  const answer = textOf(messages.findLast(m => m.role === 'assistant' && textOf(m.content).trim())?.content).trim();
47
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
+ };
48
54
  const parts = [];
49
- for (const a of asked) parts.push(`Member: ${a}`);
50
- if (answer) parts.push(`${asked.length ? 'Agent' : 'Agent (continuing)'}: ${answer}`);
51
- 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));
52
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}`);
53
64
  // Studio redacts again; its own keys are not in the general patterns.
54
65
  let text = redactedText(parts.join('\n\n')).replace(/\bas_(?:live|run)_[A-Za-z0-9_-]{8,}/g, '[REDACTED_SECRET]');
55
66
  return Buffer.byteLength(text) > MAX_TURN ? Buffer.from(text).subarray(0, MAX_TURN - 3).toString('utf8').replace(/�*$/, '') + '…' : text;
56
67
  }
57
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
+
58
79
  export class TeamMemory {
59
- constructor({ origin, cwd, stateDir, conversationId, renew = null, fetchImpl = fetch, now = () => Date.now() }) {
60
- this.renew = renew;
61
- Object.assign(this, { origin, cwd, stateDir, conversationId, fetchImpl, now, repo: undefined, offUntil: 0, retain: true, pendingGit: null, briefed: false });
62
- 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();
63
84
  this.knownPath = stateDir ? path.join(stateDir, 'memory-known.json') : null;
64
85
  }
65
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
+
66
97
  // Memory answered for this Studio recently: the conversation gets the
67
98
  // memory tools (their schema is fixed when it opens).
68
- static known(stateDir, origin, now = Date.now()) {
69
- 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; }
70
101
  catch { return false; }
71
102
  }
72
103
  #remember(on) {
73
104
  if (!this.knownPath) return;
74
- 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 */ }
75
106
  }
76
107
 
77
108
  async repository() {
@@ -80,103 +111,160 @@ export class TeamMemory {
80
111
  }
81
112
  get active() { return this.now() >= this.offUntil; }
82
113
 
83
- async #post(grant, op, body, timeout, renew = this.renew) {
84
- const send = g => this.fetchImpl(`${this.origin}/v1/memory/${op}`, { method: 'POST', signal: AbortSignal.timeout(timeout),
85
- headers: { Authorization: `Bearer ${g.token}`, 'Content-Type': 'application/json', 'User-Agent': CLIENT_AGENT, 'X-Session-Id': g.role_id },
86
- body: JSON.stringify(body) });
87
- let response = await send(grant);
88
- // Every model request renews its role's token and voids the one before, so
89
- // a memory call beside one (the commits after the brief, a search) can
90
- // carry a dead token: renew once, the way a web search does, and ask again.
91
- if (response.status === 401 && renew) {
92
- const fresh = await renew(grant).catch(() => null);
93
- if (fresh?.token && fresh.token !== grant.token) response = await send(fresh);
94
- }
95
- // 204: memory is off for the team; 404: a Studio without team memory.
96
- if (response.status === 204 || response.status === 404) { this.offUntil = this.now() + OFF_MS; this.#remember(false); return null; }
97
- if (!response.ok) throw Error(`memory-${op}-${response.status}`);
98
- this.#remember(true);
99
- 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(); }
100
148
  }
101
149
 
102
- // The round's start: send what waited, and on a conversation's first
103
- // round the brief (returned for the caller to add to the conversation).
104
150
  async beforeRound(grant, prompt, hasBrief) {
151
+ this.syncScope();
105
152
  if (!grant || !this.active || !(await this.repository())) return null;
106
- await this.#drain(grant);
107
- if (hasBrief || this.briefed || !this.active) return null;
108
- this.briefed = true;
153
+ if ((hasBrief || this.briefed) && this.protocol) { this.startBackground(grant); return null; }
154
+ this.count('brief_attempts');
109
155
  let brief;
156
+ this.lastBrief = 'error';
110
157
  try { brief = await this.#post(grant, 'brief', { repo_key: this.repo, prompt: String(prompt ?? '').slice(0, 2000) }, BRIEF_MS); }
111
- catch { return null; }
158
+ catch { this.count('brief_errors'); return null; }
159
+ this.lastBrief = !brief ? 'off' : brief.text?.trim() ? 'ok' : 'empty';
112
160
  if (!brief) return null;
161
+ this.briefed = true; this.protocol = brief.protocol_version ?? 1;
113
162
  this.retain = brief.retain_sessions !== false;
114
- 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);
115
165
  return { text: typeof brief.text === 'string' ? brief.text : '', facts: brief.facts ?? 0, pages: Array.isArray(brief.pages) ? brief.pages : [] };
116
166
  }
117
167
 
118
- async #sendGit(grant, known) {
119
- 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();
120
180
  if (!/^[0-9a-f]{40}$/.test(head ?? '') || head === known) return;
121
- const range = known && (await git(this.cwd, ['merge-base', '--is-ancestor', known, head]).then(out => out !== null)) ? [`${known}..${head}`] : [head];
122
- 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);
123
183
  if (!log) return;
124
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) }; })
125
185
  .filter(c => /^[0-9a-f]{40}$/.test(c.sha) && c.subject?.trim() && c.date);
126
186
  if (!commits.length) return;
127
187
  // Oldest first, in calls under the body cap; the head is recorded with the last.
128
188
  commits.reverse();
129
- for (let i = 0; i < commits.length;) {
189
+ for (let i = 0; i < commits.length && !signal?.aborted;) {
130
190
  const batch = []; let bytes = 0;
131
191
  while (i < commits.length && (bytes += Buffer.byteLength(JSON.stringify(commits[i]))) < MAX_GIT_BODY) batch.push(commits[i++]);
132
192
  if (!batch.length) batch.push(commits[i++]);
133
193
  const last = i >= commits.length;
134
- 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;
135
195
  }
136
196
  }
137
197
 
138
- // The round's end, before its run closes: the round goes to memory, or to
139
- // the outbox when Studio did not take it.
140
- async afterRound(grant, messages, changedFiles) {
141
- 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);
142
203
  if (!grant || !this.active || !this.retain || !(await this.repository())) return;
143
- const text = turnRecord(messages, changedFiles);
204
+ const text = turnRecord(messages, changedFiles, event.lessons ?? []);
144
205
  if (!text) return;
145
- const item = { repo_key: this.repo, conversation_id: this.conversationId, text };
146
- try { await this.#post(grant, 'retain', item, WRITE_MS); }
147
- 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);
148
224
  }
149
225
 
150
- #queue(item) {
151
- if (!this.outboxPath) return;
152
- try {
153
- const size = fs.existsSync(this.outboxPath) ? fs.statSync(this.outboxPath).size : 0;
154
- if (size + Buffer.byteLength(JSON.stringify(item)) > MAX_OUTBOX) return;
155
- fs.mkdirSync(path.dirname(this.outboxPath), { recursive: true });
156
- fs.appendFileSync(this.outboxPath, JSON.stringify(item) + '\n', { mode: 0o600 });
157
- } catch { /* memory is optional */ }
158
- }
159
-
160
- async #drain(grant) {
161
- if (!this.outboxPath || !fs.existsSync(this.outboxPath)) return;
162
- let items;
163
- try { items = fs.readFileSync(this.outboxPath, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); fs.rmSync(this.outboxPath, { force: true }); }
164
- catch { return; }
165
- const left = [];
166
- for (const [i, item] of items.entries()) {
167
- if (i >= 5 || !this.active) { left.push(item); continue; }
168
- 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); }
169
246
  }
170
- for (const item of left) if (this.active) this.#queue(item);
247
+ }
248
+
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 */ }
171
257
  }
172
258
 
173
259
  async search(grant, query, renew) {
174
260
  if (!grant || !(await this.repository())) return 'Team memory is not available for this folder (no origin remote).';
261
+ this.count(renew?.role && renew.role !== 'main' ? 'helper_search_calls' : 'search_calls');
175
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.'; }
176
263
  catch { return 'Team memory did not answer; go on without it.'; }
177
264
  }
178
265
  async page(grant, name, renew) {
179
266
  if (!grant || !(await this.repository())) return 'Team memory is not available for this folder (no origin remote).';
267
+ this.count('page_calls');
180
268
  try {
181
269
  const page = await this.#post(grant, 'page', { repo_key: this.repo, name }, TOOL_MS, renew ?? this.renew);
182
270
  return page ? (page.markdown?.trim() || `The page "${name}" has no content yet.`) : 'Team memory is off for this team.';
@@ -188,10 +276,15 @@ export const PAGE_NAMES = ['Component map', 'Core concepts', 'Conventions and pa
188
276
 
189
277
  // The agent's memory tools; helpers get only the search, on their own run.
190
278
  // A fresh token for a role, shared with that role's requests running beside it.
191
- export const renewer = (owner, role, verify) => async () => {
192
- const share = shareGrant(owner, role);
193
- try { const grant = await share.grant; verify?.(grant); if (role === 'main') owner.grant = grant; return grant; }
194
- finally { releaseGrant(owner, role, share); }
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;
195
288
  };
196
289
  export function memoryTools(self, { pages = true, grant = () => self.grant, renew } = {}) {
197
290
  const tools = [{ name: 'memory_search', label: 'Team memory',
@@ -211,27 +304,87 @@ export function briefMessage(brief) {
211
304
  const pages = brief.pages.length ? `\n\nKnowledge pages (read with memory_page): ${brief.pages.join(', ')}.` : '';
212
305
  return { customType: MEMORY_ENTRY, content: brief.text + pages, display: false, details: { facts: brief.facts } };
213
306
  }
214
- 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
+ }
215
341
 
216
342
  // The managed session's three hooks (session.mjs): the memory and its tools
217
343
  // when the conversation opens, a round's start once its run is granted, and
218
344
  // a round's end while its run token is still valid. None can fail a turn.
219
345
  export function attachMemory(self, manager, agentDir, verify) {
220
- self.memory = new TeamMemory({ origin: self.origin, cwd: self.cwd, stateDir: agentDir ?? null, conversationId: manager.getSessionId(), renew: renewer(self, 'main', 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) });
221
347
  return self.memory;
222
348
  }
223
- 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) : [];
224
353
  // A helper (scout, research, verify, review) reads the team memory with its own run token; it never writes.
225
- export const helperMemoryTools = (managed, grant, renew) => managed.memory?.stateDir && TeamMemory.known(managed.memory.stateDir, managed.origin)
354
+ export const helperMemoryTools = (managed, grant, renew) => managed.memory?.stateDir && TeamMemory.known(managed.memory.stateDir, managed.origin, memoryScope(managed.origin, managed.manifest))
226
355
  ? memoryTools(managed, { pages: false, grant, renew }) : [];
227
356
  export async function openRound(self, manager, text) {
228
- 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 });
229
361
  try {
230
- const brief = await self.memory?.beforeRound(self.grant, text, hasBrief(manager));
231
- 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);
232
371
  } catch { /* memory is optional */ }
233
372
  }
234
373
  export async function closeRound(self) {
235
- if (!self.grant || self.preflight || !self.memory) return;
236
- 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))); }
237
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;
@@ -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.1",
3
+ "version": "1.28.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -15,6 +15,12 @@ import { headlineText, outcomeText, processItems } from '../packages/piagent-web
15
15
  import { turnEndCopy, turnEndOf } from '../packages/piagent-webui/shared/turn-end.ts';
16
16
 
17
17
  const LOCALE = 'vi';
18
+ export const HELP_TEXT = [
19
+ 'Cuộc trò chuyện công ty: /undo hoàn tác file lượt trước đã sửa · /bypass on|off|status quyền lệnh cần internet · /fast on|off|status (khi Harness cho phép) · /skill:<tên> hoặc /<lệnh> của bạn và của project.',
20
+ 'Pi: /session thông tin cuộc trò chuyện · /resume mở cuộc trò chuyện công ty khác · /new cuộc mới · /compact nén ngữ cảnh · /tree, /fork nhánh · /name đặt tên · /copy, /export · /hotkeys toàn bộ phím tắt · /quit thoát.',
21
+ 'Phím: ! <lệnh> chạy shell · Esc dừng lượt đang chạy · Ctrl+C xoá ô nhập, hai lần để thoát · Ctrl+O xem thêm.',
22
+ 'Mở lại sau: piagent studio --project <thư mục>, rồi /resume. Đừng mở cuộc trò chuyện công ty bằng pi cá nhân.',
23
+ ].join('\n');
18
24
  const PERMISSION_STATUS = 'agent-watch-permission';
19
25
  const ASK = 'Hỏi trước (mặc định)', BYPASS = 'Bypass: chỉ hỏi việc bắt buộc';
20
26
  const PERMISSION_TEXT = {
@@ -117,6 +123,12 @@ export function companyTerminalExtension({ managed, packageRoot, sdkRoot, bindin
117
123
  : 'Fast đang tắt: các lượt chạy tốc độ thường.', 'info');
118
124
  } });
119
125
 
126
+ // /help is answered here, as on the dashboard: no model request (Pi has no
127
+ // /help of its own, so the text would otherwise go to the agent).
128
+ pi.registerCommand('help', {
129
+ description: 'Lệnh và phím tắt của Terminal công ty (không gửi model)',
130
+ handler: async (_args, ctx) => ctx.ui.notify(HELP_TEXT, 'info') });
131
+
120
132
  // The Harness's notes carry English for the agent; the member reads them in Vietnamese.
121
133
  pi.registerMessageRenderer('agent-watch-process', (message, _options, theme) => {
122
134
  const lines = processLines(message.details);
@@ -781,6 +781,17 @@ if [[ "$WITH_HERDR" == true ]]; then
781
781
  fi
782
782
  fi
783
783
 
784
+ # Personal Pi must not load Piagent twice (an extension path into another
785
+ # Piagent copy beside the package) or keep old Agent Watch company providers:
786
+ # Pi refuses to start, or personal Pi reaches Studio outside the Harness.
787
+ echo
788
+ echo "Personal Pi configuration:"
789
+ if [[ "$DRY_RUN" == true ]]; then
790
+ print_cmd node "$PLATFORM_ROOT/scripts/personal-pi-config.mjs" repair
791
+ else
792
+ node "$PLATFORM_ROOT/scripts/personal-pi-config.mjs" repair || echo "WARN: could not check the personal Pi configuration; run: node \"$PLATFORM_ROOT/scripts/personal-pi-config.mjs\" check" >&2
793
+ fi
794
+
784
795
  echo
785
796
  echo "Installed packages:"
786
797
  if [[ "$DRY_RUN" == true ]]; then