@tea-agent/loop-agent 0.44.0-next.13 → 0.44.0-next.14
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 +12 -0
- package/README.md +12 -12
- package/dist/adapters/loop-agent.js +34 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +5 -5
- package/dist/cli/program.js +1 -4
- package/dist/commands/init-legacy-audit.js +330 -0
- package/dist/commands/init-upgrade.js +644 -104
- package/dist/commands/init.js +685 -330
- package/dist/commands/operator-guide.js +99 -0
- package/dist/commands/operator.js +16 -3
- package/dist/executors/pi-sdk-executor.js +3 -1
- package/dist/governance/harness.js +2 -7
- package/dist/governance/manifest-types.js +28 -9
- package/dist/governance/project-capability-scanner.js +263 -0
- package/dist/governance/project-config.js +96 -0
- package/dist/governance/resource-catalog.js +151 -0
- package/dist/infrastructure/console/app-data.js +6 -2
- package/dist/shared/operator/capabilities.js +2 -2
- package/dist/shared/package-metadata.js +23 -7
- package/dist/shared/pi-context-pressure/extension.js +23 -0
- package/dist/shared/pi-context-pressure/index.js +1 -0
- package/dist/shared/pi-context-pressure/telemetry.js +4 -0
- package/dist/shared/pi-context-pressure/tool-result-pruner.js +98 -0
- package/dist/shared/update/npm-client.js +49 -1
- package/dist/worker/cli.js +3 -5
- package/dist/worker/console/chat/browser-automation.js +114 -5
- package/dist/worker/console/chat/browser-routes.js +21 -0
- package/dist/worker/console/chat/chat-event-store.js +7 -6
- package/dist/worker/console/chat/context-insights.js +84 -19
- package/dist/worker/console/chat/pet/pet-affinity.js +169 -0
- package/dist/worker/console/chat/pet/pet-persist.js +109 -0
- package/dist/worker/console/chat/pet/pet-registry.js +142 -0
- package/dist/worker/console/chat/pet/pet-remarks.js +48 -0
- package/dist/worker/console/chat/pet/pet-routes.js +125 -0
- package/dist/worker/console/chat/pet/pet-service.js +262 -0
- package/dist/worker/console/chat/pet/pet-state.js +99 -0
- package/dist/worker/console/chat/pi-mode-loop-isolation.js +17 -2
- package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +2 -2
- package/dist/worker/console/chat/pi-runtime.js +7 -2
- package/dist/worker/console/chat/routes.js +43 -7
- package/dist/worker/console/chat/scheduled-goal-request.js +14 -6
- package/dist/worker/console/chat/session-store.js +20 -3
- package/dist/worker/console/console-shutdown.js +34 -0
- package/dist/worker/console/server.js +2 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BA7qCL_r.js → abnfDiagram-N423BO3Z-DASc69AM.js} +1 -1
- package/dist/worker/console/static/assets/{arc-Df09ajwL.js → arc-h09p-LAc.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CpF9sw5R.js → architectureDiagram-T3A2C74G-CZ3dtLkh.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BtPys8ja.js → blockDiagram-VBNYF7ZC-DMrRhMXG.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DZPnTWqT.js → c4Diagram-5PPSVZJV-DQxisHV6.js} +1 -1
- package/dist/worker/console/static/assets/channel-D1zgKZfg.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BD9D4h1p.js → chunk-2GRJ4B5K-CefVN9zh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-Svn8yBmn.js → chunk-2Q5K7J3B-DmR1Koq2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-B8EbQj-S.js → chunk-5RXB4S5H-NlzrSW7L.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-tENZpcpF.js → chunk-5VM5RSS4-DfelJv-0.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DBx71Lco.js → chunk-6Q2QTUOP-BK6RFy58.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-1v_QamMz.js → chunk-GF5L2VYU-BAeh9lz4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BAMCNNBG.js → chunk-JWPE2WC7-ChPS5WzG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BvuEury-.js → chunk-KBJHAD2P-BoVe5IM6.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-YqFuHp8s.js → chunk-RYQCIY6F-C4q_0Lw9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-ouI5T7Mr.js → chunk-XXDRQBXY-Bb9TvjeY.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CXNNsY2M.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CXNNsY2M.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-Bri0_Izk.js → cose-bilkent-JH36ORCC-DmNnL8Ff.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-e8Vhhi4X.js → cynefin-VYW2F7L2-Contvx58.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-tSIXGQiJ.js → cynefinDiagram-MW4NZA55-Cigq7cjk.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-Rl-sF5pt.js → dagre-VZM6K2ZE-dEbC0nxb.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Db6ddPz8.js → diagram-7IWD3JNH-CzIztESV.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Jwd_Xw5S.js → diagram-B4RE2ZJO-Bo14zbaY.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CeC_8InC.js → diagram-LBJQPF4R-C4xUyil6.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-LKPRaNq8.js → diagram-Q27KOJAE-CaBY_uYa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-DlVQytMW.js → diagram-UB23O5K3-ClUXUeiu.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DnfJZwO-.js → ebnfDiagram-BXEA7PRR-BbGCgjC6.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-3axVSXca.js → erDiagram-JOGREHBK-Dr-AcwSl.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-xe04-_L-.js → flowDiagram-UKHOOZJN-BG8Gcfyc.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-hpmRINiD.js → ganttDiagram-PKOTCBZU-CFVaPs5r.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js → gitGraphDiagram-DS77QQ5N-ChJ8pm-O.js} +1 -1
- package/dist/worker/console/static/assets/index-B_5GhVIO.js +488 -0
- package/dist/worker/console/static/assets/index-C7YMBluX.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BF6ksWTX.js → infoDiagram-6WML65LV-C5J9-uCZ.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DXisdeD7.js → ishikawaDiagram-WSZJBQD7-BcfjqzDv.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BWu65wmu.js → journeyDiagram-NVQOT4AX-BzzCO-rD.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-hfNhHqAI.js → kanban-definition-27J2QSJJ-NCHUZ46h.js} +1 -1
- package/dist/worker/console/static/assets/{linear-D6HTfQcp.js → linear-BKZlWCUU.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-Iv91R1WP.js → mermaid.core-DbTaQjFE.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CfPChwjR.js → mindmap-definition-FAOFIHXS-CroYl5xQ.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-B7TcoqHz.js → pegDiagram-VL7TDLO6-DgMBx-9k.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-ouyPKMtQ.js → pieDiagram-7S7Q4E2Y-CyWDmbq1.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-B9tuyLbh.js → quadrantDiagram-CIZ2JOQS-BgGEOqSP.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Ck4q9nJs.js → railroadDiagram-AXF67PYL-BMZ7oeeV.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-wXLX5Dnp.js → requirementDiagram-LRYGKXZP-DxqyGhcc.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-D2XWw6_m.js → sankeyDiagram-W5VNT64P-DnSvNCG8.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DzxKiug4.js → sequenceDiagram-SI44F4Z6-BiZh6f-R.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-DWUqK5Us.js → sizeCapture-X5ZJPWSS-BKXSI8WB.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-D5Pa4O5g.js → stateDiagram-OKZ733FA-CfSgoYFm.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DgHi3vjb.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-VH7cRnsJ.js → swimlanes-SLNWSIFB-CyGADcHe.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bgvdt1Y0.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-C4HM5NoZ.js → timeline-definition-Z64GVDOM-HmPaSPOK.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-Bu-4hOlA.js → vennDiagram-T6HMQDX7-VA2VBbez.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CasFEvvl.js → wardleyDiagram-T6FBY63Y-BxjiBPZa.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-D-gpwPSA.js → xychartDiagram-ELKLHX3M--hUY-qFj.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static/pets/ember/pet.json +167 -0
- package/dist/worker/console/static/pets/ember/spritesheet.png +0 -0
- package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +4 -3
- package/dist/worker/console/static-src/operator-chat/use-stat-dialog.js +95 -0
- package/dist/worker/console/workspace-context.js +14 -0
- package/dist/worker/console/workspace-initialization.js +25 -16
- package/dist/worker/delivery/package.js +4 -1
- package/dist/worker/observe/static/operator-chrome.js +1 -42
- package/dist/workflows/dag/backend-test-pytest-collection.js +4 -1
- package/dist/workflows/dag/final-verification.js +45 -10
- package/dist/workflows/dag/hybrid/sources.js +14 -14
- package/dist/workflows/dag/hybrid/templates/backend-test.js +5 -5
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +4 -4
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +6 -6
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +5 -5
- package/dist/workflows/dag/skill-instructions.js +102 -32
- package/dist/workflows/dag/skill-snapshot.js +3 -0
- package/dist/workflows/dag/skills.js +7 -7
- package/dist/workflows/dag/validate.js +2 -2
- package/dist/workflows/dag/workspace-checkpoint.js +13 -1
- package/docs/architecture/runtime-boundaries.md +28 -22
- package/docs/init-surface.manifest.json +43 -295
- package/docs/templates/agent-dag.base.json +9 -9
- package/docs/templates/agent-dag.final-verification.json +6 -6
- package/docs/templates/agent-dag.supervised-implementation.json +12 -12
- package/docs/templates/frontend-implementation-dag.json +2 -2
- package/docs/templates/frontend-test-dag.json +2 -2
- package/docs/templates/hybrid-dag.json +9 -9
- package/docs/templates/knowledge-sync-dag.json +8 -8
- package/package.json +6 -2
- package/resource-catalog.v1.json +1973 -0
- package/resources/agent-bridge/loop-agent/SKILL.md +40 -0
- package/resources/init-history/legacy-surface.v1.json +33 -0
- package/skills/init-update/SKILL.md +34 -0
- package/skills/init-update/agents/openai.yaml +6 -0
- package/dist/worker/console/static/assets/channel-DiTNLZUm.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +0 -1
- package/dist/worker/console/static/assets/index-C_8_gWal.css +0 -1
- package/dist/worker/console/static/assets/index-DybcVXCu.js +0 -488
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +0 -8
|
@@ -57,8 +57,18 @@ export class BrowserAutomationService {
|
|
|
57
57
|
disposed = false;
|
|
58
58
|
dialogQueue = [];
|
|
59
59
|
downloadQueue = [];
|
|
60
|
+
mirrorPage;
|
|
61
|
+
mirrorSession;
|
|
62
|
+
mirrorSeq = 0;
|
|
63
|
+
mirrorFrame;
|
|
60
64
|
constructor(options) {
|
|
61
65
|
this.options = options;
|
|
66
|
+
// The visible window stays the default so human picking works out of the
|
|
67
|
+
// box. LOOP_BROWSER_HEADLESS=1 (or "true"/"on") switches the browser to a
|
|
68
|
+
// background (headless) instance that never opens a window.
|
|
69
|
+
if (this.options.headless === undefined) {
|
|
70
|
+
this.options.headless = browserHeadlessEnabled();
|
|
71
|
+
}
|
|
62
72
|
}
|
|
63
73
|
/** Runtime entrypoint: tools and HTTP must enter once, binding the target before queuing. */
|
|
64
74
|
run(input, operation) {
|
|
@@ -156,13 +166,14 @@ export class BrowserAutomationService {
|
|
|
156
166
|
*/
|
|
157
167
|
async state() {
|
|
158
168
|
if (!this.browserPromise && !this.disposed)
|
|
159
|
-
return emptyBrowserState();
|
|
169
|
+
return { ...emptyBrowserState(), headless: this.options.headless === true };
|
|
160
170
|
// Polling never starts a context, changes selection, or waits on a quarantined page.
|
|
161
171
|
const page = this.operations.current?.target.page ?? this.activePage;
|
|
162
172
|
if (!page || page.isClosed())
|
|
163
|
-
return { ...emptyBrowserState(), running: !!this.browserPromise, tabs: await this.tabSummaries() };
|
|
173
|
+
return { ...emptyBrowserState(), running: !!this.browserPromise, headless: this.options.headless === true, tabs: await this.tabSummaries() };
|
|
164
174
|
const state = {
|
|
165
175
|
running: true,
|
|
176
|
+
headless: this.options.headless === true,
|
|
166
177
|
tabId: this.trackPage(page),
|
|
167
178
|
url: page.url(),
|
|
168
179
|
title: this.recovering.has(page) ? "Page busy — script may still be running" : await page.title().catch(() => ""),
|
|
@@ -172,6 +183,36 @@ export class BrowserAutomationService {
|
|
|
172
183
|
};
|
|
173
184
|
return state;
|
|
174
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Passive screencast frame of the active page for the panel mirror view.
|
|
188
|
+
* Like state(), polling never launches the browser and never touches the
|
|
189
|
+
* operation queue, so a stuck page cannot block the panel.
|
|
190
|
+
*/
|
|
191
|
+
async panelFrame(since = 0) {
|
|
192
|
+
if (!this.browserPromise || this.disposed) {
|
|
193
|
+
await this.stopMirror().catch(() => undefined);
|
|
194
|
+
return { running: false, changed: false, seq: this.mirrorSeq };
|
|
195
|
+
}
|
|
196
|
+
const page = this.operations.current?.target.page ?? this.activePage;
|
|
197
|
+
if (!page || page.isClosed()) {
|
|
198
|
+
return { running: true, changed: false, seq: this.mirrorSeq };
|
|
199
|
+
}
|
|
200
|
+
if (page !== this.mirrorPage || !this.mirrorSession) {
|
|
201
|
+
await this.startMirror(page);
|
|
202
|
+
}
|
|
203
|
+
const frame = this.mirrorFrame;
|
|
204
|
+
if (!frame || this.mirrorSeq <= since) {
|
|
205
|
+
return { running: true, changed: false, seq: this.mirrorSeq };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
running: true,
|
|
209
|
+
changed: true,
|
|
210
|
+
seq: this.mirrorSeq,
|
|
211
|
+
url: page.url(),
|
|
212
|
+
title: await page.title().catch(() => ""),
|
|
213
|
+
image: `data:image/jpeg;base64,${frame.data}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
175
216
|
async search(rawQuery, rawUrl) {
|
|
176
217
|
const query = rawQuery.trim();
|
|
177
218
|
if (!query)
|
|
@@ -346,7 +387,14 @@ export class BrowserAutomationService {
|
|
|
346
387
|
async screenshot(options) {
|
|
347
388
|
return this.snapshot({ ...options, screenshot: true });
|
|
348
389
|
}
|
|
390
|
+
/** True when this service runs a visible browser instead of the background default. */
|
|
391
|
+
get visibleBrowser() {
|
|
392
|
+
return this.options.headless === false;
|
|
393
|
+
}
|
|
349
394
|
async focus() {
|
|
395
|
+
if (!this.visibleBrowser) {
|
|
396
|
+
throw new BrowserAutomationError("HEADLESS_NO_WINDOW", "浏览器正在后台运行(LOOP_BROWSER_HEADLESS=1),没有可聚焦的窗口;取消该设置并重启 Console 可恢复可见窗口,或在右侧面板预览当前页面");
|
|
397
|
+
}
|
|
350
398
|
const page = await this.page();
|
|
351
399
|
await page.bringToFront();
|
|
352
400
|
return { ok: true };
|
|
@@ -358,7 +406,7 @@ export class BrowserAutomationService {
|
|
|
358
406
|
}
|
|
359
407
|
async pick(input) {
|
|
360
408
|
if (this.options.headless === true)
|
|
361
|
-
throw new BrowserAutomationError("PICK_UNAVAILABLE", "human picking requires the visible external browser");
|
|
409
|
+
throw new BrowserAutomationError("PICK_UNAVAILABLE", "human picking requires the visible external browser; unset LOOP_BROWSER_HEADLESS (or set it to 0) and restart the console to launch one");
|
|
362
410
|
const page = await this.page();
|
|
363
411
|
await page.bringToFront();
|
|
364
412
|
const element = await pickBrowserElement(page, input.message, clamp(input.timeoutMs ?? 30_000, 1, 60_000), this.operations.current?.signal).catch((error) => {
|
|
@@ -480,6 +528,7 @@ export class BrowserAutomationService {
|
|
|
480
528
|
if (this.disposed)
|
|
481
529
|
return;
|
|
482
530
|
this.disposed = true;
|
|
531
|
+
await this.stopMirror().catch(() => undefined);
|
|
483
532
|
const pending = this.browserPromise;
|
|
484
533
|
this.browserPromise = undefined;
|
|
485
534
|
this.diagnostics.reset();
|
|
@@ -581,6 +630,9 @@ export class BrowserAutomationService {
|
|
|
581
630
|
this.dialogQueue.length = 0;
|
|
582
631
|
this.downloadQueue.length = 0;
|
|
583
632
|
this.tabs.clear();
|
|
633
|
+
this.mirrorPage = undefined;
|
|
634
|
+
this.mirrorSession = undefined;
|
|
635
|
+
this.mirrorFrame = undefined;
|
|
584
636
|
}
|
|
585
637
|
});
|
|
586
638
|
}
|
|
@@ -628,9 +680,53 @@ export class BrowserAutomationService {
|
|
|
628
680
|
this.tabs.delete(id);
|
|
629
681
|
if (this.activePage === page)
|
|
630
682
|
this.activePage = undefined;
|
|
683
|
+
if (this.mirrorPage === page) {
|
|
684
|
+
this.mirrorPage = undefined;
|
|
685
|
+
this.mirrorSession = undefined;
|
|
686
|
+
this.mirrorFrame = undefined;
|
|
687
|
+
}
|
|
631
688
|
});
|
|
632
689
|
return id;
|
|
633
690
|
}
|
|
691
|
+
/** Attaches a CDP screencast to the page, replacing any previous session. */
|
|
692
|
+
async startMirror(page) {
|
|
693
|
+
await this.stopMirror();
|
|
694
|
+
this.mirrorFrame = undefined;
|
|
695
|
+
try {
|
|
696
|
+
const session = await page.context().newCDPSession(page);
|
|
697
|
+
session.on("Page.screencastFrame", (frame) => {
|
|
698
|
+
this.mirrorSeq += 1;
|
|
699
|
+
this.mirrorFrame = { data: frame.data };
|
|
700
|
+
void session.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => undefined);
|
|
701
|
+
});
|
|
702
|
+
await session.send("Page.startScreencast", {
|
|
703
|
+
format: "jpeg",
|
|
704
|
+
quality: 85,
|
|
705
|
+
maxWidth: BROWSER_VIEWPORT_WIDTH,
|
|
706
|
+
maxHeight: BROWSER_VIEWPORT_HEIGHT,
|
|
707
|
+
everyNthFrame: 1,
|
|
708
|
+
});
|
|
709
|
+
this.mirrorSession = session;
|
|
710
|
+
this.mirrorPage = page;
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
await this.stopMirror().catch(() => undefined);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async stopMirror() {
|
|
717
|
+
const session = this.mirrorSession;
|
|
718
|
+
this.mirrorSession = undefined;
|
|
719
|
+
this.mirrorPage = undefined;
|
|
720
|
+
if (!session)
|
|
721
|
+
return;
|
|
722
|
+
try {
|
|
723
|
+
await session.send("Page.stopScreencast");
|
|
724
|
+
}
|
|
725
|
+
catch {
|
|
726
|
+
// The session may already be gone with its page or context.
|
|
727
|
+
}
|
|
728
|
+
await session.detach().catch(() => undefined);
|
|
729
|
+
}
|
|
634
730
|
dialogFor(page) {
|
|
635
731
|
const index = this.dialogQueue.findIndex((entry) => entry.page === page);
|
|
636
732
|
const dialog = index >= 0 ? this.dialogQueue[index]?.dialog : undefined;
|
|
@@ -729,17 +825,30 @@ function emptyBrowserState() {
|
|
|
729
825
|
ariaSnapshot: "",
|
|
730
826
|
};
|
|
731
827
|
}
|
|
732
|
-
|
|
828
|
+
/** Linux GitHub runners (and similar CI) have no usable Chromium sandbox. */
|
|
829
|
+
export function chromiumSandboxEnabled(env = process.env) {
|
|
830
|
+
return env.GITHUB_ACTIONS !== "true" && env.CI !== "true";
|
|
831
|
+
}
|
|
832
|
+
export function chromiumLaunchOptions(headless, env = process.env) {
|
|
733
833
|
return {
|
|
734
834
|
headless: headless ?? false,
|
|
735
835
|
viewport: undefined,
|
|
736
836
|
acceptDownloads: true,
|
|
737
837
|
locale: "zh-CN",
|
|
738
838
|
timezoneId: "Asia/Shanghai",
|
|
739
|
-
|
|
839
|
+
// Desktop: drop Playwright's implicit --no-sandbox so Chrome does not show
|
|
840
|
+
// the "unsupported command-line flag" infobar. CI Linux has no usable
|
|
841
|
+
// user-namespace sandbox, so keep Playwright's --no-sandbox there.
|
|
842
|
+
chromiumSandbox: chromiumSandboxEnabled(env),
|
|
843
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
740
844
|
ignoreDefaultArgs: ["--enable-automation"],
|
|
741
845
|
};
|
|
742
846
|
}
|
|
847
|
+
/** LOOP_BROWSER_HEADLESS=1/true/on runs the browser in the background (headless). */
|
|
848
|
+
export function browserHeadlessEnabled(env = process.env) {
|
|
849
|
+
const raw = env.LOOP_BROWSER_HEADLESS?.trim().toLowerCase();
|
|
850
|
+
return raw === "1" || raw === "true" || raw === "on";
|
|
851
|
+
}
|
|
743
852
|
/**
|
|
744
853
|
* Bounded settle after a committed navigation. `domcontentloaded` fires before
|
|
745
854
|
* late client-side redirects (for example an engine that reroutes to a regional
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP API for the workspace-controlled external browser. The sidebar iframe
|
|
3
3
|
* is an independent preview; these endpoints operate the Playwright page.
|
|
4
|
+
* `GET panel/frame` additionally serves passive screencast frames so the
|
|
5
|
+
* panel can mirror the shared window (same context, login state included).
|
|
4
6
|
*/
|
|
5
7
|
import { embeddabilityOf, extractFrameAncestors, normalizeBrowserUrl, } from "./browser-policy.js";
|
|
6
8
|
function workspaceIdOf(req) {
|
|
@@ -170,6 +172,25 @@ export async function handleBrowserRequest(req, res, deps, pathname) {
|
|
|
170
172
|
clearTimeout(timer);
|
|
171
173
|
}
|
|
172
174
|
}
|
|
175
|
+
if (method === "GET" && sub === "panel" && parts[1] === "frame") {
|
|
176
|
+
const scope = requireScope(req, deps);
|
|
177
|
+
if (!scope.ok) {
|
|
178
|
+
sendJson(res, scope.status, {
|
|
179
|
+
ok: false,
|
|
180
|
+
error: { code: scope.code, message: scope.message },
|
|
181
|
+
});
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
if (!deps.automation) {
|
|
185
|
+
sendJson(res, 200, { ok: true, data: { running: false, changed: false, seq: 0 } });
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
const sinceRaw = new URL(req.url ?? "/", "http://localhost").searchParams.get("since");
|
|
189
|
+
const parsed = sinceRaw === null ? 0 : Number(sinceRaw);
|
|
190
|
+
const frame = await deps.automation.panelFrame(Number.isFinite(parsed) ? parsed : 0);
|
|
191
|
+
sendJson(res, 200, { ok: true, data: frame });
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
173
194
|
if (method === "POST" && parts.length >= 1) {
|
|
174
195
|
const scope = requireScope(req, deps);
|
|
175
196
|
if (!scope.ok) {
|
|
@@ -502,8 +502,8 @@ const DEFAULT_PERSIST_DEBOUNCE_MS = 250;
|
|
|
502
502
|
* interim flush is forced, so a fast stream never widens the loss window
|
|
503
503
|
* unboundedly even if the `at` clock stalls. */
|
|
504
504
|
const DEFAULT_PERSIST_MAX_PENDING_UPDATES = 250;
|
|
505
|
-
/** Conservative per-event scale for the byte pre-check gate (UTF-8 multibyte ≤ 4 bytes/char
|
|
506
|
-
const BYTE_GATE_SCALE =
|
|
505
|
+
/** Conservative per-event scale for the byte pre-check gate (UTF-8 multibyte ≤ 4 bytes/char). Persist is compact JSON, so this is no longer multiplied by pretty-print expansion. */
|
|
506
|
+
const BYTE_GATE_SCALE = 4;
|
|
507
507
|
/** Fixed envelope slack (schema keys, turnCount, nextSeq, minSeq, turns) for the byte pre-check gate. */
|
|
508
508
|
const BYTE_GATE_SLACK = 8192;
|
|
509
509
|
/** Legacy epoch for event files written before M5.1 (no epoch field). */
|
|
@@ -704,7 +704,8 @@ export function createChatEventStore(options) {
|
|
|
704
704
|
const maxBytes = options?.maxBytesPerSession ?? DEFAULT_MAX_BYTES;
|
|
705
705
|
const coalesceWindowMs = options?.coalesceWindowMs ?? DEFAULT_COALESCE_WINDOW_MS;
|
|
706
706
|
const eventEpoch = options?.epoch?.trim() || LEGACY_EPOCH;
|
|
707
|
-
const persistWriter = options?.persistWriter ??
|
|
707
|
+
const persistWriter = options?.persistWriter ??
|
|
708
|
+
((file, payload) => writeSecureJsonSync(file, payload, { pretty: false }));
|
|
708
709
|
const persistDebounceMs = options?.persistDebounceMs ?? DEFAULT_PERSIST_DEBOUNCE_MS;
|
|
709
710
|
const persistMaxPendingUpdates = options?.persistMaxPendingUpdates ?? DEFAULT_PERSIST_MAX_PENDING_UPDATES;
|
|
710
711
|
const persistenceDir = options?.appData?.chatEvents
|
|
@@ -807,10 +808,10 @@ export function createChatEventStore(options) {
|
|
|
807
808
|
epoch: ring.epoch,
|
|
808
809
|
};
|
|
809
810
|
}
|
|
810
|
-
/** Serialized exactly as `writeSecureJsonSync` writes it, so byte
|
|
811
|
-
* (D4) and the on-disk bytes use the same formatting. */
|
|
811
|
+
/** Serialized exactly as compact `writeSecureJsonSync` writes it, so byte
|
|
812
|
+
* accounting (D4) and the on-disk bytes use the same formatting. */
|
|
812
813
|
function serializeRingForPersist(sessionId, ring) {
|
|
813
|
-
return `${JSON.stringify(ringPayload(sessionId, ring)
|
|
814
|
+
return `${JSON.stringify(ringPayload(sessionId, ring))}\n`;
|
|
814
815
|
}
|
|
815
816
|
function bytesOfRing(sessionId, ring) {
|
|
816
817
|
return Buffer.byteLength(serializeRingForPersist(sessionId, ring), "utf8");
|
|
@@ -261,6 +261,52 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
261
261
|
const browserMessages = [];
|
|
262
262
|
const browserInjected = [];
|
|
263
263
|
const browserToolResults = [];
|
|
264
|
+
const liveNodes = [];
|
|
265
|
+
/** Nodes removed by a prune/compaction, newest last, for the prune card. */
|
|
266
|
+
const archivedNodes = [];
|
|
267
|
+
/** Remove the ledger nodes whose seq the removal claims; deduct slices and browser rows. */
|
|
268
|
+
function claimNodes(claimed, removedAtSeq) {
|
|
269
|
+
if (claimed.size === 0)
|
|
270
|
+
return [];
|
|
271
|
+
const removedIds = [];
|
|
272
|
+
const kept = [];
|
|
273
|
+
for (const node of liveNodes) {
|
|
274
|
+
if (claimed.has(node.seq)) {
|
|
275
|
+
const slice = sliceOf(composition, node.category);
|
|
276
|
+
slice.tokens = Math.max(0, slice.tokens - node.tokens);
|
|
277
|
+
slice.items = Math.max(0, slice.items - 1);
|
|
278
|
+
archivedNodes.push({ ...node, removedAt: removedAtSeq });
|
|
279
|
+
removedIds.push(node.element.id);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
kept.push(node);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (removedIds.length > 0) {
|
|
286
|
+
liveNodes.length = 0;
|
|
287
|
+
liveNodes.push(...kept);
|
|
288
|
+
const drop = (list) => {
|
|
289
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
290
|
+
const row = list[i];
|
|
291
|
+
if (row && claimed.has(row.seq))
|
|
292
|
+
list.splice(i, 1);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
drop(browserMessages);
|
|
296
|
+
drop(browserToolResults);
|
|
297
|
+
drop(browserInjected);
|
|
298
|
+
}
|
|
299
|
+
return removedIds;
|
|
300
|
+
}
|
|
301
|
+
/** Parse "<sessionId>:<seq>" entry ids into a keep-from seq anchor. */
|
|
302
|
+
function entrySeqOf(entryId) {
|
|
303
|
+
if (typeof entryId !== "string" || !entryId)
|
|
304
|
+
return undefined;
|
|
305
|
+
const idx = entryId.lastIndexOf(":");
|
|
306
|
+
const tail = idx >= 0 ? entryId.slice(idx + 1) : entryId;
|
|
307
|
+
const seq = Number.parseInt(tail, 10);
|
|
308
|
+
return Number.isInteger(seq) && seq >= 0 ? seq : undefined;
|
|
309
|
+
}
|
|
264
310
|
if (options.systemPromptPreview) {
|
|
265
311
|
addSlice(composition, "system", estimateTokensFromText(options.systemPromptPreview));
|
|
266
312
|
}
|
|
@@ -306,14 +352,16 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
306
352
|
if (text) {
|
|
307
353
|
const tokens = estimateTokensFromText(text);
|
|
308
354
|
addSlice(composition, "user", tokens);
|
|
309
|
-
|
|
355
|
+
const element = {
|
|
310
356
|
id: `user:${event.seq}`,
|
|
311
357
|
label: "用户",
|
|
312
358
|
tokens,
|
|
313
359
|
preview: text,
|
|
314
360
|
seq: event.seq,
|
|
315
361
|
at: event.at,
|
|
316
|
-
}
|
|
362
|
+
};
|
|
363
|
+
browserMessages.push(element);
|
|
364
|
+
liveNodes.push({ seq: event.seq, tokens, category: "user", element });
|
|
317
365
|
if (!turnUserBrief.has(event.turnId)) {
|
|
318
366
|
turnUserBrief.set(event.turnId, text.slice(0, 200));
|
|
319
367
|
}
|
|
@@ -338,14 +386,16 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
338
386
|
if (text) {
|
|
339
387
|
const tokens = estimateTokensFromText(text);
|
|
340
388
|
addSlice(composition, "assistant", tokens);
|
|
341
|
-
|
|
389
|
+
const element = {
|
|
342
390
|
id: `assistant:${event.seq}`,
|
|
343
391
|
label: thinking ? "助手(含思考)" : "助手",
|
|
344
392
|
tokens,
|
|
345
393
|
preview: text,
|
|
346
394
|
seq: event.seq,
|
|
347
395
|
at: event.at,
|
|
348
|
-
}
|
|
396
|
+
};
|
|
397
|
+
browserMessages.push(element);
|
|
398
|
+
liveNodes.push({ seq: event.seq, tokens, category: "assistant", element });
|
|
349
399
|
}
|
|
350
400
|
const toolsCalled = stepToolsCalled.get(event.turnId) ?? [];
|
|
351
401
|
const recorded = asRecord(data.timing);
|
|
@@ -474,7 +524,7 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
474
524
|
if (resultPreview) {
|
|
475
525
|
const tokens = estimateTokensFromText(resultPreview);
|
|
476
526
|
addSlice(composition, "toolResults", tokens);
|
|
477
|
-
|
|
527
|
+
const element = {
|
|
478
528
|
id: `result:${event.seq}`,
|
|
479
529
|
label: typeof data.toolName === "string" && data.toolName
|
|
480
530
|
? data.toolName
|
|
@@ -484,7 +534,9 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
484
534
|
status: data.isError === true ? "error" : "ok",
|
|
485
535
|
seq: event.seq,
|
|
486
536
|
at: event.at,
|
|
487
|
-
}
|
|
537
|
+
};
|
|
538
|
+
browserToolResults.push(element);
|
|
539
|
+
liveNodes.push({ seq: event.seq, tokens, category: "toolResults", element });
|
|
488
540
|
}
|
|
489
541
|
break;
|
|
490
542
|
}
|
|
@@ -531,6 +583,30 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
531
583
|
const after = num(data.estimatedTokensAfter);
|
|
532
584
|
const reclaimed = before !== undefined && after !== undefined ? before - after : null;
|
|
533
585
|
const pruned = data.pruned === true || data.kind === "prune";
|
|
586
|
+
// --- Exact removal (dsh-context parity) ---
|
|
587
|
+
// A prune/compaction claims live nodes either by explicit seq list
|
|
588
|
+
// (shadowedSeqs) or by a keep-from anchor (firstKeptEntryId = the
|
|
589
|
+
// first ring entry that survives). Claim exactly those nodes from
|
|
590
|
+
// the ledger: deduct slices, drop browser rows, archive the nodes.
|
|
591
|
+
const explicitSeqs = Array.isArray(data.shadowedSeqs)
|
|
592
|
+
? data.shadowedSeqs.filter((value) => typeof value === "number" && Number.isInteger(value) && value >= 0)
|
|
593
|
+
: undefined;
|
|
594
|
+
const keepFromSeq = entrySeqOf(data.firstKeptEntryId);
|
|
595
|
+
let removedIds = [];
|
|
596
|
+
let removalBasis;
|
|
597
|
+
if (explicitSeqs && explicitSeqs.length > 0) {
|
|
598
|
+
removedIds = claimNodes(new Set(explicitSeqs), event.seq);
|
|
599
|
+
removalBasis = "seqs";
|
|
600
|
+
}
|
|
601
|
+
else if (keepFromSeq !== undefined && liveNodes.length > 0) {
|
|
602
|
+
const rangeSeqs = new Set();
|
|
603
|
+
for (const node of liveNodes) {
|
|
604
|
+
if (node.seq < keepFromSeq)
|
|
605
|
+
rangeSeqs.add(node.seq);
|
|
606
|
+
}
|
|
607
|
+
removedIds = claimNodes(rangeSeqs, event.seq);
|
|
608
|
+
removalBasis = "range";
|
|
609
|
+
}
|
|
534
610
|
contextEvents.push({
|
|
535
611
|
seq: event.seq,
|
|
536
612
|
at: event.at,
|
|
@@ -548,25 +624,14 @@ export function deriveContextInsights(events, options = {}) {
|
|
|
548
624
|
...(Array.isArray(data.shadowedSeqs) ? { shadowedItems: data.shadowedSeqs.length } : {}),
|
|
549
625
|
...(typeof data.reason === "string" ? { reason: data.reason } : {}),
|
|
550
626
|
...(pruned ? { pruned: true } : {}),
|
|
627
|
+
...(removedIds.length > 0 ? { removedIds } : {}),
|
|
628
|
+
...(removalBasis !== undefined ? { removalBasis } : {}),
|
|
551
629
|
detail: boundedText(asRecord(data.details)?.text, 300),
|
|
552
630
|
});
|
|
553
631
|
const lastStep = trend.at(-1);
|
|
554
632
|
if (lastStep && event.kind !== "compaction_start") {
|
|
555
633
|
lastStep.eventMark = pruned ? "prune" : "compact";
|
|
556
634
|
}
|
|
557
|
-
if (reclaimed !== null && reclaimed > 0) {
|
|
558
|
-
const userSlice = sliceOf(composition, "user");
|
|
559
|
-
const assistantSlice = sliceOf(composition, "assistant");
|
|
560
|
-
const toolSlice = sliceOf(composition, "toolResults");
|
|
561
|
-
let remaining = reclaimed;
|
|
562
|
-
for (const slice of [userSlice, assistantSlice, toolSlice]) {
|
|
563
|
-
const take = Math.min(slice.tokens, remaining);
|
|
564
|
-
slice.tokens -= take;
|
|
565
|
-
remaining -= take;
|
|
566
|
-
if (remaining <= 0)
|
|
567
|
-
break;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
635
|
break;
|
|
571
636
|
}
|
|
572
637
|
default:
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet affinity + treat economy — ported from @linxin666/dsh-pet
|
|
3
|
+
* affinity.ts + treats.ts. Pure, clock-injected; persistence lives in the
|
|
4
|
+
* service. Ranks use plain ASCII glyphs (repo bans emoji characters).
|
|
5
|
+
* @module loop-agent/console/chat/pet/pet-affinity
|
|
6
|
+
*/
|
|
7
|
+
import { BUILTIN_PET_REMARKS, builtinPetRemark, } from "./pet-remarks.js";
|
|
8
|
+
export const PET_AFFINITY_MAX = 999_999_999;
|
|
9
|
+
export const PET_AFFINITY_RANKS = [
|
|
10
|
+
{ min: 0, name: "一粒火星", glyph: "*" },
|
|
11
|
+
{ min: 25, name: "小火苗", glyph: "**" },
|
|
12
|
+
{ min: 50, name: "炉边伙伴", glyph: "***" },
|
|
13
|
+
{ min: 80, name: "炽热搭档", glyph: "****" },
|
|
14
|
+
{ min: 200, name: "烈焰挚友", glyph: "*****" },
|
|
15
|
+
{ min: 500, name: "篝火达人", glyph: "******" },
|
|
16
|
+
{ min: 2_000, name: "火焰大师", glyph: "*******" },
|
|
17
|
+
{ min: 10_000, name: "燎原之势", glyph: "********" },
|
|
18
|
+
{ min: 100_000, name: "恒星之火", glyph: "*********" },
|
|
19
|
+
];
|
|
20
|
+
export const defaultPetAffinityConfig = {
|
|
21
|
+
turnReward: 1,
|
|
22
|
+
petReward: 1,
|
|
23
|
+
petCooldownMs: 10_000,
|
|
24
|
+
feedReward: 5,
|
|
25
|
+
feedCooldownMs: 30_000,
|
|
26
|
+
};
|
|
27
|
+
export function emptyPetAffinity() {
|
|
28
|
+
return {
|
|
29
|
+
points: 0,
|
|
30
|
+
lastPetAt: 0,
|
|
31
|
+
lastFeedAt: 0,
|
|
32
|
+
pets: 0,
|
|
33
|
+
feeds: 0,
|
|
34
|
+
petRejects: 0,
|
|
35
|
+
feedRejects: 0,
|
|
36
|
+
turns: 0,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function petRankOf(points) {
|
|
40
|
+
let rank = PET_AFFINITY_RANKS[0];
|
|
41
|
+
for (const candidate of PET_AFFINITY_RANKS) {
|
|
42
|
+
if (points >= candidate.min)
|
|
43
|
+
rank = candidate;
|
|
44
|
+
}
|
|
45
|
+
return rank;
|
|
46
|
+
}
|
|
47
|
+
export function petAffinityViewOf(state, nowMs, config = defaultPetAffinityConfig) {
|
|
48
|
+
const rank = petRankOf(state.points);
|
|
49
|
+
return {
|
|
50
|
+
points: state.points,
|
|
51
|
+
rank: rank.name,
|
|
52
|
+
rankGlyph: rank.glyph,
|
|
53
|
+
pets: state.pets,
|
|
54
|
+
feeds: state.feeds,
|
|
55
|
+
turns: state.turns,
|
|
56
|
+
petCooldown: nowMs - state.lastPetAt < config.petCooldownMs,
|
|
57
|
+
feedCooldown: nowMs - state.lastFeedAt < config.feedCooldownMs,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function clampPoints(points) {
|
|
61
|
+
return Math.min(PET_AFFINITY_MAX, Math.max(0, points));
|
|
62
|
+
}
|
|
63
|
+
/** Deterministic round-robin reaction from a persisted counter. */
|
|
64
|
+
function countedRemark(kind, count) {
|
|
65
|
+
const pool = BUILTIN_PET_REMARKS[kind];
|
|
66
|
+
return pool[Math.max(0, Math.floor(count)) % pool.length] ?? builtinPetRemark(kind);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Apply one interaction to a copy of the state. Cooldowns only apply once
|
|
70
|
+
* the pet has been interacted with at least once (last*At === 0 = never).
|
|
71
|
+
*/
|
|
72
|
+
export function applyPetInteraction(state, kind, nowMs, config = defaultPetAffinityConfig) {
|
|
73
|
+
const next = { ...state };
|
|
74
|
+
if (kind === "pet") {
|
|
75
|
+
if (state.lastPetAt !== 0 &&
|
|
76
|
+
nowMs - state.lastPetAt < config.petCooldownMs) {
|
|
77
|
+
next.petRejects += 1;
|
|
78
|
+
return {
|
|
79
|
+
affinity: next,
|
|
80
|
+
delta: 0,
|
|
81
|
+
reaction: countedRemark("petCooldown", state.petRejects),
|
|
82
|
+
accepted: false,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
next.lastPetAt = nowMs;
|
|
86
|
+
next.pets += 1;
|
|
87
|
+
next.points = clampPoints(state.points + config.petReward);
|
|
88
|
+
return {
|
|
89
|
+
affinity: next,
|
|
90
|
+
delta: config.petReward,
|
|
91
|
+
reaction: countedRemark("pet", state.pets),
|
|
92
|
+
accepted: true,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (kind === "feed") {
|
|
96
|
+
if (state.lastFeedAt !== 0 &&
|
|
97
|
+
nowMs - state.lastFeedAt < config.feedCooldownMs) {
|
|
98
|
+
next.feedRejects += 1;
|
|
99
|
+
return {
|
|
100
|
+
affinity: next,
|
|
101
|
+
delta: 0,
|
|
102
|
+
reaction: countedRemark("feedCooldown", state.feedRejects),
|
|
103
|
+
accepted: false,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
next.lastFeedAt = nowMs;
|
|
107
|
+
next.feeds += 1;
|
|
108
|
+
next.points = clampPoints(state.points + config.feedReward);
|
|
109
|
+
return {
|
|
110
|
+
affinity: next,
|
|
111
|
+
delta: config.feedReward,
|
|
112
|
+
reaction: countedRemark("feed", state.feeds),
|
|
113
|
+
accepted: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { affinity: state, delta: 0, reaction: "", accepted: false };
|
|
117
|
+
}
|
|
118
|
+
/** Reward one completed turn (host calls this on phase done). */
|
|
119
|
+
export function applyPetTurnReward(state, config = defaultPetAffinityConfig) {
|
|
120
|
+
const next = { ...state };
|
|
121
|
+
next.turns += 1;
|
|
122
|
+
next.points = clampPoints(state.points + config.turnReward);
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
export const defaultPetTreatConfig = {
|
|
126
|
+
turnsPerTreat: 30,
|
|
127
|
+
timeTreatMs: 300 * 60_000,
|
|
128
|
+
maxTreats: 20,
|
|
129
|
+
};
|
|
130
|
+
export function emptyPetTreatLedger() {
|
|
131
|
+
return { treats: 0, lastTreatGrantAt: 0, turnsAtLastTreatGrant: 0 };
|
|
132
|
+
}
|
|
133
|
+
function capTreats(treats, max) {
|
|
134
|
+
return Math.min(max, Math.max(0, treats));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Settle treat grants from both sources (work output + wall-clock time).
|
|
138
|
+
* Lazy settlement from persisted anchors — no timer, no drift. A zero-gain
|
|
139
|
+
* first settlement still writes the time anchor so the clock can start.
|
|
140
|
+
*/
|
|
141
|
+
export function settlePetTreatGrants(ledger, turns, nowMs, config = defaultPetTreatConfig) {
|
|
142
|
+
const turnDelta = Math.max(0, turns - ledger.turnsAtLastTreatGrant);
|
|
143
|
+
const workGrants = Math.floor(turnDelta / config.turnsPerTreat);
|
|
144
|
+
const timeAnchor = ledger.lastTreatGrantAt === 0 ? nowMs : ledger.lastTreatGrantAt;
|
|
145
|
+
const timeGrants = Math.floor(Math.max(0, nowMs - timeAnchor) / config.timeTreatMs);
|
|
146
|
+
const gained = workGrants + timeGrants;
|
|
147
|
+
if (gained <= 0) {
|
|
148
|
+
if (ledger.lastTreatGrantAt === 0) {
|
|
149
|
+
return { ledger: { ...ledger, lastTreatGrantAt: nowMs }, gained: 0 };
|
|
150
|
+
}
|
|
151
|
+
return { ledger, gained: 0 };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
ledger: {
|
|
155
|
+
treats: capTreats(ledger.treats + gained, config.maxTreats),
|
|
156
|
+
lastTreatGrantAt: timeGrants > 0 ? timeAnchor + timeGrants * config.timeTreatMs : timeAnchor,
|
|
157
|
+
turnsAtLastTreatGrant: workGrants > 0
|
|
158
|
+
? turns - (turnDelta % config.turnsPerTreat)
|
|
159
|
+
: ledger.turnsAtLastTreatGrant,
|
|
160
|
+
},
|
|
161
|
+
gained,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Consume one treat for a feed; refuses when the stock is empty. */
|
|
165
|
+
export function consumePetTreat(ledger) {
|
|
166
|
+
if (ledger.treats <= 0)
|
|
167
|
+
return { ok: false };
|
|
168
|
+
return { ok: true, ledger: { ...ledger, treats: ledger.treats - 1 } };
|
|
169
|
+
}
|