contexel 0.1.16

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahesh Vaikri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/maheshvaikri-code/contexel/main/logo/contexel_logo.png" width="340" alt="contexel logo">
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://www.npmjs.com/package/contexel"><img src="https://img.shields.io/npm/v/contexel?label=npm" alt="npm"></a>
7
+ <a href="https://pypi.org/project/contexel/"><img src="https://img.shields.io/pypi/v/contexel?label=pypi" alt="PyPI"></a>
8
+ <a href="https://github.com/maheshvaikri-code/contexel/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT"></a>
9
+ </p>
10
+
11
+ # contexel
12
+
13
+ Deterministic, dependency-free **context shaping** for code-writing agents —
14
+ the TypeScript build of [contexel](https://github.com/maheshvaikri-code/contexel).
15
+
16
+ Tool calls return verbose, duplicated, sometimes hostile data. contexel sits at
17
+ the tool → context boundary and shapes it: select the fields that matter, drop
18
+ duplicates, gate by provenance, quarantine injection patterns, rescore by
19
+ relevance, truncate, rank, and trim to a token budget — every stage a pure
20
+ function over `Record<string, unknown>[]`, every run reproducible, every drop
21
+ attributable in an audit record.
22
+
23
+ **This package is a parity port, not a re-imagining.** The Python
24
+ implementation is canon; both languages run their test suites against the same
25
+ golden vectors (`parity/vectors.json`, generated by Python) so that the same
26
+ records, policy, and budget produce the same shaped output in either language.
27
+ Versions track the Python release. Design and boundaries:
28
+ [ADR-001](https://github.com/maheshvaikri-code/contexel/blob/main/docs/adr/001-typescript-parity.md).
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install contexel # zero runtime dependencies, Node >= 18, ESM
34
+ ```
35
+
36
+ ## Sixty seconds
37
+
38
+ ```ts
39
+ import { select, dedupe, rescore, truncateField, rank, trimToBudget, trace } from "contexel";
40
+
41
+ const raw = await searchTool(userQuery); // a batch of verbose results
42
+
43
+ const [context, t] = trace({ idField: "url" }, () => {
44
+ let r = select(raw, { fields: ["title", "url", "snippet", "published"] });
45
+ r = dedupe(r, { key: "url" });
46
+ r = rescore(r, { query: userQuery, fields: ["title", "snippet"] });
47
+ r = truncateField(r, { field: "snippet", maxTokens: 120 });
48
+ r = rank(r, { by: "score", desc: true });
49
+ return trimToBudget(r, { maxTokens: 1500 });
50
+ });
51
+
52
+ console.log(t.report()); // per-stage: records in -> out, tokens before -> after
53
+ console.log(t.audit()); // policy fingerprints + which stage dropped which IDs
54
+ ```
55
+
56
+ Or pin the policy at the tool boundary, so the model never sees raw output:
57
+
58
+ ```ts
59
+ import { shaped, stage, select, dedupe, trimToBudget } from "contexel";
60
+
61
+ const searchCode = shaped([
62
+ stage(select, { fields: ["path", "line", "snippet"] }),
63
+ stage(dedupe, { key: ["path", "line"] }),
64
+ stage(trimToBudget, { maxTokens: 2000 }),
65
+ ])(async (query: string) => repo.search(query)); // async tools are awaited, then shaped
66
+ ```
67
+
68
+ ## The eight stages
69
+
70
+ | Stage | What it does |
71
+ |---|---|
72
+ | `select` | keep only the listed fields |
73
+ | `dedupe` | drop duplicates by key or whole-record fingerprint (type-aware: `1`, `"1"`, `true` stay distinct) |
74
+ | `allowlist` | fail-closed provenance gate — keep records whose field is in the allowed set (a missing field reads as `null`, kept only if `null` is explicitly allowed) |
75
+ | `quarantine` | drop or flag records matching injection patterns ("ignore previous instructions", …) |
76
+ | `rescore` | batch BM25 relevance with word-boundary matching and in-order proximity bonus |
77
+ | `rank` | stable sort by a field; records missing the field sort last |
78
+ | `truncateField` | cut one field to a token budget, `…` suffix |
79
+ | `trimToBudget` | keep records until the total token budget is spent; `minRecords` guarantees the best records survive a too-small budget instead of returning `[]` |
80
+
81
+ Plus `merge` for combining multiple sources under one schema, `pipeline`/`stage`
82
+ for composition with a stable policy fingerprint, `trace`/`audit` for the
83
+ governance record, and `tokens.scoped` for concurrency-safe (AsyncLocalStorage)
84
+ tokenizer and serializer overrides per tenant.
85
+
86
+ ## Parity, precisely
87
+
88
+ - Shaped output is structurally identical to Python's for JSON-safe records —
89
+ enforced by golden vectors covering every stage, the composed contract, the
90
+ audit record, canonical serialization, and token counts.
91
+ - Token counting is per Unicode **code point** (matches Python `len`), not
92
+ UTF-16 units — `"café"` costs the same in both languages.
93
+ - Documented boundaries (see ADR-001): JS collapses `1.0` to `"1"` in
94
+ serialization; Unicode word classes differ at the margins; pipeline
95
+ fingerprints are language-local in v1.
96
+
97
+ ## Links
98
+
99
+ - Docs: <https://maheshvaikri-code.github.io/contexel/>
100
+ - Benchmarks (methodology and limits stated): <https://github.com/maheshvaikri-code/contexel/blob/main/benchmarks/COMPARISON.md>
101
+ - Python package: <https://pypi.org/project/contexel/>
102
+ - License: MIT
@@ -0,0 +1,11 @@
1
+ /**
2
+ * contexel — deterministic, dependency-free context shaping for
3
+ * code-writing agents. TypeScript parity of the Python package, enforced
4
+ * by shared golden vectors (see the repo's parity/ directory and ADR-001).
5
+ */
6
+ export { select, dedupe, allowlist, quarantine, rescore, rank, truncateField, trimToBudget, merge, type Rec, type Records, } from "./stages.js";
7
+ export { pipeline, stage, type StageFn } from "./pipeline.js";
8
+ export { shaped } from "./shaped.js";
9
+ export { trace, currentTrace, Trace, type Entry } from "./trace.js";
10
+ export * as tokens from "./tokens.js";
11
+ export declare const VERSION = "0.1.16";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * contexel — deterministic, dependency-free context shaping for
3
+ * code-writing agents. TypeScript parity of the Python package, enforced
4
+ * by shared golden vectors (see the repo's parity/ directory and ADR-001).
5
+ */
6
+ export { select, dedupe, allowlist, quarantine, rescore, rank, truncateField, trimToBudget, merge, } from "./stages.js";
7
+ export { pipeline, stage } from "./pipeline.js";
8
+ export { shaped } from "./shaped.js";
9
+ export { trace, currentTrace, Trace } from "./trace.js";
10
+ export * as tokens from "./tokens.js";
11
+ export const VERSION = "0.1.16";
@@ -0,0 +1,11 @@
1
+ import type { Records } from "./stages.js";
2
+ export type StageFn = (records: Records) => Records;
3
+ interface Described extends StageFn {
4
+ __contexelDesc?: string;
5
+ fingerprint?: string;
6
+ }
7
+ /** Bind params to a stage so it slots into a pipeline as `records => records`. */
8
+ export declare function stage<P>(fn: (records: Records, params: P) => Records, params: P): StageFn;
9
+ /** Compose stages left-to-right; the callable exposes `.fingerprint`. */
10
+ export declare function pipeline(stages: StageFn[]): Described;
11
+ export {};
@@ -0,0 +1,50 @@
1
+ /**
2
+ * In-language composition — parity port. Any `records => records` callable
3
+ * is a valid stage. Pipelines carry a stable, address-free fingerprint
4
+ * (language-local in v1 per ADR-001) recorded into any active trace.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { currentTrace } from "./trace.js";
8
+ function stableRepr(value) {
9
+ if (value === null ||
10
+ typeof value === "string" ||
11
+ typeof value === "number" ||
12
+ typeof value === "boolean") {
13
+ return JSON.stringify(value);
14
+ }
15
+ if (Array.isArray(value))
16
+ return "[" + value.map(stableRepr).join(",") + "]";
17
+ if (typeof value === "function")
18
+ return value.name || "function";
19
+ if (typeof value === "object") {
20
+ const entries = Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
21
+ return "{" + entries.map(([k, v]) => `${JSON.stringify(k)}:${stableRepr(v)}`).join(",") + "}";
22
+ }
23
+ return typeof value;
24
+ }
25
+ /** Bind params to a stage so it slots into a pipeline as `records => records`. */
26
+ export function stage(fn, params) {
27
+ const run = (records) => fn(records, params);
28
+ Object.defineProperty(run, "name", { value: fn.name || "stage" });
29
+ run.__contexelDesc = `${fn.name || "stage"}(${stableRepr(params)})`;
30
+ return run;
31
+ }
32
+ /** Compose stages left-to-right; the callable exposes `.fingerprint`. */
33
+ export function pipeline(stages) {
34
+ const descs = stages.map((s) => s.__contexelDesc ?? (s.name || "callable"));
35
+ const fingerprint = createHash("sha256")
36
+ .update(JSON.stringify(descs))
37
+ .digest("hex")
38
+ .slice(0, 16);
39
+ const run = (records) => {
40
+ const tr = currentTrace();
41
+ if (tr && !tr.policies.includes(fingerprint))
42
+ tr.policies.push(fingerprint);
43
+ let out = records;
44
+ for (const s of stages)
45
+ out = s(out);
46
+ return out;
47
+ };
48
+ run.fingerprint = fingerprint;
49
+ return run;
50
+ }
package/dist/py.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Python-semantics helpers — the parity layer under stages/tokens/trace.
3
+ * Each function replicates the exact CPython behavior the canon relies on;
4
+ * remaining margins (exotic repr escapes, huge integers, list-vs-list
5
+ * comparison in sorts) are documented in ADR-001.
6
+ */
7
+ /** repr(float): fixed notation for 1e-4 <= |x| < 1e16, else scientific
8
+ * with a sign and a two-digit exponent. JS switches at 1e-7/1e21, so
9
+ * String(1e-5) === "0.00001" where Python says "1e-05". */
10
+ export declare function pyFloat(value: number): string;
11
+ /** str() for numbers: int digits, nan/inf like Python, pyFloat otherwise.
12
+ * Integer-valued floats collapse to int form (ADR-001 boundary 4a). */
13
+ export declare function pyNumber(value: number): string;
14
+ /** repr(): containers render Python-style ({'k': 'v'}, ['a', 1]). */
15
+ export declare function pyRepr(value: unknown): string;
16
+ /** str(): what Python's `str(r.get(f, ""))` produces for JSON-safe values —
17
+ * None -> "None", True -> "True", nested containers via repr. */
18
+ export declare function pyStr(value: unknown): string;
19
+ /** round(x, nd) with CPython semantics: correctly rounded on the double's
20
+ * EXACT value, ties to even. A tie exists iff x is exactly k/2^(nd+1) with
21
+ * k odd (the only doubles whose decimal expansion ends in ...5 at digit
22
+ * nd+1); everything else is unambiguous and toFixed rounds it correctly.
23
+ * So 2.675 (really 2.67499...82) rounds DOWN to 2.67 like Python, while a
24
+ * true tie like 0.90625 -> 0.9062 goes to even. */
25
+ export declare function pyRound(x: number, nd: number): number;
26
+ /** Python string `<`: code-point order. JS `<` is UTF-16 order, which sorts
27
+ * astral characters below U+E000..U+FFFF. */
28
+ export declare function compareCodePoints(a: string, b: string): number;
29
+ /** Python comparison for sort keys: numbers/bools inter-compare, strings by
30
+ * code point, anything else raises TypeError like CPython (lists, which
31
+ * Python would compare element-wise, are an ADR-001 margin). */
32
+ export declare function pyCompare(a: unknown, b: unknown): number;
33
+ /** Membership key with Python hash/eq semantics: True == 1 == 1.0, None is
34
+ * its own class; dict/list/set raise TypeError (unhashable) like set(). */
35
+ export declare function pyEqKey(value: unknown): string;
36
+ /** Map a UTF-16 index to a code-point index (Python str positions). The
37
+ * common no-surrogate case is the identity, at no cost. */
38
+ export declare function codePointIndexer(text: string): (utf16Index: number) => number;
39
+ export declare function pyRstrip(text: string): string;
package/dist/py.js ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Python-semantics helpers — the parity layer under stages/tokens/trace.
3
+ * Each function replicates the exact CPython behavior the canon relies on;
4
+ * remaining margins (exotic repr escapes, huge integers, list-vs-list
5
+ * comparison in sorts) are documented in ADR-001.
6
+ */
7
+ /** repr(float): fixed notation for 1e-4 <= |x| < 1e16, else scientific
8
+ * with a sign and a two-digit exponent. JS switches at 1e-7/1e21, so
9
+ * String(1e-5) === "0.00001" where Python says "1e-05". */
10
+ export function pyFloat(value) {
11
+ const [mant, expStr] = value.toExponential().split("e");
12
+ const exp = parseInt(expStr, 10);
13
+ const neg = mant.startsWith("-");
14
+ const digits = mant.replace("-", "").replace(".", "");
15
+ let body;
16
+ if (exp < -4 || exp >= 16) {
17
+ const m = digits.length > 1 ? digits[0] + "." + digits.slice(1) : digits;
18
+ body = m + "e" + (exp < 0 ? "-" : "+") + String(Math.abs(exp)).padStart(2, "0");
19
+ }
20
+ else if (exp >= 0) {
21
+ body = digits.slice(0, exp + 1).padEnd(exp + 1, "0") + "." + (digits.slice(exp + 1) || "0");
22
+ }
23
+ else {
24
+ body = "0." + "0".repeat(-exp - 1) + digits;
25
+ }
26
+ return neg ? "-" + body : body;
27
+ }
28
+ /** str() for numbers: int digits, nan/inf like Python, pyFloat otherwise.
29
+ * Integer-valued floats collapse to int form (ADR-001 boundary 4a). */
30
+ export function pyNumber(value) {
31
+ if (Number.isNaN(value))
32
+ return "nan";
33
+ if (!Number.isFinite(value))
34
+ return value > 0 ? "inf" : "-inf";
35
+ if (Number.isInteger(value))
36
+ return String(value);
37
+ return pyFloat(value);
38
+ }
39
+ function pyTypeName(value) {
40
+ if (value === null || value === undefined)
41
+ return "NoneType";
42
+ if (Array.isArray(value))
43
+ return "list";
44
+ if (value instanceof Set)
45
+ return "set";
46
+ switch (typeof value) {
47
+ case "boolean": return "bool";
48
+ case "number": return Number.isInteger(value) ? "int" : "float";
49
+ case "string": return "str";
50
+ case "object": return "dict";
51
+ default: return typeof value;
52
+ }
53
+ }
54
+ /** repr(str): quote choice + escapes for control characters. Exotic
55
+ * non-printables (Unicode Cf/Zs beyond Latin-1) stay literal — an ADR-001
56
+ * margin; ASCII and common text are exact. */
57
+ function pyReprStr(s) {
58
+ const q = s.includes("'") && !s.includes('"') ? '"' : "'";
59
+ let out = q;
60
+ for (const ch of s) {
61
+ const c = ch.codePointAt(0);
62
+ if (ch === "\\" || ch === q)
63
+ out += "\\" + ch;
64
+ else if (ch === "\n")
65
+ out += "\\n";
66
+ else if (ch === "\r")
67
+ out += "\\r";
68
+ else if (ch === "\t")
69
+ out += "\\t";
70
+ else if (c < 0x20 || (c >= 0x7f && c <= 0xa0) || c === 0xad) {
71
+ out += "\\x" + c.toString(16).padStart(2, "0");
72
+ }
73
+ else
74
+ out += ch;
75
+ }
76
+ return out + q;
77
+ }
78
+ /** repr(): containers render Python-style ({'k': 'v'}, ['a', 1]). */
79
+ export function pyRepr(value) {
80
+ if (value === undefined || value === null)
81
+ return "None";
82
+ if (typeof value === "boolean")
83
+ return value ? "True" : "False";
84
+ if (typeof value === "number")
85
+ return pyNumber(value);
86
+ if (typeof value === "string")
87
+ return pyReprStr(value);
88
+ if (Array.isArray(value))
89
+ return "[" + value.map(pyRepr).join(", ") + "]";
90
+ if (typeof value === "object" && !(value instanceof Set)) {
91
+ const entries = Object.entries(value).map(([k, v]) => `${pyReprStr(k)}: ${pyRepr(v)}`);
92
+ return "{" + entries.join(", ") + "}";
93
+ }
94
+ return String(value);
95
+ }
96
+ /** str(): what Python's `str(r.get(f, ""))` produces for JSON-safe values —
97
+ * None -> "None", True -> "True", nested containers via repr. */
98
+ export function pyStr(value) {
99
+ if (typeof value === "string")
100
+ return value;
101
+ return pyRepr(value);
102
+ }
103
+ /** round(x, nd) with CPython semantics: correctly rounded on the double's
104
+ * EXACT value, ties to even. A tie exists iff x is exactly k/2^(nd+1) with
105
+ * k odd (the only doubles whose decimal expansion ends in ...5 at digit
106
+ * nd+1); everything else is unambiguous and toFixed rounds it correctly.
107
+ * So 2.675 (really 2.67499...82) rounds DOWN to 2.67 like Python, while a
108
+ * true tie like 0.90625 -> 0.9062 goes to even. */
109
+ export function pyRound(x, nd) {
110
+ const scaled2 = x * 2 ** (nd + 1); // power-of-two scaling is exact
111
+ if (Number.isInteger(scaled2) && Math.abs(scaled2) % 2 === 1) {
112
+ const trunc = x.toFixed(nd + 1).slice(0, -1); // exact digits, drop the "5"
113
+ const digits = trunc.replace(/[-.]/g, "");
114
+ const last = digits ? digits.charCodeAt(digits.length - 1) - 48 : 0;
115
+ const v = Number(last % 2 === 0 ? trunc : bumpLastDigit(trunc));
116
+ return v === 0 ? 0 : v;
117
+ }
118
+ const v = Number(x.toFixed(nd));
119
+ return v === 0 ? 0 : v;
120
+ }
121
+ function bumpLastDigit(s) {
122
+ const neg = s.startsWith("-");
123
+ const chars = (neg ? s.slice(1) : s).split("");
124
+ let carry = 1;
125
+ for (let i = chars.length - 1; i >= 0 && carry; i--) {
126
+ if (chars[i] === ".")
127
+ continue;
128
+ const d = chars[i].charCodeAt(0) - 48 + carry;
129
+ chars[i] = String(d % 10);
130
+ carry = d >= 10 ? 1 : 0;
131
+ }
132
+ if (carry)
133
+ chars.unshift("1");
134
+ return (neg ? "-" : "") + chars.join("");
135
+ }
136
+ /** Python string `<`: code-point order. JS `<` is UTF-16 order, which sorts
137
+ * astral characters below U+E000..U+FFFF. */
138
+ export function compareCodePoints(a, b) {
139
+ const ia = a[Symbol.iterator]();
140
+ const ib = b[Symbol.iterator]();
141
+ for (;;) {
142
+ const ra = ia.next();
143
+ const rb = ib.next();
144
+ if (ra.done && rb.done)
145
+ return 0;
146
+ if (ra.done)
147
+ return -1;
148
+ if (rb.done)
149
+ return 1;
150
+ const ca = ra.value.codePointAt(0);
151
+ const cb = rb.value.codePointAt(0);
152
+ if (ca !== cb)
153
+ return ca < cb ? -1 : 1;
154
+ }
155
+ }
156
+ /** Python comparison for sort keys: numbers/bools inter-compare, strings by
157
+ * code point, anything else raises TypeError like CPython (lists, which
158
+ * Python would compare element-wise, are an ADR-001 margin). */
159
+ export function pyCompare(a, b) {
160
+ const an = typeof a === "number" || typeof a === "boolean";
161
+ const bn = typeof b === "number" || typeof b === "boolean";
162
+ if (an && bn) {
163
+ const x = Number(a);
164
+ const y = Number(b);
165
+ return x < y ? -1 : x > y ? 1 : 0;
166
+ }
167
+ if (typeof a === "string" && typeof b === "string")
168
+ return compareCodePoints(a, b);
169
+ throw new TypeError(`'<' not supported between instances of '${pyTypeName(b)}' and '${pyTypeName(a)}'`);
170
+ }
171
+ /** Membership key with Python hash/eq semantics: True == 1 == 1.0, None is
172
+ * its own class; dict/list/set raise TypeError (unhashable) like set(). */
173
+ export function pyEqKey(value) {
174
+ if (value === undefined || value === null)
175
+ return "None";
176
+ if (typeof value === "boolean")
177
+ return "num:" + (value ? 1 : 0);
178
+ if (typeof value === "number")
179
+ return "num:" + value;
180
+ if (typeof value === "string")
181
+ return "str:" + value;
182
+ throw new TypeError(`unhashable type: '${pyTypeName(value)}'`);
183
+ }
184
+ /** Map a UTF-16 index to a code-point index (Python str positions). The
185
+ * common no-surrogate case is the identity, at no cost. */
186
+ export function codePointIndexer(text) {
187
+ if (!/[\uD800-\uDFFF]/.test(text))
188
+ return (i) => i;
189
+ const map = new Map();
190
+ let cp = 0;
191
+ let i = 0;
192
+ while (i < text.length) {
193
+ map.set(i, cp);
194
+ i += text.codePointAt(i) > 0xffff ? 2 : 1;
195
+ cp++;
196
+ }
197
+ map.set(text.length, cp);
198
+ return (idx) => map.get(idx);
199
+ }
200
+ /** Trailing-whitespace strip matching Python str.rstrip(): includes the
201
+ * file/group/record/unit separators (0x1C-0x1F) and NEL (0x85), and does
202
+ * NOT strip U+FEFF (which JS \s wrongly includes). */
203
+ const PY_TRAILING_WS = /[\t-\r\x1c-\x1f \x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/u;
204
+ export function pyRstrip(text) {
205
+ return text.replace(PY_TRAILING_WS, "");
206
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Apply a contexel pipeline at the tool boundary — parity port. The model
3
+ * calls the tool normally and never writes the reshaping. Async-first: a
4
+ * tool returning a promise is awaited, then shaped (an improvement over
5
+ * static detection — JS shapes any thenable result).
6
+ */
7
+ import type { Records } from "./stages.js";
8
+ import { type StageFn } from "./pipeline.js";
9
+ type Tool<A extends unknown[]> = (...args: A) => Records | Promise<Records>;
10
+ export declare function shaped(pipe: StageFn | StageFn[]): <A extends unknown[]>(fn: Tool<A>) => (...args: A) => Records | Promise<Records>;
11
+ export {};
package/dist/shaped.js ADDED
@@ -0,0 +1,13 @@
1
+ import { pipeline } from "./pipeline.js";
2
+ export function shaped(pipe) {
3
+ const runner = typeof pipe === "function" ? pipe : pipeline(pipe);
4
+ return function (fn) {
5
+ return (...args) => {
6
+ const result = fn(...args);
7
+ if (result && typeof result.then === "function") {
8
+ return result.then((records) => runner(records));
9
+ }
10
+ return runner(result);
11
+ };
12
+ };
13
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Deterministic context-economy stages — parity port of contexel's Python
3
+ * `stages` module. Every stage is `(records, params) => records`, never
4
+ * mutates its input, and is auto-traced when a trace is active. Behavior
5
+ * matches the Python implementation on the shared golden vectors
6
+ * (parity/vectors.json); boundaries are documented in ADR-001.
7
+ */
8
+ import * as tokens from "./tokens.js";
9
+ export type Rec = Record<string, unknown>;
10
+ export type Records = Rec[];
11
+ export declare const select: (records: {
12
+ [x: string]: unknown;
13
+ }[], params: {
14
+ fields: string | string[];
15
+ }) => {
16
+ [x: string]: unknown;
17
+ }[];
18
+ export declare const dedupe: (records: {
19
+ [x: string]: unknown;
20
+ }[], params?: {
21
+ key?: string | string[] | null;
22
+ } | undefined) => {
23
+ [x: string]: unknown;
24
+ }[];
25
+ export declare const allowlist: (records: {
26
+ [x: string]: unknown;
27
+ }[], params: {
28
+ field: string;
29
+ allowed: unknown[] | Set<unknown>;
30
+ }) => {
31
+ [x: string]: unknown;
32
+ }[];
33
+ export declare const quarantine: (records: {
34
+ [x: string]: unknown;
35
+ }[], params?: {
36
+ fields?: string | string[];
37
+ patterns?: string[];
38
+ action?: "drop" | "flag";
39
+ into?: string;
40
+ } | undefined) => {
41
+ [x: string]: unknown;
42
+ }[];
43
+ export declare const rescore: (records: {
44
+ [x: string]: unknown;
45
+ }[], params: {
46
+ query: string | string[];
47
+ fields?: string | string[];
48
+ into?: string;
49
+ proximity?: boolean;
50
+ match?: "word" | "substring";
51
+ }) => {
52
+ [x: string]: unknown;
53
+ }[];
54
+ export declare const rank: (records: {
55
+ [x: string]: unknown;
56
+ }[], params: {
57
+ by: string | ((r: Rec) => unknown);
58
+ desc?: boolean;
59
+ }) => {
60
+ [x: string]: unknown;
61
+ }[];
62
+ export declare const truncateField: (records: {
63
+ [x: string]: unknown;
64
+ }[], params: {
65
+ field: string;
66
+ maxTokens: number;
67
+ tokenizer?: tokens.Tokenizer | null;
68
+ suffix?: string;
69
+ }) => {
70
+ [x: string]: unknown;
71
+ }[];
72
+ export declare const trimToBudget: (records: {
73
+ [x: string]: unknown;
74
+ }[], params: {
75
+ maxTokens: number;
76
+ tokenizer?: tokens.Tokenizer | null;
77
+ minRecords?: number;
78
+ }) => {
79
+ [x: string]: unknown;
80
+ }[];
81
+ /** Combiner (not auto-traced, matching Python): normalize and concatenate
82
+ * record lists from different tools; `schema` maps unified name -> source
83
+ * name or candidate list (first match wins). */
84
+ export declare function merge(sources: Records[], options?: {
85
+ schema?: Record<string, string | string[]>;
86
+ dedupeKey?: string | null;
87
+ }): Records;
package/dist/stages.js ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Deterministic context-economy stages — parity port of contexel's Python
3
+ * `stages` module. Every stage is `(records, params) => records`, never
4
+ * mutates its input, and is auto-traced when a trace is active. Behavior
5
+ * matches the Python implementation on the shared golden vectors
6
+ * (parity/vectors.json); boundaries are documented in ADR-001.
7
+ */
8
+ import * as tokens from "./tokens.js";
9
+ import { traced } from "./trace.js";
10
+ import { codePointIndexer, pyCompare, pyEqKey, pyRound, pyRstrip, pyStr, } from "./py.js";
11
+ /** A bare string means ONE field, not its characters (parity with Python). */
12
+ function fieldList(fields) {
13
+ return typeof fields === "string" ? [fields] : fields;
14
+ }
15
+ /** Own-property write that cannot hit setters or the "__proto__" trap —
16
+ * a key named "__proto__" becomes an ordinary data property, as in Python. */
17
+ function defineField(out, key, value) {
18
+ Object.defineProperty(out, key, {
19
+ value,
20
+ writable: true,
21
+ enumerable: true,
22
+ configurable: true,
23
+ });
24
+ }
25
+ /* ------------------------------ fingerprints ----------------------------- */
26
+ /** Python-mapped type tag: int/float distinction for JSON-sourced numbers. */
27
+ function typedKey(value) {
28
+ if (value === null || value === undefined)
29
+ return "NoneType:None";
30
+ if (typeof value === "boolean")
31
+ return `bool:${value}`;
32
+ if (typeof value === "number") {
33
+ return Number.isInteger(value) ? `int:${value}` : `float:${value}`;
34
+ }
35
+ if (typeof value === "string")
36
+ return `str:${JSON.stringify(value)}`;
37
+ if (Array.isArray(value))
38
+ return "list:[" + value.map(typedKey).join(",") + "]";
39
+ if (value instanceof Set) {
40
+ return "set:(" + [...value].map(typedKey).sort().join(",") + ")";
41
+ }
42
+ if (typeof value === "object") {
43
+ const entries = Object.entries(value)
44
+ .map(([k, v]) => `${JSON.stringify(k)}:${typedKey(v)}`)
45
+ .sort();
46
+ return "dict:{" + entries.join(",") + "}";
47
+ }
48
+ return `${typeof value}:${String(value)}`;
49
+ }
50
+ /* -------------------------------- stages --------------------------------- */
51
+ export const select = traced("select", (records, params) => {
52
+ const keep = fieldList(params.fields);
53
+ return records.map((r) => {
54
+ const out = {};
55
+ for (const k of keep)
56
+ if (Object.hasOwn(r, k))
57
+ defineField(out, k, r[k]);
58
+ return out;
59
+ });
60
+ });
61
+ export const dedupe = traced("dedupe", (records, params = {}) => {
62
+ const key = params.key ?? null;
63
+ const keys = key === null ? null : Array.isArray(key) ? key : [key];
64
+ const seen = new Set();
65
+ const out = [];
66
+ for (const r of records) {
67
+ const basis = keys === null ? r : keys.map((k) => (Object.hasOwn(r, k) ? r[k] : null));
68
+ const fp = typedKey(basis);
69
+ if (seen.has(fp))
70
+ continue;
71
+ seen.add(fp);
72
+ out.push(r);
73
+ }
74
+ return out;
75
+ });
76
+ export const allowlist = traced("allowlist", (records, params) => {
77
+ // Python builds set(allowed) up front: an unhashable entry raises.
78
+ const allowed = new Set();
79
+ for (const a of params.allowed)
80
+ allowed.add(pyEqKey(a));
81
+ return records.filter((r) => {
82
+ // Python r.get(field): a missing field reads as None, and None can
83
+ // legitimately be allowed; unhashable values fail closed.
84
+ const v = Object.hasOwn(r, params.field) ? r[params.field] : null;
85
+ try {
86
+ return allowed.has(pyEqKey(v));
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ });
92
+ });
93
+ const INJECTION_PATTERNS = [
94
+ "ignore\\s+(?:all\\s+|any\\s+)?(?:previous|prior|above|earlier)\\s+(?:instructions|messages|prompts|context)",
95
+ "disregard\\s+(?:all\\s+|the\\s+)?(?:previous|prior|above|earlier)",
96
+ "you\\s+are\\s+now\\s+",
97
+ "new\\s+instructions\\s*:",
98
+ "system\\s+prompt",
99
+ "</?\\s*(?:system|assistant|instructions?)\\s*>",
100
+ ];
101
+ export const quarantine = traced("quarantine", (records, params = {}) => {
102
+ const fields = fieldList(params.fields ?? ["snippet"]);
103
+ const action = params.action ?? "drop";
104
+ const into = params.into ?? "quarantined";
105
+ if (action !== "drop" && action !== "flag") {
106
+ throw new Error(`action must be 'drop' or 'flag', got ${String(action)}`);
107
+ }
108
+ const matcher = new RegExp((params.patterns ?? INJECTION_PATTERNS).join("|"), "i");
109
+ const out = [];
110
+ for (const r of records) {
111
+ // Python str(r.get(f, "")): None -> "None", nested containers via
112
+ // repr — a payload inside a dict or list still hits the matcher.
113
+ const text = fields
114
+ .map((f) => (Object.hasOwn(r, f) ? pyStr(r[f]) : ""))
115
+ .join(" ");
116
+ const hit = matcher.test(text);
117
+ if (action === "flag")
118
+ out.push({ ...r, [into]: hit });
119
+ else if (!hit)
120
+ out.push(r);
121
+ }
122
+ return out;
123
+ });
124
+ /* Word matching: identical to Python's [^\W_] on ASCII (all parity
125
+ vectors); exotic Unicode word classes may differ at the margin. */
126
+ const WORD = /[\p{L}\p{N}]+/gu;
127
+ export const rescore = traced("rescore", (records, params) => {
128
+ const fields = fieldList(params.fields ?? ["snippet"]);
129
+ const into = params.into ?? "score";
130
+ const proximity = params.proximity ?? true;
131
+ const match = params.match ?? "word";
132
+ const rawTerms = typeof params.query === "string"
133
+ ? params.query.toLowerCase().match(WORD) ?? []
134
+ : params.query.flatMap((p) => String(p).toLowerCase().match(WORD) ?? []);
135
+ const terms = [...new Set(rawTerms)];
136
+ if (!terms.length || !records.length) {
137
+ return records.map((r) => ({ ...r, [into]: 0.0 }));
138
+ }
139
+ const texts = records.map((r) => fields
140
+ .map((f) => (Object.hasOwn(r, f) ? pyStr(r[f]) : ""))
141
+ .join(" ")
142
+ .toLowerCase());
143
+ let finder = null;
144
+ if (match === "word") {
145
+ const alt = [...terms]
146
+ .sort((a, b) => b.length - a.length)
147
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
148
+ .join("|");
149
+ // boundary guards: underscore counts as a boundary (snake_case splits)
150
+ finder = new RegExp(`(?<![\\p{L}\\p{N}])(?:${alt})(?![\\p{L}\\p{N}])`, "gu");
151
+ }
152
+ const n = records.length;
153
+ const tfs = [];
154
+ const firsts = [];
155
+ const df = new Map(terms.map((t) => [t, 0]));
156
+ for (const text of texts) {
157
+ // Python positions are code-point indices; JS regex/indexOf yield
158
+ // UTF-16 units — convert so the proximity window means the same thing.
159
+ const toCp = codePointIndexer(text);
160
+ const row = new Map();
161
+ const first = new Map();
162
+ if (finder) {
163
+ finder.lastIndex = 0;
164
+ for (const m of text.matchAll(finder)) {
165
+ const t = m[0];
166
+ row.set(t, (row.get(t) ?? 0) + 1);
167
+ if (!first.has(t))
168
+ first.set(t, toCp(m.index ?? 0));
169
+ }
170
+ }
171
+ else {
172
+ for (const t of terms) {
173
+ let idx = text.indexOf(t);
174
+ let cnt = 0;
175
+ const firstIdx = idx;
176
+ while (idx !== -1) {
177
+ cnt++;
178
+ idx = text.indexOf(t, idx + 1);
179
+ }
180
+ if (cnt) {
181
+ row.set(t, cnt);
182
+ first.set(t, toCp(firstIdx));
183
+ }
184
+ }
185
+ }
186
+ for (const t of row.keys())
187
+ df.set(t, (df.get(t) ?? 0) + 1);
188
+ tfs.push(row);
189
+ firsts.push(first);
190
+ }
191
+ const idf = new Map(terms.map((t) => [t, Math.log((n - (df.get(t) ?? 0) + 0.5) / ((df.get(t) ?? 0) + 0.5) + 1.0)]));
192
+ const k1 = 1.5;
193
+ const window = 80;
194
+ return records.map((r, i) => {
195
+ const row = tfs[i];
196
+ const first = firsts[i];
197
+ let score = 0;
198
+ for (const [t, tf] of row)
199
+ score += (idf.get(t) ?? 0) * (tf / (tf + k1));
200
+ if (proximity && row.size > 1) {
201
+ for (let j = 0; j + 1 < terms.length; j++) {
202
+ const t1 = terms[j];
203
+ const t2 = terms[j + 1];
204
+ if (first.has(t1) && first.has(t2)) {
205
+ const gap = first.get(t2) - first.get(t1);
206
+ if (gap > 0 && gap <= window) {
207
+ score += ((idf.get(t1) ?? 0) + (idf.get(t2) ?? 0)) / 4;
208
+ }
209
+ }
210
+ }
211
+ }
212
+ return { ...r, [into]: pyRound(score, 6) };
213
+ });
214
+ });
215
+ export const rank = traced("rank", (records, params) => {
216
+ const desc = params.desc ?? true;
217
+ const by = params.by;
218
+ const dir = desc ? -1 : 1;
219
+ // decorate-sort-undecorate like Python sorted(key=...): the key is
220
+ // computed once per record, the sort is stable, and reverse=desc keeps
221
+ // ties in original order (never reverse a stable ascending sort).
222
+ const sortKeyed = (rs, keyOf) => {
223
+ const keyed = rs.map((r) => [keyOf(r), r]);
224
+ keyed.sort((p, q) => dir * pyCompare(p[0], q[0]));
225
+ return keyed.map((p) => p[1]);
226
+ };
227
+ if (typeof by === "function")
228
+ return sortKeyed([...records], by);
229
+ const present = records.filter((r) => Object.hasOwn(r, by));
230
+ const missing = records.filter((r) => !Object.hasOwn(r, by));
231
+ return [...sortKeyed(present, (r) => r[by]), ...missing];
232
+ });
233
+ function truncateText(text, maxTokens, tokenizer, suffix) {
234
+ if (tokens.count(text, tokenizer) <= maxTokens)
235
+ return text;
236
+ const budget = Math.max(1, maxTokens - tokens.count(suffix, tokenizer));
237
+ if (tokens.isHeuristic(tokenizer)) {
238
+ // ceil(len/4) is invertible: longest prefix within budget = 4*budget
239
+ // code points (Python slices by code points, so must we).
240
+ const cps = [...text];
241
+ return pyRstrip(cps.slice(0, 4 * budget).join("")) + suffix;
242
+ }
243
+ const cps = [...text];
244
+ let lo = 0;
245
+ let hi = cps.length;
246
+ while (lo < hi) {
247
+ const mid = (lo + hi + 1) >> 1;
248
+ if (tokens.count(cps.slice(0, mid).join(""), tokenizer) <= budget)
249
+ lo = mid;
250
+ else
251
+ hi = mid - 1;
252
+ }
253
+ return pyRstrip(cps.slice(0, lo).join("")) + suffix;
254
+ }
255
+ export const truncateField = traced("truncate_field", (records, params) => {
256
+ const suffix = params.suffix ?? "…";
257
+ return records.map((r) => {
258
+ const value = r[params.field];
259
+ if (typeof value !== "string")
260
+ return r;
261
+ return {
262
+ ...r,
263
+ [params.field]: truncateText(value, params.maxTokens, params.tokenizer ?? null, suffix),
264
+ };
265
+ });
266
+ });
267
+ export const trimToBudget = traced("trim_to_budget", (records, params) => {
268
+ // minRecords guards the silent-failure edge: a too-small budget would
269
+ // return [], indistinguishable from "nothing matched" — keep the first
270
+ // (best-ranked) records anyway; the overrun shows in the audit.
271
+ const minRecords = params.minRecords ?? 0;
272
+ const out = [];
273
+ let used = 0;
274
+ for (const r of records) {
275
+ const cost = tokens.count(r, params.tokenizer ?? null) + 2; // list framing
276
+ if (used + cost > params.maxTokens && out.length >= minRecords)
277
+ break;
278
+ out.push(r);
279
+ used += cost;
280
+ }
281
+ return out;
282
+ });
283
+ /** Combiner (not auto-traced, matching Python): normalize and concatenate
284
+ * record lists from different tools; `schema` maps unified name -> source
285
+ * name or candidate list (first match wins). */
286
+ export function merge(sources, options = {}) {
287
+ const combined = [];
288
+ for (const src of sources) {
289
+ for (const r of src) {
290
+ if (!options.schema) {
291
+ combined.push(r);
292
+ continue;
293
+ }
294
+ const mapped = {};
295
+ for (const [unified, candidates] of Object.entries(options.schema)) {
296
+ const names = typeof candidates === "string" ? [candidates] : candidates;
297
+ for (const name of names) {
298
+ if (Object.hasOwn(r, name)) {
299
+ defineField(mapped, unified, r[name]);
300
+ break;
301
+ }
302
+ }
303
+ }
304
+ combined.push(mapped);
305
+ }
306
+ }
307
+ if (options.dedupeKey != null) {
308
+ return dedupe(combined, { key: options.dedupeKey });
309
+ }
310
+ return combined;
311
+ }
@@ -0,0 +1,27 @@
1
+ export type Tokenizer = (text: string) => number;
2
+ export type Serializer = (value: unknown) => string;
3
+ export declare function codePointLength(text: string): number;
4
+ /** Python-compatible canonical JSON (sort_keys, ensure_ascii=False, default=str). */
5
+ export declare function pythonJson(value: unknown): string;
6
+ interface Scope {
7
+ tokenizer?: Tokenizer;
8
+ serializer?: Serializer;
9
+ }
10
+ /** Process-wide default tokenizer; null resets to the built-in estimator. */
11
+ export declare function setTokenizer(tokenizer: Tokenizer | null): void;
12
+ /** Process-wide default serializer; null resets to canonical JSON. */
13
+ export declare function setSerializer(serializer: Serializer | null): void;
14
+ /**
15
+ * Context-local token accounting — safe for concurrent tenants. The JS
16
+ * analog of Python's `tokens.scoped()`: overrides apply within `fn` and
17
+ * every async continuation it spawns; other tasks are untouched.
18
+ */
19
+ export declare function scoped<T>(overrides: Scope, fn: () => T): T;
20
+ /** True when the tokenizer in effect is the built-in estimator (enables
21
+ * the closed-form truncate fast path, which relies on ceil(len/4)). */
22
+ export declare function isHeuristic(tokenizer?: Tokenizer | null): boolean;
23
+ /** Render a value exactly the way token counting will price it. */
24
+ export declare function serialize(value: unknown): string;
25
+ /** Token cost of any serializable value (strings bypass the serializer). */
26
+ export declare function count(value: unknown, tokenizer?: Tokenizer | null): number;
27
+ export {};
package/dist/tokens.js ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Token measurement — parity port of contexel's Python `tokens` module.
3
+ *
4
+ * Counting is encoding-relative: a record's cost is the token count of its
5
+ * serialized text. The canonical serialization replicates Python's
6
+ * `json.dumps(value, ensure_ascii=False, default=str, sort_keys=True)` —
7
+ * including the ", " / ": " separators — so both languages price identical
8
+ * records identically. Token length counts Unicode code points (Python
9
+ * `len`), not UTF-16 units.
10
+ *
11
+ * Parity boundary (ADR-001): JS has one number type — a JSON `1.0` parses
12
+ * to `1` and serializes as `"1"`, where Python emits `"1.0"`.
13
+ */
14
+ import { AsyncLocalStorage } from "node:async_hooks";
15
+ import { compareCodePoints, pyFloat } from "./py.js";
16
+ export function codePointLength(text) {
17
+ let n = 0;
18
+ for (const _ of text)
19
+ n++;
20
+ return n;
21
+ }
22
+ function heuristic(text) {
23
+ if (!text)
24
+ return 0;
25
+ return Math.max(1, Math.ceil(codePointLength(text) / 4));
26
+ }
27
+ /** Python-compatible canonical JSON (sort_keys, ensure_ascii=False, default=str). */
28
+ export function pythonJson(value) {
29
+ if (value === null || value === undefined)
30
+ return "null";
31
+ if (typeof value === "boolean")
32
+ return value ? "true" : "false";
33
+ if (typeof value === "number") {
34
+ if (!Number.isFinite(value))
35
+ return value > 0 ? "Infinity" : value < 0 ? "-Infinity" : "NaN";
36
+ // Python repr thresholds for floats ("1e-05", not JS's "0.00001");
37
+ // integer-valued numbers collapse to int form (ADR-001 boundary 4a).
38
+ return Number.isInteger(value) ? String(value) : pyFloat(value);
39
+ }
40
+ if (typeof value === "string")
41
+ return JSON.stringify(value);
42
+ if (Array.isArray(value)) {
43
+ return "[" + value.map(pythonJson).join(", ") + "]";
44
+ }
45
+ if (value instanceof Set) {
46
+ // Python json.dumps falls to default=str for sets; not JSON data — best effort.
47
+ return JSON.stringify(String(value));
48
+ }
49
+ if (typeof value === "object") {
50
+ // Python sorted() compares by code point; JS default string order is
51
+ // UTF-16, which sorts astral-plane keys differently.
52
+ const entries = Object.entries(value)
53
+ .sort(([a], [b]) => compareCodePoints(a, b));
54
+ return "{" + entries.map(([k, v]) => JSON.stringify(k) + ": " + pythonJson(v)).join(", ") + "}";
55
+ }
56
+ return JSON.stringify(String(value)); // default=str
57
+ }
58
+ let processTokenizer = null;
59
+ let processSerializer = null;
60
+ const scopeStore = new AsyncLocalStorage();
61
+ /** Process-wide default tokenizer; null resets to the built-in estimator. */
62
+ export function setTokenizer(tokenizer) {
63
+ processTokenizer = tokenizer;
64
+ }
65
+ /** Process-wide default serializer; null resets to canonical JSON. */
66
+ export function setSerializer(serializer) {
67
+ processSerializer = serializer;
68
+ }
69
+ /**
70
+ * Context-local token accounting — safe for concurrent tenants. The JS
71
+ * analog of Python's `tokens.scoped()`: overrides apply within `fn` and
72
+ * every async continuation it spawns; other tasks are untouched.
73
+ */
74
+ export function scoped(overrides, fn) {
75
+ // merge with any enclosing scope so nested overrides compose, like
76
+ // Python's two independent ContextVars; an explicit `undefined` means
77
+ // "not provided", never "clobber the outer override"
78
+ const merged = { ...scopeStore.getStore() };
79
+ if (overrides.tokenizer !== undefined)
80
+ merged.tokenizer = overrides.tokenizer;
81
+ if (overrides.serializer !== undefined)
82
+ merged.serializer = overrides.serializer;
83
+ return scopeStore.run(merged, fn);
84
+ }
85
+ function activeTokenizer() {
86
+ return scopeStore.getStore()?.tokenizer ?? processTokenizer ?? heuristic;
87
+ }
88
+ function activeSerializer() {
89
+ return scopeStore.getStore()?.serializer ?? processSerializer ?? pythonJson;
90
+ }
91
+ /** True when the tokenizer in effect is the built-in estimator (enables
92
+ * the closed-form truncate fast path, which relies on ceil(len/4)). */
93
+ export function isHeuristic(tokenizer) {
94
+ return (tokenizer ?? activeTokenizer()) === heuristic;
95
+ }
96
+ /** Render a value exactly the way token counting will price it. */
97
+ export function serialize(value) {
98
+ if (typeof value === "string")
99
+ return value;
100
+ return activeSerializer()(value);
101
+ }
102
+ /** Token cost of any serializable value (strings bypass the serializer). */
103
+ export function count(value, tokenizer) {
104
+ const tok = tokenizer ?? activeTokenizer();
105
+ return tok(typeof value === "string" ? value : serialize(value));
106
+ }
@@ -0,0 +1,31 @@
1
+ export interface Entry {
2
+ stage: string;
3
+ inCount: number;
4
+ outCount: number;
5
+ tokensBefore: number;
6
+ tokensAfter: number;
7
+ droppedIds: unknown[];
8
+ }
9
+ export declare class Trace {
10
+ entries: Entry[];
11
+ policies: string[];
12
+ idField: string | null;
13
+ constructor(idField?: string | null);
14
+ get tokensBefore(): number;
15
+ get tokensAfter(): number;
16
+ get reduction(): number;
17
+ report(): string;
18
+ /** JSON-able governance record: policy fingerprints, per-stage dropped
19
+ * record ids (stage = removal reason; records lacking the field are
20
+ * untracked), token movement. */
21
+ audit(): Record<string, unknown>;
22
+ }
23
+ export declare function currentTrace(): Trace | null;
24
+ /** Run `fn` with an active trace; returns [result, trace]. */
25
+ export declare function trace<T>(options: {
26
+ idField?: string;
27
+ } | null, fn: () => T): [T, Trace];
28
+ type Rec = Record<string, unknown>;
29
+ /** Wrap a stage so an active trace records its effect (incl. dropped ids). */
30
+ export declare function traced<A extends unknown[]>(name: string, fn: (records: Rec[], ...args: A) => Rec[]): (records: Rec[], ...args: A) => Rec[];
31
+ export {};
package/dist/trace.js ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Per-stage observability — parity port of contexel's Python `trace`.
3
+ * `trace({idField}, fn)` runs `fn` with an active trace (AsyncLocalStorage,
4
+ * the JS analog of contextvars) and records what every stage did; with
5
+ * `idField` it also records which records each stage dropped (records
6
+ * lacking the field are untracked). `audit()` is the governance record.
7
+ */
8
+ import { AsyncLocalStorage } from "node:async_hooks";
9
+ import * as tokens from "./tokens.js";
10
+ import { pyRound } from "./py.js";
11
+ export class Trace {
12
+ entries = [];
13
+ policies = [];
14
+ idField;
15
+ constructor(idField = null) {
16
+ this.idField = idField;
17
+ }
18
+ get tokensBefore() {
19
+ return this.entries.length ? this.entries[0].tokensBefore : 0;
20
+ }
21
+ get tokensAfter() {
22
+ return this.entries.length ? this.entries[this.entries.length - 1].tokensAfter : 0;
23
+ }
24
+ get reduction() {
25
+ const before = this.tokensBefore;
26
+ return before === 0 ? 0 : 1 - this.tokensAfter / before;
27
+ }
28
+ report() {
29
+ if (!this.entries.length)
30
+ return "(no stages traced)";
31
+ const lines = this.entries.map((e) => `${e.stage.padEnd(16)} ${String(e.inCount).padStart(5)} -> ` +
32
+ `${String(e.outCount).padEnd(5)} ${e.tokensBefore.toLocaleString("en-US").padStart(7)} -> ` +
33
+ `${e.tokensAfter.toLocaleString("en-US").padEnd(7)} tokens`);
34
+ lines.push("-".repeat(56));
35
+ lines.push(`${"total".padEnd(16)} ${"".padStart(5)} ${"".padEnd(5)} ` +
36
+ `${this.tokensBefore.toLocaleString("en-US").padStart(7)} -> ` +
37
+ `${this.tokensAfter.toLocaleString("en-US").padEnd(7)} tokens ` +
38
+ `(${pyRound(this.reduction * 100, 0)}% reduction)`);
39
+ return lines.join("\n");
40
+ }
41
+ /** JSON-able governance record: policy fingerprints, per-stage dropped
42
+ * record ids (stage = removal reason; records lacking the field are
43
+ * untracked), token movement. */
44
+ audit() {
45
+ return {
46
+ policies: [...this.policies],
47
+ id_field: this.idField,
48
+ tokens_before: this.tokensBefore,
49
+ tokens_after: this.tokensAfter,
50
+ reduction: pyRound(this.reduction, 4), // Python round(): half to even
51
+ stages: this.entries.map((e) => ({
52
+ stage: e.stage,
53
+ records_in: e.inCount,
54
+ records_out: e.outCount,
55
+ tokens_before: e.tokensBefore,
56
+ tokens_after: e.tokensAfter,
57
+ dropped_ids: [...e.droppedIds],
58
+ })),
59
+ };
60
+ }
61
+ }
62
+ const store = new AsyncLocalStorage();
63
+ export function currentTrace() {
64
+ return store.getStore() ?? null;
65
+ }
66
+ /** Run `fn` with an active trace; returns [result, trace]. */
67
+ export function trace(options, fn) {
68
+ const tr = new Trace(options?.idField ?? null);
69
+ const result = store.run(tr, fn);
70
+ return [result, tr];
71
+ }
72
+ function idKey(value) {
73
+ if (value !== null && typeof value === "object")
74
+ return tokens.pythonJson(value);
75
+ return value;
76
+ }
77
+ /** Wrap a stage so an active trace records its effect (incl. dropped ids). */
78
+ export function traced(name, fn) {
79
+ return (records, ...args) => {
80
+ const tr = currentTrace();
81
+ if (!tr)
82
+ return fn(records, ...args);
83
+ const beforeN = records.length;
84
+ const beforeTok = tokens.count(records);
85
+ const out = fn(records, ...args);
86
+ const dropped = [];
87
+ const fid = tr.idField;
88
+ if (fid) {
89
+ const remaining = new Map();
90
+ let unidentified = 0; // survivors without the id field absorb drops
91
+ for (const r of out) {
92
+ if (r !== null && typeof r === "object" && Object.hasOwn(r, fid)) {
93
+ const k = idKey(r[fid]);
94
+ remaining.set(k, (remaining.get(k) ?? 0) + 1);
95
+ }
96
+ else {
97
+ unidentified++;
98
+ }
99
+ }
100
+ for (const r of records) {
101
+ if (r !== null && typeof r === "object" && Object.hasOwn(r, fid)) {
102
+ const original = r[fid];
103
+ const k = idKey(original);
104
+ const left = remaining.get(k) ?? 0;
105
+ if (left > 0)
106
+ remaining.set(k, left - 1);
107
+ else if (unidentified > 0)
108
+ unidentified--;
109
+ else
110
+ dropped.push(original);
111
+ }
112
+ }
113
+ }
114
+ tr.entries.push({
115
+ stage: name,
116
+ inCount: beforeN,
117
+ outCount: out.length,
118
+ tokensBefore: beforeTok,
119
+ tokensAfter: tokens.count(out),
120
+ droppedIds: dropped,
121
+ });
122
+ return out;
123
+ };
124
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "contexel",
3
+ "version": "0.1.16",
4
+ "description": "Deterministic, dependency-free context shaping for code-writing agents — TypeScript parity of the Python package, enforced by shared golden vectors.",
5
+ "keywords": ["llm", "agents", "context-engineering", "tokens", "tool-use", "rag"],
6
+ "license": "MIT",
7
+ "author": "Mahesh Vaikri",
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ },
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": ["dist", "README.md"],
19
+ "engines": { "node": ">=18" },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "test": "tsc -p tsconfig.test.json && node --test dist-test/test/parity.test.js dist-test/test/behavior.test.js",
23
+ "prepublishOnly": "npm run build && npm test"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/maheshvaikri-code/contexel.git",
28
+ "directory": "ts"
29
+ },
30
+ "homepage": "https://maheshvaikri-code.github.io/contexel/",
31
+ "devDependencies": {
32
+ "@types/node": "^24.0.0",
33
+ "typescript": "^5.5.0"
34
+ }
35
+ }