@inetafrica/open-claudia 3.5.18 → 3.5.19
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 +9 -0
- package/core/dedup-adjudicator.js +42 -19
- package/core/recall-filter.js +28 -9
- package/core/utility-agent.js +7 -0
- package/package.json +1 -1
- package/test-provider-language.js +6 -1
- package/test-provider-recall.js +38 -5
- package/test-utility-provider-policy.js +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.5.19
|
|
4
|
+
|
|
5
|
+
The memory duplicate-check fix as its own release (Sumeet, 8 Oct 13:08 EAT: "I'd rather just push out another release now rather than waiting for another day or so"). Everything in 3.5.18 below comes with it.
|
|
6
|
+
|
|
7
|
+
- **The memory duplicate check works.** Before a reviewer's journal fact is stored, the dedup judge (3.2.0) checks it against similar stored lines: store, skip, update or merge. Its reply format was a bare JSON array, and Claude Code turns the format into a tool whose input the API requires to be an object, so every call from 3.2.2 on got "API Error: 400 tools.8.custom.input_schema.type: Input should be 'object'" and the fact was stored as proposed (1,459 times on the owner's bot, after 248 "Unknown utility purpose: dedup" on 3.2.0). The reply is now `{"verdicts": [...]}` (a bare array is still read), and each judgement logs one line with its verdicts (`[dedup-adjudicator] judged 2 facts: 1 store, 1 skip`). Exact repeats were already skipped without the judge.
|
|
8
|
+
- **The classic recall filter works on Claude.** The relevance filter had the same bare-array format, so with `/engine classic` every Claude-run filter call failed and recall kept every keyword match. Its reply is now `{"relevant": [...]}`. Live calls still skip the filter.
|
|
9
|
+
- **A reply format without an object root is refused before any model runs** (`INVALID_UTILITY_SCHEMA`), so the next one fails in tests instead of quietly on every call.
|
|
10
|
+
- **Tests:** `test-dedup-adjudicator.js` (the judge through the real utility agent and the fake Claude and Codex CLIs; the object reply and a bare array), `test-provider-recall.js` (the filter's object schema reaches Claude Code, and Codex enforces it), `test-utility-provider-policy.js` (array, untyped and non-object roots refused before any spawn), `test-codex-output-schema.js` (every `*_OUTPUT_SCHEMA` in core/ has an object root).
|
|
11
|
+
|
|
3
12
|
## 3.5.18
|
|
4
13
|
|
|
5
14
|
Usage limits (Sumeet, 7 Oct 15:00 EAT: "track usage and notify the user that they are going to hit their limit, if the limit is hit can the bot auto schedule to continue when the limit replenishes"; 15:14 EAT: "if the user sends it a message it cancels and if the session limit message comes up again it will just rearm").
|
|
@@ -17,23 +17,32 @@ const GATE_MIN_SCORE = Number(process.env.PACK_DEDUP_GATE_SCORE || 25);
|
|
|
17
17
|
const ADJUDICATOR_TIMEOUT_MS = Number(process.env.PACK_DEDUP_TIMEOUT_MS || 45000);
|
|
18
18
|
const MAX_CANDIDATES_PER_FACT = 3;
|
|
19
19
|
|
|
20
|
+
// The root must be an object: Claude Code turns the schema into a tool whose
|
|
21
|
+
// input_schema the API requires to be an object. The bare array this used to
|
|
22
|
+
// be got a 400 on every call from 3.2.2 to 3.5.18, so dedup failed open.
|
|
20
23
|
const ADJUDICATOR_OUTPUT_SCHEMA = Object.freeze({
|
|
21
|
-
type: "
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
type: "object",
|
|
25
|
+
required: ["verdicts"],
|
|
26
|
+
properties: {
|
|
27
|
+
verdicts: {
|
|
28
|
+
type: "array",
|
|
29
|
+
items: {
|
|
30
|
+
type: "object",
|
|
31
|
+
required: ["id", "verdict"],
|
|
32
|
+
properties: {
|
|
33
|
+
id: { type: "string" },
|
|
34
|
+
verdict: { type: "string", enum: ["store", "update", "merge", "skip"] },
|
|
35
|
+
target: { type: "string" },
|
|
36
|
+
merged: { type: "string" },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
30
39
|
},
|
|
31
40
|
},
|
|
32
41
|
});
|
|
33
42
|
|
|
34
43
|
const ADJUDICATOR_SYSTEM_PROMPT = [
|
|
35
44
|
"You are a dedup judge for an assistant's long-term memory.",
|
|
36
|
-
|
|
45
|
+
'Reply with ONLY a JSON object {"verdicts": [...]} — no prose, no markdown fences.',
|
|
37
46
|
].join("\n");
|
|
38
47
|
|
|
39
48
|
function enabled() {
|
|
@@ -113,20 +122,29 @@ function buildAdjudicationPrompt(pairs) {
|
|
|
113
122
|
"",
|
|
114
123
|
...blocks,
|
|
115
124
|
"",
|
|
116
|
-
`Reply with ONLY a JSON
|
|
125
|
+
`Reply with ONLY a JSON object like {"verdicts":[{"id":"f1","verdict":"store"}]} covering every FACT id.`,
|
|
117
126
|
].join("\n");
|
|
118
127
|
}
|
|
119
128
|
|
|
129
|
+
// The reply is {"verdicts": [...]}; a bare array is still read.
|
|
130
|
+
function verdictList(value) {
|
|
131
|
+
if (Array.isArray(value)) return value;
|
|
132
|
+
return value && typeof value === "object" && Array.isArray(value.verdicts) ? value.verdicts : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
function parseVerdicts(result) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
const fromJson = verdictList(result?.json);
|
|
137
|
+
if (fromJson) return fromJson;
|
|
138
|
+
const text = String(result?.text || "");
|
|
139
|
+
for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*\]/]) {
|
|
140
|
+
const match = text.match(pattern);
|
|
141
|
+
if (!match) continue;
|
|
142
|
+
try {
|
|
143
|
+
const list = verdictList(JSON.parse(match[0]));
|
|
144
|
+
if (list) return list;
|
|
145
|
+
} catch (_) { /* try the other shape */ }
|
|
129
146
|
}
|
|
147
|
+
return null;
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
// Phase 3 — turn raw verdicts into a transformed decision plus replacement
|
|
@@ -220,6 +238,11 @@ function createDedupAdjudicator(dependencies = {}) {
|
|
|
220
238
|
});
|
|
221
239
|
rawVerdicts = parseVerdicts(result) || [];
|
|
222
240
|
llmUsed = true;
|
|
241
|
+
// One line per judgement, so its verdicts can be checked in the log
|
|
242
|
+
// (before 3.5.19 it logged only failures, and failed on every call).
|
|
243
|
+
const counts = {};
|
|
244
|
+
for (const v of rawVerdicts) if (v && typeof v.verdict === "string") counts[v.verdict] = (counts[v.verdict] || 0) + 1;
|
|
245
|
+
console.log(`[dedup-adjudicator] judged ${pairs.length} fact${pairs.length === 1 ? "" : "s"}: ${Object.entries(counts).map(([verdict, n]) => `${n} ${verdict}`).join(", ") || "no verdicts"}`);
|
|
223
246
|
} catch (error) {
|
|
224
247
|
console.warn(`[dedup-adjudicator] failed open: ${error.message}`);
|
|
225
248
|
rawVerdicts = [];
|
package/core/recall-filter.js
CHANGED
|
@@ -7,9 +7,15 @@
|
|
|
7
7
|
const FILTER_TIMEOUT_MS = Number(process.env.RECALL_FILTER_TIMEOUT_MS || 20000);
|
|
8
8
|
const MESSAGE_CLIP_CHARS = 1500;
|
|
9
9
|
const CONTEXT_CLIP_CHARS = 3500;
|
|
10
|
+
// The root must be an object: Claude Code turns the schema into a tool whose
|
|
11
|
+
// input_schema the API requires to be an object, so the bare array this used
|
|
12
|
+
// to be got a 400 on every Claude call and the filter failed open.
|
|
10
13
|
const FILTER_OUTPUT_SCHEMA = Object.freeze({
|
|
11
|
-
type: "
|
|
12
|
-
|
|
14
|
+
type: "object",
|
|
15
|
+
required: ["relevant"],
|
|
16
|
+
properties: {
|
|
17
|
+
relevant: { type: "array", items: { type: "string" } },
|
|
18
|
+
},
|
|
13
19
|
});
|
|
14
20
|
|
|
15
21
|
function enabled() {
|
|
@@ -18,15 +24,28 @@ function enabled() {
|
|
|
18
24
|
|
|
19
25
|
const FILTER_SYSTEM_PROMPT = [
|
|
20
26
|
"You are a relevance judge for an assistant's memory recall.",
|
|
21
|
-
|
|
27
|
+
'Reply with ONLY a JSON object {"relevant": [candidate ids]} — no prose, no markdown fences.',
|
|
22
28
|
].join("\n");
|
|
23
29
|
|
|
30
|
+
// The reply is {"relevant": [...]}; a bare array is still read.
|
|
31
|
+
function idList(value) {
|
|
32
|
+
if (Array.isArray(value)) return value;
|
|
33
|
+
return value && typeof value === "object" && Array.isArray(value.relevant) ? value.relevant : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
24
36
|
function parseIds(result) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
29
|
-
|
|
37
|
+
const fromJson = idList(result?.json);
|
|
38
|
+
if (fromJson) return fromJson;
|
|
39
|
+
const text = String(result?.text || "");
|
|
40
|
+
for (const pattern of [/\{[\s\S]*\}/, /\[[\s\S]*?\]/]) {
|
|
41
|
+
const match = text.match(pattern);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
try {
|
|
44
|
+
const ids = idList(JSON.parse(match[0]));
|
|
45
|
+
if (ids) return ids;
|
|
46
|
+
} catch (_) { /* try the other shape */ }
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
30
49
|
}
|
|
31
50
|
|
|
32
51
|
function createRecallFilter(dependencies = {}) {
|
|
@@ -62,7 +81,7 @@ function createRecallFilter(dependencies = {}) {
|
|
|
62
81
|
"Candidates:",
|
|
63
82
|
...lines,
|
|
64
83
|
"",
|
|
65
|
-
`Reply with ONLY a JSON
|
|
84
|
+
`Reply with ONLY a JSON object of the relevant ids, e.g. {"relevant": ["${candidates[0].id}"]}. Use {"relevant": []} if none are relevant.`,
|
|
66
85
|
].join("\n");
|
|
67
86
|
try {
|
|
68
87
|
const result = await runUtility(prompt, {
|
package/core/utility-agent.js
CHANGED
|
@@ -123,6 +123,13 @@ function validateSchema(value, schema, location = "$", depth = 0) {
|
|
|
123
123
|
|
|
124
124
|
function prepareSchemaFile(schema) {
|
|
125
125
|
if (!schema) return null;
|
|
126
|
+
// Claude Code turns the schema into a tool whose input_schema the API
|
|
127
|
+
// requires to be an object, and OpenAI strict mode wants an object root too.
|
|
128
|
+
// Any other root is refused on every call, which a fail-open caller shows
|
|
129
|
+
// only as a warning (the dedup adjudicator's array root, 3.2.2 to 3.5.18).
|
|
130
|
+
if (typeof schema !== "object" || Array.isArray(schema) || schema.type !== "object") {
|
|
131
|
+
throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", 'outputSchema must have top-level type "object"');
|
|
132
|
+
}
|
|
126
133
|
let serialized;
|
|
127
134
|
try { serialized = JSON.stringify(schema); }
|
|
128
135
|
catch (_) { throw new UtilityAgentError("INVALID_UTILITY_SCHEMA", "outputSchema must be JSON-serializable"); }
|
package/package.json
CHANGED
|
@@ -586,7 +586,12 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
586
586
|
// step without tools, the same cap for scheduled jobs' own conversations,
|
|
587
587
|
// and usage-limit pick-ups with 80/95% warnings. This Mac bot first, then the
|
|
588
588
|
// fleet once it is stable here.
|
|
589
|
-
|
|
589
|
+
// 3.5.19 approved by Sumeet 2026-10-08 13:08 EAT ("I'd rather just push out
|
|
590
|
+
// another release now rather than waiting for another day or so"): the memory
|
|
591
|
+
// dedup judge and the classic recall filter reply in an object (the API
|
|
592
|
+
// refused their bare-array formats on every Claude call), and the utility
|
|
593
|
+
// agent refuses any reply format without an object root. This Mac bot first.
|
|
594
|
+
assert.strictEqual(pkg.version, "3.5.19", "release version must remain unchanged without explicit approval");
|
|
590
595
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
591
596
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
592
597
|
}
|
package/test-provider-recall.js
CHANGED
|
@@ -20,7 +20,7 @@ const { createClaudeProvider } = require("./core/providers/claude");
|
|
|
20
20
|
const { createCodexProvider } = require("./core/providers/codex");
|
|
21
21
|
const { createUtilityPolicy } = require("./core/providers/utility-policy");
|
|
22
22
|
const { createUtilityAgentService } = require("./core/utility-agent");
|
|
23
|
-
const { createRecallFilter } = require("./core/recall-filter");
|
|
23
|
+
const { createRecallFilter, FILTER_OUTPUT_SCHEMA } = require("./core/recall-filter");
|
|
24
24
|
const discoverer = require("./core/recall/discoverer");
|
|
25
25
|
const metrics = require("./core/recall/metrics");
|
|
26
26
|
const warmWalker = require("./core/recall/warm-walker");
|
|
@@ -89,15 +89,25 @@ async function main() {
|
|
|
89
89
|
const capture = path.join(root, `${kind}-filter.json`);
|
|
90
90
|
const h = harness(kind, {
|
|
91
91
|
FAKE_AGENT_CAPTURE: capture,
|
|
92
|
-
FAKE_AGENT_TEXT: '["pack:keep"]',
|
|
92
|
+
FAKE_AGENT_TEXT: '{"relevant":["pack:keep"]}',
|
|
93
93
|
});
|
|
94
94
|
const filter = createRecallFilter({ spawnUtilityAgent: h.utility.spawnUtilityAgent });
|
|
95
95
|
const kept = await filter.judgeRelevance("keep this project", [
|
|
96
96
|
{ id: "pack:keep", name: "Keep", description: "relevant" },
|
|
97
97
|
{ id: "pack:drop", name: "Drop", description: "unrelated" },
|
|
98
98
|
], { provider: kind, context: "same project" });
|
|
99
|
-
assert.deepStrictEqual([...kept], ["pack:keep"]);
|
|
100
99
|
const record = JSON.parse(fs.readFileSync(capture, "utf8"));
|
|
100
|
+
if (kind === "claude") {
|
|
101
|
+
// Claude Code turns the schema into a tool whose input_schema must be an
|
|
102
|
+
// object; the bare array the filter's schema was got a 400 on every call.
|
|
103
|
+
assert.ok(record.argv.includes("--json-schema"), "Claude Code is handed the filter's schema");
|
|
104
|
+
assert.strictEqual(JSON.parse(record.argv[record.argv.indexOf("--json-schema") + 1]).type, "object",
|
|
105
|
+
"Claude Code gets an object-root schema: the API refuses any other tool input_schema");
|
|
106
|
+
} else {
|
|
107
|
+
assert.ok(record.argv.includes("--output-schema"), "the filter's schema passes strict mode, so Codex enforces it");
|
|
108
|
+
}
|
|
109
|
+
assert.ok(kept, `${kind}: the relevance judgement is read (not failed open)`);
|
|
110
|
+
assert.deepStrictEqual([...kept], ["pack:keep"]);
|
|
101
111
|
assert.strictEqual(record.stdin.present, true);
|
|
102
112
|
assert.ok(!record.argv.some((arg) => /keep this project/.test(arg)), "recall candidates stay out of argv");
|
|
103
113
|
if (kind === "claude") {
|
|
@@ -108,6 +118,29 @@ async function main() {
|
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
120
|
|
|
121
|
+
// The filter's schema has an object root and the reply is {"relevant": [...]};
|
|
122
|
+
// the prompts ask for that object, and a bare array is still read.
|
|
123
|
+
assert.strictEqual(FILTER_OUTPUT_SCHEMA.type, "object", "the recall filter schema's top-level type is object");
|
|
124
|
+
assert.deepStrictEqual(FILTER_OUTPUT_SCHEMA.required, ["relevant"]);
|
|
125
|
+
assert.strictEqual(FILTER_OUTPUT_SCHEMA.properties.relevant.type, "array");
|
|
126
|
+
for (const [shape, reply] of [
|
|
127
|
+
["wrapped JSON", { json: { relevant: ["pack:a"] } }],
|
|
128
|
+
["wrapped text", { text: 'Kept: {"relevant": ["pack:a"]}' }],
|
|
129
|
+
["bare array JSON", { json: ["pack:a"] }],
|
|
130
|
+
["bare array text", { text: '["pack:a"]' }],
|
|
131
|
+
]) {
|
|
132
|
+
let asked = null;
|
|
133
|
+
const kept = await createRecallFilter({
|
|
134
|
+
spawnUtilityAgent: async (prompt, request) => { asked = { prompt, request }; return reply; },
|
|
135
|
+
}).judgeRelevance("alpha question", [{ id: "pack:a", name: "A" }, { id: "pack:b", name: "B" }]);
|
|
136
|
+
assert.ok(kept, `${shape}: the ids are read`);
|
|
137
|
+
assert.deepStrictEqual([...kept], ["pack:a"], `${shape}: only the kept candidate`);
|
|
138
|
+
assert.strictEqual(asked.request.outputSchema, FILTER_OUTPUT_SCHEMA);
|
|
139
|
+
assert.match(asked.request.systemPrompt, /\{"relevant"/, "the system prompt asks for the object");
|
|
140
|
+
assert.match(asked.prompt, /\{"relevant": \["pack:a"\]\}/, "the task prompt asks for the object");
|
|
141
|
+
assert.doesNotMatch(`${asked.request.systemPrompt}\n${asked.prompt}`, /JSON array/, "nothing asks for a bare array any more");
|
|
142
|
+
}
|
|
143
|
+
|
|
111
144
|
const failedFilter = createRecallFilter({
|
|
112
145
|
spawnUtilityAgent: async () => { throw new Error("fixture unavailable"); },
|
|
113
146
|
});
|
|
@@ -120,7 +153,7 @@ async function main() {
|
|
|
120
153
|
const legacyClaudeCapture = path.join(root, "legacy-claude-filter.json");
|
|
121
154
|
const legacyClaude = harness("claude", {
|
|
122
155
|
FAKE_AGENT_CAPTURE: legacyClaudeCapture,
|
|
123
|
-
FAKE_AGENT_TEXT: '["pack:a"]',
|
|
156
|
+
FAKE_AGENT_TEXT: '{"relevant":["pack:a"]}',
|
|
124
157
|
}, { RECALL_FILTER_MODEL: "legacy-claude-filter" });
|
|
125
158
|
await createRecallFilter({ spawnUtilityAgent: legacyClaude.utility.spawnUtilityAgent })
|
|
126
159
|
.judgeRelevance("query", [{ id: "pack:a", name: "A" }], { provider: "claude" });
|
|
@@ -135,7 +168,7 @@ async function main() {
|
|
|
135
168
|
const codexExactCapture = path.join(root, "exact-codex-filter.json");
|
|
136
169
|
const codexExact = harness("codex", {
|
|
137
170
|
FAKE_AGENT_CAPTURE: codexExactCapture,
|
|
138
|
-
FAKE_AGENT_TEXT: '["pack:a"]',
|
|
171
|
+
FAKE_AGENT_TEXT: '{"relevant":["pack:a"]}',
|
|
139
172
|
}, {
|
|
140
173
|
RECALL_FILTER_MODEL: "legacy-must-not-cross",
|
|
141
174
|
RECALL_FILTER_MODEL_CODEX: "codex-filter-exact",
|
|
@@ -386,6 +386,33 @@ async function main() {
|
|
|
386
386
|
cwd: root,
|
|
387
387
|
}), "UTILITY_SCHEMA_INVALID");
|
|
388
388
|
|
|
389
|
+
// A schema whose root is not an object never reaches a provider. Claude Code
|
|
390
|
+
// turns it into a tool whose input_schema must be an object (OpenAI strict
|
|
391
|
+
// mode wants one too): the dedup adjudicator's array root got "400 ...
|
|
392
|
+
// input_schema.type: Input should be 'object'" at the API on every call and
|
|
393
|
+
// showed only as a fail-open warning. It fails here, before any spawn.
|
|
394
|
+
const arrayRootCapture = path.join(root, "array-root-capture.json");
|
|
395
|
+
const arrayRootService = createUtilityAgentService({
|
|
396
|
+
registry: registry(providers({ claude: { FAKE_AGENT_CAPTURE: arrayRootCapture, FAKE_AGENT_TEXT: "[]" } })),
|
|
397
|
+
env: { UTILITY_PROVIDER: "claude" },
|
|
398
|
+
});
|
|
399
|
+
for (const outputSchema of [
|
|
400
|
+
{ type: "array", items: { type: "string" } },
|
|
401
|
+
{ items: { type: "object" } },
|
|
402
|
+
[{ type: "object" }],
|
|
403
|
+
]) {
|
|
404
|
+
await assert.rejects(arrayRootService.spawnUtilityAgent("judge", {
|
|
405
|
+
purpose: "dedup", tier: "low", systemPrompt: "judge", readOnly: true, outputSchema, cwd: root,
|
|
406
|
+
}), (error) => {
|
|
407
|
+
assert.ok(error instanceof UtilityAgentError);
|
|
408
|
+
assert.strictEqual(error.code, "INVALID_UTILITY_SCHEMA");
|
|
409
|
+
assert.match(error.message, /top-level type "object"/);
|
|
410
|
+
return true;
|
|
411
|
+
}, `${JSON.stringify(outputSchema)} is refused`);
|
|
412
|
+
}
|
|
413
|
+
assert.strictEqual(fs.existsSync(arrayRootCapture), false, "no provider ran for a schema it would refuse");
|
|
414
|
+
assert.deepStrictEqual(arrayRootService.stats(), { activeGlobal: 0, activeByPurpose: {} }, "the refused calls released their slots");
|
|
415
|
+
|
|
389
416
|
const truncService = createUtilityAgentService({
|
|
390
417
|
registry: registry(providers({ claude: { FAKE_AGENT_TEXT: "0123456789abcdefghijklmnopqrstuvwxyz" } })),
|
|
391
418
|
env: { UTILITY_PROVIDER: "claude" },
|