diff --git a/KNOWN-LIMITS.md b/KNOWN-LIMITS.md index 9129759..efa0295 100644 --- a/KNOWN-LIMITS.md +++ b/KNOWN-LIMITS.md @@ -1,8 +1,8 @@ @@ -3248,9 +3248,15 @@ Before this change, `observeRungs` found content markers with two case-sensitive regex, so content ending in an upper- or mixed-case closing think tag was inline reasoning to one witness and not R3 to the other. Reach now uses the same regex, and `test/reach-glm-leak-fixtures.test.js` pins agreement on eight probes plus six -real glm-5.3-flash bodies where `think:false` still leaked a closing tag. What -remains: the two regexes are two copies, not one import, so they can drift again; -the probe table is the only tripwire. Separately, the `ollama launch claude` +real glm-5.3-flash bodies where `think:false` still leaked a closing tag. The +two copies are now one: `src/reasoning/think-tag.js` defines `THINK_TAG`, both +witnesses import and re-export it, and `test/think-tag-one-source.test.js` pins +object identity, the `i` flag, a match table, and a source scan that fails if +either witness file defines its own `const THINK_TAG` again (closed 2026-10-27, +branch `lotor-lane/l79-think-tag-one-source`). What remains: the scan keys on +that one spelling, so a copy under another name would pass it; identity is the +real check, and it only holds while both witnesses read the shared binding. +Separately, the `ollama launch claude` executor channel drops lowercase think tags (glm) and mangles backslash escapes (minimax), so test sources on this lane build the less-than sign with `String.fromCharCode(60)`. That is a property of the authoring channel, not of diff --git a/src/reach/index.js b/src/reach/index.js index 0742751..216353f 100644 --- a/src/reach/index.js +++ b/src/reach/index.js @@ -17,6 +17,8 @@ */ import crypto from 'node:crypto'; +import { THINK_TAG } from '../reasoning/think-tag.js'; +export { THINK_TAG }; export const RUNG_ORDER = Object.freeze(['R0', 'R1', 'R3', 'R4']); @@ -36,9 +38,6 @@ function isFiniteNumber(value) { return typeof value === 'number' && Number.isFinite(value); } -// Matches src/reasoning's THINK_TAG so the two witnesses agree. -const THINK_TAG = /<\/?think>/i; - // A top_logprobs element that is not an object (null, a number, a string) // is malformed. It hashes as null, so the digest still moves if it changes, // and its position is reported instead of throwing a TypeError. diff --git a/src/reasoning/index.js b/src/reasoning/index.js index f61f86b..f7838c7 100644 --- a/src/reasoning/index.js +++ b/src/reasoning/index.js @@ -3,6 +3,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { createStore } from '../store/index.js'; import { claudeUserText } from '../views/chapters.js'; +import { THINK_TAG } from './think-tag.js'; +export { THINK_TAG }; /** * src/reasoning/index.js @@ -117,7 +119,6 @@ const HEAD_BYTES = 4096; // rewrite detection: digest of the file head const MAX_UNCLAIMED = 512; // segments seen but not yet claimed by a hook const MAX_CLAIMED = 4096; // ids already recorded, so a reset never doubles const MAX_DIGEST_ROWS = 64; // per receipt; segmentDigest still covers all -const THINK_TAG = /<\/?think>/i; function sha256(text) { return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); diff --git a/src/reasoning/think-tag.js b/src/reasoning/think-tag.js new file mode 100644 index 0000000..b60503a --- /dev/null +++ b/src/reasoning/think-tag.js @@ -0,0 +1,11 @@ +/* + * src/reasoning/think-tag.js + * + * The one shared THINK_TAG definition for src/reach and src/reasoning + * (KNOWN-LIMITS 79: separate copies can drift and make the two witnesses + * disagree). The less-than is built with String.fromCharCode(60) because the + * agent authoring channel is known to drop that character sequence and to + * mangle backslashes; a forward slash inside a RegExp constructor string is a + * plain character, so no escape is needed. + */ +export const THINK_TAG = new RegExp(String.fromCharCode(60) + '/?think>', 'i'); \ No newline at end of file diff --git a/test/think-tag-one-source.test.js b/test/think-tag-one-source.test.js new file mode 100644 index 0000000..7eb568e --- /dev/null +++ b/test/think-tag-one-source.test.js @@ -0,0 +1,64 @@ +/* + * test/think-tag-one-source.test.js + * + * KNOWN-LIMITS 79: pin that reach and reasoning share one THINK_TAG + * definition, pin its flags, pin its behaviour with probes built from a + * fromCharCode less-than (never typed), and scan both witness sources for a + * reintroduced local const THINK_TAG (the drift tripwire). + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as reachMod from '../src/reach/index.js'; +import * as reasoningMod from '../src/reasoning/index.js'; + +const LT = String.fromCharCode(60); + +test('one source: reach and reasoning export the very same regex object', () => { + assert.equal(reachMod.THINK_TAG, reasoningMod.THINK_TAG); +}); + +test('shared regex has flags exactly i (no g, so .test has no lastIndex state)', () => { + assert.equal(reachMod.THINK_TAG.flags, 'i'); +}); + +test('behaviour table: matches', () => { + const re = reasoningMod.THINK_TAG; + const matches = [ + LT + 'think>', + LT + '/think>', + LT + 'THINK>', + LT + '/Think>', + 'a ' + LT + '/tHiNk> b' + ]; + for (const probe of matches) { + assert.equal(re.test(probe), true, 'should match: ' + JSON.stringify(probe)); + } +}); + +test('behaviour table: does not match', () => { + const re = reasoningMod.THINK_TAG; + const doesNotMatch = [ + 'think>', + LT + 'thinking>', + LT + 'think >', + LT + '/ think>', + '' + ]; + for (const probe of doesNotMatch) { + assert.equal(re.test(probe), false, 'should not match: ' + JSON.stringify(probe)); + } +}); + +test('drift tripwire: neither witness source contains const THINK_TAG', () => { + const here = path.dirname(fileURLToPath(import.meta.url)); + for (const rel of ['../src/reach/index.js', '../src/reasoning/index.js']) { + const file = path.resolve(here, rel); + const text = fs.readFileSync(file, 'utf8'); + assert.equal(text.includes('const THINK_TAG'), false, + file + ' contains const THINK_TAG again'); + } +}); \ No newline at end of file