@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 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: "array",
22
- items: {
23
- type: "object",
24
- required: ["id", "verdict"],
25
- properties: {
26
- id: { type: "string" },
27
- verdict: { type: "string", enum: ["store", "update", "merge", "skip"] },
28
- target: { type: "string" },
29
- merged: { type: "string" },
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
- "Reply with ONLY a JSON array of verdicts — no prose, no markdown fences.",
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 array like [{"id":"f1","verdict":"store"}] covering every FACT id.`,
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
- if (Array.isArray(result?.json)) return result.json;
122
- const match = String(result?.text || "").match(/\[[\s\S]*\]/);
123
- if (!match) return null;
124
- try {
125
- const parsed = JSON.parse(match[0]);
126
- return Array.isArray(parsed) ? parsed : null;
127
- } catch (_) {
128
- return null;
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 = [];
@@ -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: "array",
12
- items: { type: "string" },
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
- "Reply with ONLY a JSON array of candidate ids — no prose, no markdown fences.",
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
- if (Array.isArray(result?.json)) return result.json;
26
- const match = String(result?.text || "").match(/\[[\s\S]*?\]/);
27
- if (!match) return null;
28
- const parsed = JSON.parse(match[0]);
29
- return Array.isArray(parsed) ? parsed : null;
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 array of the relevant ids, e.g. ["${candidates[0].id}"]. Use [] if none are relevant.`,
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, {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.5.18",
3
+ "version": "3.5.19",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -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
- assert.strictEqual(pkg.version, "3.5.18", "release version must remain unchanged without explicit approval");
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
  }
@@ -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" },