alp-code 0.9.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 +770 -0
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/alp.config.yaml +5 -0
- package/dist/src/agents/agent-definition.js +28 -0
- package/dist/src/agents/capability-catalog.js +33 -0
- package/dist/src/agents/compaction.js +36 -0
- package/dist/src/agents/errors.js +12 -0
- package/dist/src/agents/librarian.js +38 -0
- package/dist/src/agents/main.js +37 -0
- package/dist/src/agents/memory-grant.js +29 -0
- package/dist/src/agents/model-context.js +70 -0
- package/dist/src/agents/modes.js +134 -0
- package/dist/src/agents/oracle.js +36 -0
- package/dist/src/agents/read-thread.js +38 -0
- package/dist/src/agents/registry.js +238 -0
- package/dist/src/agents/render-identity.js +38 -0
- package/dist/src/agents/review.js +37 -0
- package/dist/src/agents/search.js +37 -0
- package/dist/src/agents/shared/house-rules.js +33 -0
- package/dist/src/agents/shared/principal.js +18 -0
- package/dist/src/agents/shared/voice.js +29 -0
- package/dist/src/agents/titling.js +32 -0
- package/dist/src/agents/types.js +15 -0
- package/dist/src/backend/execution-backend.js +2 -0
- package/dist/src/backend/local-execution-store.js +144 -0
- package/dist/src/backend/local-process-backend.js +533 -0
- package/dist/src/backend/local-supervisor.js +104 -0
- package/dist/src/cli/alp.js +380 -0
- package/dist/src/cli/commands/context.js +203 -0
- package/dist/src/cli/commands/delegate.js +136 -0
- package/dist/src/cli/commands/identity-sync.js +31 -0
- package/dist/src/cli/commands/init.js +184 -0
- package/dist/src/cli/commands/mode.js +22 -0
- package/dist/src/cli/commands/principal.js +114 -0
- package/dist/src/cli/commands/run-main.js +90 -0
- package/dist/src/cli/commands/runtime.js +21 -0
- package/dist/src/cli/mode-preference-store.js +62 -0
- package/dist/src/cli/mode-selector.js +178 -0
- package/dist/src/cli/update-check.js +77 -0
- package/dist/src/context/checkpoint.js +134 -0
- package/dist/src/context/compact-journal.js +153 -0
- package/dist/src/context/compact-payload.js +121 -0
- package/dist/src/context/continuity.js +70 -0
- package/dist/src/context/types.js +2 -0
- package/dist/src/delegation/backend-registry.js +40 -0
- package/dist/src/delegation/delegation-service.js +300 -0
- package/dist/src/delegation/types.js +12 -0
- package/dist/src/execution/execution-policy.js +96 -0
- package/dist/src/execution/execution-service.js +115 -0
- package/dist/src/execution/execution-store.js +78 -0
- package/dist/src/execution/identity-capsule.js +65 -0
- package/dist/src/execution/types.js +12 -0
- package/dist/src/hooks/execution-bridge.js +84 -0
- package/dist/src/index.js +4 -0
- package/dist/src/memory/adapters/markdown-file-store.js +257 -0
- package/dist/src/memory/adapters/memory-api-client.js +2 -0
- package/dist/src/memory/adapters/memory-path-mapper.js +76 -0
- package/dist/src/memory/adapters/remote-api-store.js +25 -0
- package/dist/src/memory/context-ranker.js +21 -0
- package/dist/src/memory/errors.js +58 -0
- package/dist/src/memory/memory-service.js +149 -0
- package/dist/src/memory/memory-store.js +2 -0
- package/dist/src/memory/types.js +2 -0
- package/dist/src/policy/capability-policy.js +29 -0
- package/dist/src/policy/delegation-policy.js +25 -0
- package/dist/src/policy/errors.js +10 -0
- package/dist/src/policy/invariants.js +31 -0
- package/dist/src/policy/memory-policy.js +22 -0
- package/dist/src/policy/policy-engine.js +85 -0
- package/dist/src/policy/types.js +8 -0
- package/dist/src/policy/workspace-policy.js +77 -0
- package/dist/src/principal/principal-profile-store.js +89 -0
- package/dist/src/runtime/adapter-files.js +147 -0
- package/dist/src/runtime/claude-adapter.js +177 -0
- package/dist/src/runtime/codex-adapter.js +169 -0
- package/dist/src/runtime/permission-rules.js +156 -0
- package/dist/src/runtime/render-session-context.js +124 -0
- package/dist/src/runtime/render-task-input.js +33 -0
- package/dist/src/runtime/runtime-adapter.js +2 -0
- package/dist/src/runtime/runtime-preference-store.js +66 -0
- package/dist/src/runtime/runtime-selector.js +178 -0
- package/dist/src/runtime/types.js +2 -0
- package/dist/src/runtime/windows-shim.js +57 -0
- package/dist/src/state-paths.js +49 -0
- package/dist/src/workflow/output-validator.js +27 -0
- package/dist/src/workflow/repair-policy.js +8 -0
- package/dist/src/workflow/types.js +22 -0
- package/dist/src/workflow/workflow-runner.js +81 -0
- package/hooks/compact-record.cjs +109 -0
- package/hooks/session-boot.cjs +112 -0
- package/hooks/session-end.cjs +34 -0
- package/package.json +48 -0
- package/scaffold/memory/INDEX.md +27 -0
- package/scaffold/memory/README.md +76 -0
- package/scaffold/memory/projects/INDEX.md +22 -0
- package/scaffold/memory/projects/PROTOCOL.md +128 -0
- package/scaffold/memory/projects/_template/PROJECT.md +45 -0
- package/scripts/alp.cjs +126 -0
- package/scripts/alp.ps1 +4 -0
- package/scripts/alp.sh +3 -0
- package/scripts/bootstrap.cjs +144 -0
- package/scripts/checkout-release.cjs +30 -0
- package/scripts/delegate.cjs +19 -0
- package/scripts/doctor.cjs +158 -0
- package/scripts/doctor.sh +3 -0
- package/scripts/ensure-state.cjs +22 -0
- package/scripts/lib/cli-link.cjs +375 -0
- package/scripts/lib/codex-role.cjs +18 -0
- package/scripts/lib/delegation/command-runner.cjs +108 -0
- package/scripts/lib/delegation/config.cjs +81 -0
- package/scripts/lib/install-paths.cjs +154 -0
- package/scripts/lib/release-manifest.cjs +42 -0
- package/scripts/lib/semver-lite.cjs +20 -0
- package/scripts/lib/state.cjs +274 -0
- package/scripts/lib/uninstall.cjs +252 -0
- package/scripts/lib/update-check-worker.cjs +21 -0
- package/scripts/lib/update.cjs +395 -0
- package/scripts/run-role.cjs +42 -0
- package/scripts/run-role.ps1 +4 -0
- package/scripts/run-role.sh +3 -0
- package/scripts/sync-project-index.sh +167 -0
- package/skills/agent-memory/SKILL.md +109 -0
- package/skills/alp-debug/SKILL.md +90 -0
- package/skills/alp-debug/references/defense-in-depth.md +118 -0
- package/skills/alp-debug/references/investigation-methodology.md +106 -0
- package/skills/alp-debug/references/log-and-ci-analysis.md +96 -0
- package/skills/alp-debug/references/performance-diagnostics.md +112 -0
- package/skills/alp-debug/references/reporting-standards.md +120 -0
- package/skills/alp-debug/references/root-cause-tracing.md +134 -0
- package/skills/alp-debug/references/systematic-debugging.md +93 -0
- package/skills/alp-debug/references/verification.md +86 -0
- package/skills/alp-debug/scripts/find-polluter.sh +63 -0
- package/skills/alp-debug/scripts/find-polluter.test.md +102 -0
- package/skills/alp-plan/SKILL.md +128 -0
- package/skills/alp-plan/references/archive-workflow.md +77 -0
- package/skills/alp-plan/references/codebase-understanding.md +55 -0
- package/skills/alp-plan/references/output-standards.md +96 -0
- package/skills/alp-plan/references/plan-organization.md +129 -0
- package/skills/alp-plan/references/red-team-personas.md +76 -0
- package/skills/alp-plan/references/red-team-workflow.md +81 -0
- package/skills/alp-plan/references/research-phase.md +57 -0
- package/skills/alp-plan/references/scope-challenge.md +82 -0
- package/skills/alp-plan/references/solution-design.md +76 -0
- package/skills/alp-plan/references/validate-question-framework.md +89 -0
- package/skills/alp-plan/references/validate-workflow.md +83 -0
- package/skills/alp-predict/SKILL.md +98 -0
- package/skills/alp-scenario/SKILL.md +86 -0
- package/skills/code-review/SKILL.md +111 -0
- package/skills/code-review/references/code-review-reception.md +114 -0
- package/skills/code-review/references/edge-case-scouting.md +78 -0
- package/skills/code-review/references/verification-before-completion.md +117 -0
- package/skills/delegation/SKILL.md +46 -0
- package/skills/docs-seeker/.env.example +15 -0
- package/skills/docs-seeker/SKILL.md +87 -0
- package/skills/docs-seeker/package.json +25 -0
- package/skills/docs-seeker/references/advanced.md +82 -0
- package/skills/docs-seeker/references/context7-patterns.md +68 -0
- package/skills/docs-seeker/references/errors.md +72 -0
- package/skills/docs-seeker/scripts/analyze-llms-txt.js +211 -0
- package/skills/docs-seeker/scripts/detect-topic.js +172 -0
- package/skills/docs-seeker/scripts/fetch-docs.js +213 -0
- package/skills/docs-seeker/scripts/tests/run-tests.js +72 -0
- package/skills/docs-seeker/scripts/tests/test-analyze-llms.js +119 -0
- package/skills/docs-seeker/scripts/tests/test-detect-topic.js +112 -0
- package/skills/docs-seeker/scripts/tests/test-fetch-docs.js +84 -0
- package/skills/docs-seeker/scripts/utils/env-loader.js +94 -0
- package/skills/docs-seeker/workflows/library-search.md +73 -0
- package/skills/docs-seeker/workflows/repo-analysis.md +90 -0
- package/skills/docs-seeker/workflows/topic-search.md +69 -0
- package/skills/git/SKILL.md +121 -0
- package/skills/git/references/branch-management.md +90 -0
- package/skills/git/references/commit-standards.md +82 -0
- package/skills/git/references/gh-cli-guide.md +132 -0
- package/skills/git/references/safety-protocols.md +86 -0
- package/skills/git/references/workflow-commit.md +89 -0
- package/skills/git/references/workflow-merge.md +63 -0
- package/skills/git/references/workflow-pr.md +70 -0
- package/skills/git/references/workflow-push.md +62 -0
- package/skills/gkg/SKILL.md +87 -0
- package/skills/gkg/references/cli-commands.md +92 -0
- package/skills/gkg/references/http-api.md +99 -0
- package/skills/gkg/references/language-support.md +54 -0
- package/skills/problem-solving/SKILL.md +86 -0
- package/skills/problem-solving/references/attribution.md +48 -0
- package/skills/problem-solving/references/collision-zone-thinking.md +71 -0
- package/skills/problem-solving/references/inversion-exercise.md +88 -0
- package/skills/problem-solving/references/meta-pattern-recognition.md +80 -0
- package/skills/problem-solving/references/scale-game.md +82 -0
- package/skills/problem-solving/references/simplification-cascades.md +83 -0
- package/skills/problem-solving/references/when-stuck.md +76 -0
- package/skills/repomix/SKILL.md +94 -0
- package/skills/repomix/references/configuration.md +134 -0
- package/skills/repomix/references/usage-patterns.md +106 -0
- package/skills/repomix/scripts/.coverage +0 -0
- package/skills/repomix/scripts/README.md +179 -0
- package/skills/repomix/scripts/repomix_batch.py +455 -0
- package/skills/repomix/scripts/repos.example.json +15 -0
- package/skills/repomix/scripts/requirements.txt +15 -0
- package/skills/repomix/scripts/tests/test_repomix_batch.py +531 -0
- package/skills/research/SKILL.md +107 -0
- package/skills/security-scan/SKILL.md +101 -0
- package/skills/security-scan/references/secret-patterns.md +75 -0
- package/skills/security-scan/references/vulnerability-patterns.md +136 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ModeSelector = void 0;
|
|
4
|
+
const node_stream_1 = require("node:stream");
|
|
5
|
+
const node_readline_1 = require("node:readline");
|
|
6
|
+
const modes_1 = require("../agents/modes");
|
|
7
|
+
const mode_preference_store_1 = require("./mode-preference-store");
|
|
8
|
+
/**
|
|
9
|
+
* Menu chọn **nấc**, không phải chọn runtime.
|
|
10
|
+
*
|
|
11
|
+
* Từ khi mỗi vai ở mỗi nấc chỉ có một model, model quyết định CLI — nên câu hỏi lúc mở phiên
|
|
12
|
+
* không còn là "chạy Claude hay Codex" mà là "việc này khó cỡ nào". Menu vẫn giữ nguyên
|
|
13
|
+
* khuôn cũ: nhớ lựa chọn lần trước, hỏi một lần trên TTY, Ctrl+C là huỷ chứ không phải chọn
|
|
14
|
+
* bừa.
|
|
15
|
+
*/
|
|
16
|
+
const MODES = modes_1.MODE_IDS;
|
|
17
|
+
function normalizeKeypress(sequence, key = {}) {
|
|
18
|
+
if (key.ctrl && key.name === "c")
|
|
19
|
+
return "cancel";
|
|
20
|
+
if (key.name === "up")
|
|
21
|
+
return "up";
|
|
22
|
+
if (key.name === "down")
|
|
23
|
+
return "down";
|
|
24
|
+
if (key.name === "return" ||
|
|
25
|
+
key.name === "enter" ||
|
|
26
|
+
sequence === "\r" ||
|
|
27
|
+
sequence === "\n") {
|
|
28
|
+
return "enter";
|
|
29
|
+
}
|
|
30
|
+
return "other";
|
|
31
|
+
}
|
|
32
|
+
function createKeypressReader(input) {
|
|
33
|
+
const decoder = new node_stream_1.PassThrough();
|
|
34
|
+
const queued = [];
|
|
35
|
+
const waiting = [];
|
|
36
|
+
let terminalError = null;
|
|
37
|
+
const onKeypress = (sequence, key) => {
|
|
38
|
+
const value = normalizeKeypress(sequence, key);
|
|
39
|
+
const waiter = waiting.shift();
|
|
40
|
+
if (waiter)
|
|
41
|
+
waiter.resolve(value);
|
|
42
|
+
else
|
|
43
|
+
queued.push(value);
|
|
44
|
+
};
|
|
45
|
+
const onError = (error) => {
|
|
46
|
+
terminalError = error;
|
|
47
|
+
for (const waiter of waiting.splice(0))
|
|
48
|
+
waiter.reject(error);
|
|
49
|
+
};
|
|
50
|
+
const onData = (chunk) => {
|
|
51
|
+
decoder.write(chunk);
|
|
52
|
+
};
|
|
53
|
+
(0, node_readline_1.emitKeypressEvents)(decoder);
|
|
54
|
+
decoder.on("keypress", onKeypress);
|
|
55
|
+
input.on("data", onData);
|
|
56
|
+
input.on("error", onError);
|
|
57
|
+
return {
|
|
58
|
+
read() {
|
|
59
|
+
const queuedKey = queued.shift();
|
|
60
|
+
if (queuedKey !== undefined)
|
|
61
|
+
return Promise.resolve(queuedKey);
|
|
62
|
+
if (terminalError !== null)
|
|
63
|
+
return Promise.reject(terminalError);
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
waiting.push({ resolve, reject });
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
close() {
|
|
69
|
+
input.removeListener("data", onData);
|
|
70
|
+
input.removeListener("error", onError);
|
|
71
|
+
decoder.removeListener("keypress", onKeypress);
|
|
72
|
+
decoder.destroy();
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function renderMenu(output, current, selectedIndex, redraw) {
|
|
77
|
+
if (redraw)
|
|
78
|
+
output.write(`\u001b[${MODES.length + 1}A`);
|
|
79
|
+
for (const [index, mode] of MODES.entries()) {
|
|
80
|
+
const pointer = index === selectedIndex ? "❯" : " ";
|
|
81
|
+
const persisted = mode === current ? " (current)" : "";
|
|
82
|
+
output.write(`\r\u001b[2K ${pointer} ${mode.padEnd(6)}${persisted} — ${modes_1.MODE_PROFILES[mode].summary}\n`);
|
|
83
|
+
}
|
|
84
|
+
output.write("\r\u001b[2K↑/↓ select · Enter confirm · Ctrl+C cancel\n");
|
|
85
|
+
}
|
|
86
|
+
class ModeSelector {
|
|
87
|
+
preferenceStore;
|
|
88
|
+
input;
|
|
89
|
+
output;
|
|
90
|
+
injectedReadKey;
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.preferenceStore = options.preferenceStore ?? new mode_preference_store_1.FileModePreferenceStore();
|
|
93
|
+
this.input = options.input ?? process.stdin;
|
|
94
|
+
this.output = options.output ?? process.stdout;
|
|
95
|
+
this.injectedReadKey = options.readKey;
|
|
96
|
+
}
|
|
97
|
+
async select(input) {
|
|
98
|
+
if (input.requestedMode !== undefined) {
|
|
99
|
+
if (!MODES.includes(input.requestedMode)) {
|
|
100
|
+
throw new Error(`invalid mode \`${String(input.requestedMode)}\``);
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, mode: input.requestedMode, source: "explicit" };
|
|
103
|
+
}
|
|
104
|
+
const preference = await this.preferenceStore.read();
|
|
105
|
+
if (preference.warning) {
|
|
106
|
+
this.output.write(`WARNING ${preference.warning}\n`);
|
|
107
|
+
}
|
|
108
|
+
const current = preference.mode ?? modes_1.DEFAULT_MODE;
|
|
109
|
+
if (!input.interactive) {
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
mode: current,
|
|
113
|
+
source: preference.mode === null ? "default" : "persisted",
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const selected = await this.prompt(current);
|
|
117
|
+
if (selected === null)
|
|
118
|
+
return { ok: false, exitCode: 130 };
|
|
119
|
+
await this.preferenceStore.write(selected);
|
|
120
|
+
return { ok: true, mode: selected, source: "interactive" };
|
|
121
|
+
}
|
|
122
|
+
async prompt(current) {
|
|
123
|
+
if (!this.injectedReadKey && !this.input) {
|
|
124
|
+
throw new Error("interactive mode selection requires terminal input");
|
|
125
|
+
}
|
|
126
|
+
const wasFlowing = this.input?.readableFlowing === true;
|
|
127
|
+
const wasRaw = Boolean(this.input?.isRaw);
|
|
128
|
+
const keypress = this.injectedReadKey || !this.input
|
|
129
|
+
? null
|
|
130
|
+
: createKeypressReader(this.input);
|
|
131
|
+
const readKey = this.injectedReadKey ?? keypress.read;
|
|
132
|
+
let changedRawMode = false;
|
|
133
|
+
let selectedIndex = Math.max(0, MODES.indexOf(current));
|
|
134
|
+
this.output.write("\nSelect mode for this ALP session:\n");
|
|
135
|
+
this.output.write("\u001b[?25l");
|
|
136
|
+
try {
|
|
137
|
+
if (keypress &&
|
|
138
|
+
!wasRaw &&
|
|
139
|
+
typeof this.input?.setRawMode === "function") {
|
|
140
|
+
this.input.setRawMode(true);
|
|
141
|
+
changedRawMode = true;
|
|
142
|
+
}
|
|
143
|
+
if (keypress && typeof this.input?.resume === "function") {
|
|
144
|
+
this.input.resume();
|
|
145
|
+
}
|
|
146
|
+
renderMenu(this.output, current, selectedIndex, false);
|
|
147
|
+
for (;;) {
|
|
148
|
+
const key = await readKey();
|
|
149
|
+
if (key === "up") {
|
|
150
|
+
selectedIndex = (selectedIndex - 1 + MODES.length) % MODES.length;
|
|
151
|
+
renderMenu(this.output, current, selectedIndex, true);
|
|
152
|
+
}
|
|
153
|
+
else if (key === "down") {
|
|
154
|
+
selectedIndex = (selectedIndex + 1) % MODES.length;
|
|
155
|
+
renderMenu(this.output, current, selectedIndex, true);
|
|
156
|
+
}
|
|
157
|
+
else if (key === "enter") {
|
|
158
|
+
return MODES[selectedIndex];
|
|
159
|
+
}
|
|
160
|
+
else if (key === "cancel") {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
keypress?.close();
|
|
167
|
+
if (keypress &&
|
|
168
|
+
!wasFlowing &&
|
|
169
|
+
typeof this.input?.pause === "function") {
|
|
170
|
+
this.input.pause();
|
|
171
|
+
}
|
|
172
|
+
if (changedRawMode)
|
|
173
|
+
this.input?.setRawMode?.(false);
|
|
174
|
+
this.output.write("\u001b[?25h");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
exports.ModeSelector = ModeSelector;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FileUpdateCheckStore = void 0;
|
|
4
|
+
exports.spawnBackgroundUpdateCheck = spawnBackgroundUpdateCheck;
|
|
5
|
+
exports.checkForUpdate = checkForUpdate;
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const node_module_1 = require("node:module");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const promises_1 = require("node:fs/promises");
|
|
11
|
+
const state_paths_1 = require("../state-paths");
|
|
12
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
class FileUpdateCheckStore {
|
|
14
|
+
file;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
this.file = options.file ?? (0, node_path_1.join)((0, state_paths_1.stateHome)(), "update-check.json");
|
|
17
|
+
}
|
|
18
|
+
async read() {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(await (0, promises_1.readFile)(this.file, "utf8"));
|
|
21
|
+
return typeof parsed?.checkedAt === "number"
|
|
22
|
+
? { checkedAt: parsed.checkedAt, latestTag: typeof parsed.latestTag === "string" ? parsed.latestTag : null }
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async write(cache) {
|
|
30
|
+
const dir = (0, node_path_1.dirname)(this.file);
|
|
31
|
+
await (0, promises_1.mkdir)(dir, { recursive: true, mode: 0o700 });
|
|
32
|
+
const tmp = (0, node_path_1.join)(dir, `.${(0, node_crypto_1.randomUUID)()}.update-check.tmp`);
|
|
33
|
+
try {
|
|
34
|
+
await (0, promises_1.writeFile)(tmp, `${JSON.stringify(cache)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
35
|
+
await (0, promises_1.rename)(tmp, this.file);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
await (0, promises_1.rm)(tmp, { force: true });
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
exports.FileUpdateCheckStore = FileUpdateCheckStore;
|
|
44
|
+
function spawnBackgroundUpdateCheck(repoRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const child = (0, node_child_process_1.spawn)(process.execPath, [(0, node_path_1.join)(repoRoot, "scripts", "lib", "update-check-worker.cjs")], {
|
|
47
|
+
cwd: repoRoot,
|
|
48
|
+
detached: true,
|
|
49
|
+
stdio: "ignore",
|
|
50
|
+
env: process.env,
|
|
51
|
+
});
|
|
52
|
+
child.unref();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* best-effort; never affect the current command */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function isNewer(repoRoot, latestTag, currentVersion) {
|
|
59
|
+
const semver = (0, node_module_1.createRequire)(__filename)((0, node_path_1.join)(repoRoot, "scripts", "lib", "semver-lite.cjs"));
|
|
60
|
+
try {
|
|
61
|
+
return semver.compare(latestTag, currentVersion) > 0;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function checkForUpdate(input) {
|
|
68
|
+
const now = input.now ?? Date.now();
|
|
69
|
+
const cached = await input.store.read();
|
|
70
|
+
if (!cached || now - cached.checkedAt >= TTL_MS)
|
|
71
|
+
(input.triggerBackgroundRefresh ?? spawnBackgroundUpdateCheck)(input.repoRoot);
|
|
72
|
+
if (!cached?.latestTag)
|
|
73
|
+
return null;
|
|
74
|
+
if (!isNewer(input.repoRoot, cached.latestTag, input.currentVersion))
|
|
75
|
+
return null;
|
|
76
|
+
return `UPDATE phiên bản mới ${cached.latestTag} hiện có (đang dùng v${input.currentVersion}) — chạy \`alp update\`\n`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CHECKPOINT_MAX_BYTES = exports.PIN_MAX_BYTES = void 0;
|
|
4
|
+
exports.checkpointDigest = checkpointDigest;
|
|
5
|
+
exports.seedCheckpoint = seedCheckpoint;
|
|
6
|
+
exports.readCheckpoint = readCheckpoint;
|
|
7
|
+
exports.writeCheckpoint = writeCheckpoint;
|
|
8
|
+
const node_crypto_1 = require("node:crypto");
|
|
9
|
+
const promises_1 = require("node:fs/promises");
|
|
10
|
+
const zod_1 = require("zod");
|
|
11
|
+
const adapter_files_1 = require("../runtime/adapter-files");
|
|
12
|
+
/** §6 Size limits — one pin, and the checkpoint as a whole, in UTF-8 bytes. */
|
|
13
|
+
exports.PIN_MAX_BYTES = 4 * 1024;
|
|
14
|
+
exports.CHECKPOINT_MAX_BYTES = 128 * 1024;
|
|
15
|
+
const EXECUTION_ID_PATTERN = /^exec_[a-zA-Z0-9_-]+$/;
|
|
16
|
+
function byteLength(value) {
|
|
17
|
+
return Buffer.byteLength(value, "utf8");
|
|
18
|
+
}
|
|
19
|
+
const continuityPinSchema = zod_1.z.object({
|
|
20
|
+
id: zod_1.z.string().min(1),
|
|
21
|
+
text: zod_1.z.string().min(1).refine((value) => byteLength(value) <= exports.PIN_MAX_BYTES, {
|
|
22
|
+
message: `pin text exceeds ${exports.PIN_MAX_BYTES} bytes`,
|
|
23
|
+
}),
|
|
24
|
+
source: zod_1.z.enum(["execution", "principal", "agent"]),
|
|
25
|
+
createdAt: zod_1.z.string().min(1),
|
|
26
|
+
});
|
|
27
|
+
const checkpointSchema = zod_1.z.object({
|
|
28
|
+
version: zod_1.z.literal(1),
|
|
29
|
+
executionId: zod_1.z.string().regex(EXECUTION_ID_PATTERN, "invalid execution ID"),
|
|
30
|
+
policyHash: zod_1.z.string().min(1),
|
|
31
|
+
runtime: zod_1.z.enum(["claude", "codex"]).nullable(),
|
|
32
|
+
createdAt: zod_1.z.string().min(1),
|
|
33
|
+
updatedAt: zod_1.z.string().min(1),
|
|
34
|
+
objective: zod_1.z.string().nullable(),
|
|
35
|
+
decisions: zod_1.z.array(continuityPinSchema),
|
|
36
|
+
constraints: zod_1.z.array(continuityPinSchema),
|
|
37
|
+
openItems: zod_1.z.array(continuityPinSchema),
|
|
38
|
+
nextActions: zod_1.z.array(continuityPinSchema),
|
|
39
|
+
integrity: zod_1.z.object({ checkpointSha256: zod_1.z.string().length(64) }),
|
|
40
|
+
}).superRefine((value, ctx) => {
|
|
41
|
+
if (byteLength(JSON.stringify(value)) > exports.CHECKPOINT_MAX_BYTES) {
|
|
42
|
+
ctx.addIssue({ code: "custom", message: `checkpoint exceeds ${exports.CHECKPOINT_MAX_BYTES} bytes` });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Sort object keys recursively so two checkpoints with the same logical content hash the
|
|
47
|
+
* same regardless of the key order they happen to be written or read in. Arrays keep their
|
|
48
|
+
* order — pins are a sequence, not a set.
|
|
49
|
+
*/
|
|
50
|
+
function sortForDigest(value) {
|
|
51
|
+
if (Array.isArray(value))
|
|
52
|
+
return value.map(sortForDigest);
|
|
53
|
+
if (value !== null && typeof value === "object") {
|
|
54
|
+
const sorted = {};
|
|
55
|
+
for (const key of Object.keys(value).sort()) {
|
|
56
|
+
sorted[key] = sortForDigest(value[key]);
|
|
57
|
+
}
|
|
58
|
+
return sorted;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/** SHA-256 over the checkpoint's canonical JSON, excluding `integrity` itself. */
|
|
63
|
+
function checkpointDigest(checkpoint) {
|
|
64
|
+
return (0, node_crypto_1.createHash)("sha256").update(JSON.stringify(sortForDigest(checkpoint))).digest("hex");
|
|
65
|
+
}
|
|
66
|
+
/** The checkpoint an execution starts with: no pins, objective seeded from the task. */
|
|
67
|
+
function seedCheckpoint(input) {
|
|
68
|
+
const timestamp = (input.now ?? (() => new Date().toISOString()))();
|
|
69
|
+
const body = {
|
|
70
|
+
version: 1,
|
|
71
|
+
executionId: input.executionId,
|
|
72
|
+
policyHash: input.policyHash,
|
|
73
|
+
runtime: null,
|
|
74
|
+
createdAt: timestamp,
|
|
75
|
+
updatedAt: timestamp,
|
|
76
|
+
objective: input.objective,
|
|
77
|
+
decisions: [],
|
|
78
|
+
constraints: [],
|
|
79
|
+
openItems: [],
|
|
80
|
+
nextActions: [],
|
|
81
|
+
};
|
|
82
|
+
return { ...body, integrity: { checkpointSha256: checkpointDigest(body) } };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Fail closed (invariant 6): a checkpoint that doesn't parse, doesn't hash, or is bound to a
|
|
86
|
+
* different execution or policy is never returned as usable — the caller gets a reason
|
|
87
|
+
* instead, and decides on its own whether that means "inject nothing" or "abort".
|
|
88
|
+
*/
|
|
89
|
+
async function readCheckpoint(path, binding) {
|
|
90
|
+
let raw;
|
|
91
|
+
try {
|
|
92
|
+
raw = await (0, promises_1.readFile)(path, "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return { ok: false, reason: `cannot read checkpoint: ${error.message}` };
|
|
96
|
+
}
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { ok: false, reason: "checkpoint is not valid JSON" };
|
|
103
|
+
}
|
|
104
|
+
const result = checkpointSchema.safeParse(parsed);
|
|
105
|
+
if (!result.success) {
|
|
106
|
+
return { ok: false, reason: `checkpoint failed schema validation: ${result.error.issues[0]?.message ?? "invalid"}` };
|
|
107
|
+
}
|
|
108
|
+
const value = result.data;
|
|
109
|
+
const { integrity, ...rest } = value;
|
|
110
|
+
if (checkpointDigest(rest) !== integrity.checkpointSha256) {
|
|
111
|
+
return { ok: false, reason: "checkpoint digest mismatch" };
|
|
112
|
+
}
|
|
113
|
+
if (value.executionId !== binding.executionId || value.policyHash !== binding.policyHash) {
|
|
114
|
+
return { ok: false, reason: "checkpoint policy binding mismatch" };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, value };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Validates before touching the filesystem at all, so a rejected write (oversize pin,
|
|
120
|
+
* oversize checkpoint) never runs `atomicRuntimeFile` — the file on disk, if any, is left
|
|
121
|
+
* exactly as it was. A write that does reach `atomicRuntimeFile` is itself temp-file-then-
|
|
122
|
+
* rename, so a process killed mid-write leaves the prior file intact too.
|
|
123
|
+
*/
|
|
124
|
+
async function writeCheckpoint(path, checkpoint) {
|
|
125
|
+
// Callers often pass a full `ContinuityCheckpointV1` they already have in hand (e.g. a
|
|
126
|
+
// seeded or previously-read one) — TS only checks excess properties on literals, so a
|
|
127
|
+
// stale `integrity` can arrive here at runtime despite the `Omit` type. Strip it before
|
|
128
|
+
// hashing rather than hash whatever happened to be attached.
|
|
129
|
+
const { integrity: _stale, ...body } = checkpoint;
|
|
130
|
+
const value = { ...body, integrity: { checkpointSha256: checkpointDigest(body) } };
|
|
131
|
+
const parsed = checkpointSchema.parse(value);
|
|
132
|
+
await (0, adapter_files_1.atomicRuntimeFile)(path, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.JOURNAL_ROTATE_BYTES = exports.JOURNAL_LINE_MAX_BYTES = void 0;
|
|
4
|
+
exports.appendCompactJournalLine = appendCompactJournalLine;
|
|
5
|
+
exports.rotateCompactJournalIfNeeded = rotateCompactJournalIfNeeded;
|
|
6
|
+
exports.replayCompactJournal = replayCompactJournal;
|
|
7
|
+
exports.reduceCompactJournal = reduceCompactJournal;
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const zod_1 = require("zod");
|
|
10
|
+
const compact_payload_1 = require("./compact-payload");
|
|
11
|
+
const EXECUTION_ID_PATTERN = /^exec_[a-zA-Z0-9_-]+$/;
|
|
12
|
+
/** §6 size limits. */
|
|
13
|
+
exports.JOURNAL_LINE_MAX_BYTES = 16 * 1024;
|
|
14
|
+
exports.JOURNAL_ROTATE_BYTES = 1024 * 1024;
|
|
15
|
+
const envelopeSchema = zod_1.z.object({
|
|
16
|
+
v: zod_1.z.literal(1),
|
|
17
|
+
at: zod_1.z.string().min(1),
|
|
18
|
+
executionId: zod_1.z.string().regex(EXECUTION_ID_PATTERN, "invalid execution ID"),
|
|
19
|
+
policyHash: zod_1.z.string().min(1),
|
|
20
|
+
runtime: zod_1.z.enum(["claude", "codex"]),
|
|
21
|
+
phase: zod_1.z.enum(["pre", "post"]),
|
|
22
|
+
source: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
23
|
+
});
|
|
24
|
+
function byteLength(value) {
|
|
25
|
+
return Buffer.byteLength(value, "utf8");
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The TS append path used by the CLI and by tests. The hook itself (`compact-record.cjs`)
|
|
29
|
+
* is zero-dependency and appends with a raw `appendFileSync` — this reimplements the same
|
|
30
|
+
* one-syscall, `O_APPEND` shape so both writers produce lines the other can replay, without
|
|
31
|
+
* pulling the hook into the dependency graph this module already has (invariant 4).
|
|
32
|
+
*/
|
|
33
|
+
async function appendCompactJournalLine(path, envelope) {
|
|
34
|
+
const line = `${JSON.stringify(envelope)}\n`;
|
|
35
|
+
if (byteLength(line) > exports.JOURNAL_LINE_MAX_BYTES) {
|
|
36
|
+
throw new Error(`journal line exceeds ${exports.JOURNAL_LINE_MAX_BYTES} bytes`);
|
|
37
|
+
}
|
|
38
|
+
await (0, promises_1.appendFile)(path, line, { encoding: "utf8", mode: 0o600, flag: "a" });
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Rotates `path` to `path.1` (overwriting any prior `.1`) when it has reached
|
|
42
|
+
* `JOURNAL_ROTATE_BYTES`. Called from `alp context status|validate`, never from the hook —
|
|
43
|
+
* the hook only ever appends (invariant 4).
|
|
44
|
+
*/
|
|
45
|
+
async function rotateCompactJournalIfNeeded(path) {
|
|
46
|
+
let size;
|
|
47
|
+
try {
|
|
48
|
+
size = (await (0, promises_1.stat)(path)).size;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (size < exports.JOURNAL_ROTATE_BYTES)
|
|
54
|
+
return false;
|
|
55
|
+
await (0, promises_1.rename)(path, `${path}.1`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
async function readIfExists(path) {
|
|
59
|
+
try {
|
|
60
|
+
return await (0, promises_1.readFile)(path, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reads `path.1` (if rotation has happened) then `path`, in that order, and turns every
|
|
68
|
+
* well-formed line into a `CompactEventV1`. A line that is not valid JSON, or does not match
|
|
69
|
+
* the envelope schema, is dropped and counted rather than failing the whole replay — a
|
|
70
|
+
* single corrupted append (e.g. a process killed mid-write) must not make the rest of the
|
|
71
|
+
* journal unreadable.
|
|
72
|
+
*/
|
|
73
|
+
async function replayCompactJournal(path) {
|
|
74
|
+
const rotated = await readIfExists(`${path}.1`);
|
|
75
|
+
const current = await readIfExists(path);
|
|
76
|
+
const lines = [rotated ?? "", current ?? ""]
|
|
77
|
+
.join("\n")
|
|
78
|
+
.split("\n")
|
|
79
|
+
.map((line) => line.trim())
|
|
80
|
+
.filter((line) => line.length > 0);
|
|
81
|
+
const events = [];
|
|
82
|
+
let droppedLines = 0;
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
let parsed;
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(line);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
droppedLines += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const result = envelopeSchema.safeParse(parsed);
|
|
93
|
+
if (!result.success) {
|
|
94
|
+
droppedLines += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const envelope = result.data;
|
|
98
|
+
const normalized = (0, compact_payload_1.normalizeCompactEvent)({
|
|
99
|
+
runtime: envelope.runtime,
|
|
100
|
+
phase: envelope.phase,
|
|
101
|
+
// `normalizeCompactEvent` treats this as unknown input and filters it against its own
|
|
102
|
+
// per-runtime whitelist; the envelope schema only guarantees an object, not that its
|
|
103
|
+
// values are already strings.
|
|
104
|
+
source: envelope.source,
|
|
105
|
+
observedAt: envelope.at,
|
|
106
|
+
});
|
|
107
|
+
if (!normalized.ok) {
|
|
108
|
+
droppedLines += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
events.push(normalized.value);
|
|
112
|
+
}
|
|
113
|
+
return { events: Object.freeze(events), droppedLines };
|
|
114
|
+
}
|
|
115
|
+
/** The pairing key a `started`/`completed` pair share — `dedupeKey` without its phase suffix. */
|
|
116
|
+
function pairKeyOf(event) {
|
|
117
|
+
return event.dedupeKey.slice(0, event.dedupeKey.lastIndexOf("|"));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Folds a replayed event list into the state `alp context status` reports. Duplicate lines
|
|
121
|
+
* (same `dedupeKey`, e.g. a hook retried by the runtime) are collapsed before anything else,
|
|
122
|
+
* so `generation` counts distinct completions rather than journal lines.
|
|
123
|
+
*
|
|
124
|
+
* `pending` tracks only the single most recent `started` — a later `started` always
|
|
125
|
+
* supersedes an earlier, still-unmatched one (the orphaned-`PreCompact` case measured on
|
|
126
|
+
* Claude 2026-09-04), and a `completed` only clears it when the pairing key actually
|
|
127
|
+
* matches: a late completion for an older event must not clear a newer pending one.
|
|
128
|
+
*/
|
|
129
|
+
function reduceCompactJournal(events) {
|
|
130
|
+
const seenDedupeKeys = new Set();
|
|
131
|
+
let generation = 0;
|
|
132
|
+
let lastCompleted = null;
|
|
133
|
+
let pending = null;
|
|
134
|
+
let pendingPairKey = null;
|
|
135
|
+
for (const event of events) {
|
|
136
|
+
if (seenDedupeKeys.has(event.dedupeKey))
|
|
137
|
+
continue;
|
|
138
|
+
seenDedupeKeys.add(event.dedupeKey);
|
|
139
|
+
const pairKey = pairKeyOf(event);
|
|
140
|
+
if (event.phase === "started") {
|
|
141
|
+
pending = event;
|
|
142
|
+
pendingPairKey = pairKey;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
generation += 1;
|
|
146
|
+
lastCompleted = event;
|
|
147
|
+
if (pairKey === pendingPairKey) {
|
|
148
|
+
pending = null;
|
|
149
|
+
pendingPairKey = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { generation, pending, lastCompleted };
|
|
153
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.filterCompactSource = filterCompactSource;
|
|
4
|
+
exports.normalizeCompactEvent = normalizeCompactEvent;
|
|
5
|
+
/**
|
|
6
|
+
* Per-runtime whitelist of hook payload keys a journal line may carry.
|
|
7
|
+
*
|
|
8
|
+
* A whitelist, not a blacklist, because a payload is a floor and not a ceiling: the schemas
|
|
9
|
+
* compiled into both binaries under-declare what they actually send. A live Claude
|
|
10
|
+
* `SessionStart` on 2026-09-03 carried `seconds_since_last_response`, `context_tokens`,
|
|
11
|
+
* `prompt_cache_likely_expired` and `estimated_cache_write_usd` that no schema mentions, and
|
|
12
|
+
* carried a different set per `source`. A blacklist would have leaked every one of them into
|
|
13
|
+
* a file ALP keeps.
|
|
14
|
+
*
|
|
15
|
+
* What is deliberately absent, and why:
|
|
16
|
+
* - `compact_summary` (Claude, 22-32 KB measured) — invariant 8. It is provider history the
|
|
17
|
+
* runtime already holds, and one line of it would blow the 16 KiB journal line limit.
|
|
18
|
+
* - `custom_instructions` (Claude `PreCompact`) — free text from the user's own prompt.
|
|
19
|
+
* - `transcript_path`, `cwd` — paths ALP already knows and does not need duplicated per line.
|
|
20
|
+
* - `hook_event_name` — the phase comes from the hook's argv, which is the value ALP
|
|
21
|
+
* controls; trusting the payload's own name for it would let the payload pick its bucket.
|
|
22
|
+
* - `permission_mode` — policy state, owned by ALP, not something to re-learn from a hook.
|
|
23
|
+
*/
|
|
24
|
+
const SOURCE_WHITELIST = Object.freeze({
|
|
25
|
+
claude: Object.freeze(["session_id", "trigger", "model", "prompt_id", "agent_id", "agent_type"]),
|
|
26
|
+
codex: Object.freeze(["session_id", "trigger", "model", "turn_id", "agent_id", "agent_type"]),
|
|
27
|
+
});
|
|
28
|
+
/** Per §6 size limits. A whitelisted key with an over-long value is truncated, not dropped. */
|
|
29
|
+
const MAX_VALUE_LENGTH = 256;
|
|
30
|
+
/**
|
|
31
|
+
* Which key carries the runtime's own id for the event.
|
|
32
|
+
*
|
|
33
|
+
* Measured 2026-09-03/04: Claude's `prompt_id` is the same value across `PreCompact`,
|
|
34
|
+
* `SessionStart(source="compact")` and `PostCompact` of one compaction. Codex's `turn_id`
|
|
35
|
+
* behaves differently by launch mode — one id per compaction in an inherited TTY, but a
|
|
36
|
+
* single root-turn id shared by every compaction under `exec` (23 of them in one session).
|
|
37
|
+
* That is why `dedupeKey` prefers an ALP-assigned sequence; see `normalizeCompactEvent`.
|
|
38
|
+
*/
|
|
39
|
+
const EVENT_ID_KEY = Object.freeze({
|
|
40
|
+
claude: "prompt_id",
|
|
41
|
+
codex: "turn_id",
|
|
42
|
+
});
|
|
43
|
+
const PHASE_BY_WIRE = Object.freeze({
|
|
44
|
+
pre: "started",
|
|
45
|
+
post: "completed",
|
|
46
|
+
});
|
|
47
|
+
function isRuntimeId(value) {
|
|
48
|
+
return value === "claude" || value === "codex";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Copy the whitelisted scalars out of a raw hook payload.
|
|
52
|
+
*
|
|
53
|
+
* Scalars only: a whitelisted key holding an object or array is dropped rather than
|
|
54
|
+
* stringified, because the point is to keep the line small and predictable, and nothing ALP
|
|
55
|
+
* reads back needs a nested value. Numbers and booleans are kept as their string spelling so
|
|
56
|
+
* a journal line has exactly one value type.
|
|
57
|
+
*/
|
|
58
|
+
function filterCompactSource(runtime, payload) {
|
|
59
|
+
if (typeof payload !== "object" || payload === null)
|
|
60
|
+
return Object.freeze({});
|
|
61
|
+
const raw = payload;
|
|
62
|
+
const source = {};
|
|
63
|
+
for (const key of SOURCE_WHITELIST[runtime]) {
|
|
64
|
+
const value = raw[key];
|
|
65
|
+
if (typeof value === "string")
|
|
66
|
+
source[key] = value.slice(0, MAX_VALUE_LENGTH);
|
|
67
|
+
else if (typeof value === "number" || typeof value === "boolean")
|
|
68
|
+
source[key] = String(value);
|
|
69
|
+
}
|
|
70
|
+
return Object.freeze(source);
|
|
71
|
+
}
|
|
72
|
+
function readTrigger(source) {
|
|
73
|
+
const trigger = source.trigger;
|
|
74
|
+
return trigger === "manual" || trigger === "auto" ? trigger : "unknown";
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Turn one journal line into a `CompactEventV1`, or say why it cannot be one.
|
|
78
|
+
*
|
|
79
|
+
* Rejects a structurally wrong line — unknown runtime, unknown phase, a `session_id` that is
|
|
80
|
+
* present but not a string — because those mean the line was written by something other than
|
|
81
|
+
* ALP's hook and guessing at it would put fiction in the reduced state. Tolerates a bad
|
|
82
|
+
* `trigger`, which degrades to `"unknown"`: the type has that state precisely so a future
|
|
83
|
+
* trigger value cannot fail an otherwise good replay.
|
|
84
|
+
*/
|
|
85
|
+
function normalizeCompactEvent(input) {
|
|
86
|
+
const issues = [];
|
|
87
|
+
if (!isRuntimeId(input.runtime))
|
|
88
|
+
issues.push(`runtime: unknown runtime ${JSON.stringify(input.runtime)}`);
|
|
89
|
+
if (input.phase !== "pre" && input.phase !== "post") {
|
|
90
|
+
issues.push(`phase: expected "pre" or "post", received ${JSON.stringify(input.phase)}`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof input.observedAt !== "string" || input.observedAt.length === 0) {
|
|
93
|
+
issues.push("observedAt: expected a non-empty timestamp");
|
|
94
|
+
}
|
|
95
|
+
if (typeof input.source !== "object" || input.source === null) {
|
|
96
|
+
issues.push("source: expected an object");
|
|
97
|
+
}
|
|
98
|
+
if (issues.length > 0)
|
|
99
|
+
return { ok: false, issues: Object.freeze(issues) };
|
|
100
|
+
const source = filterCompactSource(input.runtime, input.source);
|
|
101
|
+
const runtimeSessionId = source.session_id ?? null;
|
|
102
|
+
const runtimeEventId = source[EVENT_ID_KEY[input.runtime]] ?? null;
|
|
103
|
+
// The compaction's identity, in the order of how well each spelling was measured to
|
|
104
|
+
// separate one compaction from the next. `observedAt` last so that a line missing every
|
|
105
|
+
// id still dedupes against a re-append of itself rather than against a different event.
|
|
106
|
+
const identity = input.sequence === undefined
|
|
107
|
+
? runtimeEventId ?? input.observedAt
|
|
108
|
+
: String(input.sequence);
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
value: Object.freeze({
|
|
112
|
+
dedupeKey: [input.runtime, runtimeSessionId ?? "-", identity, PHASE_BY_WIRE[input.phase]].join("|"),
|
|
113
|
+
runtime: input.runtime,
|
|
114
|
+
phase: PHASE_BY_WIRE[input.phase],
|
|
115
|
+
trigger: readTrigger(source),
|
|
116
|
+
runtimeSessionId,
|
|
117
|
+
runtimeEventId,
|
|
118
|
+
observedAt: input.observedAt,
|
|
119
|
+
}),
|
|
120
|
+
};
|
|
121
|
+
}
|