@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.
- package/CHANGELOG.md +39 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/languages.json +2 -2
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +12 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +12 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +13 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +13 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/extensions/company-session-guard.ts +36 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +2 -0
- package/packages/piagent-core/managed/memory-lessons.mjs +63 -0
- package/packages/piagent-core/managed/memory-outbox.mjs +93 -0
- package/packages/piagent-core/managed/memory.mjs +246 -93
- package/packages/piagent-core/managed/session.mjs +6 -6
- package/packages/piagent-core/package.json +1 -1
- package/scripts/company-terminal.mjs +12 -0
- package/scripts/install-global.sh +11 -0
- package/scripts/personal-pi-config.mjs +137 -0
- package/scripts/piagent-cli.mjs +8 -0
- package/scripts/piagent-studio.mjs +4 -0
- package/scripts/team-doctor.sh +4 -0
- package/scripts/update-global.mjs +9 -2
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
// Team memory for
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
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,
|
|
62
|
-
this.
|
|
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
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
async afterRound(grant, messages, changedFiles) {
|
|
141
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
#
|
|
151
|
-
if (!this.
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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) =>
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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) => (
|
|
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
|
-
//
|
|
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
|
};
|
|
@@ -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
|