entroly-wasm 1.0.16 → 1.0.18

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/index.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ export * from "./pkg/entroly_wasm";
2
+
3
+ export type EntrolyProvider = "openai" | "anthropic" | "gemini";
4
+
5
+ export interface EntrolyAppSdkOptions {
6
+ budget?: number;
7
+ preserveLastN?: number;
8
+ provider?: EntrolyProvider;
9
+ }
10
+
11
+ export interface EntrolyAppDecision {
12
+ provider: EntrolyProvider;
13
+ action: "compress" | "observe";
14
+ tokensBefore: number;
15
+ tokensAfter: number;
16
+ requestFingerprint: string;
17
+ }
18
+
19
+ export interface EntrolyOptimizedParams<T = Record<string, unknown>> {
20
+ params: T;
21
+ decision: EntrolyAppDecision;
22
+ }
23
+
24
+ export function estimateTokens(value: unknown): number;
25
+ export function stableStringify(value: unknown): string;
26
+ export function optimizeMessages<T extends Array<Record<string, unknown>>>(
27
+ messages: T,
28
+ options?: EntrolyAppSdkOptions,
29
+ ): { messages: T; tokensBefore: number; tokensAfter: number };
30
+ export function optimizeRequestParams<T extends Record<string, unknown>>(
31
+ params: T,
32
+ options?: EntrolyAppSdkOptions,
33
+ ): EntrolyOptimizedParams<T>;
34
+ export function optimizeOpenAIParams<T extends Record<string, unknown>>(
35
+ params: T,
36
+ options?: EntrolyAppSdkOptions,
37
+ ): EntrolyOptimizedParams<T>;
38
+ export function optimizeAnthropicParams<T extends Record<string, unknown>>(
39
+ params: T,
40
+ options?: EntrolyAppSdkOptions,
41
+ ): EntrolyOptimizedParams<T>;
42
+ export function optimizeGeminiParams<T extends Record<string, unknown>>(
43
+ params: T,
44
+ options?: EntrolyAppSdkOptions,
45
+ ): EntrolyOptimizedParams<T>;
46
+
47
+ export function createEntrolyMiddleware(options?: EntrolyAppSdkOptions): {
48
+ specificationVersion: "v3";
49
+ transformParams(args: { params: Record<string, unknown> }): Promise<Record<string, unknown>>;
50
+ wrapGenerate(args: { doGenerate: () => Promise<unknown> }): Promise<unknown>;
51
+ wrapStream(args: { doStream: () => Promise<unknown> }): Promise<unknown>;
52
+ };
53
+
54
+ export function wrapOpenAI<TClient>(client: TClient, options?: EntrolyAppSdkOptions): unknown;
55
+ export function wrapAnthropic<TClient>(client: TClient, options?: EntrolyAppSdkOptions): unknown;
56
+ export function wrapGemini<TClient>(client: TClient, options?: EntrolyAppSdkOptions): unknown;
57
+
58
+ export interface ContextReceiptDocument {
59
+ source_path?: string;
60
+ source?: string;
61
+ path?: string;
62
+ text?: string;
63
+ content?: string;
64
+ }
65
+
66
+ export type ContextReceiptInput =
67
+ | Record<string, string>
68
+ | Array<[string, string] | ContextReceiptDocument | string>;
69
+
70
+ export interface ContextReceiptOptions {
71
+ query?: string;
72
+ budget?: number;
73
+ tokenBudget?: number;
74
+ token_budget?: number;
75
+ chunkTokens?: number;
76
+ chunk_tokens?: number;
77
+ overlapTokens?: number;
78
+ overlap_tokens?: number;
79
+ }
80
+
81
+ export function ingestReceiptDocuments(
82
+ documents: ContextReceiptInput,
83
+ options?: ContextReceiptOptions,
84
+ ): Record<string, unknown>;
85
+
86
+ export function selectReceiptContext(
87
+ index: Record<string, unknown>,
88
+ options: ContextReceiptOptions & { query: string },
89
+ ): Record<string, unknown>;
90
+
91
+ export function createContextReceipt(
92
+ documents: ContextReceiptInput,
93
+ options: ContextReceiptOptions & { query: string },
94
+ ): Record<string, unknown>;
95
+
96
+ export function renderContextReceipt(receipt: Record<string, unknown>): string;
97
+ export function explainReceiptOmission(receipt: Record<string, unknown>, chunkId: string): string;
package/index.js CHANGED
@@ -65,6 +65,25 @@ const { ValueTracker, EVOLUTION_TAX_RATE, estimateCost } = require('./js/value_t
65
65
  const { exportPromoted: exportAgentSkills } = require('./js/agentskills_export');
66
66
  const { TelegramGateway, DiscordGateway, SlackGateway } = require('./js/gateways');
67
67
  const { VaultObserver } = require('./js/vault_observer');
68
+ const {
69
+ createContextReceipt,
70
+ explainReceiptOmission,
71
+ ingestReceiptDocuments,
72
+ renderContextReceipt,
73
+ selectReceiptContext,
74
+ } = require('./js/context_receipts');
75
+ const {
76
+ createEntrolyMiddleware,
77
+ optimizeAnthropicParams,
78
+ optimizeGeminiParams,
79
+ optimizeMessages,
80
+ optimizeOpenAIParams,
81
+ optimizeRequestParams,
82
+ stableStringify,
83
+ wrapAnthropic,
84
+ wrapGemini,
85
+ wrapOpenAI,
86
+ } = require('./js/app_sdk');
68
87
 
69
88
  function classifyQueryTransition(...args) {
70
89
  if (!classifyQueryTransitionRust) {
@@ -141,4 +160,23 @@ module.exports = {
141
160
  // Observe the shared vault — works with skills promoted by the
142
161
  // Python daemon OR any node-side orchestrator. No daemon required.
143
162
  VaultObserver,
163
+
164
+ // App SDK: middleware shape plus native provider wrappers.
165
+ createEntrolyMiddleware,
166
+ optimizeAnthropicParams,
167
+ optimizeGeminiParams,
168
+ optimizeMessages,
169
+ optimizeOpenAIParams,
170
+ optimizeRequestParams,
171
+ stableStringify,
172
+ wrapAnthropic,
173
+ wrapGemini,
174
+ wrapOpenAI,
175
+
176
+ // Context Receipts: auditable context selection receipts for JS users.
177
+ ingestReceiptDocuments,
178
+ selectReceiptContext,
179
+ createContextReceipt,
180
+ renderContextReceipt,
181
+ explainReceiptOmission,
144
182
  };
package/js/app_sdk.js ADDED
@@ -0,0 +1,241 @@
1
+ // App-level SDK helpers for JavaScript/TypeScript applications.
2
+ //
3
+ // These helpers are dependency-free. They do not import provider SDKs or the
4
+ // Vercel AI SDK; instead they expose plain middleware/wrapper shapes that those
5
+ // libraries can consume. All transforms are local and preserve provider-owned
6
+ // controls such as model, temperature, tools, tool_choice, safetySettings, and
7
+ // thinking fields.
8
+
9
+ function estimateTokens(value) {
10
+ if (value == null) return 0;
11
+ if (typeof value === 'string') return Math.ceil(value.length / 4);
12
+ if (Array.isArray(value)) return value.reduce((n, item) => n + estimateTokens(item), 0);
13
+ if (typeof value === 'object') {
14
+ if (typeof value.text === 'string') return estimateTokens(value.text);
15
+ if (typeof value.content === 'string' || Array.isArray(value.content)) {
16
+ return estimateTokens(value.content);
17
+ }
18
+ if (Array.isArray(value.parts)) return estimateTokens(value.parts);
19
+ }
20
+ return 0;
21
+ }
22
+
23
+ function cloneJson(value) {
24
+ if (value == null) return value;
25
+ return JSON.parse(JSON.stringify(value));
26
+ }
27
+
28
+ function stableStringify(value) {
29
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
30
+ if (value && typeof value === 'object') {
31
+ return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
32
+ }
33
+ return JSON.stringify(value);
34
+ }
35
+
36
+ function fingerprint(value) {
37
+ const crypto = require('crypto');
38
+ return crypto.createHash('sha256').update(stableStringify(value)).digest('hex').slice(0, 12);
39
+ }
40
+
41
+ function compactText(text, tokenBudget) {
42
+ if (typeof text !== 'string' || tokenBudget <= 0 || estimateTokens(text) <= tokenBudget) {
43
+ return text;
44
+ }
45
+ const maxChars = Math.max(16, tokenBudget * 4);
46
+ const marker = '\n...[entroly: middle omitted; request stayed local]...\n';
47
+ if (maxChars <= marker.length + 8) return text.slice(0, maxChars);
48
+ const headChars = Math.floor((maxChars - marker.length) * 0.62);
49
+ const tailChars = Math.max(0, maxChars - marker.length - headChars);
50
+ return `${text.slice(0, headChars).trimEnd()}${marker}${text.slice(-tailChars).trimStart()}`;
51
+ }
52
+
53
+ function transformContent(content, tokenBudget) {
54
+ if (typeof content === 'string') return compactText(content, tokenBudget);
55
+ if (Array.isArray(content)) {
56
+ return content.map(part => transformContent(part, tokenBudget));
57
+ }
58
+ if (content && typeof content === 'object') {
59
+ const out = { ...content };
60
+ if (typeof out.text === 'string') out.text = compactText(out.text, tokenBudget);
61
+ else if (typeof out.content === 'string' || Array.isArray(out.content)) {
62
+ out.content = transformContent(out.content, tokenBudget);
63
+ } else if (Array.isArray(out.parts)) {
64
+ out.parts = out.parts.map(part => transformContent(part, tokenBudget));
65
+ }
66
+ return out;
67
+ }
68
+ return content;
69
+ }
70
+
71
+ function optimizeMessages(messages, options) {
72
+ if (!Array.isArray(messages)) return { messages, tokensBefore: 0, tokensAfter: 0 };
73
+ const budget = Math.max(1, Number(options.budget || 32000));
74
+ const preserveLastN = Math.max(1, Number(options.preserveLastN || 4));
75
+ const out = cloneJson(messages);
76
+ let tokensBefore = estimateTokens(out);
77
+ if (tokensBefore <= budget) return { messages: out, tokensBefore, tokensAfter: tokensBefore };
78
+
79
+ const targetOlderTokens = Math.max(1, budget - estimateTokens(out.slice(-preserveLastN)));
80
+ const older = Math.max(1, out.length - preserveLastN);
81
+ const perOldMessageBudget = Math.max(16, Math.floor(targetOlderTokens / older));
82
+ for (let i = 0; i < out.length - preserveLastN; i += 1) {
83
+ out[i].content = transformContent(out[i].content, perOldMessageBudget);
84
+ }
85
+ return { messages: out, tokensBefore, tokensAfter: estimateTokens(out) };
86
+ }
87
+
88
+ function optimizeOpenAIParams(params, options = {}) {
89
+ const out = { ...params };
90
+ const before = estimateTokens(out.messages || out.input || out.prompt);
91
+ let after = before;
92
+ if (Array.isArray(out.messages)) {
93
+ const result = optimizeMessages(out.messages, options);
94
+ out.messages = result.messages;
95
+ after = result.tokensAfter;
96
+ } else if (typeof out.input === 'string') {
97
+ out.input = compactText(out.input, Number(options.budget || 32000));
98
+ after = estimateTokens(out.input);
99
+ } else if (typeof out.prompt === 'string') {
100
+ out.prompt = compactText(out.prompt, Number(options.budget || 32000));
101
+ after = estimateTokens(out.prompt);
102
+ }
103
+ return {
104
+ params: out,
105
+ decision: {
106
+ provider: 'openai',
107
+ action: after < before ? 'compress' : 'observe',
108
+ tokensBefore: before,
109
+ tokensAfter: after,
110
+ requestFingerprint: fingerprint(out),
111
+ },
112
+ };
113
+ }
114
+
115
+ function optimizeAnthropicParams(params, options = {}) {
116
+ const out = { ...params };
117
+ const before = estimateTokens(out.messages || out.system);
118
+ let after = before;
119
+ if (Array.isArray(out.messages)) {
120
+ const result = optimizeMessages(out.messages, options);
121
+ out.messages = result.messages;
122
+ after = result.tokensAfter + estimateTokens(out.system);
123
+ }
124
+ return {
125
+ params: out,
126
+ decision: {
127
+ provider: 'anthropic',
128
+ action: after < before ? 'compress' : 'observe',
129
+ tokensBefore: before,
130
+ tokensAfter: after,
131
+ requestFingerprint: fingerprint(out),
132
+ },
133
+ };
134
+ }
135
+
136
+ function optimizeGeminiParams(params, options = {}) {
137
+ const out = { ...params };
138
+ const contents = Array.isArray(out.contents) ? cloneJson(out.contents) : out.contents;
139
+ const before = estimateTokens(contents);
140
+ let after = before;
141
+ if (Array.isArray(contents)) {
142
+ const asMessages = contents.map(item => ({
143
+ role: item.role || 'user',
144
+ content: Array.isArray(item.parts) ? item.parts : item,
145
+ }));
146
+ const result = optimizeMessages(asMessages, options);
147
+ out.contents = result.messages.map(item => ({
148
+ role: item.role,
149
+ parts: Array.isArray(item.content) ? item.content : [{ text: String(item.content || '') }],
150
+ }));
151
+ after = estimateTokens(out.contents);
152
+ }
153
+ return {
154
+ params: out,
155
+ decision: {
156
+ provider: 'gemini',
157
+ action: after < before ? 'compress' : 'observe',
158
+ tokensBefore: before,
159
+ tokensAfter: after,
160
+ requestFingerprint: fingerprint(out),
161
+ },
162
+ };
163
+ }
164
+
165
+ function optimizeRequestParams(params, options = {}) {
166
+ const provider = (options.provider || '').toLowerCase();
167
+ if (provider === 'anthropic' || (params && Array.isArray(params.messages) && typeof params.system === 'string')) {
168
+ return optimizeAnthropicParams(params, options);
169
+ }
170
+ if (provider === 'gemini' || Array.isArray(params && params.contents)) {
171
+ return optimizeGeminiParams(params, options);
172
+ }
173
+ return optimizeOpenAIParams(params, options);
174
+ }
175
+
176
+ function createEntrolyMiddleware(options = {}) {
177
+ return {
178
+ specificationVersion: 'v3',
179
+ transformParams: async ({ params }) => optimizeRequestParams(params, options).params,
180
+ wrapGenerate: async ({ doGenerate }) => doGenerate(),
181
+ wrapStream: async ({ doStream }) => doStream(),
182
+ };
183
+ }
184
+
185
+ function wrapOpenAI(client, options = {}) {
186
+ return {
187
+ raw: client,
188
+ chat: {
189
+ completions: {
190
+ create: (params, ...rest) => client.chat.completions.create(
191
+ optimizeOpenAIParams(params, options).params,
192
+ ...rest,
193
+ ),
194
+ },
195
+ },
196
+ responses: client.responses ? {
197
+ create: (params, ...rest) => client.responses.create(
198
+ optimizeOpenAIParams(params, options).params,
199
+ ...rest,
200
+ ),
201
+ } : undefined,
202
+ };
203
+ }
204
+
205
+ function wrapAnthropic(client, options = {}) {
206
+ return {
207
+ raw: client,
208
+ messages: {
209
+ create: (params, ...rest) => client.messages.create(
210
+ optimizeAnthropicParams(params, options).params,
211
+ ...rest,
212
+ ),
213
+ },
214
+ };
215
+ }
216
+
217
+ function wrapGemini(client, options = {}) {
218
+ return {
219
+ raw: client,
220
+ models: {
221
+ generateContent: (params, ...rest) => client.models.generateContent(
222
+ optimizeGeminiParams(params, options).params,
223
+ ...rest,
224
+ ),
225
+ },
226
+ };
227
+ }
228
+
229
+ module.exports = {
230
+ createEntrolyMiddleware,
231
+ estimateTokens,
232
+ optimizeAnthropicParams,
233
+ optimizeGeminiParams,
234
+ optimizeMessages,
235
+ optimizeOpenAIParams,
236
+ optimizeRequestParams,
237
+ stableStringify,
238
+ wrapAnthropic,
239
+ wrapGemini,
240
+ wrapOpenAI,
241
+ };
@@ -0,0 +1,443 @@
1
+ const crypto = require('crypto');
2
+
3
+ const SCHEMA_VERSION = 'context-receipt.v1';
4
+
5
+ function sha256(value) {
6
+ return crypto.createHash('sha256').update(String(value).replace(/\r\n/g, '\n').replace(/\r/g, '\n')).digest('hex');
7
+ }
8
+
9
+ function fingerprint(text) {
10
+ return `sha256:${sha256(text)}`;
11
+ }
12
+
13
+ function stableValue(value) {
14
+ if (Array.isArray(value)) return value.map(stableValue);
15
+ if (value && typeof value === 'object') {
16
+ return Object.keys(value).sort().reduce((out, key) => {
17
+ out[key] = stableValue(value[key]);
18
+ return out;
19
+ }, {});
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function stableHash(value) {
25
+ return sha256(JSON.stringify(stableValue(value)));
26
+ }
27
+
28
+ function tokenize(text) {
29
+ return String(text)
30
+ .replace(/-/g, ' ')
31
+ .toLowerCase()
32
+ .match(/[a-z0-9][a-z0-9_']*/g) || [];
33
+ }
34
+
35
+ function estimateTokens(text) {
36
+ return Math.max(1, tokenize(text).length);
37
+ }
38
+
39
+ function titleForPath(sourcePath) {
40
+ const name = String(sourcePath).split(/[\\/]/).pop() || sourcePath;
41
+ return name.replace(/\.[^.]+$/, '').replace(/[_-]/g, ' ');
42
+ }
43
+
44
+ function normalizeDocs(documents) {
45
+ if (!documents) return [];
46
+ if (!Array.isArray(documents) && typeof documents === 'object') {
47
+ return Object.entries(documents).map(([sourcePath, text]) => [String(sourcePath), String(text)]);
48
+ }
49
+ return documents.map((item, idx) => {
50
+ if (Array.isArray(item)) return [String(item[0]), String(item[1] || '')];
51
+ if (item && typeof item === 'object') {
52
+ return [
53
+ String(item.source_path || item.source || item.path || `document_${idx}.txt`),
54
+ String(Object.prototype.hasOwnProperty.call(item, 'text') ? item.text : item.content || ''),
55
+ ];
56
+ }
57
+ return [`document_${idx}.txt`, String(item || '')];
58
+ });
59
+ }
60
+
61
+ function isHeading(line) {
62
+ const trimmed = line.trim();
63
+ return /^#{1,6}\s+/.test(trimmed)
64
+ || /^(section|article|clause|exhibit|schedule|addendum)\s+[A-Za-z0-9.-]+/i.test(trimmed)
65
+ || /^\d+(\.\d+)*\s+\S+/.test(trimmed);
66
+ }
67
+
68
+ function paragraphBlocks(text) {
69
+ const blocks = [];
70
+ let offset = 0;
71
+ let start = null;
72
+ let current = [];
73
+ let heading = null;
74
+ let page = null;
75
+ const flush = (end) => {
76
+ if (start !== null && current.length) {
77
+ const raw = current.join('').trim();
78
+ if (raw) blocks.push({ text: raw, start, end, heading, page });
79
+ }
80
+ start = null;
81
+ current = [];
82
+ };
83
+ for (const line of String(text).match(/[^\n]*\n|[^\n]+$/g) || []) {
84
+ const trimmed = line.trim();
85
+ const pageMatch = trimmed.match(/\bpage\s+(\d+)\b/i);
86
+ if (pageMatch) page = Number(pageMatch[1]);
87
+ if (trimmed && isHeading(trimmed)) {
88
+ flush(offset);
89
+ heading = trimmed.replace(/^#+\s*/, '');
90
+ }
91
+ if (!trimmed) {
92
+ flush(offset);
93
+ } else {
94
+ if (start === null) start = offset;
95
+ current.push(line);
96
+ }
97
+ offset += line.length;
98
+ }
99
+ flush(String(text).length);
100
+ return blocks;
101
+ }
102
+
103
+ function chunkDocument(sourcePath, text, documentId, docFingerprint, chunkTokens = 360) {
104
+ const raw = [];
105
+ let pending = [];
106
+ let start = null;
107
+ let end = 0;
108
+ let heading = null;
109
+ let page = null;
110
+ let tokens = 0;
111
+ const flush = () => {
112
+ if (start !== null && pending.length) {
113
+ raw.push({ text: pending.join('\n\n').trim(), start, end, heading, page });
114
+ }
115
+ pending = [];
116
+ start = null;
117
+ tokens = 0;
118
+ };
119
+ for (const block of paragraphBlocks(text)) {
120
+ const count = estimateTokens(block.text);
121
+ if (pending.length && tokens + count > chunkTokens) flush();
122
+ if (start === null) {
123
+ start = block.start;
124
+ heading = block.heading;
125
+ page = block.page;
126
+ }
127
+ pending.push(block.text);
128
+ end = block.end;
129
+ tokens += count;
130
+ }
131
+ flush();
132
+
133
+ let running = 0;
134
+ return raw.map((chunk, idx) => {
135
+ const count = estimateTokens(chunk.text);
136
+ const fp = fingerprint(`${docFingerprint}\n${chunk.start}:${chunk.end}\n${chunk.text}`);
137
+ const chunkId = `chk_${stableHash({ doc: documentId, start: chunk.start, end: chunk.end, fp }).slice(0, 12)}`;
138
+ const out = {
139
+ chunk_id: chunkId,
140
+ document_id: documentId,
141
+ source_path: sourcePath,
142
+ title: titleForPath(sourcePath),
143
+ section_heading: chunk.heading,
144
+ page_number: chunk.page,
145
+ chunk_index: idx,
146
+ byte_start: chunk.start,
147
+ byte_end: chunk.end,
148
+ token_start: running,
149
+ token_end: running + count,
150
+ token_count: count,
151
+ fingerprint: fp,
152
+ text: chunk.text,
153
+ };
154
+ running += count;
155
+ return out;
156
+ });
157
+ }
158
+
159
+ function ingestReceiptDocuments(documents, options = {}) {
160
+ const chunkTokens = Math.max(40, Number(options.chunkTokens || options.chunk_tokens || 360));
161
+ const normalized = normalizeDocs(documents).sort((a, b) => a[0].localeCompare(b[0]));
162
+ const records = [];
163
+ const chunks = [];
164
+ const sourceFingerprints = {};
165
+ for (const [sourcePath, text] of normalized) {
166
+ const docFp = fingerprint(text);
167
+ const documentId = `doc_${stableHash({ sourcePath, docFp }).slice(0, 12)}`;
168
+ const docChunks = chunkDocument(sourcePath, text, documentId, docFp, chunkTokens);
169
+ sourceFingerprints[sourcePath] = docFp;
170
+ records.push({
171
+ document_id: documentId,
172
+ source_path: sourcePath,
173
+ title: titleForPath(sourcePath),
174
+ fingerprint: docFp,
175
+ token_count: estimateTokens(text),
176
+ byte_count: Buffer.byteLength(text),
177
+ chunk_ids: docChunks.map((c) => c.chunk_id),
178
+ });
179
+ chunks.push(...docChunks);
180
+ }
181
+ return {
182
+ schema_version: SCHEMA_VERSION,
183
+ documents: records,
184
+ chunks,
185
+ chunk_token_limit: chunkTokens,
186
+ chunk_overlap: Number(options.overlapTokens || options.overlap_tokens || 32),
187
+ source_fingerprints: sourceFingerprints,
188
+ };
189
+ }
190
+
191
+ function rankChunks(index, query) {
192
+ const queryTerms = Array.from(new Set(tokenize(query).filter((t) => t.length > 1)));
193
+ const docs = index.chunks.map((chunk) => ({ chunk, terms: tokenize(chunk.text) }));
194
+ const df = {};
195
+ for (const doc of docs) {
196
+ for (const term of new Set(doc.terms)) df[term] = (df[term] || 0) + 1;
197
+ }
198
+ const docCount = Math.max(1, docs.length);
199
+ const avgLen = docs.reduce((sum, doc) => sum + doc.terms.length, 0) / docCount || 1;
200
+ return docs.map(({ chunk, terms }) => {
201
+ const tf = {};
202
+ for (const term of terms) tf[term] = (tf[term] || 0) + 1;
203
+ let lexical = 0;
204
+ const matched = new Set();
205
+ const heading = String(chunk.section_heading || '').toLowerCase();
206
+ const sourcePath = String(chunk.source_path || '').toLowerCase();
207
+ for (const term of queryTerms) {
208
+ const freq = tf[term] || 0;
209
+ const inHeading = heading.includes(term);
210
+ const inPath = sourcePath.includes(term);
211
+ if (freq || inHeading || inPath) matched.add(term);
212
+ const idf = Math.log((docCount - (df[term] || 0) + 0.5) / ((df[term] || 0) + 0.5) + 1);
213
+ if (freq) lexical += idf * ((freq * 2.2) / (freq + 1.2 * (1 - 0.75 + 0.75 * terms.length / Math.max(1, avgLen))));
214
+ if (inHeading) lexical += idf * 2.5;
215
+ if (inPath) lexical += idf * 1.5;
216
+ }
217
+ lexical *= 1 + matched.size / Math.max(1, queryTerms.length);
218
+ const reasons = matched.size ? [`lexical match: ${Array.from(matched).slice(0, 8).join(', ')}`] : ['low lexical overlap; retained as lower-ranked candidate'];
219
+ if (/(as defined in|subject to|pursuant to|see section)/i.test(chunk.text)) reasons.push('contains explicit dependency/reference language');
220
+ return {
221
+ chunk_id: chunk.chunk_id,
222
+ lexical_score: Number(lexical.toFixed(6)),
223
+ semantic_score: 0,
224
+ rerank_score: 0,
225
+ final_score: Number(lexical.toFixed(6)),
226
+ reasons,
227
+ };
228
+ }).sort((a, b) => b.final_score - a.final_score || a.chunk_id.localeCompare(b.chunk_id));
229
+ }
230
+
231
+ function norm(value) {
232
+ return String(value).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
233
+ }
234
+
235
+ function detectDependencies(index) {
236
+ const termDefs = new Map();
237
+ for (const chunk of index.chunks) {
238
+ for (const match of chunk.text.matchAll(/"([^"]{2,80})"\s+(means|shall mean|is defined as|refers to)\b/gi)) {
239
+ const term = norm(match[1]);
240
+ if (!termDefs.has(term)) termDefs.set(term, []);
241
+ termDefs.get(term).push(chunk);
242
+ }
243
+ }
244
+ const links = [];
245
+ const seen = new Set();
246
+ const add = (source, target, relationType, evidence) => {
247
+ const key = `${source.chunk_id}:${target ? target.chunk_id : ''}:${relationType}:${evidence}`;
248
+ if (seen.has(key)) return;
249
+ seen.add(key);
250
+ links.push({
251
+ source_chunk_id: source.chunk_id,
252
+ target_chunk_id: target ? target.chunk_id : null,
253
+ relation_type: relationType,
254
+ evidence,
255
+ source_document_id: source.document_id,
256
+ target_document_id: target ? target.document_id : null,
257
+ resolved: Boolean(target),
258
+ warning: target ? null : `Unresolved reference: ${evidence}`,
259
+ });
260
+ };
261
+ const resolve = (source, label) => {
262
+ const target = norm(label);
263
+ return index.chunks.find((chunk) => chunk.chunk_id !== source.chunk_id && (
264
+ norm(chunk.section_heading || '').includes(target)
265
+ || norm(chunk.source_path).includes(target)
266
+ || norm(chunk.text.slice(0, 300)).includes(target)
267
+ ));
268
+ };
269
+ for (const chunk of index.chunks) {
270
+ const lower = norm(chunk.text);
271
+ for (const [term, defs] of termDefs.entries()) {
272
+ if (lower.includes(term) && defs.every((def) => def.chunk_id !== chunk.chunk_id)) add(chunk, defs[0], 'defined_term', term);
273
+ }
274
+ const patterns = [
275
+ ['defined_in', /\bas defined in\s+((section|clause|article|exhibit|schedule|addendum)\s+[A-Za-z0-9.-]+)/gi],
276
+ ['subject_to', /\bsubject to\s+((section|clause|article|exhibit|schedule|addendum)\s+[A-Za-z0-9.-]+)/gi],
277
+ ['pursuant_to', /\bpursuant to\s+((section|clause|article|exhibit|schedule|addendum)\s+[A-Za-z0-9.-]+)/gi],
278
+ ['see_reference', /\bsee\s+((section|clause|article|exhibit|schedule|addendum)\s+[A-Za-z0-9.-]+)/gi],
279
+ ];
280
+ for (const [relationType, re] of patterns) {
281
+ for (const match of chunk.text.matchAll(re)) add(chunk, resolve(chunk, match[1]), relationType, match[1]);
282
+ }
283
+ }
284
+ return links;
285
+ }
286
+
287
+ function selectReceiptContext(index, options = {}) {
288
+ const query = options.query || '';
289
+ const tokenBudget = Number(options.tokenBudget || options.token_budget || options.budget || 8000);
290
+ const ranked = rankChunks(index, query);
291
+ const deps = detectDependencies(index);
292
+ const chunksById = Object.fromEntries(index.chunks.map((chunk) => [chunk.chunk_id, chunk]));
293
+ const selectedIds = [];
294
+ let selectedTokens = 0;
295
+ const warnings = [];
296
+ const add = (chunkId) => {
297
+ const chunk = chunksById[chunkId];
298
+ if (!chunk || selectedIds.includes(chunkId) || selectedTokens + chunk.token_count > tokenBudget) return false;
299
+ selectedIds.push(chunkId);
300
+ selectedTokens += chunk.token_count;
301
+ return true;
302
+ };
303
+ for (const rank of ranked) {
304
+ if (!add(rank.chunk_id)) continue;
305
+ for (const dep of deps.filter((d) => d.source_chunk_id === rank.chunk_id)) {
306
+ if (dep.target_chunk_id && !selectedIds.includes(dep.target_chunk_id) && !add(dep.target_chunk_id)) {
307
+ warnings.push(`Dependency not included due to budget: ${rank.chunk_id} -> ${dep.target_chunk_id} (${dep.relation_type})`);
308
+ } else if (!dep.resolved && dep.warning) {
309
+ warnings.push(dep.warning);
310
+ }
311
+ }
312
+ }
313
+ const rankById = Object.fromEntries(ranked.map((rank) => [rank.chunk_id, rank]));
314
+ const selected_context = selectedIds.map((chunkId) => {
315
+ const chunk = chunksById[chunkId];
316
+ const sourceDeps = deps.filter((d) => d.source_chunk_id === chunkId);
317
+ return {
318
+ chunk_id: chunk.chunk_id,
319
+ source_path: chunk.source_path,
320
+ section_heading: chunk.section_heading || null,
321
+ page_number: chunk.page_number || null,
322
+ byte_start: chunk.byte_start,
323
+ byte_end: chunk.byte_end,
324
+ token_start: chunk.token_start,
325
+ token_end: chunk.token_end,
326
+ token_count: chunk.token_count,
327
+ score: rankById[chunkId] ? rankById[chunkId].final_score : 0,
328
+ reasons: rankById[chunkId] ? rankById[chunkId].reasons : ['included as dependency'],
329
+ dependencies_included: sourceDeps.map((d) => d.target_chunk_id).filter((id) => id && selectedIds.includes(id)),
330
+ dependencies_missing: sourceDeps.map((d) => d.target_chunk_id || d.evidence).filter((id) => !selectedIds.includes(id)),
331
+ fingerprint: chunk.fingerprint,
332
+ text: chunk.text,
333
+ };
334
+ });
335
+ const omitted_context = ranked.filter((rank) => !selectedIds.includes(rank.chunk_id)).slice(0, 20).map((rank) => {
336
+ const chunk = chunksById[rank.chunk_id];
337
+ return {
338
+ chunk_id: chunk.chunk_id,
339
+ source_path: chunk.source_path,
340
+ section_heading: chunk.section_heading || null,
341
+ page_number: chunk.page_number || null,
342
+ token_count: chunk.token_count,
343
+ score: rank.final_score,
344
+ reasons: rank.reasons,
345
+ omission_reason: selectedTokens + chunk.token_count > tokenBudget ? 'budget_limit' : 'lower ranked than selected context under token budget',
346
+ fingerprint: chunk.fingerprint,
347
+ text_preview: chunk.text.replace(/\s+/g, ' ').slice(0, 240),
348
+ };
349
+ });
350
+ const sourceTokens = index.chunks.reduce((sum, chunk) => sum + chunk.token_count, 0);
351
+ const selectedToSource = selectedTokens / Math.max(1, sourceTokens);
352
+ const relevantOmitted = omitted_context.filter((item) => item.score > 0).length;
353
+ if (relevantOmitted) warnings.push(`${relevantOmitted} relevant chunk(s) were omitted; inspect omitted_context.`);
354
+ const unresolved = deps.filter((dep) => !dep.resolved).length;
355
+ if (unresolved) warnings.push(`${unresolved} dependency reference(s) could not be resolved to an ingested chunk.`);
356
+ const ranking_reasons = Object.fromEntries(ranked.map((rank) => [rank.chunk_id, rank.reasons]));
357
+ const source_fingerprints = {
358
+ documents: Object.fromEntries(index.documents.map((doc) => [doc.source_path, doc.fingerprint])),
359
+ chunks: Object.fromEntries(index.chunks.map((chunk) => [chunk.chunk_id, chunk.fingerprint])),
360
+ };
361
+ const risk_summary = {
362
+ coverage_score: Number((0.45 * selectedToSource + 0.35 * (selected_context.length / Math.max(1, index.chunks.length)) + 0.2 * (1 - unresolved / Math.max(1, deps.length))).toFixed(6)),
363
+ review_level: relevantOmitted || unresolved ? 'medium' : 'low',
364
+ selected_chunks: selected_context.length,
365
+ total_chunks: index.chunks.length,
366
+ omitted_relevant_chunks: relevantOmitted,
367
+ unresolved_dependency_count: unresolved,
368
+ controls: {
369
+ dependency_closure: unresolved ? 'partial' : 'complete',
370
+ omitted_evidence_pressure: relevantOmitted ? 'medium' : 'low',
371
+ replayable_fingerprints: true,
372
+ local_no_llm_judgment: true,
373
+ },
374
+ };
375
+ const payload = {
376
+ schema_version: SCHEMA_VERSION,
377
+ query,
378
+ token_budget: tokenBudget,
379
+ selected_context,
380
+ omitted_context,
381
+ dependency_links: deps,
382
+ ranking_reasons,
383
+ compression_ratio: {
384
+ source_tokens: sourceTokens,
385
+ selected_tokens: selectedTokens,
386
+ tokens_saved: Math.max(0, sourceTokens - selectedTokens),
387
+ selected_to_source_ratio: Number(selectedToSource.toFixed(6)),
388
+ source_to_selected_ratio: Number((sourceTokens / Math.max(1, selectedTokens)).toFixed(6)),
389
+ reduction_pct: Number(((1 - selectedToSource) * 100).toFixed(3)),
390
+ },
391
+ source_fingerprints,
392
+ risk_summary,
393
+ warnings: Array.from(new Set(warnings)),
394
+ outcome_links: [],
395
+ };
396
+ const reproducibility_hash = stableHash(payload);
397
+ return { receipt_id: `cr_${reproducibility_hash.slice(0, 12)}`, ...payload, reproducibility_hash };
398
+ }
399
+
400
+ function createContextReceipt(documents, options = {}) {
401
+ return selectReceiptContext(ingestReceiptDocuments(documents, options), options);
402
+ }
403
+
404
+ function renderContextReceipt(receipt) {
405
+ const lines = [
406
+ `# Context Receipt ${receipt.receipt_id}`,
407
+ '',
408
+ `Query: \`${receipt.query}\``,
409
+ '',
410
+ '## Coverage And Risk Controls',
411
+ '',
412
+ `- Coverage score: ${receipt.risk_summary.coverage_score}`,
413
+ `- Review level: ${receipt.risk_summary.review_level}`,
414
+ `- Dependency closure: ${receipt.risk_summary.controls.dependency_closure}`,
415
+ '',
416
+ '## Included Context',
417
+ '',
418
+ ];
419
+ for (const item of receipt.selected_context || []) {
420
+ lines.push(`### ${item.chunk_id}`, `- Source: \`${item.source_path}\``, `- Why: ${item.reasons.join('; ')}`, '');
421
+ }
422
+ lines.push('## Omitted Context', '');
423
+ for (const item of receipt.omitted_context || []) {
424
+ lines.push(`### ${item.chunk_id}`, `- Why omitted: ${item.omission_reason}`, `- Preview: ${item.text_preview}`, '');
425
+ }
426
+ return `${lines.join('\n')}\n`;
427
+ }
428
+
429
+ function explainReceiptOmission(receipt, chunkId) {
430
+ const omitted = (receipt.omitted_context || []).find((item) => item.chunk_id === chunkId);
431
+ if (omitted) return `${chunkId} was omitted from ${omitted.source_path}: ${omitted.omission_reason}.`;
432
+ if ((receipt.selected_context || []).some((item) => item.chunk_id === chunkId)) return `${chunkId} was not omitted; it is present in selected_context.`;
433
+ return `${chunkId} is not present in this receipt.`;
434
+ }
435
+
436
+ module.exports = {
437
+ SCHEMA_VERSION,
438
+ ingestReceiptDocuments,
439
+ selectReceiptContext,
440
+ createContextReceipt,
441
+ renderContextReceipt,
442
+ explainReceiptOmission,
443
+ };
package/js/server.js CHANGED
@@ -89,11 +89,10 @@ class EntrolyMCPServer {
89
89
  this.flowOrchestrator = new FlowOrchestrator(this.vault, this.epistemicRouter, this.beliefCompiler, this.verifier, this.changePipe, this.sourceDir);
90
90
  this.workspaceListener = new WorkspaceChangeListener(this.vault, this.beliefCompiler, this.verifier, this.changePipe, this.sourceDir);
91
91
 
92
- // Try loading persistent index
93
- if (loadIndex(this.engine, this.indexPath)) {
94
- const n = this.engine.fragment_count();
95
- this._log(`Loaded persistent index: ${n} fragments`);
96
- }
92
+ // Persistent index loads LAZILY (first tool use / deferred warm-up) so
93
+ // construction and the MCP handshake stay instant — never block the client's
94
+ // ~5-10s connect window on a cold index read. npm twin of the pip/MCP fix.
95
+ this._indexLoaded = false;
97
96
 
98
97
  this._buffer = '';
99
98
  this._initialized = false;
@@ -101,6 +100,55 @@ class EntrolyMCPServer {
101
100
 
102
101
  _log(msg) { process.stderr.write(`${new Date().toISOString()} [entroly] ${msg}\n`); }
103
102
 
103
+ // Lazily perform the full one-time warm-up (persisted index load + auto-index
104
+ // + background daemons) on the calling thread. Idempotent: the FIRST tool call
105
+ // pays this cost; every later call is an instant no-op.
106
+ //
107
+ // Crucially this is NEVER called from the MCP handshake (initialize /
108
+ // tools/list) and NEVER from a timer — only from handleTool. autoIndex is a
109
+ // synchronous repo scan, so a setImmediate/setTimeout defer would still block
110
+ // the single-threaded event loop and stall the handshake (a timer can even
111
+ // fire before the first request arrives). Triggering strictly on the first
112
+ // tools/call guarantees an instant handshake for every client regardless of
113
+ // message ordering, with no race — the npm twin of the pip/MCP instant-start
114
+ // fix ("first request pays"). Node is single-threaded, so there is also no
115
+ // borrow race (unlike the pip background path).
116
+ _ensureWarm() {
117
+ if (this._indexLoaded) return;
118
+ this._indexLoaded = true; // set before work so a throw cannot cause re-entry
119
+
120
+ // 1) Restore the persisted index BEFORE auto-index — auto-index skips when
121
+ // the engine already has fragments, and a later load would otherwise
122
+ // overwrite freshly ingested state.
123
+ try {
124
+ if (loadIndex(this.engine, this.indexPath)) {
125
+ this._log(`Loaded persistent index: ${this.engine.fragment_count()} fragments`);
126
+ }
127
+ } catch (e) {
128
+ this._log(`Index load failed (non-fatal): ${e.message}`);
129
+ }
130
+
131
+ // 2) Auto-index the project (internally skips when already warm) and start
132
+ // the incremental file watcher.
133
+ try {
134
+ const result = autoIndex(this.engine);
135
+ if (result.status === 'indexed') {
136
+ this._log(`Auto-indexed ${result.files_indexed} files (${result.total_tokens.toLocaleString()} tokens) in ${result.duration_s}s`);
137
+ }
138
+ startIncrementalWatcher(this.engine);
139
+ } catch (e) {
140
+ this._log(`Auto-index failed (non-fatal): ${e.message}`);
141
+ }
142
+
143
+ // 3) Start the autotune daemon — hot-reloads weights from tuning_config.json.
144
+ try {
145
+ const tid = startAutotuneDaemon(this.engine);
146
+ if (tid) this._log('Autotune daemon started (hot-reload every 30s)');
147
+ } catch (e) {
148
+ this._log(`Autotune: failed to start daemon: ${e.message}`);
149
+ }
150
+ }
151
+
104
152
  _projectDir(candidate = '') {
105
153
  const resolved = resolveProjectDirectory(this.sourceDir, candidate || '.');
106
154
  if (!resolved) throw new Error(`Path must remain within project root: ${candidate}`);
@@ -166,6 +214,7 @@ class EntrolyMCPServer {
166
214
 
167
215
  // ── Tool Handlers (36 tools) ──
168
216
  handleTool(name, args) {
217
+ this._ensureWarm(); // lazy one-time index load; instant no-op once warm
169
218
  switch (name) {
170
219
  // ── Core Engine Tools ──
171
220
  case 'remember_fragment':
@@ -482,33 +531,15 @@ class EntrolyMCPServer {
482
531
  async run() {
483
532
  this._log(`Starting Entroly MCP server v${this.config.serverVersion} (Wasm engine)`);
484
533
 
485
- // Auto-index on startup
486
- try {
487
- const result = autoIndex(this.engine);
488
- if (result.status === 'indexed') {
489
- this._log(`Auto-indexed ${result.files_indexed} files (${result.total_tokens.toLocaleString()} tokens) in ${result.duration_s}s`);
490
- }
491
- startIncrementalWatcher(this.engine);
492
- } catch (e) {
493
- this._log(`Auto-index failed (non-fatal): ${e.message}`);
494
- }
495
-
496
- // Start autotune daemon — reads tuning_config.json, hot-reloads weights
497
- try {
498
- const tid = startAutotuneDaemon(this.engine);
499
- if (tid) this._log('Autotune daemon started (hot-reload every 30s)');
500
- } catch (e) {
501
- this._log(`Autotune: failed to start daemon: ${e.message}`);
502
- }
503
-
504
- // Graceful shutdown
505
- process.on('SIGTERM', () => {
506
- this._log('Shutdown — persisting state...');
507
- try { persistIndex(this.engine, this.indexPath); } catch {}
508
- process.exit(0);
509
- });
510
-
511
- // Read JSONRPC from stdin
534
+ // Attach the stdio transport and DO NOT warm up here. The MCP handshake
535
+ // (initialize / tools/list) is answered immediately; the heavy one-time
536
+ // warm-up (index load + auto-index + daemons) is deferred to the first
537
+ // actual tool call (handleTool -> _ensureWarm). autoIndex is a synchronous
538
+ // repo scan, so warming on a timer would still block the single-threaded
539
+ // event loop and stall the handshake (the timer can even fire before the
540
+ // first request arrives). Triggering strictly on the first tools/call gives
541
+ // an instant handshake for every client with no race — the npm twin of the
542
+ // pip/MCP instant-start fix (cold start ~6.5s → instant handshake).
512
543
  process.stdin.setEncoding('utf-8');
513
544
  process.stdin.on('data', (chunk) => {
514
545
  this._buffer += chunk;
@@ -516,7 +547,15 @@ class EntrolyMCPServer {
516
547
  });
517
548
  process.stdin.on('end', () => {
518
549
  this._log('stdin closed — shutting down');
519
- try { persistIndex(this.engine, this.indexPath); } catch {}
550
+ if (this._indexLoaded) { try { persistIndex(this.engine, this.indexPath); } catch {} }
551
+ });
552
+
553
+ // Graceful shutdown — only persist if we actually warmed (loaded) the index,
554
+ // otherwise an early shutdown would overwrite the good index with empty state.
555
+ process.on('SIGTERM', () => {
556
+ this._log('Shutdown — persisting state...');
557
+ if (this._indexLoaded) { try { persistIndex(this.engine, this.indexPath); } catch {} }
558
+ process.exit(0);
520
559
  });
521
560
  }
522
561
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "entroly-wasm",
3
- "version": "1.0.16",
4
- "description": "Local proxy that cuts your Claude / OpenAI / Gemini bill 70%+. Drop-in for Cursor, Claude Code, Codex, Aider 30 seconds, no code changes. Pure WebAssembly, no Python dependency.",
3
+ "version": "1.0.18",
4
+ "description": "Auditable context control plane for AI agents: context receipts, local optimization, MCP, and app SDK helpers. Pure WebAssembly, no Python dependency.",
5
5
  "main": "index.js",
6
- "types": "pkg/entroly_wasm.d.ts",
6
+ "types": "index.d.ts",
7
7
  "bin": {
8
8
  "entroly-wasm": "./bin/entroly-wasm.js"
9
9
  },
@@ -11,6 +11,7 @@
11
11
  "LICENSE",
12
12
  "NOTICE",
13
13
  "index.js",
14
+ "index.d.ts",
14
15
  "data/tuning_defaults.json",
15
16
  "pkg/entroly_wasm_bg.wasm",
16
17
  "pkg/entroly_wasm.js",
@@ -29,6 +30,8 @@
29
30
  "js/skills.js",
30
31
  "js/multimodal.js",
31
32
  "js/value_tracker.js",
33
+ "js/app_sdk.js",
34
+ "js/context_receipts.js",
32
35
  "js/agentskills_export.js",
33
36
  "js/gateways.js",
34
37
  "js/vault_observer.js",
@@ -49,6 +52,8 @@
49
52
  },
50
53
  "keywords": [
51
54
  "entroly",
55
+ "context-receipts",
56
+ "context-control-plane",
52
57
  "context-optimization",
53
58
  "mcp",
54
59
  "mcp-server",
Binary file