@tea-agent/loop-agent 0.44.0-next.12 → 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 +27 -2
- 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 +686 -330
- package/dist/commands/operator-guide.js +99 -0
- package/dist/commands/operator.js +16 -3
- package/dist/executors/dag-pi-executor.js +11 -6
- package/dist/executors/pi-sdk-executor.js +3 -1
- package/dist/executors/pi-writer-tool-policy.js +60 -4
- package/dist/executors/playwright-cli-launcher.js +31 -45
- package/dist/executors/shell-write-guard.js +24 -67
- package/dist/governance/exec-plans.js +10 -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/task/config-types.js +4 -2
- package/dist/task/source-prepare/build-draft.js +1 -0
- package/dist/task/source-prepare/path-policy.js +17 -7
- package/dist/worker/cli.js +3 -5
- package/dist/worker/console/chat/browser-automation.js +455 -41
- package/dist/worker/console/chat/browser-content.js +74 -0
- package/dist/worker/console/chat/browser-diagnostics.js +250 -0
- package/dist/worker/console/chat/browser-evaluation.js +58 -0
- package/dist/worker/console/chat/browser-operation-queue.js +19 -0
- package/dist/worker/console/chat/browser-pick.js +113 -0
- package/dist/worker/console/chat/browser-routes.js +54 -5
- package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
- package/dist/worker/console/chat/browser-storage.js +89 -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 +125 -174
- package/dist/worker/console/chat/pi-runtime.js +8 -3
- package/dist/worker/console/chat/repo-browser.js +80 -2
- package/dist/worker/console/chat/repo-preview-kind.js +155 -0
- 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-mode.js +1 -1
- package/dist/worker/console/chat/session-store.js +24 -5
- package/dist/worker/console/chat/tools.js +12 -0
- 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-r1NAPU_5.js → abnfDiagram-N423BO3Z-DASc69AM.js} +1 -1
- package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-h09p-LAc.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CZ3dtLkh.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-DMrRhMXG.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.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-B9fh0IkF.js → chunk-2GRJ4B5K-CefVN9zh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-DmR1Koq2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-NlzrSW7L.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-DfelJv-0.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-BK6RFy58.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-BAeh9lz4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-ChPS5WzG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BoVe5IM6.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-C4q_0Lw9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.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-iMqPuBsL.js → cose-bilkent-JH36ORCC-DmNnL8Ff.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-Contvx58.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-Cigq7cjk.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-dEbC0nxb.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-CzIztESV.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Bo14zbaY.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-C4xUyil6.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-CaBY_uYa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-ClUXUeiu.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-BbGCgjC6.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-Dr-AcwSl.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-BG8Gcfyc.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-CFVaPs5r.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.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-DIeAmJ45.js → infoDiagram-6WML65LV-C5J9-uCZ.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-BcfjqzDv.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BzzCO-rD.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-NCHUZ46h.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-BKZlWCUU.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-DbTaQjFE.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CroYl5xQ.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-DgMBx-9k.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-CyWDmbq1.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-BgGEOqSP.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-BMZ7oeeV.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-DxqyGhcc.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-DnSvNCG8.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-BiZh6f-R.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-BKXSI8WB.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.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-ChFLJb-v.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-DZZeiAPr.js → timeline-definition-Z64GVDOM-HmPaSPOK.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-VA2VBbez.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-BxjiBPZa.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.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/open-preview-in-browser.js +57 -9
- package/dist/worker/console/static-src/operator-chat/use-stat-dialog.js +95 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
- package/dist/worker/console/workspace-context.js +15 -0
- package/dist/worker/console/workspace-initialization.js +25 -16
- package/dist/worker/delivery/package.js +4 -1
- package/dist/worker/materialize/harness-task-lineage.js +4 -2
- package/dist/worker/observe/static/console-theme.css +33 -29
- package/dist/worker/observe/static/operator-chrome.js +1 -42
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
- package/dist/workflows/dag/backend-test-layout.js +27 -7
- package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +4 -1
- package/dist/workflows/dag/final-verification.js +45 -10
- package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
- package/dist/workflows/dag/frontend-test-layout.js +41 -12
- package/dist/workflows/dag/hybrid/sources.js +35 -16
- package/dist/workflows/dag/hybrid/templates/backend-test.js +70 -55
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +62 -41
- 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/test-artifact-namespace.js +73 -0
- package/dist/workflows/dag/validate.js +24 -5
- 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/README.md +4 -4
- 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/backend-test-dag.generate-pytest.prompt.md +3 -3
- package/docs/templates/backend-test-dag.json +708 -708
- package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
- package/docs/templates/frontend-implementation-dag.json +2 -2
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
- package/docs/templates/frontend-test-dag.json +48 -39
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/hybrid-dag.json +9 -9
- package/docs/templates/knowledge-sync-dag.json +8 -8
- package/package.json +12 -3
- 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/fe-test-ui-scout/SKILL.md +4 -4
- package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
- package/skills/init-update/SKILL.md +34 -0
- package/skills/init-update/agents/openai.yaml +6 -0
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +4 -4
- package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
- package/dist/worker/console/static/assets/index-CTMZsDmh.js +0 -486
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
const stateSchema = z.object({
|
|
7
|
+
cookies: z.array(z.object({
|
|
8
|
+
name: z.string().min(1), value: z.string(), domain: z.string().min(1),
|
|
9
|
+
path: z.string().startsWith("/"), expires: z.number().finite(),
|
|
10
|
+
httpOnly: z.boolean(), secure: z.boolean(), sameSite: z.enum(["Strict", "Lax", "None"]),
|
|
11
|
+
partitionKey: z.string().optional(),
|
|
12
|
+
}).strict()).max(3000),
|
|
13
|
+
origins: z.array(z.object({
|
|
14
|
+
origin: z.string().refine((value) => {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value);
|
|
17
|
+
return ["http:", "https:"].includes(url.protocol) && url.origin === value;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}),
|
|
23
|
+
localStorage: z.array(z.object({ name: z.string(), value: z.string() }).strict()).max(10000),
|
|
24
|
+
}).strict()).max(100),
|
|
25
|
+
}).strict();
|
|
26
|
+
export async function exportBrowserStorage(context, directory) {
|
|
27
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
28
|
+
const state = await context.storageState();
|
|
29
|
+
const target = path.join(directory, `${randomUUID()}.json`);
|
|
30
|
+
const temporary = `${target}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(temporary, JSON.stringify(state), { mode: 0o600, flag: "wx" });
|
|
33
|
+
await rename(temporary, target);
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
await rm(temporary, { force: true });
|
|
37
|
+
}
|
|
38
|
+
return { path: target, cookieCount: state.cookies.length, origins: state.origins.map((entry) => entry.origin), scope: "cookies+localStorage", excluded: ["sessionStorage", "IndexedDB", "OPFS", "credentials"] };
|
|
39
|
+
}
|
|
40
|
+
/** Parse and validate fully before obtaining or mutating a browser context. */
|
|
41
|
+
export async function readBrowserStorage(filename, directory) {
|
|
42
|
+
const root = await realpath(directory);
|
|
43
|
+
const parent = await realpath(path.dirname(path.resolve(filename)));
|
|
44
|
+
const target = path.join(parent, path.basename(filename));
|
|
45
|
+
if (parent !== root || path.extname(target) !== ".json")
|
|
46
|
+
throw new Error("storage import requires a JSON file directly in the browser storage directory");
|
|
47
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
48
|
+
try {
|
|
49
|
+
const stat = await handle.stat();
|
|
50
|
+
if (!stat.isFile() || stat.size > 5 * 1024 * 1024)
|
|
51
|
+
throw new Error("storage file must be regular and at most 5 MiB");
|
|
52
|
+
const raw = await handle.readFile("utf8");
|
|
53
|
+
try {
|
|
54
|
+
return stateSchema.parse(JSON.parse(raw));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error("invalid storage state: only cookies and http(s) origin localStorage are supported");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await handle.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function mergeBrowserStorage(context, state) {
|
|
65
|
+
// A temporary routed document writes only listed keys, without running the site's application.
|
|
66
|
+
const page = await context.newPage();
|
|
67
|
+
let cdp;
|
|
68
|
+
try {
|
|
69
|
+
cdp = await context.newCDPSession(page);
|
|
70
|
+
// page.route alone does not intercept a response supplied by an installed service worker.
|
|
71
|
+
await cdp.send("Network.enable");
|
|
72
|
+
await cdp.send("Network.setBypassServiceWorker", { bypass: true });
|
|
73
|
+
await page.route("**/*", (route) => route.fulfill({ status: 200, contentType: "text/html", body: "<!doctype html><title>Import login state</title>" }));
|
|
74
|
+
await context.addCookies(state.cookies);
|
|
75
|
+
for (const entry of state.origins) {
|
|
76
|
+
await page.goto(entry.origin, { waitUntil: "domcontentloaded", timeout: 10_000 });
|
|
77
|
+
await page.evaluate((items) => { for (const item of items)
|
|
78
|
+
localStorage.setItem(item.name, item.value); }, entry.localStorage);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (cause) {
|
|
82
|
+
throw new Error("storage merge failed; some entries may have been applied, inspect state before retrying", { cause });
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await cdp?.detach().catch(() => undefined);
|
|
86
|
+
await page.close();
|
|
87
|
+
}
|
|
88
|
+
return { mode: "merge", cookieCount: state.cookies.length, origins: state.origins.map((entry) => entry.origin), reloadRequired: true };
|
|
89
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pet persistence — ported from @linxin666/dsh-pet persist.ts, retargeted
|
|
3
|
+
* from $DSH_HOME to the console data directory. One JSON file, atomic
|
|
4
|
+
* rename write, tolerant read (corrupt file falls back to defaults).
|
|
5
|
+
* @module loop-agent/console/chat/pet/pet-persist
|
|
6
|
+
*/
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { PET_AFFINITY_MAX, emptyPetAffinity, } from "./pet-affinity.js";
|
|
10
|
+
import { defaultPetTreatConfig, emptyPetTreatLedger, } from "./pet-affinity.js";
|
|
11
|
+
export const DEFAULT_PET_ID = "ember";
|
|
12
|
+
export const DEFAULT_PET_NAME = "小橘灵";
|
|
13
|
+
export const defaultPetDisplayConfig = {
|
|
14
|
+
visible: false,
|
|
15
|
+
size: 160,
|
|
16
|
+
right: 24,
|
|
17
|
+
bottom: 120,
|
|
18
|
+
};
|
|
19
|
+
export const PET_DISPLAY_SIZE_MIN = 32;
|
|
20
|
+
export const PET_DISPLAY_SIZE_MAX = 1024;
|
|
21
|
+
export const PET_DISPLAY_INSET_MAX = 10_000;
|
|
22
|
+
export const PET_NAME_MAX_LENGTH = 20;
|
|
23
|
+
export function emptyPetPersist() {
|
|
24
|
+
return {
|
|
25
|
+
petPersistVersion: 2,
|
|
26
|
+
petId: DEFAULT_PET_ID,
|
|
27
|
+
names: {},
|
|
28
|
+
affinity: emptyPetAffinity(),
|
|
29
|
+
treats: emptyPetTreatLedger(),
|
|
30
|
+
display: { ...defaultPetDisplayConfig },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function finiteNum(value, fallback) {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
35
|
+
? value
|
|
36
|
+
: fallback;
|
|
37
|
+
}
|
|
38
|
+
function clamp(value, max) {
|
|
39
|
+
return Math.min(max, Math.max(0, value));
|
|
40
|
+
}
|
|
41
|
+
function loadPetNames(parsed) {
|
|
42
|
+
const names = {};
|
|
43
|
+
if (typeof parsed.names !== "object" || parsed.names === null)
|
|
44
|
+
return names;
|
|
45
|
+
for (const [id, value] of Object.entries(parsed.names)) {
|
|
46
|
+
if (id === "" || typeof value !== "string")
|
|
47
|
+
continue;
|
|
48
|
+
const name = value.trim();
|
|
49
|
+
if (name === "")
|
|
50
|
+
continue;
|
|
51
|
+
names[id] = name.slice(0, PET_NAME_MAX_LENGTH);
|
|
52
|
+
}
|
|
53
|
+
return names;
|
|
54
|
+
}
|
|
55
|
+
/** Load persisted state; missing or corrupt files fall back to defaults. */
|
|
56
|
+
export function loadPetPersist(dir) {
|
|
57
|
+
try {
|
|
58
|
+
const raw = readFileSync(join(dir, "pet.json"), "utf8");
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
const base = emptyPetPersist();
|
|
61
|
+
const rawAffinity = (parsed.affinity ?? {});
|
|
62
|
+
const affinity = {
|
|
63
|
+
points: clamp(finiteNum(rawAffinity.points, 0), PET_AFFINITY_MAX),
|
|
64
|
+
lastPetAt: clamp(finiteNum(rawAffinity.lastPetAt, 0), Number.MAX_SAFE_INTEGER),
|
|
65
|
+
lastFeedAt: clamp(finiteNum(rawAffinity.lastFeedAt, 0), Number.MAX_SAFE_INTEGER),
|
|
66
|
+
pets: clamp(finiteNum(rawAffinity.pets, 0), Number.MAX_SAFE_INTEGER),
|
|
67
|
+
feeds: clamp(finiteNum(rawAffinity.feeds, 0), Number.MAX_SAFE_INTEGER),
|
|
68
|
+
petRejects: clamp(finiteNum(rawAffinity.petRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
69
|
+
feedRejects: clamp(finiteNum(rawAffinity.feedRejects, 0), Number.MAX_SAFE_INTEGER),
|
|
70
|
+
turns: clamp(finiteNum(rawAffinity.turns, 0), Number.MAX_SAFE_INTEGER),
|
|
71
|
+
};
|
|
72
|
+
const rawTreats = (parsed.treats ?? {});
|
|
73
|
+
const treats = {
|
|
74
|
+
treats: clamp(finiteNum(rawTreats.treats, 0), defaultPetTreatConfig.maxTreats),
|
|
75
|
+
lastTreatGrantAt: clamp(finiteNum(rawTreats.lastTreatGrantAt, 0), Number.MAX_SAFE_INTEGER),
|
|
76
|
+
turnsAtLastTreatGrant: clamp(finiteNum(rawTreats.turnsAtLastTreatGrant, 0), Number.MAX_SAFE_INTEGER),
|
|
77
|
+
};
|
|
78
|
+
const rawDisplay = (parsed.display ?? {});
|
|
79
|
+
const display = {
|
|
80
|
+
visible: parsed.petPersistVersion === 2 && typeof rawDisplay.visible === "boolean"
|
|
81
|
+
? rawDisplay.visible
|
|
82
|
+
: base.display.visible,
|
|
83
|
+
size: Math.round(Math.min(PET_DISPLAY_SIZE_MAX, Math.max(PET_DISPLAY_SIZE_MIN, finiteNum(rawDisplay.size, base.display.size)))),
|
|
84
|
+
right: Math.round(clamp(finiteNum(rawDisplay.right, base.display.right), PET_DISPLAY_INSET_MAX)),
|
|
85
|
+
bottom: Math.round(clamp(finiteNum(rawDisplay.bottom, base.display.bottom), PET_DISPLAY_INSET_MAX)),
|
|
86
|
+
};
|
|
87
|
+
const petId = typeof parsed.petId === "string" && parsed.petId.trim() !== ""
|
|
88
|
+
? parsed.petId.trim()
|
|
89
|
+
: base.petId;
|
|
90
|
+
const names = loadPetNames(parsed);
|
|
91
|
+
if (typeof parsed.name === "string" &&
|
|
92
|
+
parsed.name.trim() !== "" &&
|
|
93
|
+
names[petId] === undefined) {
|
|
94
|
+
names[petId] = parsed.name.trim().slice(0, PET_NAME_MAX_LENGTH);
|
|
95
|
+
}
|
|
96
|
+
return { petPersistVersion: 2, petId, names, affinity, treats, display };
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return emptyPetPersist();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Atomically persist state (write temp + rename). */
|
|
103
|
+
export function savePetPersist(data, dir) {
|
|
104
|
+
mkdirSync(dir, { recursive: true });
|
|
105
|
+
const target = join(dir, "pet.json");
|
|
106
|
+
const tmp = `${target}.tmp`;
|
|
107
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
|
108
|
+
renameSync(tmp, target);
|
|
109
|
+
}
|