@securecoms/agent-runtime 0.12.1 → 0.12.2
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/README.md +19 -5
- package/dist/bin.js +309 -179
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -207,9 +207,13 @@ the runtime works as follows in the owner's private chat:
|
|
|
207
207
|
recalls memories.
|
|
208
208
|
- **The memory tool gate.** On every owner private-chat turn, and on any turn
|
|
209
209
|
that carries memories, the gate checks each tool call before it runs:
|
|
210
|
+
<!-- [COPY, approved by Eric 2026-10-12] skills spec 2026-10-12 section 7 C-3. -->
|
|
211
|
+
- What a tool may do is decided by its handler, not its name. A skill file
|
|
212
|
+
that reuses a built-in's name with a different handler is not loaded.
|
|
210
213
|
- `current_time`, `echo` and `memory_note` run as usual.
|
|
211
|
-
- `web_search`, `fetch_url`, `
|
|
212
|
-
|
|
214
|
+
- Network reads (`web_search`, `fetch_url`, `sql_query`, `http_request` GET)
|
|
215
|
+
run only when the values they will send pass the gate. Numbers are checked
|
|
216
|
+
too. The gate refuses an argument that holds a
|
|
213
217
|
number the owner did not type in this message (digits, number words, and
|
|
214
218
|
Roman numerals longer than one letter); a URL, an e-mail address or a
|
|
215
219
|
base64-like token longer than 24 characters that is not in the owner's
|
|
@@ -222,9 +226,14 @@ the runtime works as follows in the owner's private chat:
|
|
|
222
226
|
number (`YEAR_WINDOW` = 1). It still counts toward the 3-word runs and the
|
|
223
227
|
200-character budget.
|
|
224
228
|
- `http_request` sends only GET on these turns. Any other method is refused.
|
|
225
|
-
-
|
|
226
|
-
|
|
227
|
-
|
|
229
|
+
- Anything that sends, changes or deletes something is refused on these
|
|
230
|
+
turns.
|
|
231
|
+
- The bridge decides which tools a turn may use. Today it allows only
|
|
232
|
+
`current_time` and `web_search` in the owner's private chat, and no custom
|
|
233
|
+
skill on any turn.
|
|
234
|
+
- The gate stops a model from pasting numbers, links and exact wording from
|
|
235
|
+
memory into a tool. It does not stop a model that rewords a memory or
|
|
236
|
+
deliberately encodes it in other words, and it binds only this runtime.
|
|
228
237
|
|
|
229
238
|
**With bridge 0.28 or older.** A turn without the bridge's `memory_unified`
|
|
230
239
|
reply works as in runtime 0.11.0: `memory_note` saves, replaces or forgets a
|
|
@@ -233,6 +242,11 @@ not run.
|
|
|
233
242
|
|
|
234
243
|
## Adding a skill
|
|
235
244
|
|
|
245
|
+
<!-- [COPY, approved by Eric 2026-10-12] skills spec 2026-10-12 section 7 C-3. -->
|
|
246
|
+
Note: a skill you add here is loaded but not offered to the model yet. The
|
|
247
|
+
bridge grants tools per turn, and it does not grant custom skills on any turn.
|
|
248
|
+
The six built-ins below are the tools agents use today.
|
|
249
|
+
|
|
236
250
|
A skill is either a JSON manifest or a `SKILL.md` file, dropped under
|
|
237
251
|
`SECURECOMS_SKILLS_DIR` in one of three layouts:
|
|
238
252
|
|
package/dist/bin.js
CHANGED
|
@@ -2585,8 +2585,12 @@ async function readCapped(res, maxBytes) {
|
|
|
2585
2585
|
}
|
|
2586
2586
|
|
|
2587
2587
|
// src/builtins/fetch-url.ts
|
|
2588
|
+
function coerceFetchUrlArgs(args) {
|
|
2589
|
+
const url = typeof args.url === "string" ? args.url.trim() : "";
|
|
2590
|
+
return { url, outbound: { fields: [{ key: "url", value: url, source: args.url }], runs: [], patterns: [], sendable: true } };
|
|
2591
|
+
}
|
|
2588
2592
|
var fetchUrl = async (args, ctx) => {
|
|
2589
|
-
const raw =
|
|
2593
|
+
const raw = coerceFetchUrlArgs(args).url;
|
|
2590
2594
|
if (!raw) return toolError('fetch_url needs a "url" string');
|
|
2591
2595
|
let parsed;
|
|
2592
2596
|
try {
|
|
@@ -2620,8 +2624,38 @@ function httpMethodIsGet(args) {
|
|
|
2620
2624
|
if (method === void 0 || method === null) return true;
|
|
2621
2625
|
return typeof method === "string" && method.toUpperCase() === "GET";
|
|
2622
2626
|
}
|
|
2623
|
-
|
|
2627
|
+
function coerceHttpRequestArgs(args) {
|
|
2624
2628
|
const name = typeof args.name === "string" ? args.name : "";
|
|
2629
|
+
const method = String(args.method ?? "GET").toUpperCase();
|
|
2630
|
+
const rawPath = String(args.path ?? "");
|
|
2631
|
+
const body = typeof args.body === "string" ? args.body : void 0;
|
|
2632
|
+
const noFragment = rawPath.split("#")[0];
|
|
2633
|
+
const queryAt = noFragment.indexOf("?");
|
|
2634
|
+
const pathPart = queryAt === -1 ? noFragment : noFragment.slice(0, queryAt);
|
|
2635
|
+
const queryPart = queryAt === -1 ? "" : noFragment.slice(queryAt + 1);
|
|
2636
|
+
let segments;
|
|
2637
|
+
let decodedPath = "";
|
|
2638
|
+
try {
|
|
2639
|
+
segments = pathPart.split("/").filter((s) => s && s !== "." && s !== "..").map((s) => decodeURIComponent(s));
|
|
2640
|
+
decodedPath = pathPart.split("/").map((s) => decodeURIComponent(s)).join("/");
|
|
2641
|
+
} catch {
|
|
2642
|
+
segments = null;
|
|
2643
|
+
}
|
|
2644
|
+
const query = [...new URLSearchParams(queryPart)];
|
|
2645
|
+
const fields = [
|
|
2646
|
+
{ key: "name", value: name, source: args.name },
|
|
2647
|
+
{ key: "method", value: method, source: args.method }
|
|
2648
|
+
];
|
|
2649
|
+
for (const s of segments ?? []) fields.push({ key: "path", value: s, source: args.path });
|
|
2650
|
+
for (const [k, v] of query) fields.push({ key: "path", value: k, source: args.path }, { key: "path", value: v, source: args.path });
|
|
2651
|
+
if (body !== void 0) fields.push({ key: "body", value: body, source: args.body });
|
|
2652
|
+
const runs2 = [[...segments ?? [], ...query.flat()].join(" ")].filter((r) => r !== "");
|
|
2653
|
+
const patterns = [decodedPath, queryPart, query.length > 0 ? String(new URLSearchParams(query)) : ""].filter((s) => s !== "");
|
|
2654
|
+
return { name, method, rawPath, segments, queryPart, query, body, outbound: { fields, runs: runs2, patterns, sendable: segments !== null } };
|
|
2655
|
+
}
|
|
2656
|
+
var httpRequest = async (args, ctx) => {
|
|
2657
|
+
const call = coerceHttpRequestArgs(args);
|
|
2658
|
+
const name = call.name;
|
|
2625
2659
|
const endpoint = ctx.skillsConfig.endpoints[name];
|
|
2626
2660
|
if (!endpoint) {
|
|
2627
2661
|
return toolError(
|
|
@@ -2631,7 +2665,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
2631
2665
|
if (endpoint.headersFileRefused) {
|
|
2632
2666
|
return toolError(secretPathRefusedMessage(`the headers file for endpoint "${name}"`));
|
|
2633
2667
|
}
|
|
2634
|
-
const method =
|
|
2668
|
+
const method = call.method;
|
|
2635
2669
|
if (!METHODS.has(method)) return toolError(`unsupported method "${method}": use GET, POST, PUT or DELETE`);
|
|
2636
2670
|
let base;
|
|
2637
2671
|
try {
|
|
@@ -2639,7 +2673,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
2639
2673
|
} catch {
|
|
2640
2674
|
return toolError(`endpoint "${name}" has an unusable baseUrl`);
|
|
2641
2675
|
}
|
|
2642
|
-
const rawPath =
|
|
2676
|
+
const rawPath = call.rawPath;
|
|
2643
2677
|
let asWritten;
|
|
2644
2678
|
try {
|
|
2645
2679
|
asWritten = new URL(rawPath, base);
|
|
@@ -2649,18 +2683,11 @@ var httpRequest = async (args, ctx) => {
|
|
|
2649
2683
|
if (asWritten.origin !== base.origin) {
|
|
2650
2684
|
return toolError(`path must stay on the configured endpoint (${base.origin}): "${rawPath}" points elsewhere`);
|
|
2651
2685
|
}
|
|
2652
|
-
|
|
2653
|
-
const queryAt = noFragment.indexOf("?");
|
|
2654
|
-
const pathPart = queryAt === -1 ? noFragment : noFragment.slice(0, queryAt);
|
|
2655
|
-
const queryPart = queryAt === -1 ? "" : noFragment.slice(queryAt + 1);
|
|
2656
|
-
let segments;
|
|
2657
|
-
try {
|
|
2658
|
-
segments = pathPart.split("/").filter((s) => s && s !== "." && s !== "..").map((s) => encodeURIComponent(decodeURIComponent(s)));
|
|
2659
|
-
} catch {
|
|
2686
|
+
if (call.segments === null) {
|
|
2660
2687
|
return toolError(`"${rawPath}" is not a usable path for endpoint "${name}"`);
|
|
2661
2688
|
}
|
|
2662
|
-
const url = new URL(segments.join("/"), base);
|
|
2663
|
-
if (queryPart) url.search = new URLSearchParams(
|
|
2689
|
+
const url = new URL(call.segments.map((s) => encodeURIComponent(s)).join("/"), base);
|
|
2690
|
+
if (call.queryPart) url.search = new URLSearchParams(call.query).toString();
|
|
2664
2691
|
const headers = { accept: "application/json" };
|
|
2665
2692
|
if (endpoint.headersFile) {
|
|
2666
2693
|
try {
|
|
@@ -2671,7 +2698,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
2671
2698
|
return toolError(`cannot read the headers file for endpoint "${name}"`);
|
|
2672
2699
|
}
|
|
2673
2700
|
}
|
|
2674
|
-
const body =
|
|
2701
|
+
const body = call.body;
|
|
2675
2702
|
if (body) headers["content-type"] = headers["content-type"] ?? "application/json";
|
|
2676
2703
|
try {
|
|
2677
2704
|
const res = await safeFetch(url.toString(), {
|
|
@@ -3068,8 +3095,18 @@ function envelope(columns, rows, truncated, reason) {
|
|
|
3068
3095
|
}
|
|
3069
3096
|
return toolError("database result metadata exceeded the response limit");
|
|
3070
3097
|
}
|
|
3071
|
-
|
|
3098
|
+
function coerceSqlQueryArgs(args) {
|
|
3072
3099
|
const name = typeof args.name === "string" ? args.name : "";
|
|
3100
|
+
const sql = String(args.sql ?? "");
|
|
3101
|
+
return {
|
|
3102
|
+
name,
|
|
3103
|
+
sql,
|
|
3104
|
+
outbound: { fields: [{ key: "name", value: name, source: args.name }, { key: "sql", value: sql, source: args.sql }], runs: [], patterns: [], sendable: true }
|
|
3105
|
+
};
|
|
3106
|
+
}
|
|
3107
|
+
var sqlQuery = async (args, ctx) => {
|
|
3108
|
+
const call = coerceSqlQueryArgs(args);
|
|
3109
|
+
const name = call.name;
|
|
3073
3110
|
const db = ctx.skillsConfig.databases[name];
|
|
3074
3111
|
if (!db) {
|
|
3075
3112
|
return toolError(
|
|
@@ -3081,7 +3118,7 @@ var sqlQuery = async (args, ctx) => {
|
|
|
3081
3118
|
}
|
|
3082
3119
|
let sql;
|
|
3083
3120
|
try {
|
|
3084
|
-
sql = assertReadOnlySelect(
|
|
3121
|
+
sql = assertReadOnlySelect(call.sql);
|
|
3085
3122
|
} catch (e) {
|
|
3086
3123
|
return toolError(e instanceof Error ? e.message : String(e));
|
|
3087
3124
|
}
|
|
@@ -3111,10 +3148,16 @@ var sqlQuery = async (args, ctx) => {
|
|
|
3111
3148
|
|
|
3112
3149
|
// src/builtins/web-search.ts
|
|
3113
3150
|
var MIN_RESULTS = 5;
|
|
3114
|
-
|
|
3151
|
+
function coerceWebSearchArgs(args) {
|
|
3115
3152
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
3116
|
-
if (!query) return toolError('web_search needs a non-empty "query"');
|
|
3117
3153
|
const count = Math.min(10, Math.max(MIN_RESULTS, Number(args.count) || MIN_RESULTS));
|
|
3154
|
+
const fields = [{ key: "query", value: query, source: args.query }];
|
|
3155
|
+
if (args.count !== void 0) fields.push({ key: "count", value: String(args.count), source: args.count });
|
|
3156
|
+
return { query, count, outbound: { fields, runs: [], patterns: [], sendable: true } };
|
|
3157
|
+
}
|
|
3158
|
+
var webSearch = async (args, ctx) => {
|
|
3159
|
+
const { query, count } = coerceWebSearchArgs(args);
|
|
3160
|
+
if (!query) return toolError('web_search needs a non-empty "query"');
|
|
3118
3161
|
const brave = ctx.readKeyFile("brave");
|
|
3119
3162
|
const tavily = brave ? null : ctx.readKeyFile("tavily");
|
|
3120
3163
|
if (!brave && !tavily) {
|
|
@@ -3261,6 +3304,174 @@ var BUILTINS = {
|
|
|
3261
3304
|
sql_query: sqlQuery
|
|
3262
3305
|
};
|
|
3263
3306
|
|
|
3307
|
+
// src/memory-note.ts
|
|
3308
|
+
var MEMORY_NOTE = "memory_note";
|
|
3309
|
+
var MEMORY_NOTE_TOOL = {
|
|
3310
|
+
type: "function",
|
|
3311
|
+
function: {
|
|
3312
|
+
name: MEMORY_NOTE,
|
|
3313
|
+
description: "Save, change or delete one short note in this chat's saved notes. Save only what the owner asked you to keep. First check the saved notes already shown to you: if the fact is already saved, do not call this tool; just tell the owner it is already remembered. `text` must be copied word for word from the owner's CURRENT message (a contiguous part of it is fine). You cannot save something said earlier, said in a call, found by a tool, or your own summary. If the owner asks you to remember something from earlier, ask them to type it in one short message. Never store credentials, keys or passwords. To change or delete a note, pass its id: [n7] is id 7. To forget a remembered message shown to you as [r12], pass op forget with id \"r12\". You can make only one change (replace or forget) per owner message: One change per message. Ask the owner to confirm the next one.",
|
|
3314
|
+
parameters: {
|
|
3315
|
+
type: "object",
|
|
3316
|
+
properties: {
|
|
3317
|
+
op: { type: "string", enum: ["add", "replace", "forget"] },
|
|
3318
|
+
text: { type: "string" },
|
|
3319
|
+
id: { anyOf: [{ type: "integer", minimum: 1 }, { type: "string", pattern: "^r[1-9][0-9]*$" }] }
|
|
3320
|
+
},
|
|
3321
|
+
required: ["op"],
|
|
3322
|
+
additionalProperties: false
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
};
|
|
3326
|
+
var MEMORY_PIN_TOOL = {
|
|
3327
|
+
type: "function",
|
|
3328
|
+
function: {
|
|
3329
|
+
name: MEMORY_NOTE,
|
|
3330
|
+
description: (
|
|
3331
|
+
// [COPY, NEW] model-facing. Signed off by Eric 2026-10-11 with the final-review copy fixes (plan Task 37).
|
|
3332
|
+
`Pin one short fact the owner asked you to keep, or forget one remembered item. Pin only when the owner asks. You may reword, but every word that carries meaning must be a word the owner typed in this message, in a pinned fact, or in the owner's earlier typed words shown to you (words spoken on a call do not count); otherwise the pin is refused. Words the owner speaks on a call in this private chat may be remembered automatically, but they cannot be pinned. Never pin credentials, keys or passwords. To forget an item shown to you as [r12], pass op forget with id "r12". You can make only one change per owner message.`
|
|
3333
|
+
),
|
|
3334
|
+
parameters: {
|
|
3335
|
+
type: "object",
|
|
3336
|
+
properties: {
|
|
3337
|
+
op: { type: "string", enum: ["add", "forget"] },
|
|
3338
|
+
text: { type: "string" },
|
|
3339
|
+
id: { type: "string", pattern: "^r[1-9][0-9]*$" }
|
|
3340
|
+
},
|
|
3341
|
+
required: ["op"],
|
|
3342
|
+
additionalProperties: false
|
|
3343
|
+
}
|
|
3344
|
+
}
|
|
3345
|
+
};
|
|
3346
|
+
function memoryNoteAllowed(auth, m) {
|
|
3347
|
+
return auth.sender_membership === "active" && auth.memory_write === true && m.memoryNote !== void 0;
|
|
3348
|
+
}
|
|
3349
|
+
var MEMORY_NOTE_MAX_TEXT_UNITS = 2 * 600;
|
|
3350
|
+
var REFUSAL = {
|
|
3351
|
+
too_long: "Not saved. The note text is too long. Ask the owner to type the fact in one short message.",
|
|
3352
|
+
provenance: `Not saved. A note must be copied word for word from the owner's current message, and that message must be short. Ask the owner to type the fact in one short message, for example "Remember: ...".`,
|
|
3353
|
+
secret: "Not saved. The text looks like a password or a key. Never store credentials.",
|
|
3354
|
+
cap: "Not saved. The notes are full or the note is too long. Replace or forget older notes first.",
|
|
3355
|
+
audience: "Not saved. Notes can be saved only from the owner's own typed message in their private chat.",
|
|
3356
|
+
cleared: "Not saved. The owner cleared the notes after this message arrived.",
|
|
3357
|
+
// [COPY] spec 7b §9.1.
|
|
3358
|
+
budget: "One change per message. Ask the owner to confirm the next one.",
|
|
3359
|
+
// [COPY, NEW] Approved by Eric 2026-10-09. The bridge emits it when the chat's memory was purged after the write was queued.
|
|
3360
|
+
purged: "Not saved. This chat's memory was removed after this message arrived.",
|
|
3361
|
+
mode: "Not saved. Notes are turned off for this agent right now."
|
|
3362
|
+
};
|
|
3363
|
+
var UNIFIED_PIN_CAP = 200;
|
|
3364
|
+
var UNIFIED_PIN_MAX_CHARS = 400;
|
|
3365
|
+
var UNIFIED_REFUSAL = {
|
|
3366
|
+
// [COPY, NEW] Mini live check 2026-10-11: the model asked the owner to retype a fact they had just typed. Approved by Eric 2026-10-11.
|
|
3367
|
+
not_owner_words: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact.",
|
|
3368
|
+
cap: `Not pinned. There are already ${UNIFIED_PIN_CAP} pinned facts, or the fact is longer than ${UNIFIED_PIN_MAX_CHARS} characters. Ask the owner to forget a pinned fact first, or to shorten it.`,
|
|
3369
|
+
secret: REFUSAL.secret,
|
|
3370
|
+
audience: "Not pinned. Facts can be pinned only from the owner's own typed message in their private chat.",
|
|
3371
|
+
cleared: REFUSAL.cleared,
|
|
3372
|
+
budget: REFUSAL.budget,
|
|
3373
|
+
purged: REFUSAL.purged,
|
|
3374
|
+
mode: "Not pinned. Memory is turned off for this agent right now.",
|
|
3375
|
+
invalid: "Not changed. The fact is already pinned or has no usable words, the id was not shown with this message, or the request was not a pin or a forget.",
|
|
3376
|
+
// [COPY, NEW] fix round 1: unified turns never fall back to the legacy "replace" wording.
|
|
3377
|
+
too_long: "Not pinned. The fact is too long. Ask the owner to type the fact in one short message.",
|
|
3378
|
+
provenance: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact in one short message.",
|
|
3379
|
+
failed: "Not changed. The memory could not be updated right now. Try again later.",
|
|
3380
|
+
// [COPY, NEW] C26: the bridge also emits these; no spec wording, so plain wording in the style of REFUSAL.
|
|
3381
|
+
message: "Not pinned. The owner's message could not be used for this change. Ask the owner to type the fact again.",
|
|
3382
|
+
store: "Not changed. The memory could not be saved right now. Try again later."
|
|
3383
|
+
};
|
|
3384
|
+
var UNIFIED_ARGS_ERROR = 'memory_note needs op add with text, or op forget with an id like "r12"';
|
|
3385
|
+
function parseArgs(raw) {
|
|
3386
|
+
let parsed;
|
|
3387
|
+
try {
|
|
3388
|
+
parsed = JSON.parse(raw);
|
|
3389
|
+
} catch {
|
|
3390
|
+
return null;
|
|
3391
|
+
}
|
|
3392
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
3393
|
+
const a = parsed;
|
|
3394
|
+
const id = typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id >= 1 ? a.id : void 0;
|
|
3395
|
+
const text = typeof a.text === "string" && a.text.length > 0 ? a.text : void 0;
|
|
3396
|
+
if (a.op === "add" && text !== void 0 && a.id === void 0) return { op: "add", text };
|
|
3397
|
+
if (a.op === "replace" && text !== void 0 && id !== void 0) return { op: "replace", text, id };
|
|
3398
|
+
const recallId = typeof a.id === "string" && /^r[1-9][0-9]{0,15}$/.test(a.id) && Number.isSafeInteger(Number(a.id.slice(1))) ? a.id : void 0;
|
|
3399
|
+
if (a.op === "forget" && a.text === void 0) {
|
|
3400
|
+
if (id !== void 0) return { op: "forget", id };
|
|
3401
|
+
if (recallId !== void 0) return { op: "forget", id: recallId };
|
|
3402
|
+
}
|
|
3403
|
+
return null;
|
|
3404
|
+
}
|
|
3405
|
+
async function runMemoryNoteCalls(calls, m, ctx = {}) {
|
|
3406
|
+
const clock = ctx.clock ?? (() => performance.now());
|
|
3407
|
+
const out = [];
|
|
3408
|
+
const refusal = ctx.unified ? UNIFIED_REFUSAL : REFUSAL;
|
|
3409
|
+
for (const call of calls) {
|
|
3410
|
+
const started = clock();
|
|
3411
|
+
let content;
|
|
3412
|
+
let cancelled = false;
|
|
3413
|
+
if (ctx.signal?.aborted) {
|
|
3414
|
+
cancelled = true;
|
|
3415
|
+
content = toolError("Tool execution cancelled");
|
|
3416
|
+
} else {
|
|
3417
|
+
const req = parseArgs(call.function.arguments);
|
|
3418
|
+
if (!req || !m.memoryNote) content = toolError(ctx.unified ? UNIFIED_ARGS_ERROR : "memory_note needs op add with text, replace with id and text, or forget with id");
|
|
3419
|
+
else if (req.text !== void 0 && req.text.length > MEMORY_NOTE_MAX_TEXT_UNITS) {
|
|
3420
|
+
content = JSON.stringify({ saved: false, reason: "too_long", message: refusal.too_long });
|
|
3421
|
+
} else {
|
|
3422
|
+
let r;
|
|
3423
|
+
try {
|
|
3424
|
+
r = await m.memoryNote(req);
|
|
3425
|
+
} catch {
|
|
3426
|
+
r = { ok: false, reason: "failed" };
|
|
3427
|
+
}
|
|
3428
|
+
if (!r.ok && r.reason === "aborted") cancelled = true;
|
|
3429
|
+
const reason = r.reason ?? "unknown";
|
|
3430
|
+
content = JSON.stringify(r.ok ? { saved: true, op: req.op, id: r.id } : { saved: false, reason, message: Object.hasOwn(refusal, reason) ? refusal[reason] : "Not saved." });
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
3434
|
+
ctx.onTiming?.({ tool_call_id: call.id, ms: clock() - started, ok: !content.includes('"saved":false') && !content.startsWith('{"error"'), cancelled });
|
|
3435
|
+
}
|
|
3436
|
+
return out;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
// src/effect-class.ts
|
|
3440
|
+
var BUILTIN_EFFECT_CLASS = Object.freeze({
|
|
3441
|
+
current_time: "local",
|
|
3442
|
+
echo: "local",
|
|
3443
|
+
web_search: "read",
|
|
3444
|
+
fetch_url: "read",
|
|
3445
|
+
sql_query: "read",
|
|
3446
|
+
http_request: "per_call"
|
|
3447
|
+
});
|
|
3448
|
+
var BUILTIN_NAMES = new Set(Object.keys(BUILTINS));
|
|
3449
|
+
function builtinNameMismatch(manifest) {
|
|
3450
|
+
if (manifest.name === MEMORY_NOTE) return true;
|
|
3451
|
+
if (manifest.handler.startsWith("builtin:")) return manifest.handler.slice("builtin:".length) !== manifest.name;
|
|
3452
|
+
return typeof manifest.name === "string" && BUILTIN_NAMES.has(manifest.name);
|
|
3453
|
+
}
|
|
3454
|
+
function effectClassOf(manifest, args) {
|
|
3455
|
+
if (!manifest) return "uninspectable";
|
|
3456
|
+
const handler = manifest.handler;
|
|
3457
|
+
if (handler.startsWith("builtin:")) {
|
|
3458
|
+
const name = handler.slice("builtin:".length);
|
|
3459
|
+
if (!Object.hasOwn(BUILTIN_EFFECT_CLASS, name)) return "uninspectable";
|
|
3460
|
+
const cls = BUILTIN_EFFECT_CLASS[name];
|
|
3461
|
+
if (cls === "per_call") return httpMethodIsGet(args) ? "read" : "side_effect";
|
|
3462
|
+
return cls;
|
|
3463
|
+
}
|
|
3464
|
+
if (handler.startsWith("script:")) {
|
|
3465
|
+
return manifest.effects === "read" ? "uninspectable" : "side_effect";
|
|
3466
|
+
}
|
|
3467
|
+
return "uninspectable";
|
|
3468
|
+
}
|
|
3469
|
+
function manifestOfferableOnGatedTurn(manifest) {
|
|
3470
|
+
if (manifest?.handler === "builtin:http_request") return true;
|
|
3471
|
+
const cls = effectClassOf(manifest, void 0);
|
|
3472
|
+
return cls === "local" || cls === "read";
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3264
3475
|
// src/side-effect-ledger.ts
|
|
3265
3476
|
import { createHash } from "node:crypto";
|
|
3266
3477
|
import { chmodSync, mkdirSync as mkdirSync3 } from "node:fs";
|
|
@@ -3446,6 +3657,10 @@ var SkillInvoker = class {
|
|
|
3446
3657
|
}
|
|
3447
3658
|
/** Later file wins, with a warning naming both paths (spec §3). */
|
|
3448
3659
|
register(manifest, path) {
|
|
3660
|
+
if (builtinNameMismatch(manifest)) {
|
|
3661
|
+
writeServiceLog("warn", "skills.builtin_name_mismatch");
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3449
3664
|
const prior = this.sources.get(manifest.name);
|
|
3450
3665
|
if (prior) writeServiceLog("warn", "skills.duplicate_replaced");
|
|
3451
3666
|
this.skills.set(manifest.name, manifest);
|
|
@@ -3503,6 +3718,18 @@ var SkillInvoker = class {
|
|
|
3503
3718
|
}
|
|
3504
3719
|
return out;
|
|
3505
3720
|
}
|
|
3721
|
+
/** R1, R2: this call's effect class, from the loaded manifest's handler, never from the name. */
|
|
3722
|
+
effectClass(name, args) {
|
|
3723
|
+
return effectClassOf(this.skills.get(name), args);
|
|
3724
|
+
}
|
|
3725
|
+
/** R4: the handler the memory tool gate inspects for; undefined when no manifest of that name is loaded. */
|
|
3726
|
+
handlerOf(name) {
|
|
3727
|
+
return this.skills.get(name)?.handler;
|
|
3728
|
+
}
|
|
3729
|
+
/** R5: false for a tool the gated-turn offer filter drops. */
|
|
3730
|
+
offerableOnGatedTurn(name) {
|
|
3731
|
+
return manifestOfferableOnGatedTurn(this.skills.get(name));
|
|
3732
|
+
}
|
|
3506
3733
|
/**
|
|
3507
3734
|
* Execute a skill by name with the given arguments.
|
|
3508
3735
|
*
|
|
@@ -4460,138 +4687,6 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
4460
4687
|
return out;
|
|
4461
4688
|
}
|
|
4462
4689
|
|
|
4463
|
-
// src/memory-note.ts
|
|
4464
|
-
var MEMORY_NOTE = "memory_note";
|
|
4465
|
-
var MEMORY_NOTE_TOOL = {
|
|
4466
|
-
type: "function",
|
|
4467
|
-
function: {
|
|
4468
|
-
name: MEMORY_NOTE,
|
|
4469
|
-
description: "Save, change or delete one short note in this chat's saved notes. Save only what the owner asked you to keep. First check the saved notes already shown to you: if the fact is already saved, do not call this tool; just tell the owner it is already remembered. `text` must be copied word for word from the owner's CURRENT message (a contiguous part of it is fine). You cannot save something said earlier, said in a call, found by a tool, or your own summary. If the owner asks you to remember something from earlier, ask them to type it in one short message. Never store credentials, keys or passwords. To change or delete a note, pass its id: [n7] is id 7. To forget a remembered message shown to you as [r12], pass op forget with id \"r12\". You can make only one change (replace or forget) per owner message: One change per message. Ask the owner to confirm the next one.",
|
|
4470
|
-
parameters: {
|
|
4471
|
-
type: "object",
|
|
4472
|
-
properties: {
|
|
4473
|
-
op: { type: "string", enum: ["add", "replace", "forget"] },
|
|
4474
|
-
text: { type: "string" },
|
|
4475
|
-
id: { anyOf: [{ type: "integer", minimum: 1 }, { type: "string", pattern: "^r[1-9][0-9]*$" }] }
|
|
4476
|
-
},
|
|
4477
|
-
required: ["op"],
|
|
4478
|
-
additionalProperties: false
|
|
4479
|
-
}
|
|
4480
|
-
}
|
|
4481
|
-
};
|
|
4482
|
-
var MEMORY_PIN_TOOL = {
|
|
4483
|
-
type: "function",
|
|
4484
|
-
function: {
|
|
4485
|
-
name: MEMORY_NOTE,
|
|
4486
|
-
description: (
|
|
4487
|
-
// [COPY, NEW] model-facing. Signed off by Eric 2026-10-11 with the final-review copy fixes (plan Task 37).
|
|
4488
|
-
`Pin one short fact the owner asked you to keep, or forget one remembered item. Pin only when the owner asks. You may reword, but every word that carries meaning must be a word the owner typed in this message, in a pinned fact, or in the owner's earlier typed words shown to you (words spoken on a call do not count); otherwise the pin is refused. Words the owner speaks on a call in this private chat may be remembered automatically, but they cannot be pinned. Never pin credentials, keys or passwords. To forget an item shown to you as [r12], pass op forget with id "r12". You can make only one change per owner message.`
|
|
4489
|
-
),
|
|
4490
|
-
parameters: {
|
|
4491
|
-
type: "object",
|
|
4492
|
-
properties: {
|
|
4493
|
-
op: { type: "string", enum: ["add", "forget"] },
|
|
4494
|
-
text: { type: "string" },
|
|
4495
|
-
id: { type: "string", pattern: "^r[1-9][0-9]*$" }
|
|
4496
|
-
},
|
|
4497
|
-
required: ["op"],
|
|
4498
|
-
additionalProperties: false
|
|
4499
|
-
}
|
|
4500
|
-
}
|
|
4501
|
-
};
|
|
4502
|
-
function memoryNoteAllowed(auth, m) {
|
|
4503
|
-
return auth.sender_membership === "active" && auth.memory_write === true && m.memoryNote !== void 0;
|
|
4504
|
-
}
|
|
4505
|
-
var MEMORY_NOTE_MAX_TEXT_UNITS = 2 * 600;
|
|
4506
|
-
var REFUSAL = {
|
|
4507
|
-
too_long: "Not saved. The note text is too long. Ask the owner to type the fact in one short message.",
|
|
4508
|
-
provenance: `Not saved. A note must be copied word for word from the owner's current message, and that message must be short. Ask the owner to type the fact in one short message, for example "Remember: ...".`,
|
|
4509
|
-
secret: "Not saved. The text looks like a password or a key. Never store credentials.",
|
|
4510
|
-
cap: "Not saved. The notes are full or the note is too long. Replace or forget older notes first.",
|
|
4511
|
-
audience: "Not saved. Notes can be saved only from the owner's own typed message in their private chat.",
|
|
4512
|
-
cleared: "Not saved. The owner cleared the notes after this message arrived.",
|
|
4513
|
-
// [COPY] spec 7b §9.1.
|
|
4514
|
-
budget: "One change per message. Ask the owner to confirm the next one.",
|
|
4515
|
-
// [COPY, NEW] Approved by Eric 2026-10-09. The bridge emits it when the chat's memory was purged after the write was queued.
|
|
4516
|
-
purged: "Not saved. This chat's memory was removed after this message arrived.",
|
|
4517
|
-
mode: "Not saved. Notes are turned off for this agent right now."
|
|
4518
|
-
};
|
|
4519
|
-
var UNIFIED_PIN_CAP = 200;
|
|
4520
|
-
var UNIFIED_PIN_MAX_CHARS = 400;
|
|
4521
|
-
var UNIFIED_REFUSAL = {
|
|
4522
|
-
// [COPY, NEW] Mini live check 2026-10-11: the model asked the owner to retype a fact they had just typed. Approved by Eric 2026-10-11.
|
|
4523
|
-
not_owner_words: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact.",
|
|
4524
|
-
cap: `Not pinned. There are already ${UNIFIED_PIN_CAP} pinned facts, or the fact is longer than ${UNIFIED_PIN_MAX_CHARS} characters. Ask the owner to forget a pinned fact first, or to shorten it.`,
|
|
4525
|
-
secret: REFUSAL.secret,
|
|
4526
|
-
audience: "Not pinned. Facts can be pinned only from the owner's own typed message in their private chat.",
|
|
4527
|
-
cleared: REFUSAL.cleared,
|
|
4528
|
-
budget: REFUSAL.budget,
|
|
4529
|
-
purged: REFUSAL.purged,
|
|
4530
|
-
mode: "Not pinned. Memory is turned off for this agent right now.",
|
|
4531
|
-
invalid: "Not changed. The fact is already pinned or has no usable words, the id was not shown with this message, or the request was not a pin or a forget.",
|
|
4532
|
-
// [COPY, NEW] fix round 1: unified turns never fall back to the legacy "replace" wording.
|
|
4533
|
-
too_long: "Not pinned. The fact is too long. Ask the owner to type the fact in one short message.",
|
|
4534
|
-
provenance: "Not pinned. A pinned fact must use the owner's own typed words from this message or from their earlier typed words shown to you. Words spoken on a call do not count. Call memory_note once more with the fact copied exactly from the owner's words in this message. Only if that is refused too, ask the owner to type the fact in one short message.",
|
|
4535
|
-
failed: "Not changed. The memory could not be updated right now. Try again later.",
|
|
4536
|
-
// [COPY, NEW] C26: the bridge also emits these; no spec wording, so plain wording in the style of REFUSAL.
|
|
4537
|
-
message: "Not pinned. The owner's message could not be used for this change. Ask the owner to type the fact again.",
|
|
4538
|
-
store: "Not changed. The memory could not be saved right now. Try again later."
|
|
4539
|
-
};
|
|
4540
|
-
var UNIFIED_ARGS_ERROR = 'memory_note needs op add with text, or op forget with an id like "r12"';
|
|
4541
|
-
function parseArgs(raw) {
|
|
4542
|
-
let parsed;
|
|
4543
|
-
try {
|
|
4544
|
-
parsed = JSON.parse(raw);
|
|
4545
|
-
} catch {
|
|
4546
|
-
return null;
|
|
4547
|
-
}
|
|
4548
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
4549
|
-
const a = parsed;
|
|
4550
|
-
const id = typeof a.id === "number" && Number.isSafeInteger(a.id) && a.id >= 1 ? a.id : void 0;
|
|
4551
|
-
const text = typeof a.text === "string" && a.text.length > 0 ? a.text : void 0;
|
|
4552
|
-
if (a.op === "add" && text !== void 0 && a.id === void 0) return { op: "add", text };
|
|
4553
|
-
if (a.op === "replace" && text !== void 0 && id !== void 0) return { op: "replace", text, id };
|
|
4554
|
-
const recallId = typeof a.id === "string" && /^r[1-9][0-9]{0,15}$/.test(a.id) && Number.isSafeInteger(Number(a.id.slice(1))) ? a.id : void 0;
|
|
4555
|
-
if (a.op === "forget" && a.text === void 0) {
|
|
4556
|
-
if (id !== void 0) return { op: "forget", id };
|
|
4557
|
-
if (recallId !== void 0) return { op: "forget", id: recallId };
|
|
4558
|
-
}
|
|
4559
|
-
return null;
|
|
4560
|
-
}
|
|
4561
|
-
async function runMemoryNoteCalls(calls, m, ctx = {}) {
|
|
4562
|
-
const clock = ctx.clock ?? (() => performance.now());
|
|
4563
|
-
const out = [];
|
|
4564
|
-
const refusal = ctx.unified ? UNIFIED_REFUSAL : REFUSAL;
|
|
4565
|
-
for (const call of calls) {
|
|
4566
|
-
const started = clock();
|
|
4567
|
-
let content;
|
|
4568
|
-
let cancelled = false;
|
|
4569
|
-
if (ctx.signal?.aborted) {
|
|
4570
|
-
cancelled = true;
|
|
4571
|
-
content = toolError("Tool execution cancelled");
|
|
4572
|
-
} else {
|
|
4573
|
-
const req = parseArgs(call.function.arguments);
|
|
4574
|
-
if (!req || !m.memoryNote) content = toolError(ctx.unified ? UNIFIED_ARGS_ERROR : "memory_note needs op add with text, replace with id and text, or forget with id");
|
|
4575
|
-
else if (req.text !== void 0 && req.text.length > MEMORY_NOTE_MAX_TEXT_UNITS) {
|
|
4576
|
-
content = JSON.stringify({ saved: false, reason: "too_long", message: refusal.too_long });
|
|
4577
|
-
} else {
|
|
4578
|
-
let r;
|
|
4579
|
-
try {
|
|
4580
|
-
r = await m.memoryNote(req);
|
|
4581
|
-
} catch {
|
|
4582
|
-
r = { ok: false, reason: "failed" };
|
|
4583
|
-
}
|
|
4584
|
-
if (!r.ok && r.reason === "aborted") cancelled = true;
|
|
4585
|
-
const reason = r.reason ?? "unknown";
|
|
4586
|
-
content = JSON.stringify(r.ok ? { saved: true, op: req.op, id: r.id } : { saved: false, reason, message: Object.hasOwn(refusal, reason) ? refusal[reason] : "Not saved." });
|
|
4587
|
-
}
|
|
4588
|
-
}
|
|
4589
|
-
out.push({ role: "tool", content, tool_call_id: call.id });
|
|
4590
|
-
ctx.onTiming?.({ tool_call_id: call.id, ms: clock() - started, ok: !content.includes('"saved":false') && !content.startsWith('{"error"'), cancelled });
|
|
4591
|
-
}
|
|
4592
|
-
return out;
|
|
4593
|
-
}
|
|
4594
|
-
|
|
4595
4690
|
// src/memory-text.ts
|
|
4596
4691
|
var QUOTES = {
|
|
4597
4692
|
"\u2018": "'",
|
|
@@ -4628,12 +4723,18 @@ var STOP_WORDS = new Set(
|
|
|
4628
4723
|
);
|
|
4629
4724
|
|
|
4630
4725
|
// src/memory-tool-gate.ts
|
|
4631
|
-
var GATED_TOOLS =
|
|
4632
|
-
var NON_NETWORK_TOOLS = /* @__PURE__ */ new Set([
|
|
4726
|
+
var GATED_TOOLS = new Set(Object.keys(BUILTIN_EFFECT_CLASS).filter((n) => BUILTIN_EFFECT_CLASS[n] !== "local"));
|
|
4727
|
+
var NON_NETWORK_TOOLS = /* @__PURE__ */ new Set([...Object.keys(BUILTIN_EFFECT_CLASS).filter((n) => BUILTIN_EFFECT_CLASS[n] === "local"), MEMORY_NOTE]);
|
|
4633
4728
|
var RUN_LEN = 3;
|
|
4634
4729
|
var COMPARE_BUDGET_CHARS = 200;
|
|
4635
4730
|
var MEMORY_GATE_REFUSAL = "Refused: this request repeats a number, link, address or wording from memory or the chat history that the owner did not type in this message. Ask the owner to confirm or type it.";
|
|
4636
|
-
var MEMORY_GATE_ACTION_REFUSAL = "Refused:
|
|
4731
|
+
var MEMORY_GATE_ACTION_REFUSAL = "Refused: in this chat, a tool that sends, changes or deletes something cannot run yet. Tell the owner it was not done.";
|
|
4732
|
+
var MEMORY_GATE_UNAVAILABLE_REFUSAL = "Refused: this tool cannot be used in this chat. Do not ask the owner to retype anything; tell them the tool is unavailable here.";
|
|
4733
|
+
var REASON = {
|
|
4734
|
+
copy: MEMORY_GATE_REFUSAL,
|
|
4735
|
+
action: MEMORY_GATE_ACTION_REFUSAL,
|
|
4736
|
+
unavailable: MEMORY_GATE_UNAVAILABLE_REFUSAL
|
|
4737
|
+
};
|
|
4637
4738
|
var URL_RE = /\b(?:https?|ftp):\/\/[^\s"'<>]+|\bwww\.[^\s"'<>]+/gi;
|
|
4638
4739
|
var EMAIL_RE = /[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+/g;
|
|
4639
4740
|
var BASE64_RE = /[A-Za-z0-9+/=_-]{25,}/g;
|
|
@@ -4646,11 +4747,12 @@ function memoryGateApplies(m, unified) {
|
|
|
4646
4747
|
const ownerDm = m.authorization.audience === "owner_dm" || m.authorization.relay_speaker === "owner";
|
|
4647
4748
|
return ownerDm || unified.items.length > 0 || unified.pinnedMore > 0;
|
|
4648
4749
|
}
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4750
|
+
var OUTBOUND = Object.freeze({
|
|
4751
|
+
"builtin:web_search": (a) => coerceWebSearchArgs(a).outbound,
|
|
4752
|
+
"builtin:fetch_url": (a) => coerceFetchUrlArgs(a).outbound,
|
|
4753
|
+
"builtin:http_request": (a) => coerceHttpRequestArgs(a).outbound,
|
|
4754
|
+
"builtin:sql_query": (a) => coerceSqlQueryArgs(a).outbound
|
|
4755
|
+
});
|
|
4654
4756
|
var runs = (ts) => {
|
|
4655
4757
|
const out = [];
|
|
4656
4758
|
for (let i = 0; i + RUN_LEN <= ts.length; i++) {
|
|
@@ -4668,8 +4770,13 @@ var MemoryToolGate = class {
|
|
|
4668
4770
|
compareRuns = /* @__PURE__ */ new Set();
|
|
4669
4771
|
spent = 0;
|
|
4670
4772
|
year;
|
|
4671
|
-
|
|
4773
|
+
tools;
|
|
4774
|
+
/**
|
|
4775
|
+
* `tools` is the turn's skill snapshot (R2, R8): the class and handler of each tool. `now` is the test seam for the
|
|
4776
|
+
* year window; production passes nothing (Date.now).
|
|
4777
|
+
*/
|
|
4672
4778
|
constructor(o) {
|
|
4779
|
+
this.tools = o.tools;
|
|
4673
4780
|
this.year = new Date((o.now ?? Date.now)()).getUTCFullYear();
|
|
4674
4781
|
const ot = tokens(o.ownerMessage);
|
|
4675
4782
|
this.ownerTokens = new Set(ot);
|
|
@@ -4684,30 +4791,48 @@ var MemoryToolGate = class {
|
|
|
4684
4791
|
isNearYear(t) {
|
|
4685
4792
|
return YEAR_RE.test(t) && Math.abs(Number(t) - this.year) <= YEAR_WINDOW;
|
|
4686
4793
|
}
|
|
4687
|
-
refuse() {
|
|
4688
|
-
return { allowed: false, reason:
|
|
4794
|
+
refuse(code, effectClass) {
|
|
4795
|
+
return { allowed: false, reason: REASON[code], gate: { code, effectClass } };
|
|
4689
4796
|
}
|
|
4797
|
+
/** R3: the verdict follows the class of the handler that will run (R1, R2), never the tool's name. */
|
|
4690
4798
|
check(toolName, argumentsJson) {
|
|
4691
|
-
if (NON_NETWORK_TOOLS.has(toolName)) return { allowed: true };
|
|
4692
|
-
if (!GATED_TOOLS.has(toolName)) return this.refuse();
|
|
4693
4799
|
let args;
|
|
4800
|
+
let parsed = true;
|
|
4694
4801
|
try {
|
|
4695
4802
|
args = JSON.parse(argumentsJson);
|
|
4696
4803
|
} catch {
|
|
4697
|
-
|
|
4804
|
+
parsed = false;
|
|
4805
|
+
}
|
|
4806
|
+
const cls = this.tools.effectClass(toolName, parsed ? args : void 0);
|
|
4807
|
+
if (cls === "local") return { allowed: true };
|
|
4808
|
+
if (cls === "uninspectable") return this.refuse("unavailable", cls);
|
|
4809
|
+
if (!parsed) return this.refuse("copy", cls);
|
|
4810
|
+
if (cls === "side_effect") return this.refuse("action", cls);
|
|
4811
|
+
const handler = this.tools.handlerOf(toolName);
|
|
4812
|
+
const outboundOf = handler !== void 0 && Object.hasOwn(OUTBOUND, handler) ? OUTBOUND[handler] : void 0;
|
|
4813
|
+
if (!outboundOf) return this.refuse("unavailable", cls);
|
|
4814
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return this.refuse("copy", cls);
|
|
4815
|
+
let out;
|
|
4816
|
+
try {
|
|
4817
|
+
out = outboundOf(args);
|
|
4818
|
+
} catch {
|
|
4819
|
+
return this.refuse("copy", cls);
|
|
4698
4820
|
}
|
|
4699
|
-
if (
|
|
4700
|
-
|
|
4821
|
+
if (!out.sendable) return this.refuse("copy", cls);
|
|
4822
|
+
for (const text of out.patterns) {
|
|
4823
|
+
for (const m of text.match(URL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4824
|
+
for (const m of text.match(EMAIL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4825
|
+
for (const m of text.match(BASE64_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4701
4826
|
}
|
|
4702
|
-
const strs = [];
|
|
4703
|
-
leaves(args, strs);
|
|
4704
4827
|
let charge = 0;
|
|
4705
4828
|
let yearUsed = false;
|
|
4706
|
-
for (const
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
for (const m of s.match(
|
|
4829
|
+
for (const f of out.fields) {
|
|
4830
|
+
if (handler === "builtin:web_search" && f.key === "count" && typeof f.source === "number") continue;
|
|
4831
|
+
const s = f.value;
|
|
4832
|
+
const asciiYears = new Set(typeof f.source === "string" ? [...s.matchAll(ASCII_YEAR_RE)].map((m) => m[0]) : []);
|
|
4833
|
+
for (const m of s.match(URL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4834
|
+
for (const m of s.match(EMAIL_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4835
|
+
for (const m of s.match(BASE64_RE) ?? []) if (!this.ownerFolded.includes(foldText(m))) return this.refuse("copy", cls);
|
|
4711
4836
|
const ts = tokens(s);
|
|
4712
4837
|
for (const t of ts) {
|
|
4713
4838
|
if (STOP_WORDS.has(t) || ONE_LETTER_ROMAN.has(t)) continue;
|
|
@@ -4716,13 +4841,14 @@ var MemoryToolGate = class {
|
|
|
4716
4841
|
yearUsed = true;
|
|
4717
4842
|
continue;
|
|
4718
4843
|
}
|
|
4719
|
-
return this.refuse();
|
|
4844
|
+
return this.refuse("copy", cls);
|
|
4720
4845
|
}
|
|
4721
4846
|
}
|
|
4722
|
-
for (const r of runs(ts)) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse();
|
|
4847
|
+
for (const r of runs(ts)) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse("copy", cls);
|
|
4723
4848
|
for (const t of ts) if (!STOP_WORDS.has(t) && this.compareTokens.has(t) && !this.ownerTokens.has(t)) charge += t.length;
|
|
4724
4849
|
}
|
|
4725
|
-
|
|
4850
|
+
for (const text of out.runs) for (const r of runs(tokens(text))) if (this.compareRuns.has(r) && !this.ownerRuns.has(r)) return this.refuse("copy", cls);
|
|
4851
|
+
if (this.spent + charge > COMPARE_BUDGET_CHARS) return this.refuse("copy", cls);
|
|
4726
4852
|
this.spent += charge;
|
|
4727
4853
|
return { allowed: true };
|
|
4728
4854
|
}
|
|
@@ -5177,9 +5303,11 @@ ${recalledText}` } : null;
|
|
|
5177
5303
|
const toolsStart = clock();
|
|
5178
5304
|
const eligibleTools = deps.tools();
|
|
5179
5305
|
const tools = eligibleTools.filter((tool) => toolIsOffered(m.authorization, tool.function.name));
|
|
5306
|
+
const gatedTurn = memoryGateApplies(m, unified);
|
|
5307
|
+
if (gatedTurn) tools.splice(0, tools.length, ...tools.filter((tool) => skills.offerableOnGatedTurn(tool.function.name)));
|
|
5180
5308
|
if (memoryNoteAllowed(m.authorization, m)) tools.push(m.memoryUnified === true ? MEMORY_PIN_TOOL : MEMORY_NOTE_TOOL);
|
|
5181
5309
|
const offeredNames = new Set(tools.map((tool) => tool.function.name));
|
|
5182
|
-
const memoryGate =
|
|
5310
|
+
const memoryGate = gatedTurn ? new MemoryToolGate({ ownerMessage: text, compareTexts: [...unified.items.map((i) => i.text), ...history.map((h) => typeof h.content === "string" ? h.content : "")], tools: skills }) : void 0;
|
|
5183
5311
|
const systemParts = [persona];
|
|
5184
5312
|
if (m.voiceGuidance) systemParts.push(`${VOICE_TURN_PREFIX}${m.voiceGuidance}`);
|
|
5185
5313
|
if (tools.length > 0) systemParts.push(toolAvailabilityLine([...offeredNames]));
|
|
@@ -5408,7 +5536,9 @@ ${notesLines.slice(0, k).join("\n")}` };
|
|
|
5408
5536
|
for (const v of verdicts) {
|
|
5409
5537
|
if (!v.verdict.allowed) {
|
|
5410
5538
|
denied++;
|
|
5411
|
-
|
|
5539
|
+
const g = v.verdict.gate;
|
|
5540
|
+
if (g) log("warn", "turn.tool_call_denied", { reason: logLabel(g.code), class: logLabel(g.effectClass) });
|
|
5541
|
+
else log("warn", "turn.tool_call_denied");
|
|
5412
5542
|
}
|
|
5413
5543
|
}
|
|
5414
5544
|
if (turnCapDenied > 0) {
|