@instafy/cli 0.2.3 → 0.2.4

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.
Files changed (52) hide show
  1. package/README.md +4 -0
  2. package/dist/bind/LICENSE +661 -0
  3. package/dist/bind/bin/instafy-bind.mjs +542 -0
  4. package/dist/bind/client/bind-client.js +1219 -0
  5. package/dist/bind/src/apply.mjs +293 -0
  6. package/dist/bind/src/bound.mjs +202 -0
  7. package/dist/bind/src/budget.mjs +58 -0
  8. package/dist/bind/src/check.mjs +176 -0
  9. package/dist/bind/src/config.mjs +180 -0
  10. package/dist/bind/src/controller.mjs +244 -0
  11. package/dist/bind/src/executors/agent.mjs +336 -0
  12. package/dist/bind/src/executors/script.mjs +163 -0
  13. package/dist/bind/src/handler-kit.mjs +150 -0
  14. package/dist/bind/src/init.mjs +238 -0
  15. package/dist/bind/src/jobs.mjs +316 -0
  16. package/dist/bind/src/net.mjs +281 -0
  17. package/dist/bind/src/page-fetch.mjs +43 -0
  18. package/dist/bind/src/preview-host.mjs +114 -0
  19. package/dist/bind/src/protocol.mjs +103 -0
  20. package/dist/bind/src/records-schema.mjs +580 -0
  21. package/dist/bind/src/records.mjs +161 -0
  22. package/dist/bind/src/save-owner.mjs +492 -0
  23. package/dist/bind/src/schema-lite.mjs +212 -0
  24. package/dist/bind/src/server.mjs +908 -0
  25. package/dist/bind/src/sse.mjs +119 -0
  26. package/dist/bind/src/starters/blank/app.js +17 -0
  27. package/dist/bind/src/starters/blank/index.html +22 -0
  28. package/dist/bind/src/starters/checklist/app.js +171 -0
  29. package/dist/bind/src/starters/checklist/bound.json +29 -0
  30. package/dist/bind/src/starters/checklist/data.json +26 -0
  31. package/dist/bind/src/starters/checklist/index.html +23 -0
  32. package/dist/bind/src/starters/compare/app.js +316 -0
  33. package/dist/bind/src/starters/compare/bound.json +26 -0
  34. package/dist/bind/src/starters/compare/data.json +170 -0
  35. package/dist/bind/src/starters/compare/index.html +23 -0
  36. package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
  37. package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
  38. package/dist/bind/src/starters/ranked/app.js +166 -0
  39. package/dist/bind/src/starters/ranked/bound.json +26 -0
  40. package/dist/bind/src/starters/ranked/data.json +142 -0
  41. package/dist/bind/src/starters/ranked/index.html +23 -0
  42. package/dist/bind/src/starters/site.js +888 -0
  43. package/dist/bind/src/starters/theme.css +492 -0
  44. package/dist/bind/src/starters/timeline/app.js +143 -0
  45. package/dist/bind/src/starters/timeline/data.json +41 -0
  46. package/dist/bind/src/starters/timeline/index.html +22 -0
  47. package/dist/bind/src/static.mjs +141 -0
  48. package/dist/bind/src/tokens.mjs +102 -0
  49. package/dist/bind/src/try.mjs +139 -0
  50. package/dist/bind/src/web.mjs +191 -0
  51. package/dist/cli.js +2117 -365
  52. package/package.json +3 -2
