@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.
- package/CHANGELOG.md +33 -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/managed/helper-run.mjs +2 -2
- 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 +258 -89
- package/packages/piagent-core/managed/session.mjs +7 -7
- package/packages/piagent-core/package.json +1 -1
|
@@ -1,21 +1,24 @@
|
|
|
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';
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
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));
|
|
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
|
-
|
|
60
|
-
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();
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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.
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
129
|
-
//
|
|
130
|
-
async afterRound(grant, messages, changedFiles) {
|
|
131
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
141
|
-
if (!this.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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) => (
|
|
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
|
};
|