@gobing-ai/ts-decision-fm 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +149 -0
- package/dist/driver.d.ts +48 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +105 -0
- package/dist/estimate.d.ts +26 -0
- package/dist/estimate.d.ts.map +1 -0
- package/dist/estimate.js +90 -0
- package/dist/fm-process.d.ts +52 -0
- package/dist/fm-process.d.ts.map +1 -0
- package/dist/fm-process.js +137 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/schema.d.ts +43 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +104 -0
- package/package.json +63 -0
- package/src/driver.ts +206 -0
- package/src/estimate.ts +97 -0
- package/src/fm-process.ts +187 -0
- package/src/index.ts +21 -0
- package/src/schema.ts +120 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fm-process.d.ts","sourceRoot":"","sources":["../src/fm-process.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAQH,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,uBAAuB,CAAC;AAE5E,6EAA6E;AAC7E,MAAM,MAAM,YAAY,GAAG,SAAS,GAAG,oCAAoC,CAAC;AAE5E,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAE9E;AAED,qHAAqH;AACrH,wBAAgB,WAAW,CAAC,OAAO,EAAE;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sDAAsD;IACtD,UAAU,CAAC,EAAE,YAAY,CAAC;CAC7B,GAAG,MAAM,EAAE,CAYX;AAED,kFAAkF;AAClF,wBAAgB,aAAa,IAAI,MAAM,EAAE,CAExC;AAaD;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,QAAQ,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAQlG;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACnC,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,CAAC,CAmBjB;AAMD;;;;GAIG;AACH,wBAAsB,YAAY,CAC9B,QAAQ,EAAE,eAAe,EACzB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EAAE,EACd,gBAAgB,EAAE,MAAM,GACzB,OAAO,CAAC,MAAM,CAAC,CAuBjB;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBrE;AAED,4FAA4F;AAC5F,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAShH"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-shot `fm` process bridge (task 0084 R4, R5, R7, R8; design § Errors).
|
|
3
|
+
* Argv builders plus run/error mapping. Spawned exclusively through the
|
|
4
|
+
* injected ts-runtime {@link ProcessExecutor} — never `node:child_process`.
|
|
5
|
+
* Prompt and instructions travel as positional arguments because
|
|
6
|
+
* `ProcessExecutor.run` has no stdin input; fm's argv limit is far above the
|
|
7
|
+
* token budget, and the pre-flight `count-tokens` check keeps it bounded.
|
|
8
|
+
*/
|
|
9
|
+
import { DecisionBackendError, DecisionConfigError, DecisionRequestError, DecisionTimeoutError, } from '@gobing-ai/ts-ai-runner';
|
|
10
|
+
/** Subcommand + flag order verified against the design's live observations. */
|
|
11
|
+
export function countTokensArgv(instructions, prompt) {
|
|
12
|
+
return ['count-tokens', '-q', '-i', instructions, prompt];
|
|
13
|
+
}
|
|
14
|
+
/** Builds the `fm respond` argv: schema flag, `-i` instructions, positional prompt, optional `-g`/`--guardrails`. */
|
|
15
|
+
export function respondArgv(options) {
|
|
16
|
+
return [
|
|
17
|
+
'respond',
|
|
18
|
+
'--no-stream',
|
|
19
|
+
'--schema',
|
|
20
|
+
options.schemaPath,
|
|
21
|
+
'-i',
|
|
22
|
+
options.instructions,
|
|
23
|
+
...(options.guardrails !== undefined ? ['--guardrails', options.guardrails] : []),
|
|
24
|
+
...(options.greedy ? ['-g'] : []),
|
|
25
|
+
options.prompt,
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
/** Bare `fm available` always exits 0 — only `--model system` is a real probe. */
|
|
29
|
+
export function availableArgv() {
|
|
30
|
+
return ['available', '--model', 'system'];
|
|
31
|
+
}
|
|
32
|
+
/** The executor reports a failed spawn (ENOENT, …) as an `error` outcome. */
|
|
33
|
+
function isSpawnFailure(result) {
|
|
34
|
+
return result.outcome === 'error' && result.exitCode === null;
|
|
35
|
+
}
|
|
36
|
+
/** fm's own text, preferring stderr, for carrying into error messages. */
|
|
37
|
+
function fmText(result) {
|
|
38
|
+
const text = (result.stderr.trim().length > 0 ? result.stderr : result.stdout).trim();
|
|
39
|
+
return text.length > 0 ? text : '(no output)';
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* R8 prerequisite probe: `fm available --model system`. A spawn failure maps to
|
|
43
|
+
* `DecisionConfigError` "fm not found"; a non-zero exit carries fm's printed
|
|
44
|
+
* reason (e.g. `modelNotReady`). An exit 0 caches availability in the driver.
|
|
45
|
+
*/
|
|
46
|
+
export async function probeFmAvailability(executor, fmPath) {
|
|
47
|
+
const result = await executor.run({ command: fmPath, args: availableArgv() });
|
|
48
|
+
if (isSpawnFailure(result)) {
|
|
49
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
50
|
+
}
|
|
51
|
+
if (result.exitCode !== 0) {
|
|
52
|
+
throw new DecisionConfigError(`fm system model unavailable: ${fmText(result)}`, 'fm-system-model');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* R4 pre-flight: `fm count-tokens -q -i <instructions> <prompt>` prints a bare
|
|
57
|
+
* integer. Non-zero exit or unparseable output is a `DecisionBackendError` —
|
|
58
|
+
* the budget comparison itself happens in the driver.
|
|
59
|
+
*/
|
|
60
|
+
export async function countPromptTokens(executor, fmPath, instructions, prompt) {
|
|
61
|
+
const result = await executor.run({ command: fmPath, args: countTokensArgv(instructions, prompt) });
|
|
62
|
+
if (isSpawnFailure(result)) {
|
|
63
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
64
|
+
}
|
|
65
|
+
if (result.exitCode !== 0) {
|
|
66
|
+
throw new DecisionBackendError(`fm count-tokens failed (exit ${result.exitCode}): ${fmText(result)}`, undefined);
|
|
67
|
+
}
|
|
68
|
+
const tokens = Number.parseInt(result.stdout.trim(), 10);
|
|
69
|
+
if (!Number.isFinite(tokens)) {
|
|
70
|
+
throw new DecisionBackendError(`fm count-tokens did not print a token count: ${result.stdout.trim() || '(empty)'}`, undefined);
|
|
71
|
+
}
|
|
72
|
+
return tokens;
|
|
73
|
+
}
|
|
74
|
+
/** Stable substrings of fm's failure text (design § Errors discrimination). */
|
|
75
|
+
const CONTEXT_SIZE_MARKER = "exceeded the model's context size";
|
|
76
|
+
const GUARDRAILS_MARKER = 'safety guardrails were triggered';
|
|
77
|
+
/**
|
|
78
|
+
* One `fm respond` sample: run with the per-sample timeout, map every failure
|
|
79
|
+
* row of the error table, return stdout on success (parsing happens in
|
|
80
|
+
* {@link parseFmRespond}).
|
|
81
|
+
*/
|
|
82
|
+
export async function runFmRespond(executor, fmPath, args, requestTimeoutMs) {
|
|
83
|
+
const result = await executor.run({ command: fmPath, args, timeout: requestTimeoutMs });
|
|
84
|
+
if (result.outcome === 'timeout') {
|
|
85
|
+
throw new DecisionTimeoutError(`fm respond exceeded requestTimeoutMs (${requestTimeoutMs}): ${fmPath} ${args.join(' ')}`, requestTimeoutMs);
|
|
86
|
+
}
|
|
87
|
+
if (isSpawnFailure(result)) {
|
|
88
|
+
throw new DecisionConfigError(`fm not found: '${fmPath}' could not be spawned`, 'fmPath');
|
|
89
|
+
}
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
const text = fmText(result);
|
|
92
|
+
if (text.includes(CONTEXT_SIZE_MARKER)) {
|
|
93
|
+
throw new DecisionRequestError(`fm prompt exceeded the model context size: ${text}`, undefined, undefined);
|
|
94
|
+
}
|
|
95
|
+
if (text.includes(GUARDRAILS_MARKER)) {
|
|
96
|
+
// Typed failure, never an answer, and the ask is not retried.
|
|
97
|
+
throw new DecisionBackendError(`fm safety guardrails were triggered: ${text}`, undefined);
|
|
98
|
+
}
|
|
99
|
+
throw new DecisionBackendError(`fm respond failed (exit ${result.exitCode}): ${text}`, undefined);
|
|
100
|
+
}
|
|
101
|
+
return result.stdout;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Parse one sample's stdout as the schema-conformant JSON object. fm prints one
|
|
105
|
+
* JSON object per call, but the key order varies — callers read by key, never
|
|
106
|
+
* by position. Non-JSON or non-object output is a `DecisionBackendError`.
|
|
107
|
+
*/
|
|
108
|
+
export function parseFmRespond(stdout) {
|
|
109
|
+
let parsed;
|
|
110
|
+
try {
|
|
111
|
+
parsed = JSON.parse(stdout);
|
|
112
|
+
}
|
|
113
|
+
catch (cause) {
|
|
114
|
+
throw new DecisionBackendError(`fm respond printed non-JSON output: ${stdout.trim() || '(empty)'}`, undefined, {
|
|
115
|
+
cause,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
119
|
+
throw new DecisionBackendError(`fm respond printed non-object JSON: ${stdout.trim()}`, undefined);
|
|
120
|
+
}
|
|
121
|
+
const record = {};
|
|
122
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
123
|
+
if (typeof value !== 'string') {
|
|
124
|
+
throw new DecisionBackendError(`fm respond printed a non-string value for '${key}'`, undefined);
|
|
125
|
+
}
|
|
126
|
+
record[key] = value;
|
|
127
|
+
}
|
|
128
|
+
return record;
|
|
129
|
+
}
|
|
130
|
+
/** A parsed value outside the declared enum is a schema violation, never an answer (R7). */
|
|
131
|
+
export function requireEnumValue(parsed, key, allowed) {
|
|
132
|
+
const value = parsed[key];
|
|
133
|
+
if (value === undefined || !allowed.includes(value)) {
|
|
134
|
+
throw new DecisionBackendError(`fm sample for '${key}' violates the schema enum: ${value === undefined ? '(missing)' : `'${value}'`}`, undefined);
|
|
135
|
+
}
|
|
136
|
+
return value;
|
|
137
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gobing-ai/ts-decision-fm — Apple on-device Foundation Model decision backend
|
|
3
|
+
* driving the macOS `fm` CLI over a one-shot process bridge (ADR-029).
|
|
4
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`;
|
|
5
|
+
* probabilities are empirical sample frequencies (ADR-030).
|
|
6
|
+
*/
|
|
7
|
+
export { createFmDriver, type FmDecisionDriver, type FmDriverOptions } from './driver';
|
|
8
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from './estimate';
|
|
9
|
+
export { availableArgv, countPromptTokens, countTokensArgv, type FmGuardrails, parseFmRespond, probeFmAvailability, requireEnumValue, respondArgv, runFmRespond, } from './fm-process';
|
|
10
|
+
export { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS, type FmSchema, formatDesc } from './schema';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAAE,MAAM,UAAU,CAAC;AACvF,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC5F,OAAO,EACH,aAAa,EACb,iBAAiB,EACjB,eAAe,EACf,KAAK,YAAY,EACjB,cAAc,EACd,mBAAmB,EACnB,gBAAgB,EAChB,WAAW,EACX,YAAY,GACf,MAAM,cAAc,CAAC;AACtB,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,eAAe,EAAE,KAAK,QAAQ,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gobing-ai/ts-decision-fm — Apple on-device Foundation Model decision backend
|
|
3
|
+
* driving the macOS `fm` CLI over a one-shot process bridge (ADR-029).
|
|
4
|
+
* Implements the {@link DecisionDriver} interface for `@gobing-ai/ts-ai-runner`;
|
|
5
|
+
* probabilities are empirical sample frequencies (ADR-030).
|
|
6
|
+
*/
|
|
7
|
+
export { createFmDriver } from './driver.js';
|
|
8
|
+
export { entropyConfidence, estimateChoice, estimateNoul, estimateScore } from './estimate.js';
|
|
9
|
+
export { availableArgv, countPromptTokens, countTokensArgv, parseFmRespond, probeFmAvailability, requireEnumValue, respondArgv, runFmRespond, } from './fm-process.js';
|
|
10
|
+
export { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS, formatDesc } from './schema.js';
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fm request construction (task 0084 R3, design § Request construction):
|
|
3
|
+
* the JSON schema passed to `fm respond --schema` and the instruction/prompt
|
|
4
|
+
* texts passed via `-i` and as the final positional argument.
|
|
5
|
+
*
|
|
6
|
+
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
+
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
+
* Score levels are string enums so every question uses the same constrained
|
|
9
|
+
* string path; the driver converts them back to numbers.
|
|
10
|
+
*/
|
|
11
|
+
import type { DecisionState, Desc, Question } from '@gobing-ai/ts-ai-runner';
|
|
12
|
+
/**
|
|
13
|
+
* Fixed `-i` instructions. It never asks for confidence, certainty or a
|
|
14
|
+
* probability — self-reported confidence would be fabricated calibration,
|
|
15
|
+
* which the decision contract forbids (ADR-030).
|
|
16
|
+
*/
|
|
17
|
+
export declare const FM_INSTRUCTIONS: string;
|
|
18
|
+
/** The JSON schema object handed to `fm respond --schema` (R3). */
|
|
19
|
+
export interface FmSchema {
|
|
20
|
+
type: 'object';
|
|
21
|
+
title: string;
|
|
22
|
+
'x-order': string[];
|
|
23
|
+
required: string[];
|
|
24
|
+
additionalProperties: false;
|
|
25
|
+
properties: Record<string, {
|
|
26
|
+
type: 'string';
|
|
27
|
+
enum: string[];
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
31
|
+
export declare function buildFmSchema(questions: Record<string, Question>): FmSchema;
|
|
32
|
+
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
33
|
+
export declare function formatDesc(desc: Desc | undefined): string;
|
|
34
|
+
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
35
|
+
export declare function declaredOptions(question: Question): string[];
|
|
36
|
+
/**
|
|
37
|
+
* Render the positional prompt: the state as text (JSON when structured), then
|
|
38
|
+
* one block per question key with the prompt `Desc` and its options with
|
|
39
|
+
* descriptions — choice label keys, score level indices with the rubric text,
|
|
40
|
+
* noul `yes`/`no` with any outcome descriptions.
|
|
41
|
+
*/
|
|
42
|
+
export declare function buildPrompt(state: DecisionState, questions: Record<string, Question>): string;
|
|
43
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAE7E;;;;GAIG;AACH,eAAO,MAAM,eAAe,QAGyB,CAAC;AAEtD,mEAAmE;AACnE,MAAM,WAAW,QAAQ;IACrB,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,oBAAoB,EAAE,KAAK,CAAC;IAC5B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,IAAI,EAAE,QAAQ,CAAC;QAAC,IAAI,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAAC;CAClE;AAED,mFAAmF;AACnF,wBAAgB,aAAa,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAwB3E;AAED,uGAAuG;AACvG,wBAAgB,UAAU,CAAC,IAAI,EAAE,IAAI,GAAG,SAAS,GAAG,MAAM,CAIzD;AAED,2GAA2G;AAC3G,wBAAgB,eAAe,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,EAAE,CAS5D;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,GAAG,MAAM,CAkC7F"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fm request construction (task 0084 R3, design § Request construction):
|
|
3
|
+
* the JSON schema passed to `fm respond --schema` and the instruction/prompt
|
|
4
|
+
* texts passed via `-i` and as the final positional argument.
|
|
5
|
+
*
|
|
6
|
+
* The schema always carries `x-order` — a hand-written object schema without it
|
|
7
|
+
* is rejected by `fm` ("The data couldn't be read because it is missing").
|
|
8
|
+
* Score levels are string enums so every question uses the same constrained
|
|
9
|
+
* string path; the driver converts them back to numbers.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Fixed `-i` instructions. It never asks for confidence, certainty or a
|
|
13
|
+
* probability — self-reported confidence would be fabricated calibration,
|
|
14
|
+
* which the decision contract forbids (ADR-030).
|
|
15
|
+
*/
|
|
16
|
+
export const FM_INSTRUCTIONS = 'You are a decision engine. Answer every question below using only the supplied state. ' +
|
|
17
|
+
'For each question choose exactly one of the listed options. ' +
|
|
18
|
+
'Reply with the JSON object the schema requires.';
|
|
19
|
+
/** Build the fm schema over the whole question map: one call answers every key. */
|
|
20
|
+
export function buildFmSchema(questions) {
|
|
21
|
+
const properties = {};
|
|
22
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
23
|
+
switch (question.kind) {
|
|
24
|
+
case 'choice':
|
|
25
|
+
properties[key] = { type: 'string', enum: Object.keys(question.labels) };
|
|
26
|
+
break;
|
|
27
|
+
case 'score':
|
|
28
|
+
properties[key] = { type: 'string', enum: question.rubric.map((_, level) => String(level)) };
|
|
29
|
+
break;
|
|
30
|
+
case 'noul':
|
|
31
|
+
properties[key] = { type: 'string', enum: ['yes', 'no'] };
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const keys = Object.keys(questions);
|
|
36
|
+
return {
|
|
37
|
+
type: 'object',
|
|
38
|
+
title: 'Decision',
|
|
39
|
+
'x-order': keys,
|
|
40
|
+
required: keys,
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Format a {@link Desc} into prompt text: strings verbatim, structured values JSON, absence empty. */
|
|
46
|
+
export function formatDesc(desc) {
|
|
47
|
+
if (typeof desc === 'string')
|
|
48
|
+
return desc;
|
|
49
|
+
if (desc === null || desc === undefined)
|
|
50
|
+
return '';
|
|
51
|
+
return JSON.stringify(desc);
|
|
52
|
+
}
|
|
53
|
+
/** The declared option values of a question, in declaration order (estimation + validation share this). */
|
|
54
|
+
export function declaredOptions(question) {
|
|
55
|
+
switch (question.kind) {
|
|
56
|
+
case 'choice':
|
|
57
|
+
return Object.keys(question.labels);
|
|
58
|
+
case 'score':
|
|
59
|
+
return question.rubric.map((_, level) => String(level));
|
|
60
|
+
case 'noul':
|
|
61
|
+
return ['yes', 'no'];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Render the positional prompt: the state as text (JSON when structured), then
|
|
66
|
+
* one block per question key with the prompt `Desc` and its options with
|
|
67
|
+
* descriptions — choice label keys, score level indices with the rubric text,
|
|
68
|
+
* noul `yes`/`no` with any outcome descriptions.
|
|
69
|
+
*/
|
|
70
|
+
export function buildPrompt(state, questions) {
|
|
71
|
+
const parts = [];
|
|
72
|
+
const stateText = typeof state === 'string' ? state : state === null || state === undefined ? '' : JSON.stringify(state);
|
|
73
|
+
if (stateText.length > 0)
|
|
74
|
+
parts.push(stateText);
|
|
75
|
+
for (const [key, question] of Object.entries(questions)) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
const prompt = formatDesc(question.prompt);
|
|
78
|
+
lines.push(`Question "${key}":${prompt.length > 0 ? ` ${prompt}` : ''}`);
|
|
79
|
+
lines.push('Options:');
|
|
80
|
+
switch (question.kind) {
|
|
81
|
+
case 'choice':
|
|
82
|
+
for (const [label, desc] of Object.entries(question.labels)) {
|
|
83
|
+
const d = formatDesc(desc);
|
|
84
|
+
lines.push(d.length > 0 ? `- ${label}: ${d}` : `- ${label}`);
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
case 'score':
|
|
88
|
+
question.rubric.forEach((desc, level) => {
|
|
89
|
+
const d = formatDesc(desc);
|
|
90
|
+
lines.push(d.length > 0 ? `- ${level}: ${d}` : `- ${level}`);
|
|
91
|
+
});
|
|
92
|
+
break;
|
|
93
|
+
case 'noul': {
|
|
94
|
+
for (const outcome of ['yes', 'no']) {
|
|
95
|
+
const d = formatDesc(question[outcome]);
|
|
96
|
+
lines.push(d.length > 0 ? `- ${outcome}: ${d}` : `- ${outcome}`);
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
parts.push(lines.join('\n'));
|
|
102
|
+
}
|
|
103
|
+
return parts.join('\n\n');
|
|
104
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gobing-ai/ts-decision-fm",
|
|
3
|
+
"version": "0.5.3",
|
|
4
|
+
"description": "@gobing-ai/ts-decision-fm — Apple on-device Foundation Model decision backend driving the macOS `fm` CLI over a one-shot process bridge.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"typescript",
|
|
7
|
+
"ai",
|
|
8
|
+
"decision",
|
|
9
|
+
"fm",
|
|
10
|
+
"foundation-models",
|
|
11
|
+
"apple",
|
|
12
|
+
"local"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/gobing-ai/ts-libs.git",
|
|
17
|
+
"directory": "packages/decision-fm"
|
|
18
|
+
},
|
|
19
|
+
"author": "Robin Min <minlongbing@gmail.com>",
|
|
20
|
+
"contributors": [
|
|
21
|
+
"Robin Min <minlongbing@gmail.com>"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"private": false,
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src",
|
|
38
|
+
"README.md",
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"NOTICE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsc -p tsconfig.build.json && bun ../../scripts/builder.ts fix-dist-esm-extensions dist",
|
|
44
|
+
"test": "NODE_ENV=test bun test --coverage --coverage-dir=.coverage --reporter=dots",
|
|
45
|
+
"test:full": "NODE_ENV=test bun test --update-snapshots --coverage --coverage-dir=.coverage",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"lint": "biome check . && bun run typecheck",
|
|
48
|
+
"format": "biome check . --write",
|
|
49
|
+
"check": "bun run lint && bun run test",
|
|
50
|
+
"prepublishOnly": "bun run build",
|
|
51
|
+
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-decision-fm-v<version> && git push --tags' && exit 1"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@gobing-ai/ts-ai-runner": "workspace:*",
|
|
55
|
+
"@gobing-ai/ts-runtime": "workspace:*"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/bun": "1.3.14"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/driver.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Apple `fm` decision driver (task 0084 R2, R8, R9; design § Main export).
|
|
3
|
+
* Satisfies the same {@link DecisionDriver} contract as the other backends over
|
|
4
|
+
* one-shot `fm` processes: one schema per ask answers the whole question map,
|
|
5
|
+
* probabilities come from k sample frequencies (ADR-030), and every failure
|
|
6
|
+
* surfaces through the decision error taxonomy — never as an answer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
type Answer,
|
|
11
|
+
DecisionConfigError,
|
|
12
|
+
type DecisionDriver,
|
|
13
|
+
DecisionRequestError,
|
|
14
|
+
type DecisionState,
|
|
15
|
+
type Question,
|
|
16
|
+
} from '@gobing-ai/ts-ai-runner';
|
|
17
|
+
import {
|
|
18
|
+
createNodeFileSystem,
|
|
19
|
+
getProcessEnv,
|
|
20
|
+
joinPath,
|
|
21
|
+
nodeBunFactory,
|
|
22
|
+
type ProcessExecutor,
|
|
23
|
+
} from '@gobing-ai/ts-runtime';
|
|
24
|
+
import { estimateChoice, estimateNoul, estimateScore } from './estimate';
|
|
25
|
+
import {
|
|
26
|
+
countPromptTokens,
|
|
27
|
+
type FmGuardrails,
|
|
28
|
+
parseFmRespond,
|
|
29
|
+
probeFmAvailability,
|
|
30
|
+
requireEnumValue,
|
|
31
|
+
respondArgv,
|
|
32
|
+
runFmRespond,
|
|
33
|
+
} from './fm-process';
|
|
34
|
+
import { buildFmSchema, buildPrompt, declaredOptions, FM_INSTRUCTIONS } from './schema';
|
|
35
|
+
|
|
36
|
+
/** Configuration options for {@link createFmDriver}. */
|
|
37
|
+
export interface FmDriverOptions {
|
|
38
|
+
/** Samples per ask. Default: 5. Ignored when `deterministic` is true. */
|
|
39
|
+
samples?: number;
|
|
40
|
+
/** One greedy sample (`fm respond -g`); probabilities are one-hot. Default: false. */
|
|
41
|
+
deterministic?: boolean;
|
|
42
|
+
/** Prompt-token budget checked with `fm count-tokens` before sampling. Default: 6_000. */
|
|
43
|
+
maxPromptTokens?: number;
|
|
44
|
+
/** Budget for one `fm respond` process. Default: 30_000. */
|
|
45
|
+
requestTimeoutMs?: number;
|
|
46
|
+
/** `--guardrails` level. Default: omitted (fm's `default`). */
|
|
47
|
+
guardrails?: FmGuardrails;
|
|
48
|
+
/** Executable. Default: 'fm'. */
|
|
49
|
+
fmPath?: string;
|
|
50
|
+
/** Injected process seam. Default: ts-runtime's ProcessExecutor. */
|
|
51
|
+
executor?: ProcessExecutor;
|
|
52
|
+
/** Host platform, injectable so tests pin the host. Default: `process.platform` (as laya-mlx). */
|
|
53
|
+
platform?: string;
|
|
54
|
+
/** Host architecture. Default: `process.arch`. */
|
|
55
|
+
arch?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The fm driver: a {@link DecisionDriver} with a declared estimator (ADR-030). */
|
|
59
|
+
export interface FmDecisionDriver extends DecisionDriver {
|
|
60
|
+
readonly name: 'fm-local';
|
|
61
|
+
/** Declared estimator — how every probability this driver returns was produced. */
|
|
62
|
+
readonly estimator: { readonly kind: 'sample-frequency'; readonly samples: number; readonly greedy: boolean };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the `fm-local` decision driver. Host platform/architecture is checked
|
|
67
|
+
* here, at construction, without spawning (R8); the `fm` availability probe
|
|
68
|
+
* runs once, on the first `ask`, and is cached for the driver's lifetime.
|
|
69
|
+
*/
|
|
70
|
+
export function createFmDriver(options: FmDriverOptions = {}): FmDecisionDriver {
|
|
71
|
+
const {
|
|
72
|
+
samples = 5,
|
|
73
|
+
deterministic = false,
|
|
74
|
+
maxPromptTokens = 6_000,
|
|
75
|
+
requestTimeoutMs = 30_000,
|
|
76
|
+
guardrails,
|
|
77
|
+
fmPath = 'fm',
|
|
78
|
+
executor = nodeBunFactory.createProcessExecutor(),
|
|
79
|
+
platform = process.platform,
|
|
80
|
+
arch = process.arch,
|
|
81
|
+
} = options;
|
|
82
|
+
|
|
83
|
+
if (!Number.isInteger(samples) || samples < 1) {
|
|
84
|
+
throw new DecisionRequestError(`samples must be a positive integer; got ${samples}`, undefined, undefined);
|
|
85
|
+
}
|
|
86
|
+
const k = deterministic ? 1 : samples;
|
|
87
|
+
|
|
88
|
+
if (platform !== 'darwin' || arch !== 'arm64') {
|
|
89
|
+
throw new DecisionConfigError(
|
|
90
|
+
`decision-fm requires macOS on Apple Silicon (darwin arm64); current platform is '${platform} ${arch}'`,
|
|
91
|
+
'PLATFORM',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fs = createNodeFileSystem();
|
|
96
|
+
let availabilityVerified = false;
|
|
97
|
+
|
|
98
|
+
async function ensureFmAvailable(): Promise<void> {
|
|
99
|
+
if (availabilityVerified) return;
|
|
100
|
+
await probeFmAvailability(executor, fmPath);
|
|
101
|
+
availabilityVerified = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name: 'fm-local',
|
|
106
|
+
estimator: { kind: 'sample-frequency', samples: k, greedy: deterministic },
|
|
107
|
+
|
|
108
|
+
async ask({
|
|
109
|
+
state,
|
|
110
|
+
questions,
|
|
111
|
+
model,
|
|
112
|
+
}: {
|
|
113
|
+
state: DecisionState;
|
|
114
|
+
questions: Record<string, Question>;
|
|
115
|
+
model?: string;
|
|
116
|
+
}): Promise<Record<string, Answer>> {
|
|
117
|
+
// R9: only the on-device system model exists (design: fm CLI facts).
|
|
118
|
+
if (model !== undefined && model !== 'system') {
|
|
119
|
+
throw new DecisionRequestError(
|
|
120
|
+
`fm-local supports only the 'system' model (or undefined); got '${model}'`,
|
|
121
|
+
undefined,
|
|
122
|
+
undefined,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const entries = Object.entries(questions);
|
|
126
|
+
if (entries.length === 0) {
|
|
127
|
+
throw new DecisionRequestError('expected a nonempty question map', undefined, undefined);
|
|
128
|
+
}
|
|
129
|
+
for (const [key, question] of entries) {
|
|
130
|
+
const optionCount =
|
|
131
|
+
question.kind === 'choice'
|
|
132
|
+
? Object.keys(question.labels).length
|
|
133
|
+
: question.kind === 'score'
|
|
134
|
+
? question.rubric.length
|
|
135
|
+
: 2; // noul is binary by type
|
|
136
|
+
if (optionCount < 2) {
|
|
137
|
+
throw new DecisionRequestError(
|
|
138
|
+
`question '${key}' has fewer than two options (${optionCount})`,
|
|
139
|
+
undefined,
|
|
140
|
+
undefined,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// R8: prerequisite probe before the first sample; cached per driver.
|
|
146
|
+
await ensureFmAvailable();
|
|
147
|
+
|
|
148
|
+
const instructions = FM_INSTRUCTIONS;
|
|
149
|
+
const prompt = buildPrompt(state, questions);
|
|
150
|
+
|
|
151
|
+
// R4: pre-flight budget — over budget rejects before any `fm respond`.
|
|
152
|
+
const tokens = await countPromptTokens(executor, fmPath, instructions, prompt);
|
|
153
|
+
if (tokens > maxPromptTokens) {
|
|
154
|
+
throw new DecisionRequestError(
|
|
155
|
+
`prompt is ${tokens} tokens, above the maxPromptTokens budget of ${maxPromptTokens}`,
|
|
156
|
+
undefined,
|
|
157
|
+
undefined,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// R3: one schema per ask over the whole question map, in a uniquely
|
|
162
|
+
// named temp file, deleted in `finally` on success and failure.
|
|
163
|
+
const schemaPath = joinPath(getProcessEnv().TMPDIR ?? '/tmp', `fm-schema-${crypto.randomUUID()}.json`);
|
|
164
|
+
try {
|
|
165
|
+
fs.writeFile(schemaPath, JSON.stringify(buildFmSchema(questions)));
|
|
166
|
+
|
|
167
|
+
// R5: k sequential samples; each stdout parsed as JSON, read by key.
|
|
168
|
+
const allowed = new Map(entries.map(([key, question]) => [key, declaredOptions(question)]));
|
|
169
|
+
const samplesByKey = new Map(entries.map(([key]) => [key, [] as string[]]));
|
|
170
|
+
for (let i = 0; i < k; i++) {
|
|
171
|
+
const stdout = await runFmRespond(
|
|
172
|
+
executor,
|
|
173
|
+
fmPath,
|
|
174
|
+
respondArgv({ schemaPath, instructions, prompt, greedy: deterministic, guardrails }),
|
|
175
|
+
requestTimeoutMs,
|
|
176
|
+
);
|
|
177
|
+
const parsed = parseFmRespond(stdout);
|
|
178
|
+
for (const [key, values] of samplesByKey) {
|
|
179
|
+
values.push(requireEnumValue(parsed, key, allowed.get(key) ?? []));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// R6: frequencies over the declared labels/levels, argmax with
|
|
184
|
+
// declaration-order / lower-level tie-break, entropy confidence.
|
|
185
|
+
const answers: Record<string, Answer> = {};
|
|
186
|
+
for (const [key, question] of entries) {
|
|
187
|
+
const values = samplesByKey.get(key) ?? [];
|
|
188
|
+
switch (question.kind) {
|
|
189
|
+
case 'choice':
|
|
190
|
+
answers[key] = estimateChoice(Object.keys(question.labels), values);
|
|
191
|
+
break;
|
|
192
|
+
case 'score':
|
|
193
|
+
answers[key] = estimateScore(question.rubric, values);
|
|
194
|
+
break;
|
|
195
|
+
case 'noul':
|
|
196
|
+
answers[key] = estimateNoul(values);
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return answers;
|
|
201
|
+
} finally {
|
|
202
|
+
fs.deleteFile(schemaPath);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|