@@ -0,0 +1,293 @@
1
+ // The apply step ("apply, no model"): executors never write site files.
2
+ // The binder validates records, enforces the lane and source policy per intent, stamps provenance,
3
+ // merges into the target file atomically and bumps data/version.json.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { DEFAULT_AGENT_LANE, DEFAULT_SCRIPT_LANES, executorOf, targetOf } from "./bound.mjs";
7
+ import { formatErrors } from "./schema-lite.mjs";
8
+ import { noteSiteWrite } from "./save-owner.mjs";
9
+ import { originAllowed, uriParamOrigins } from "./net.mjs";
10
+ import {
11
+ MAX_FILE_BYTES, RECORDS_PATH_RE, RECORDS_SCHEMA_ID, emptyRecordsFile, mergeRecords, normalizeRecord, rid, trimRecords, validateRecord,
12
+ } from "./records.mjs";
13
+
14
+ const FUTURE_SLACK_MS = 5 * 60 * 1000;
15
+
16
+ // A viewer's position must not land in shared records (every viewer reads them, and the site's files are
17
+ // committed): params that look like coordinates, and any the intent lists in `private_params`, go to the
18
+ // handler only. prov.intent keeps the rest and names what it withheld, so retry() knows it cannot re-send.
19
+ const LOCATION_PARAM_RE = /^(lat|lng|lon|long|latitude|longitude|coords?|coordinates|position|geo|geolocation|location|accuracy|altitude|heading|near|near_me|my_location|user_location)$/i;
20
+ const COORD_PAIR_RE = /^\s*-?\d{1,3}\.\d+\s*,\s*-?\d{1,3}\.\d+\s*$/;
21
+
22
+ function looksLikePosition(value) {
23
+ if (typeof value === "string") return COORD_PAIR_RE.test(value);
24
+ if (!value || typeof value !== "object") return false;
25
+ if (Array.isArray(value)) return value.length >= 2 && value.length <= 3 && value.every((n) => typeof n === "number") && Math.abs(value[0]) <= 180 && Math.abs(value[1]) <= 180 && value.some((n) => !Number.isInteger(n));
26
+ return Object.keys(value).some((key) => LOCATION_PARAM_RE.test(key));
27
+ }
28
+
29
+ /** The params safe to keep in shared records, and the names withheld from them. */
30
+ export function shareableParams(params, def = {}) {
31
+ const declared = Array.isArray(def.private_params) ? def.private_params : [];
32
+ const kept = {};
33
+ const withheld = [];
34
+ for (const [key, value] of Object.entries(params && typeof params === "object" ? params : {})) {
35
+ if (declared.includes(key) || LOCATION_PARAM_RE.test(key) || looksLikePosition(value)) withheld.push(key);
36
+ else kept[key] = value;
37
+ }
38
+ return { params: kept, withheld };
39
+ }
40
+ export const DEFAULT_AGENT_TTL_S = 3600;
41
+
42
+ function writeJsonAtomic(file, value, { pretty = false } = {}) {
43
+ const text = `${JSON.stringify(value, null, pretty ? 2 : 0)}\n`;
44
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
45
+ fs.writeFileSync(tmp, text);
46
+ fs.renameSync(tmp, file);
47
+ // In a space's checkout, the serving binder owns this write and commits it (save-owner.mjs).
48
+ noteSiteWrite(file, text);
49
+ return Buffer.byteLength(text);
50
+ }
51
+
52
+ function insideSite(siteDir, file) {
53
+ const realSite = fs.realpathSync(siteDir);
54
+ const realDir = fs.realpathSync(path.dirname(file));
55
+ return realDir === realSite || realDir.startsWith(realSite + path.sep);
56
+ }
57
+
58
+ export function versionFile(siteDir) {
59
+ return path.join(siteDir, "data", "version.json");
60
+ }
61
+
62
+ export function readVersion(siteDir) {
63
+ try {
64
+ const value = JSON.parse(fs.readFileSync(versionFile(siteDir), "utf8"));
65
+ if (value && typeof value.v === "number") return { files: {}, jobs: {}, ...value };
66
+ } catch {
67
+ // start fresh
68
+ }
69
+ return { v: 0, updated_at: null, files: {}, jobs: {} };
70
+ }
71
+
72
+ /** v + 1; files[path] = new v for touched files; jobs merged (last 200 kept). */
73
+ export function bumpVersion(siteDir, { files = [], jobs = null } = {}) {
74
+ fs.mkdirSync(path.join(siteDir, "data"), { recursive: true });
75
+ const version = readVersion(siteDir);
76
+ version.v = (version.v || 0) + 1;
77
+ version.updated_at = new Date().toISOString();
78
+ for (const file of files) version.files[file] = version.v;
79
+ if (jobs) {
80
+ version.jobs = { ...version.jobs, ...jobs };
81
+ const keys = Object.keys(version.jobs);
82
+ if (keys.length > 200) for (const key of keys.slice(0, keys.length - 200)) delete version.jobs[key];
83
+ }
84
+ writeJsonAtomic(versionFile(siteDir), version, { pretty: true });
85
+ return version;
86
+ }
87
+
88
+ export function readRecordsFile(file) {
89
+ if (!fs.existsSync(file)) return emptyRecordsFile();
90
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
91
+ if (value?.schema !== RECORDS_SCHEMA_ID || !Array.isArray(value.records)) throw new Error(`${file} is not a ${RECORDS_SCHEMA_ID} file`);
92
+ return value;
93
+ }
94
+
95
+ const RID_RE = /^[A-Za-z0-9_.:-]{1,96}$/;
96
+
97
+ /**
98
+ * Agent replies are filled in before validation. The binder, not the model, owns:
99
+ * - prov.lane (the intent's agent lane) and prov.observed_at (when the binder received the reply; a model's
100
+ * clock or training-data date never decides a merge);
101
+ * - ttl_s and source defaults;
102
+ * - cid/oid/iid when they are missing or malformed (built from ref, attr and source).
103
+ */
104
+ function prepareAgentRecord(record, { lane, ttl, now }) {
105
+ if (!record || typeof record !== "object" || Array.isArray(record)) return record;
106
+ const prov = record.prov && typeof record.prov === "object" && !Array.isArray(record.prov) ? record.prov : {};
107
+ record.prov = prov;
108
+ prov.lane = lane;
109
+ prov.observed_at = now.toISOString();
110
+ if (prov.ttl_s === undefined) prov.ttl_s = ttl;
111
+ if (prov.source === undefined) {
112
+ try {
113
+ prov.source = new URL(prov.url).hostname.replace(/^www\./, "");
114
+ } catch {
115
+ prov.source = "octo";
116
+ }
117
+ }
118
+ if (record.type === "claim" && (typeof record.cid !== "string" || !RID_RE.test(record.cid))) {
119
+ record.cid = rid("clm", record.ref, record.attr, prov.source);
120
+ }
121
+ if (record.type === "offer" && (typeof record.oid !== "string" || !RID_RE.test(record.oid))) {
122
+ record.oid = rid("off", record.ref, prov.source, record.variant?.label || record.label || "");
123
+ }
124
+ if (record.type === "item" && (typeof record.iid !== "string" || !RID_RE.test(record.iid))) {
125
+ record.iid = rid("itm", prov.source, record.name?.en || Object.values(record.name || {})[0] || "");
126
+ }
127
+ return record;
128
+ }
129
+
130
+ /** Script records get the fields a handler may leave out: `prov.observed_at` (the binder's clock), `prov.ttl_s`,
131
+ * `prov.lane` (local when allowed) and `prov.source` (the URL's host). What the handler set stays. */
132
+ export const DEFAULT_SCRIPT_TTL_S = 3600;
133
+ function prepareScriptRecord(record, { lanes, now }) {
134
+ if (!record || typeof record !== "object" || Array.isArray(record)) return record;
135
+ if (record.prov === undefined) record.prov = {};
136
+ const prov = record.prov;
137
+ if (!prov || typeof prov !== "object" || Array.isArray(prov)) return record;
138
+ if (prov.observed_at === undefined) prov.observed_at = now.toISOString();
139
+ if (prov.ttl_s === undefined) prov.ttl_s = DEFAULT_SCRIPT_TTL_S;
140
+ if (prov.lane === undefined) prov.lane = lanes.includes("local") ? "local" : lanes[0];
141
+ if (prov.source === undefined && typeof prov.url === "string") {
142
+ try {
143
+ prov.source = new URL(prov.url).hostname.replace(/^www\./, "");
144
+ } catch {
145
+ // left out: the schema names it
146
+ }
147
+ }
148
+ return record;
149
+ }
150
+
151
+ function recordUrls(record) {
152
+ return [record.prov?.url, record.prov?.license?.url, record.handoff?.url].filter((url) => typeof url === "string");
153
+ }
154
+
155
+ /** The ref an intent's records are about: the schema-checked params.ref, else the envelope ref. */
156
+ export function intentRef(intent) {
157
+ if (typeof intent?.params?.ref === "string" && intent.params.ref) return intent.params.ref;
158
+ return typeof intent?.ref === "string" && intent.ref ? intent.ref : null;
159
+ }
160
+
161
+ /** Origins an agent intent's records may link to: agent.allowed_origins, else the origins of its URL params. */
162
+ export function agentOrigins(def, intent) {
163
+ if (def.agent?.allowed_origins?.length) return def.agent.allowed_origins;
164
+ const fromParams = uriParamOrigins(def.params, intent?.params);
165
+ return fromParams.length ? fromParams : null;
166
+ }
167
+
168
+ /**
169
+ * Check and stamp records from one executor batch. Pure (no file writes).
170
+ * Agent records must be about the intent's ref, stay inside its origins and use its lane.
171
+ * Returns {accepted, rejected: [{index, reason}]}.
172
+ */
173
+ export function checkRecords(records, { name, def, intent, job, now = new Date() }) {
174
+ const executor = executorOf(def);
175
+ const agentLane = def.agent?.lane || DEFAULT_AGENT_LANE;
176
+ const lanes = def.lanes || (executor === "agent" ? [agentLane] : DEFAULT_SCRIPT_LANES);
177
+ const ttl = def.agent?.ttl_s ?? DEFAULT_AGENT_TTL_S;
178
+ const scopeRef = executor === "agent" ? intentRef(intent) : null;
179
+ const origins = executor === "agent" ? agentOrigins(def, intent) : null;
180
+ const accepted = [];
181
+ const rejected = [];
182
+ (Array.isArray(records) ? records : []).forEach((raw, index) => {
183
+ let record = normalizeRecord(raw);
184
+ if (executor === "agent") record = prepareAgentRecord(record, { lane: agentLane, ttl, now });
185
+ else record = prepareScriptRecord(record, { lanes, now });
186
+ const problems = validateRecord(record);
187
+ if (problems.length) {
188
+ rejected.push({ index, reason: formatErrors(problems, 2) });
189
+ return;
190
+ }
191
+ if (!lanes.includes(record.prov.lane)) {
192
+ rejected.push({ index, reason: `lane "${record.prov.lane}" is not allowed for ${name} (${lanes.join(", ")})` });
193
+ return;
194
+ }
195
+ if (def.sources && !def.sources.includes(record.prov.source)) {
196
+ rejected.push({ index, reason: `source "${record.prov.source}" is not declared in ${name}.sources` });
197
+ return;
198
+ }
199
+ if (executor === "agent" && record.type !== "item") {
200
+ if (!scopeRef) {
201
+ rejected.push({ index, reason: `${name} has no ref (add a "ref" param), so its agent records cannot be scoped` });
202
+ return;
203
+ }
204
+ if (record.ref !== scopeRef) {
205
+ rejected.push({ index, reason: `ref "${String(record.ref).slice(0, 60)}" is not the clicked ref ${scopeRef}` });
206
+ return;
207
+ }
208
+ }
209
+ if (executor === "agent" && origins) {
210
+ const outside = recordUrls(record).find((url) => !originAllowed(url, origins));
211
+ if (outside) {
212
+ rejected.push({ index, reason: `URL outside allowed_origins: ${outside.slice(0, 80)}` });
213
+ return;
214
+ }
215
+ }
216
+ const observed = Date.parse(record.prov.observed_at);
217
+ if (observed > now.getTime() + FUTURE_SLACK_MS) record.prov.observed_at = now.toISOString();
218
+ const shared = shareableParams(intent.params, def);
219
+ record.prov.intent = { name, params: shared.params, id: intent.id, ...(shared.withheld.length ? { withheld: shared.withheld } : {}) };
220
+ record.prov.job = job;
221
+ record.prov.observed_by = `instafy-bind/${executor}`;
222
+ accepted.push(record);
223
+ });
224
+ return { accepted, rejected };
225
+ }
226
+
227
+ /**
228
+ * Merge accepted records into the intent's target file and bump data/version.json.
229
+ * A file that would pass 200 KB (map viewers ignore larger files) drops expired, then oldest, records.
230
+ * Returns {rel, v, version, total, applied, added, replaced, kept, dropped, trimmed, bytes, limit}.
231
+ */
232
+ export function writeRecords(siteDir, rel, records, { jobs = null, attribution = [], limit = MAX_FILE_BYTES } = {}) {
233
+ if (!RECORDS_PATH_RE.test(rel)) throw new Error(`records path "${rel}" must look like data/records/<name>.json`);
234
+ const file = path.join(siteDir, rel);
235
+ fs.mkdirSync(path.dirname(file), { recursive: true });
236
+ if (!insideSite(siteDir, file)) throw new Error(`records path "${rel}" leaves the site`);
237
+ const current = readRecordsFile(file);
238
+ const merged = mergeRecords(current.records, records);
239
+ const trim = trimRecords(merged.records, limit);
240
+ const stored = new Set(trim.records);
241
+ const out = {
242
+ schema: RECORDS_SCHEMA_ID,
243
+ writer: { cmd: "instafy-bind", updated_at: new Date().toISOString() },
244
+ attribution: [...new Set([...(current.attribution || []), ...attribution.filter(Boolean)])].slice(0, 10),
245
+ records: trim.records,
246
+ };
247
+ const bytes = writeJsonAtomic(file, out);
248
+ const version = bumpVersion(siteDir, { files: [rel], jobs });
249
+ return {
250
+ rel, v: version.files[rel], version: version.v, total: trim.records.length, applied: merged.applied.filter((r) => stored.has(r)),
251
+ added: merged.added, replaced: merged.replaced, kept: merged.kept, dropped: merged.dropped, trimmed: trim.trimmed, bytes, limit,
252
+ };
253
+ }
254
+
255
+ /** checkRecords + writeRecords for one batch. `applied` is what was stored (and is what viewers are sent). */
256
+ export function applyBatch(siteDir, records, { name, def, intent, job, ids = [intent.id], bound = null, now }) {
257
+ const { accepted, rejected } = checkRecords(records, { name, def, intent, job, now });
258
+ if (accepted.length === 0) return { accepted, applied: [], rejected, file: null, trimmed: 0 };
259
+ const jobs = Object.fromEntries(ids.map((id) => [id, "running"]));
260
+ const file = writeRecords(siteDir, targetOf(name, def), accepted, { jobs, attribution: bound?.attribution || [] });
261
+ return { accepted, applied: file.applied, rejected, file, trimmed: file.trimmed };
262
+ }
263
+
264
+ const collected = new Map();
265
+
266
+ /** All records the site currently holds, across declared targets and results.files. Cached per version.json v. */
267
+ export function collectRecords(siteDir, bound) {
268
+ const v = readVersion(siteDir).v;
269
+ const cached = collected.get(siteDir);
270
+ if (cached && cached.v === v && cached.bound === bound) return cached.value;
271
+ const value = collectRecordsUncached(siteDir, bound);
272
+ collected.set(siteDir, { v, bound, value });
273
+ return value;
274
+ }
275
+
276
+ function collectRecordsUncached(siteDir, bound) {
277
+ const files = new Set(bound.results?.files || []);
278
+ for (const [name, def] of Object.entries(bound.intents)) files.add(targetOf(name, def));
279
+ const version = readVersion(siteDir);
280
+ for (const rel of Object.keys(version.files || {})) if (RECORDS_PATH_RE.test(rel)) files.add(rel);
281
+ const records = [];
282
+ const versions = {};
283
+ for (const rel of files) {
284
+ try {
285
+ const value = readRecordsFile(path.join(siteDir, rel));
286
+ records.push(...value.records);
287
+ versions[rel] = version.files?.[rel] ?? 0;
288
+ } catch {
289
+ // unreadable files are skipped; the static file still shows the problem
290
+ }
291
+ }
292
+ return { schema: RECORDS_SCHEMA_ID, v: version.v, files: versions, records };
293
+ }
@@ -0,0 +1,202 @@
1
+ // bound.json: the per-site manifest of intents and how each one runs.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { checkSchema, validate, formatErrors } from "./schema-lite.mjs";
5
+ import { LANES, RECORDS_PATH_RE } from "./records.mjs";
6
+ import { RESERVED_INTENTS } from "./protocol.mjs";
7
+
8
+ export const BOUND_FILE = "bound.json";
9
+ export const DEFAULT_SCRIPT_TIMEOUT_S = 90;
10
+ export const DEFAULT_AGENT_TIMEOUT_S = 300;
11
+ export const DEFAULT_SCRIPT_LANES = ["open", "official", "local"];
12
+ export const DEFAULT_AGENT_LANE = "assist";
13
+ /** Lanes an Octo turn may write: its output is a model's reading, never "official" or "open" data. */
14
+ export const AGENT_LANES = ["assist", "capture"];
15
+
16
+ const ORIGIN_PATTERN = "^https?://[A-Za-z0-9*._-]+(:[0-9]{1,5})?$";
17
+
18
+ export const BOUND_SCHEMA = Object.freeze({
19
+ type: "object",
20
+ required: ["version", "intents"],
21
+ additionalProperties: false,
22
+ properties: {
23
+ $schema: { type: "string" },
24
+ version: { const: 1 },
25
+ title: { type: "string", maxLength: 120 },
26
+ intents: { type: "object", maxProperties: 40, additionalProperties: { $ref: "#/$defs/intent" } },
27
+ results: {
28
+ type: "object",
29
+ additionalProperties: false,
30
+ properties: {
31
+ files: { type: "array", maxItems: 40, items: { $ref: "#/$defs/recordsPath" } },
32
+ poll_s: { type: "number", minimum: 1, maximum: 300 },
33
+ sse: { type: "boolean" },
34
+ },
35
+ },
36
+ attribution: { type: "array", maxItems: 10, items: { type: "string", maxLength: 300 } },
37
+ parentOrigins: { type: "array", maxItems: 10, items: { type: "string", pattern: "^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$" } },
38
+ },
39
+ $defs: {
40
+ recordsPath: { type: "string", pattern: RECORDS_PATH_RE.source },
41
+ lane: { enum: LANES },
42
+ intent: {
43
+ type: "object",
44
+ required: ["label", "params"],
45
+ additionalProperties: false,
46
+ properties: {
47
+ label: { type: "string", minLength: 1, maxLength: 40 },
48
+ emoji: { type: "string", maxLength: 8 },
49
+ describe: { type: "string", maxLength: 200 },
50
+ params: { type: "object" },
51
+ prefill: { type: "object", additionalProperties: { type: "string", maxLength: 200 } },
52
+ ui: { type: "array", maxItems: 8, items: { type: "string", maxLength: 60 } },
53
+ target: {
54
+ type: "object",
55
+ additionalProperties: false,
56
+ properties: {
57
+ records: { $ref: "#/$defs/recordsPath" },
58
+ },
59
+ },
60
+ pending: { type: "string", pattern: "^(item|none|cell:[a-z][a-z0-9_]{0,47})$" },
61
+ confirm: { type: "boolean" },
62
+ say: { type: "string", minLength: 1, maxLength: 120 },
63
+ cooldown_s: { type: "number", minimum: 0, maximum: 3600 },
64
+ timeout_s: { type: "number", minimum: 1, maximum: 1800 },
65
+ sources: { type: "array", maxItems: 20, items: { type: "string", maxLength: 80 } },
66
+ executor: { enum: ["script", "agent"] },
67
+ handler: { type: "string", pattern: "^[A-Za-z0-9_./-]{1,120}\\.mjs$" },
68
+ lanes: { type: "array", minItems: 1, maxItems: 7, items: { $ref: "#/$defs/lane" } },
69
+ env: { type: "array", maxItems: 10, items: { type: "string", pattern: "^[A-Z][A-Z0-9_]{0,63}$" } },
70
+ private_params: { type: "array", maxItems: 20, items: { type: "string", minLength: 1, maxLength: 64 } },
71
+ agent: {
72
+ type: "object",
73
+ required: ["prompt"],
74
+ additionalProperties: false,
75
+ properties: {
76
+ prompt: { type: "string", minLength: 1, maxLength: 4000 },
77
+ allowed_origins: { type: "array", maxItems: 20, items: { type: "string", pattern: ORIGIN_PATTERN } },
78
+ lane: { enum: AGENT_LANES },
79
+ timeout_s: { type: "number", minimum: 10, maximum: 1800 },
80
+ ttl_s: { type: "integer", minimum: 0, maximum: 31536000 },
81
+ },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ });
87
+
88
+ /** Which executor an intent uses: explicit `executor`, else `handler` means script, `agent` means agent. */
89
+ export function executorOf(def) {
90
+ if (def.executor) return def.executor;
91
+ if (def.handler) return "script";
92
+ if (def.agent) return "agent";
93
+ return null;
94
+ }
95
+
96
+ /** Problems with a bound.json object: [{path, message}]. */
97
+ export function checkBound(bound) {
98
+ const problems = validate(BOUND_SCHEMA, bound, { max: 30 });
99
+ if (problems.length) return problems;
100
+ for (const [name, def] of Object.entries(bound.intents)) {
101
+ const p = `$.intents.${name}`;
102
+ if (!/^[a-z][a-z0-9_]{0,47}$/.test(name)) problems.push({ path: p, message: "intent names must match ^[a-z][a-z0-9_]{0,47}$" });
103
+ if (RESERVED_INTENTS.includes(name)) problems.push({ path: p, message: `"${name}" is reserved` });
104
+ problems.push(...checkSchema(def.params, { path: `${p}.params` }));
105
+ if (def.params?.type !== "object") problems.push({ path: `${p}.params.type`, message: 'params must be a schema with type "object"' });
106
+ const executor = executorOf(def);
107
+ if (!executor) problems.push({ path: p, message: 'needs "handler" (script) or "agent" (agent)' });
108
+ if (executor === "script" && !def.handler) problems.push({ path: `${p}.handler`, message: "script intents need a handler" });
109
+ if (executor === "agent" && !def.agent) problems.push({ path: `${p}.agent`, message: "agent intents need agent.prompt" });
110
+ const laneSet = executor === "agent" ? AGENT_LANES : DEFAULT_SCRIPT_LANES;
111
+ for (const lane of def.lanes || []) {
112
+ if (executor && !laneSet.includes(lane)) {
113
+ problems.push({ path: `${p}.lanes`, message: `${executor} intents may only write lanes ${laneSet.join(", ")} (got "${lane}")` });
114
+ }
115
+ }
116
+ if (def.say !== undefined && executor !== "agent") {
117
+ problems.push({ path: `${p}.say`, message: "say is for agent intents: it is the chat line Instafy sends for an Ask Octo click" });
118
+ }
119
+ if (executor === "agent" && typeof def.say === "string") {
120
+ const declared = new Set(Object.keys(def.params?.properties || {}));
121
+ for (const [, key] of def.say.matchAll(/\{\s*([A-Za-z0-9_]+)\s*\}/g)) {
122
+ if (!declared.has(key)) problems.push({ path: `${p}.say`, message: `{${key}} is not a declared param` });
123
+ }
124
+ }
125
+ if (executor === "agent" && def.agent?.prompt) {
126
+ const declared = new Set(Object.keys(def.params?.properties || {}));
127
+ for (const [, key] of def.agent.prompt.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) {
128
+ if (key !== "params" && !declared.has(key)) {
129
+ problems.push({ path: `${p}.agent.prompt`, message: `{{${key}}} is not a declared param` });
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return problems;
135
+ }
136
+
137
+ /** Read and check <siteDir>/bound.json. Throws with a readable message when it is unusable. */
138
+ export function loadBound(siteDir) {
139
+ const file = path.join(siteDir, BOUND_FILE);
140
+ let bound;
141
+ try {
142
+ bound = JSON.parse(fs.readFileSync(file, "utf8"));
143
+ } catch (error) {
144
+ throw new Error(`${file}: ${error.code === "ENOENT" ? "not found" : error.message}`);
145
+ }
146
+ const problems = checkBound(bound);
147
+ if (problems.length) throw new Error(`${file}: ${formatErrors(problems, 5)}`);
148
+ return bound;
149
+ }
150
+
151
+ /**
152
+ * Resolve how an intent runs. Returns {executor, handler?, ok, reason?}.
153
+ * Script handlers must stay inside the site.
154
+ */
155
+ export function resolveIntent(def, { siteDir } = {}) {
156
+ const executor = executorOf(def);
157
+ if (executor === "agent") return { executor, ok: true };
158
+ if (executor !== "script") return { executor, ok: false, reason: "no executor" };
159
+ const base = siteDir;
160
+ const abs = path.resolve(base, def.handler);
161
+ let real;
162
+ try {
163
+ real = fs.realpathSync(abs);
164
+ } catch {
165
+ return { executor, ok: false, reason: `handler ${def.handler} not found` };
166
+ }
167
+ const realBase = fs.realpathSync(base);
168
+ if (real !== realBase && !real.startsWith(realBase + path.sep)) {
169
+ return { executor, ok: false, reason: `handler ${def.handler} is outside the site` };
170
+ }
171
+ if (path.relative(realBase, real).split(path.sep).some((part) => part.startsWith("."))) {
172
+ return { executor, ok: false, reason: `handler ${def.handler} is in a hidden folder` };
173
+ }
174
+ return { executor, ok: true, handler: real };
175
+ }
176
+
177
+ /** Records file for an intent: target.records or data/records/<name>.json. */
178
+ export function targetOf(name, def) {
179
+ return def.target?.records || `data/records/${name}.json`;
180
+ }
181
+
182
+ /** Public, page-safe description of the intents (what the client's `ready` resolves to). */
183
+ export function intentCatalog(bound, runnable) {
184
+ return Object.entries(bound.intents)
185
+ .filter(([name]) => runnable.has(name))
186
+ .map(([name, def]) => ({
187
+ name,
188
+ label: def.label,
189
+ emoji: def.emoji,
190
+ describe: def.describe,
191
+ params: def.params,
192
+ prefill: def.prefill,
193
+ ui: def.ui,
194
+ pending: def.pending || "item",
195
+ confirm: Boolean(def.confirm),
196
+ cooldown_s: def.cooldown_s ?? 0,
197
+ executor: executorOf(def),
198
+ target: { ...def.target, records: targetOf(name, def) },
199
+ ...(Array.isArray(def.private_params) && def.private_params.length ? { private_params: def.private_params } : {}),
200
+ ...(executorOf(def) === "agent" && typeof def.say === "string" ? { say: def.say } : {}),
201
+ }));
202
+ }
@@ -0,0 +1,58 @@
1
+ // Per-site Octo budget: at most N agent dispatches (first turns and repair turns) per rolling hour.
2
+ // Persisted in <site>/.bind/agent-budget.json so a restart does not reset it.
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { bindDir } from "./tokens.mjs";
6
+
7
+ export const DEFAULT_AGENT_BUDGET_PER_HOUR = 20;
8
+ const WINDOW_MS = 60 * 60 * 1000;
9
+
10
+ export class AgentBudget {
11
+ /** @param {{dir: string, perHour?: number, now?: () => number}} options */
12
+ constructor({ dir, perHour = DEFAULT_AGENT_BUDGET_PER_HOUR, now = () => Date.now() }) {
13
+ this.file = dir ? path.join(bindDir(dir), "agent-budget.json") : null;
14
+ this.perHour = perHour;
15
+ this.now = now;
16
+ this.stamps = [];
17
+ if (this.file) {
18
+ try {
19
+ const saved = JSON.parse(fs.readFileSync(this.file, "utf8"));
20
+ if (Array.isArray(saved.dispatches)) this.stamps = saved.dispatches.filter((t) => Number.isFinite(t));
21
+ } catch {
22
+ // no budget file yet
23
+ }
24
+ }
25
+ }
26
+
27
+ prune() {
28
+ const cutoff = this.now() - WINDOW_MS;
29
+ this.stamps = this.stamps.filter((t) => t > cutoff);
30
+ }
31
+
32
+ remaining() {
33
+ this.prune();
34
+ return Math.max(0, this.perHour - this.stamps.length);
35
+ }
36
+
37
+ /** Seconds until one more dispatch fits, 0 when one fits now. */
38
+ retryAfterS() {
39
+ this.prune();
40
+ if (this.stamps.length < this.perHour) return 0;
41
+ const oldest = Math.min(...this.stamps);
42
+ return Math.max(1, Math.ceil((oldest + WINDOW_MS - this.now()) / 1000));
43
+ }
44
+
45
+ /** Record one dispatch. Returns false (and records nothing) when the budget is spent. */
46
+ take() {
47
+ if (this.remaining() <= 0) return false;
48
+ this.stamps.push(this.now());
49
+ if (this.file) {
50
+ try {
51
+ fs.writeFileSync(this.file, `${JSON.stringify({ per_hour: this.perHour, dispatches: this.stamps })}\n`, { mode: 0o600 });
52
+ } catch {
53
+ // the in-memory budget still applies
54
+ }
55
+ }
56
+ return true;
57
+ }
58
+ }