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 +97 -0
- package/index.js +38 -0
- package/js/app_sdk.js +241 -0
- package/js/context_receipts.js +443 -0
- package/js/server.js +72 -33
- package/package.json +8 -3
- package/pkg/entroly_wasm_bg.wasm +0 -0
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
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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": "
|
|
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",
|
package/pkg/entroly_wasm_bg.wasm
CHANGED
|
Binary file
|