bare-agent 0.16.2 → 0.18.0

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.
@@ -0,0 +1,70 @@
1
+ export type Message = import("../types").Message;
2
+ export type ToolDef = import("../types").ToolDef;
3
+ export type ToolCall = import("../types").ToolCall;
4
+ export type GenerateResult = import("../types").GenerateResult;
5
+ export type GeminiOptions = {
6
+ /**
7
+ * - Google AI Studio (Gemini) API key.
8
+ */
9
+ apiKey?: string | undefined;
10
+ /**
11
+ * - Model ID.
12
+ */
13
+ model?: string | undefined;
14
+ /**
15
+ * - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
16
+ */
17
+ baseUrl?: string | undefined;
18
+ /**
19
+ * - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
20
+ */
21
+ exposeErrorBody?: boolean | undefined;
22
+ };
23
+ /**
24
+ * @typedef {object} GeminiOptions
25
+ * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
26
+ * @property {string} [model='gemini-2.5-flash'] - Model ID.
27
+ * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
28
+ * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
29
+ */
30
+ /**
31
+ * Google Gemini provider (native `generateContent` API, NOT the OpenAI-compat endpoint — that endpoint
32
+ * omits the cache token tier, so it can't feed the cost meter; verified by POC). Converts the Loop's
33
+ * OpenAI-format messages to Gemini `contents`, declares tools as `functionDeclarations`, and normalizes
34
+ * `usageMetadata` to the neutral Usage shape. Gemini auto-caches (implicit caching on 2.5 models), so the
35
+ * cache-read tier populates with no opt-in.
36
+ */
37
+ export class GeminiProvider {
38
+ /** @param {GeminiOptions} [options] */
39
+ constructor(options?: GeminiOptions);
40
+ apiKey: string | undefined;
41
+ model: string;
42
+ baseUrl: string;
43
+ exposeErrorBody: boolean;
44
+ /**
45
+ * Generate a response from the Gemini API.
46
+ * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
47
+ * @param {ToolDef[]} [tools=[]] - Tool definitions.
48
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
49
+ * @returns {Promise<GenerateResult>}
50
+ * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
51
+ */
52
+ generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
53
+ /**
54
+ * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
55
+ * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder
56
+ * (verified live). Gemini bills "thinking" (`thoughtsTokenCount`) as output, so fold it into output
57
+ * (total = prompt + candidates + thoughts — confirmed against live usageMetadata). Implicit caching
58
+ * has no separate write tier → cacheCreationTokens 0.
59
+ * @param {any} u - raw `data.usageMetadata`
60
+ * @returns {import('../types').Usage}
61
+ */
62
+ _normalizeUsage(u: any): import("../types").Usage;
63
+ /**
64
+ * @param {string} path
65
+ * @param {Record<string, any>} body
66
+ * @returns {Promise<any>}
67
+ */
68
+ _request(path: string, body: Record<string, any>): Promise<any>;
69
+ _warnedInsecure: boolean | undefined;
70
+ }
@@ -0,0 +1,197 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const http = require('http');
5
+ const { ProviderError } = require('./errors');
6
+
7
+ /** @typedef {import('../types').Message} Message */
8
+ /** @typedef {import('../types').ToolDef} ToolDef */
9
+ /** @typedef {import('../types').ToolCall} ToolCall */
10
+ /** @typedef {import('../types').GenerateResult} GenerateResult */
11
+
12
+ /** @param {string} hostname @returns {boolean} */
13
+ function isLoopbackHost(hostname) {
14
+ const h = hostname.replace(/^\[|\]$/g, ''); // strip IPv6 brackets
15
+ return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.startsWith('127.');
16
+ }
17
+
18
+ /**
19
+ * @typedef {object} GeminiOptions
20
+ * @property {string} [apiKey] - Google AI Studio (Gemini) API key.
21
+ * @property {string} [model='gemini-2.5-flash'] - Model ID.
22
+ * @property {string} [baseUrl='https://generativelanguage.googleapis.com/v1beta'] - API base (override for proxies; posts to `${baseUrl}/models/${model}:generateContent`).
23
+ * @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default; `err.message` still carries the API error).
24
+ */
25
+
26
+ /**
27
+ * Google Gemini provider (native `generateContent` API, NOT the OpenAI-compat endpoint — that endpoint
28
+ * omits the cache token tier, so it can't feed the cost meter; verified by POC). Converts the Loop's
29
+ * OpenAI-format messages to Gemini `contents`, declares tools as `functionDeclarations`, and normalizes
30
+ * `usageMetadata` to the neutral Usage shape. Gemini auto-caches (implicit caching on 2.5 models), so the
31
+ * cache-read tier populates with no opt-in.
32
+ */
33
+ class GeminiProvider {
34
+ /** @param {GeminiOptions} [options] */
35
+ constructor(options = {}) {
36
+ this.apiKey = options.apiKey?.trim();
37
+ this.model = options.model || 'gemini-2.5-flash';
38
+ this.baseUrl = options.baseUrl || 'https://generativelanguage.googleapis.com/v1beta';
39
+ this.exposeErrorBody = options.exposeErrorBody === true;
40
+ }
41
+
42
+ /**
43
+ * Generate a response from the Gemini API.
44
+ * @param {Message[]} messages - Conversation messages (OpenAI format, auto-converted).
45
+ * @param {ToolDef[]} [tools=[]] - Tool definitions.
46
+ * @param {Record<string, any>} [options={}] - Options (temperature, maxTokens).
47
+ * @returns {Promise<GenerateResult>}
48
+ * @throws {Error} `[GeminiProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
49
+ */
50
+ async generate(messages, tools = [], options = {}) {
51
+ const systemParts = [];
52
+ /** @type {any[]} */
53
+ const contents = [];
54
+ // Gemini matches a functionResponse to its call by NAME, but OpenAI tool results carry only the
55
+ // tool_call_id. Track id→name from assistant tool_calls so a following tool message can name itself.
56
+ /** @type {Map<string,string>} */
57
+ const toolNames = new Map();
58
+
59
+ for (const m of messages) {
60
+ if (m.role === 'system') {
61
+ if (typeof m.content === 'string' && m.content) systemParts.push({ text: m.content });
62
+ continue;
63
+ }
64
+ if (m.role === 'tool') {
65
+ const id = m.tool_call_id || '';
66
+ const name = toolNames.get(id) || id || 'tool';
67
+ contents.push({ role: 'user', parts: [{ functionResponse: { name, response: { content: m.content } } }] });
68
+ continue;
69
+ }
70
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
71
+ /** @type {any[]} */
72
+ const parts = [];
73
+ if (m.content) parts.push({ text: m.content });
74
+ for (const tc of m.tool_calls) {
75
+ toolNames.set(tc.id, tc.function.name);
76
+ let args = tc.function.arguments;
77
+ if (typeof args === 'string') { try { args = JSON.parse(args); } catch { args = {}; } }
78
+ parts.push({ functionCall: { name: tc.function.name, args: args || {} } });
79
+ }
80
+ contents.push({ role: 'model', parts });
81
+ continue;
82
+ }
83
+ // plain user / assistant text
84
+ const role = m.role === 'assistant' ? 'model' : 'user';
85
+ contents.push({ role, parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }] });
86
+ }
87
+
88
+ /** @type {Record<string, any>} */
89
+ const body = { contents };
90
+ if (systemParts.length) body.systemInstruction = { parts: systemParts };
91
+ if (tools.length > 0) {
92
+ body.tools = [{
93
+ functionDeclarations: tools.map(t => ({
94
+ name: t.name,
95
+ description: t.description,
96
+ ...(t.parameters && { parameters: t.parameters }),
97
+ })),
98
+ }];
99
+ }
100
+ /** @type {Record<string, any>} */
101
+ const genConfig = {};
102
+ if (options.maxTokens) genConfig.maxOutputTokens = options.maxTokens;
103
+ if (options.temperature != null) genConfig.temperature = options.temperature;
104
+ if (Object.keys(genConfig).length) body.generationConfig = genConfig;
105
+
106
+ const data = await this._request(`/models/${this.model}:generateContent`, body);
107
+
108
+ let text = '';
109
+ /** @type {ToolCall[]} */
110
+ const toolCalls = [];
111
+ let fnSeq = 0;
112
+ const parts = data.candidates?.[0]?.content?.parts || [];
113
+ for (const part of parts) {
114
+ if (typeof part.text === 'string') text += part.text;
115
+ if (part.functionCall) {
116
+ // Gemini gives no call id; synthesize a stable one so the Loop can pair the tool result.
117
+ toolCalls.push({ id: `gemini_call_${fnSeq++}`, name: part.functionCall.name, arguments: part.functionCall.args || {} });
118
+ }
119
+ }
120
+
121
+ return {
122
+ text,
123
+ toolCalls,
124
+ model: data.modelVersion || this.model,
125
+ usage: this._normalizeUsage(data.usageMetadata),
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Normalize Gemini `usageMetadata` to the neutral {@link Usage} shape. Like OpenAI, `promptTokenCount`
131
+ * INCLUDES the cached tokens (`cachedContentTokenCount`), so subtract for the uncached remainder
132
+ * (verified live). Gemini bills "thinking" (`thoughtsTokenCount`) as output, so fold it into output
133
+ * (total = prompt + candidates + thoughts — confirmed against live usageMetadata). Implicit caching
134
+ * has no separate write tier → cacheCreationTokens 0.
135
+ * @param {any} u - raw `data.usageMetadata`
136
+ * @returns {import('../types').Usage}
137
+ */
138
+ _normalizeUsage(u) {
139
+ const cacheRead = u?.cachedContentTokenCount || 0;
140
+ return {
141
+ inputTokens: Math.max(0, (u?.promptTokenCount || 0) - cacheRead),
142
+ outputTokens: (u?.candidatesTokenCount || 0) + (u?.thoughtsTokenCount || 0),
143
+ cacheReadTokens: cacheRead,
144
+ cacheCreationTokens: 0,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {string} path
150
+ * @param {Record<string, any>} body
151
+ * @returns {Promise<any>}
152
+ */
153
+ _request(path, body) {
154
+ return new Promise((resolve, reject) => {
155
+ const url = new URL(this.baseUrl + path);
156
+ const transport = url.protocol === 'https:' ? https : http;
157
+ const payload = JSON.stringify(body);
158
+
159
+ // Plaintext key to a remote host exposes it on the wire; loopback is the legitimate http case.
160
+ if (this.apiKey && url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !this._warnedInsecure) {
161
+ this._warnedInsecure = true;
162
+ console.warn(`[GeminiProvider] sending x-goog-api-key over PLAINTEXT http to ${url.hostname} — key exposed on the wire. Use https.`);
163
+ }
164
+
165
+ const req = transport.request(url, {
166
+ method: 'POST',
167
+ headers: {
168
+ 'Content-Type': 'application/json',
169
+ 'Content-Length': Buffer.byteLength(payload),
170
+ ...(this.apiKey && { 'x-goog-api-key': this.apiKey }),
171
+ },
172
+ }, (res) => {
173
+ let chunks = '';
174
+ res.on('data', d => (chunks += d));
175
+ res.on('end', () => {
176
+ try {
177
+ const parsed = JSON.parse(chunks);
178
+ if ((res.statusCode ?? 0) >= 400) {
179
+ return reject(new ProviderError(
180
+ `[GeminiProvider] ${parsed.error?.message || `HTTP ${res.statusCode}`}`,
181
+ /** @type {any} */ ({ status: res.statusCode, body: this.exposeErrorBody ? parsed : undefined })
182
+ ));
183
+ }
184
+ resolve(parsed);
185
+ } catch (e) {
186
+ reject(new Error(`[GeminiProvider] Invalid JSON response: ${chunks.slice(0, 200)}`));
187
+ }
188
+ });
189
+ });
190
+ req.on('error', reject);
191
+ req.write(payload);
192
+ req.end();
193
+ });
194
+ }
195
+ }
196
+
197
+ module.exports = { GeminiProvider };
@@ -44,6 +44,16 @@ export class OpenAIProvider {
44
44
  * @throws {Error} `[OpenAIProvider] ...` — on HTTP errors (4xx/5xx) or invalid JSON response.
45
45
  */
46
46
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
47
+ /**
48
+ * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
49
+ * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
50
+ * crucially, `prompt_tokens` INCLUDES those cached tokens, so we subtract them to get the uncached
51
+ * remainder (else the cached tokens are double-counted and priced at the full input rate, a ~2x
52
+ * over-charge on a warm prompt). OpenAI has no separate cache-write tier → cacheCreationTokens 0.
53
+ * @param {any} u - raw `data.usage`
54
+ * @returns {import('../types').Usage}
55
+ */
56
+ _normalizeUsage(u: any): import("../types").Usage;
47
57
  /**
48
58
  * @param {string} path
49
59
  * @param {Record<string, any>} body
@@ -73,10 +73,26 @@ class OpenAIProvider {
73
73
  arguments: JSON.parse(tc.function.arguments),
74
74
  })),
75
75
  model: data.model || this.model,
76
- usage: {
77
- inputTokens: data.usage?.prompt_tokens || 0,
78
- outputTokens: data.usage?.completion_tokens || 0,
79
- },
76
+ usage: this._normalizeUsage(data.usage),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Normalize OpenAI usage to the neutral {@link Usage} shape. OpenAI auto-caches prompt prefixes
82
+ * (>=1024 tokens) and reports the cached portion in `prompt_tokens_details.cached_tokens` —
83
+ * crucially, `prompt_tokens` INCLUDES those cached tokens, so we subtract them to get the uncached
84
+ * remainder (else the cached tokens are double-counted and priced at the full input rate, a ~2x
85
+ * over-charge on a warm prompt). OpenAI has no separate cache-write tier → cacheCreationTokens 0.
86
+ * @param {any} u - raw `data.usage`
87
+ * @returns {import('../types').Usage}
88
+ */
89
+ _normalizeUsage(u) {
90
+ const cacheRead = u?.prompt_tokens_details?.cached_tokens || 0;
91
+ return {
92
+ inputTokens: Math.max(0, (u?.prompt_tokens || 0) - cacheRead),
93
+ outputTokens: u?.completion_tokens || 0,
94
+ cacheReadTokens: cacheRead,
95
+ cacheCreationTokens: 0,
80
96
  };
81
97
  }
82
98
 
@@ -1,6 +1,7 @@
1
1
  import { OpenAIProvider } from "./provider-openai";
2
2
  import { AnthropicProvider } from "./provider-anthropic";
3
+ import { GeminiProvider } from "./provider-gemini";
3
4
  import { OllamaProvider } from "./provider-ollama";
4
5
  import { CLIPipeProvider } from "./provider-clipipe";
5
6
  import { FallbackProvider } from "./provider-fallback";
6
- export { OpenAIProvider as OpenAI, AnthropicProvider as Anthropic, OllamaProvider as Ollama, CLIPipeProvider as CLIPipe, FallbackProvider as Fallback, OpenAIProvider, AnthropicProvider, OllamaProvider, CLIPipeProvider, FallbackProvider };
7
+ export { OpenAIProvider as OpenAI, AnthropicProvider as Anthropic, GeminiProvider as Gemini, OllamaProvider as Ollama, CLIPipeProvider as CLIPipe, FallbackProvider as Fallback, OpenAIProvider, AnthropicProvider, GeminiProvider, OllamaProvider, CLIPipeProvider, FallbackProvider };
package/src/providers.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const { OpenAIProvider } = require('./provider-openai');
4
4
  const { AnthropicProvider } = require('./provider-anthropic');
5
+ const { GeminiProvider } = require('./provider-gemini');
5
6
  const { OllamaProvider } = require('./provider-ollama');
6
7
  const { CLIPipeProvider } = require('./provider-clipipe');
7
8
  const { FallbackProvider } = require('./provider-fallback');
@@ -10,6 +11,7 @@ module.exports = {
10
11
  // Short names (canonical — used throughout docs and the integration guide)
11
12
  OpenAI: OpenAIProvider,
12
13
  Anthropic: AnthropicProvider,
14
+ Gemini: GeminiProvider,
13
15
  Ollama: OllamaProvider,
14
16
  CLIPipe: CLIPipeProvider,
15
17
  Fallback: FallbackProvider,
@@ -17,6 +19,7 @@ module.exports = {
17
19
  // `const { OpenAIProvider } = require('bare-agent/providers')` also works.
18
20
  OpenAIProvider,
19
21
  AnthropicProvider,
22
+ GeminiProvider,
20
23
  OllamaProvider,
21
24
  CLIPipeProvider,
22
25
  FallbackProvider,
@@ -0,0 +1,86 @@
1
+ export type Verdict = import("./evaluator").Verdict;
2
+ export type RefineOptions = {
3
+ /**
4
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
5
+ * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
6
+ * transcript — anchoring on a wrong answer defeats the independent verifier.
7
+ */
8
+ attempt: (args: {
9
+ iteration: number;
10
+ lastResult: any;
11
+ critique: string | null;
12
+ contract: string | null;
13
+ }) => any;
14
+ /**
15
+ * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
16
+ */
17
+ evaluate: (result: any, ctx: {
18
+ iteration: number;
19
+ contract: string | null;
20
+ }) => (Verdict | Promise<Verdict>);
21
+ /**
22
+ * - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
23
+ * `evaluate` so generator and critic agree on what success means.
24
+ */
25
+ contract?: string | undefined;
26
+ /**
27
+ * - Hard cap. The REAL bound is bareguard maxTurns / budget.
28
+ */
29
+ maxIterations?: number | undefined;
30
+ /**
31
+ * - Stop on the first satisfied verdict.
32
+ */
33
+ stopOnPass?: boolean | undefined;
34
+ };
35
+ export type RefineOutcome = {
36
+ /**
37
+ * - The last (best available) result.
38
+ */
39
+ result: any;
40
+ /**
41
+ * - Its verdict.
42
+ */
43
+ verdict: Verdict | null;
44
+ /**
45
+ * - How many attempts ran.
46
+ */
47
+ iterations: number;
48
+ /**
49
+ * - Every attempt + verdict, in order.
50
+ */
51
+ history: Array<{
52
+ result: any;
53
+ verdict: Verdict;
54
+ }>;
55
+ };
56
+ /** @typedef {import('./evaluator').Verdict} Verdict */
57
+ /**
58
+ * @typedef {object} RefineOptions
59
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
60
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
61
+ * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
62
+ * transcript — anchoring on a wrong answer defeats the independent verifier.
63
+ * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
64
+ * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
65
+ * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
66
+ * `evaluate` so generator and critic agree on what success means.
67
+ * @property {number} [maxIterations=3] - Hard cap. The REAL bound is bareguard maxTurns / budget.
68
+ * @property {boolean} [stopOnPass=true] - Stop on the first satisfied verdict.
69
+ */
70
+ /**
71
+ * @typedef {object} RefineOutcome
72
+ * @property {any} result - The last (best available) result.
73
+ * @property {Verdict|null} verdict - Its verdict.
74
+ * @property {number} iterations - How many attempts ran.
75
+ * @property {Array<{result: any, verdict: Verdict}>} history - Every attempt + verdict, in order.
76
+ */
77
+ /**
78
+ * Thin, Loop-agnostic generate → evaluate → regenerate loop (a port of Managed Agents "Outcomes":
79
+ * iterate → grade → revise, bounded). Knows nothing about providers or `loop.js`; it drives caller-supplied
80
+ * `attempt`/`evaluate` until a satisfied verdict, a terminal `failed`, or `maxIterations`. A `HaltError`
81
+ * thrown by either callback (e.g. a governance cap) propagates as a clean exit — never caught here.
82
+ *
83
+ * @param {RefineOptions} options
84
+ * @returns {Promise<RefineOutcome>}
85
+ */
86
+ export function refine(options: RefineOptions): Promise<RefineOutcome>;
package/src/refine.js ADDED
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /** @typedef {import('./evaluator').Verdict} Verdict */
4
+
5
+ /**
6
+ * @typedef {object} RefineOptions
7
+ * @property {(args: {iteration: number, lastResult: any, critique: string|null, contract: string|null}) => any} attempt
8
+ * Build one generation. On iteration 0, `lastResult`/`critique` are null. Fresh-feedback (D6/A1) is the
9
+ * consumer's to realize here: seed a NEW Loop with `{goal + critique}` rather than continuing the failed
10
+ * transcript — anchoring on a wrong answer defeats the independent verifier.
11
+ * @property {(result: any, ctx: {iteration: number, contract: string|null}) => (Verdict | Promise<Verdict>)} evaluate
12
+ * Judge a result — typically `evaluator.evaluate(goal, result, { rubric, contract })`.
13
+ * @property {string} [contract] - The shared definition of done (A3/D10), forwarded to BOTH `attempt` and
14
+ * `evaluate` so generator and critic agree on what success means.
15
+ * @property {number} [maxIterations=3] - Hard cap. The REAL bound is bareguard maxTurns / budget.
16
+ * @property {boolean} [stopOnPass=true] - Stop on the first satisfied verdict.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} RefineOutcome
21
+ * @property {any} result - The last (best available) result.
22
+ * @property {Verdict|null} verdict - Its verdict.
23
+ * @property {number} iterations - How many attempts ran.
24
+ * @property {Array<{result: any, verdict: Verdict}>} history - Every attempt + verdict, in order.
25
+ */
26
+
27
+ /**
28
+ * Thin, Loop-agnostic generate → evaluate → regenerate loop (a port of Managed Agents "Outcomes":
29
+ * iterate → grade → revise, bounded). Knows nothing about providers or `loop.js`; it drives caller-supplied
30
+ * `attempt`/`evaluate` until a satisfied verdict, a terminal `failed`, or `maxIterations`. A `HaltError`
31
+ * thrown by either callback (e.g. a governance cap) propagates as a clean exit — never caught here.
32
+ *
33
+ * @param {RefineOptions} options
34
+ * @returns {Promise<RefineOutcome>}
35
+ */
36
+ async function refine(options) {
37
+ const { attempt, evaluate } = options;
38
+ if (typeof attempt !== 'function') throw new Error('[refine] requires an attempt(args) function');
39
+ if (typeof evaluate !== 'function') throw new Error('[refine] requires an evaluate(result, ctx) function');
40
+ const contract = typeof options.contract === 'string' ? options.contract : null;
41
+ const mi = options.maxIterations;
42
+ const maxIterations = typeof mi === 'number' && Number.isInteger(mi) && mi > 0 ? mi : 3;
43
+ const stopOnPass = options.stopOnPass !== false;
44
+
45
+ /** @type {Array<{result: any, verdict: Verdict}>} */
46
+ const history = [];
47
+ let lastResult = null;
48
+ /** @type {Verdict|null} */
49
+ let lastVerdict = null;
50
+
51
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
52
+ const result = await attempt({ iteration, lastResult, critique: lastVerdict ? lastVerdict.critique : null, contract });
53
+ const verdict = await evaluate(result, { iteration, contract });
54
+ history.push({ result, verdict });
55
+ lastResult = result;
56
+ lastVerdict = verdict;
57
+
58
+ if (stopOnPass && verdict.pass) break;
59
+ if (verdict.status === 'failed') break; // terminal — revising a fundamentally-wrong approach burns budget
60
+ }
61
+
62
+ return { result: lastResult, verdict: lastVerdict, iterations: history.length, history };
63
+ }
64
+
65
+ module.exports = { refine };
@@ -0,0 +1,118 @@
1
+ export type RememberOptions = {
2
+ /**
3
+ * - LLM provider implementing `generate(messages, tools, opts)`.
4
+ */
5
+ provider: Provider;
6
+ /**
7
+ * - The Store socket (or a `Memory` wrapper). remember writes through
8
+ * `.store(content, metadata)` ONLY — no other verb, no backend assumptions.
9
+ */
10
+ store: Pick<Store, "store">;
11
+ /**
12
+ * - Optional definition of what counts as durable for this task; appended to the
13
+ * distiller prompt to steer it (the A3 contract idea, reused on the write side).
14
+ */
15
+ contract?: string | undefined;
16
+ /**
17
+ * - Merged into every stored fact's metadata. `kind` is always `'fact'`
18
+ * (NOT overridable — `remember` writes facts; `fact` is litectx's canonical durable kind and harmless to other
19
+ * stores, so the stored label always matches the `facts` counter). Add ANY OTHER metadata here (tags, or litectx
20
+ * `format`/`scope` to differentiate facts). NEVER carries ctx — ctx rides in its own option, never persisted.
21
+ */
22
+ metadata?: Record<string, any> | undefined;
23
+ /**
24
+ * - The run ctx. If it carries
25
+ * the loop-lent `recordMemoryOp`, each fact write counts against `result.metrics.memory.facts`.
26
+ */
27
+ ctx?: ({
28
+ recordMemoryOp?: (kind: string) => void;
29
+ } & Record<string, any>) | undefined;
30
+ /**
31
+ * - Budget hook;
32
+ * each distill pass forwards usage so a bareguard gate keeps counting (mirror of Evaluator).
33
+ */
34
+ onLlmResult?: ((payload: {
35
+ usage: any;
36
+ model: string | null;
37
+ kind: "remember";
38
+ }) => any) | undefined;
39
+ /**
40
+ * - Override the distiller system prompt (defaults to the F5-validated one).
41
+ */
42
+ prompt?: string | undefined;
43
+ };
44
+ export type RememberOutcome = {
45
+ /**
46
+ * - The facts written, in order — deduplicated within this call by exact string
47
+ * (so `facts.length` is the count stored). Cross-run / semantic dedup is the store's/consumer's job.
48
+ */
49
+ facts: string[];
50
+ /**
51
+ * - How many non-empty spans were processed.
52
+ */
53
+ spans: number;
54
+ };
55
+ export type Provider = import("../types").Provider;
56
+ export type Store = import("../types").Store;
57
+ /**
58
+ * @typedef {object} RememberOptions
59
+ * @property {Provider} provider - LLM provider implementing `generate(messages, tools, opts)`.
60
+ * @property {Pick<Store, 'store'>} store - The Store socket (or a `Memory` wrapper). remember writes through
61
+ * `.store(content, metadata)` ONLY — no other verb, no backend assumptions.
62
+ * @property {string} [contract] - Optional definition of what counts as durable for this task; appended to the
63
+ * distiller prompt to steer it (the A3 contract idea, reused on the write side).
64
+ * @property {Record<string, any>} [metadata] - Merged into every stored fact's metadata. `kind` is always `'fact'`
65
+ * (NOT overridable — `remember` writes facts; `fact` is litectx's canonical durable kind and harmless to other
66
+ * stores, so the stored label always matches the `facts` counter). Add ANY OTHER metadata here (tags, or litectx
67
+ * `format`/`scope` to differentiate facts). NEVER carries ctx — ctx rides in its own option, never persisted.
68
+ * @property {{ recordMemoryOp?: (kind: string) => void } & Record<string, any>} [ctx] - The run ctx. If it carries
69
+ * the loop-lent `recordMemoryOp`, each fact write counts against `result.metrics.memory.facts`.
70
+ * @property {(payload: { usage: any, model: string|null, kind: 'remember' }) => any} [onLlmResult] - Budget hook;
71
+ * each distill pass forwards usage so a bareguard gate keeps counting (mirror of Evaluator).
72
+ * @property {string} [prompt] - Override the distiller system prompt (defaults to the F5-validated one).
73
+ */
74
+ /**
75
+ * @typedef {object} RememberOutcome
76
+ * @property {string[]} facts - The facts written, in order — deduplicated within this call by exact string
77
+ * (so `facts.length` is the count stored). Cross-run / semantic dedup is the store's/consumer's job.
78
+ * @property {number} spans - How many non-empty spans were processed.
79
+ */
80
+ /**
81
+ * Distill durable facts from harvested spans and persist them through the Store socket.
82
+ *
83
+ * @param {Array<string | { content?: string, text?: string }>} spans - The harvested spans (stash's feedstock).
84
+ * Each is a transcript chunk — a raw string, or an object with `content`/`text`. Empty/blank spans are skipped.
85
+ * @param {RememberOptions} options
86
+ * @returns {Promise<RememberOutcome>}
87
+ */
88
+ export function remember(spans: Array<string | {
89
+ content?: string;
90
+ text?: string;
91
+ }>, options?: RememberOptions): Promise<RememberOutcome>;
92
+ /**
93
+ * remember — the consolidation pass (eval-assist F5). The "future glue" the PRD parked: it turns the spans
94
+ * `stash` harvested out of the live transcript into durable facts, written back through the GENERIC four-verb
95
+ * `Store` socket (`store`/`search`/`get`/`delete`). Backend-agnostic by construction — works with the
96
+ * JsonFileStore, SQLite, litectx, or a custom store — so it carries NO litectx coupling (the reason the
97
+ * read-litectx's-promotion-count alternative was rejected: it reaches past the socket into one backend).
98
+ *
99
+ * One cheap LLM pass per span distills durable signal (decisions, config, identifiers, stable preferences)
100
+ * and DROPS ephemeral chatter, superseded values, and unanswered questions. The distiller prompt below is the
101
+ * one validated live on the real Anthropic wire in `poc/f5-remember-distill.mjs` (faithfulness + correction +
102
+ * discrimination, all falsifiable). A wrong distiller poisons Memory silently, so that POC must stay green.
103
+ *
104
+ * SECURITY — memory-poisoning surface: facts are model output distilled over UNTRUSTED transcript content and
105
+ * written to durable memory. The grounding prompt refuses a direct "record this fact" injection (validated in the
106
+ * F5 POC + integration test), but treat recalled facts as untrusted CONTEXT, never as authority — and never feed
107
+ * a recalled fact into a privileged action without the same gate you'd apply to any model output.
108
+ *
109
+ * Composes AROUND a Loop (like Evaluator/refine), never inside `loop.js`. Optional, flagged-and-deletable.
110
+ * Budget visibility: each distill pass forwards `usage` to `opts.onLlmResult` (mirror of Evaluator) so a wired
111
+ * bareguard gate keeps counting. A `HaltError` from the provider propagates clean. Metering: each fact write
112
+ * counts against `result.metrics.memory.facts` via the loop-lent `ctx.recordMemoryOp('facts')` hook — the
113
+ * honest producer that field waited for. `facts` is DISJOINT from `stored`: remember writes via `store.store`
114
+ * WITHOUT threading ctx, so it never also trips the generic-write counter.
115
+ */
116
+ /** @typedef {import('../types').Provider} Provider */
117
+ /** @typedef {import('../types').Store} Store */
118
+ export const DISTILL_PROMPT: string;