@multileaf/ai-workflow 0.5.0 → 0.6.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.
- package/CHANGELOG.md +13 -0
- package/README.md +20 -3
- package/dist/decision.d.ts +4 -1
- package/dist/decision.js +18 -6
- package/dist/model-onboarding.d.ts +22 -0
- package/dist/model-onboarding.js +200 -0
- package/dist/semantic.d.ts +2 -1
- package/dist/semantic.js +44 -2
- package/dist/workflow.d.ts +0 -3
- package/dist/workflow.js +50 -85
- package/docs/README.md +5 -0
- package/docs/decision-model-evaluation.md +1 -1
- package/docs/release-process.md +1 -1
- package/docs/semantic-mapping.md +19 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.6.0] - 2026-10-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Guided provider onboarding during interactive `aiw install`: each capability (semantic embedding, then decision triage) asks whether to configure it, offers Ollama or an external OpenAI-compatible provider (external only when Ollama is unreachable), lists the locally available models (embedding-only filtering for semantic), asks permission to run `ollama pull embeddinggemma-2` when no embedding models exist, collects endpoint URL, model name, and an optional token environment variable for external providers, and validates the setup with a live request before saving — with retry or skip on failure.
|
|
12
|
+
- External provider support: `semantic` and `decision` settings accept `apiKeyEnv` (an uppercase environment variable name); the token is read from the environment at call time and never stored. Semantic requests go to `<baseUrl>/v1/embeddings` with bearer authentication and decision requests send `Authorization` headers.
|
|
13
|
+
- Model usage reporting: every `aiw scan` states which models were actually used — embedding (chunks indexed or retrieval queries, batch request count, estimated tokens) and interpreter (model, facts inferred, scoped files, estimated tokens) — or reports `Models used: none (deterministic scan).`
|
|
14
|
+
- Per-stage token estimates recorded in `.aiw/usage.yml` (`scan.embedding`, `scan.interpreter`) and inspectable with `aiw token-usage`. Token counts are local project state; telemetry never carries them.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Interactive onboarding messages shortened to single-line prompts.
|
|
19
|
+
- Embedding-model classification during onboarding now requires positive evidence (model name contains "embed" or Ollama family "embedding"); the previous heuristic misclassified `gpt-oss:20b` as an embedding model.
|
|
7
20
|
## [0.5.0] - 2026-10-08
|
|
8
21
|
|
|
9
22
|
### Added
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ AI-assisted development often relies on implicit prompts, duplicated configurati
|
|
|
19
19
|
|
|
20
20
|
## Installation
|
|
21
21
|
|
|
22
|
-
The published package supports Node.js 18 or newer. Full development checks require Node.js 20.19 or newer for the current lint and test toolchain; CI
|
|
22
|
+
The published package supports Node.js 18 or newer. Full development checks require Node.js 20.19 or newer for the current lint and test toolchain; CI and release validation run on Node.js 24. Node.js 18 remains the declared runtime floor but is not currently in the release validation matrix.
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
npx @multileaf/ai-workflow install --target codex
|
|
@@ -27,6 +27,8 @@ npx @multileaf/ai-workflow install --target codex
|
|
|
27
27
|
|
|
28
28
|
Then run `/ai-init` in Codex. The bootstrap scans the repository, explains project-specific recommendations, and waits for the user's selections before installing optional resources.
|
|
29
29
|
|
|
30
|
+
During an interactive install, AI Workflow also offers optional local capabilities after the base installation: it probes the configured Ollama endpoint, offers Ollama or an external OpenAI-compatible provider (endpoint URL plus an optional token environment variable), lists the embedding and decision models actually available, can run `ollama pull` for a default embedding model when none exist, and validates the chosen setup with a live request before recording it in `.aiw/settings.yml`. It then asks for telemetry consent with an explicit default of No (see [Privacy and security](#privacy-and-security)). Non-interactive installs skip every prompt.
|
|
31
|
+
|
|
30
32
|
Supported targets are `codex`, `claude`, `cursor`, `gemini`, `copilot`, and `universal`. Every command uses the same entry point:
|
|
31
33
|
|
|
32
34
|
```bash
|
|
@@ -241,6 +243,18 @@ Analysis uses two complementary layers:
|
|
|
241
243
|
|
|
242
244
|
Detected facts cannot be replaced by conflicting AI output. Inferred facts remain unconfirmed until the user accepts, rejects, or edits them. Context selection excludes known sensitive filenames, binaries, dependencies, Git internals, generated output, and checkpoints, then applies pattern-based redaction. This is a defense-in-depth filter, not a general secret scanner; review eligible text before enabling an external AI provider.
|
|
243
245
|
|
|
246
|
+
The interpreter accepts an injected provider from a host application, or a built-in OpenAI-compatible provider configured through an `ai` section in `.aiw/settings.yml` — for example a local Ollama model such as `gpt-oss:20b` — so scans can interpret projects without any host integration. Without the section, scans stay deterministic and offline. See [Project Intelligence](docs/project-intelligence.md).
|
|
247
|
+
|
|
248
|
+
## Optional local models
|
|
249
|
+
|
|
250
|
+
AI Workflow can use local models served by an OpenAI-compatible endpoint (Ollama by default) for three opt-in capabilities, all gated by `.aiw/settings.yml` sections and disabled by default:
|
|
251
|
+
|
|
252
|
+
- **Semantic mapping** (`semantic` section): `aiw scan` builds a versioned chunk index with batched embedding requests, skipping lockfiles and generated vendor manifests; `aiw semantic query` returns ranked matches and can optionally re-rank them with the decision model (`--rerank`). When an index exists, the AI interpreter's context is scoped to deterministic key files plus the top semantically relevant files, reducing token usage. See [Semantic Mapping](docs/semantic-mapping.md).
|
|
253
|
+
- **Advisory decision triage** (`decision` section): `aiw decision triage` returns advisory evidence-completeness and gate decisions with confidence, and `aiw recommend` orders capability recommendations by decision-model relevance to the confirmed profile. Ranking is advisory and never changes evidence states or bypasses human approval. See [Decision Model Evaluation](docs/decision-model-evaluation.md).
|
|
254
|
+
- **Built-in AI interpretation** (`ai` section): the scoped project interpreter works without a host-injected provider.
|
|
255
|
+
|
|
256
|
+
All model-driven features verify model availability before use and degrade to fully deterministic behavior when unconfigured. Every `aiw scan` reports which models were actually used — embedding (chunks indexed or retrieval queries) and interpreter (facts inferred, estimated tokens) — and records token estimates per stage in `.aiw/usage.yml`, inspectable with `aiw token-usage`. Token accounting is local project state; telemetry never carries token counts or any content.
|
|
257
|
+
|
|
244
258
|
## Resource packages and recommendations
|
|
245
259
|
|
|
246
260
|
Resources are distributed as versioned packages that may contain:
|
|
@@ -326,10 +340,10 @@ The typed SDK is available from `@multileaf/ai-workflow/plugin-sdk`. It exports
|
|
|
326
340
|
|
|
327
341
|
## Privacy and security
|
|
328
342
|
|
|
329
|
-
Telemetry is disabled by default and requires explicit consent.
|
|
343
|
+
Telemetry is disabled by default and requires explicit consent — offered once during interactive installation with an explicit default of No. Each event contains only the command name and its outcome; never arguments, file paths, or file contents. When telemetry is enabled but no host transport is configured, events accumulate in a rotated local buffer (`.aiw/telemetry-buffer.jsonl`, 200-event cap) that `aiw telemetry status --verbose` can inspect and `aiw telemetry disable` purges.
|
|
330
344
|
|
|
331
345
|
```bash
|
|
332
|
-
npx @multileaf/ai-workflow telemetry status
|
|
346
|
+
npx @multileaf/ai-workflow telemetry status --verbose
|
|
333
347
|
npx @multileaf/ai-workflow telemetry enable
|
|
334
348
|
npx @multileaf/ai-workflow telemetry disable
|
|
335
349
|
```
|
|
@@ -364,6 +378,9 @@ npm run release:check
|
|
|
364
378
|
- [Quality and testing](docs/quality.md)
|
|
365
379
|
- [Token efficiency](docs/token-efficiency.md)
|
|
366
380
|
- [Multi-agent orchestration](docs/orchestration.md)
|
|
381
|
+
- [Semantic mapping](docs/semantic-mapping.md)
|
|
382
|
+
- [Decision model evaluation](docs/decision-model-evaluation.md)
|
|
383
|
+
- [Telemetry and privacy](docs/telemetry.md)
|
|
367
384
|
- [Release process](docs/release-process.md)
|
|
368
385
|
|
|
369
386
|
## License
|
package/dist/decision.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type DecisionConfig = {
|
|
|
3
3
|
enabled: boolean;
|
|
4
4
|
model: string;
|
|
5
5
|
baseUrl: string;
|
|
6
|
+
apiKeyEnv?: string;
|
|
6
7
|
};
|
|
7
8
|
export type DecisionQuestion = {
|
|
8
9
|
type: "noul";
|
|
@@ -47,7 +48,8 @@ export declare function serializeDecisionConfig(config: DecisionConfig): string;
|
|
|
47
48
|
export declare function parseDecisionConfig(text: string): DecisionConfig;
|
|
48
49
|
export declare function loadDecisionConfig(fs: FileSystem, aiwPath: string): DecisionConfig | undefined;
|
|
49
50
|
export declare function parseDecisionResponse(text: string): DecisionResponse;
|
|
50
|
-
|
|
51
|
+
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
52
|
+
export declare function createOllamaDecisionProvider(config: DecisionConfig, fetchImpl?: FetchLike, env?: Readonly<Record<string, string | undefined>>): DecisionProvider;
|
|
51
53
|
export type RecommendationRankingCandidate = {
|
|
52
54
|
id: string;
|
|
53
55
|
rationale: string;
|
|
@@ -60,3 +62,4 @@ export type SemanticRerankCandidate = {
|
|
|
60
62
|
};
|
|
61
63
|
export declare function buildSemanticRerankRequest(query: string, candidates: SemanticRerankCandidate[]): DecisionRequest;
|
|
62
64
|
export declare function buildEvidenceTriageRequest(artifact: string): DecisionRequest;
|
|
65
|
+
export {};
|
package/dist/decision.js
CHANGED
|
@@ -13,18 +13,22 @@ export function parseDecisionMapping(raw) {
|
|
|
13
13
|
const enabled = record.enabled;
|
|
14
14
|
const model = record.model;
|
|
15
15
|
const baseUrl = record.baseUrl;
|
|
16
|
+
const apiKeyEnv = record.apiKeyEnv;
|
|
16
17
|
if (typeof enabled !== "boolean")
|
|
17
18
|
throw new Error("decision.enabled must be a boolean");
|
|
18
19
|
if (!enabled)
|
|
19
20
|
return { ...DEFAULT_DECISION_CONFIG, enabled: false };
|
|
21
|
+
if (apiKeyEnv !== undefined &&
|
|
22
|
+
(typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
|
|
23
|
+
throw new Error("decision.apiKeyEnv must be an uppercase environment variable name");
|
|
20
24
|
if (typeof model !== "string" || model.trim() === "")
|
|
21
25
|
throw new Error("decision.model must be a non-empty string when decision mapping is enabled");
|
|
22
26
|
if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
|
|
23
27
|
throw new Error("decision.baseUrl must be an http(s) URL when decision mapping is enabled");
|
|
24
|
-
return { enabled: true, model, baseUrl };
|
|
28
|
+
return { enabled: true, model, baseUrl, ...(apiKeyEnv ? { apiKeyEnv } : {}) };
|
|
25
29
|
}
|
|
26
30
|
export function serializeDecisionConfig(config) {
|
|
27
|
-
return `decision:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n`;
|
|
31
|
+
return `decision:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}${config.apiKeyEnv ? `\n apiKeyEnv: ${config.apiKeyEnv}` : ""}\n`;
|
|
28
32
|
}
|
|
29
33
|
export function parseDecisionConfig(text) {
|
|
30
34
|
const document = parseDocument(text);
|
|
@@ -60,11 +64,19 @@ export function parseDecisionResponse(text) {
|
|
|
60
64
|
result[key] = parseAnswer(value);
|
|
61
65
|
return { model: parsed.model, answers: result };
|
|
62
66
|
}
|
|
63
|
-
export function createOllamaDecisionProvider(config) {
|
|
67
|
+
export function createOllamaDecisionProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
|
|
68
|
+
const externalToken = config.apiKeyEnv ? env[config.apiKeyEnv] : undefined;
|
|
69
|
+
const authHeaders = (extra = {}) => externalToken ? { ...extra, authorization: `Bearer ${externalToken}` } : extra;
|
|
64
70
|
return {
|
|
65
71
|
async available() {
|
|
66
72
|
try {
|
|
67
|
-
|
|
73
|
+
if (config.apiKeyEnv !== undefined) {
|
|
74
|
+
const response = await fetchImpl(`${config.baseUrl}/v1/models`, {
|
|
75
|
+
headers: authHeaders(),
|
|
76
|
+
});
|
|
77
|
+
return response.ok;
|
|
78
|
+
}
|
|
79
|
+
const response = await fetchImpl(`${config.baseUrl}/api/tags`);
|
|
68
80
|
if (!response.ok)
|
|
69
81
|
return false;
|
|
70
82
|
const body = (await response.json());
|
|
@@ -81,9 +93,9 @@ export function createOllamaDecisionProvider(config) {
|
|
|
81
93
|
const questionCount = Object.keys(request.questions).length;
|
|
82
94
|
if (questionCount === 0 || questionCount > 64)
|
|
83
95
|
throw new Error("Decision requests must contain between 1 and 64 questions");
|
|
84
|
-
const response = await
|
|
96
|
+
const response = await fetchImpl(`${config.baseUrl}/v1/systemone`, {
|
|
85
97
|
method: "POST",
|
|
86
|
-
headers: { "content-type": "application/json" },
|
|
98
|
+
headers: authHeaders({ "content-type": "application/json" }),
|
|
87
99
|
body: JSON.stringify({
|
|
88
100
|
model: config.model,
|
|
89
101
|
state: request.state,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FileSystem } from "./types.js";
|
|
2
|
+
export type ConfirmQuestion = (question: string) => Promise<string>;
|
|
3
|
+
export type OllamaModel = {
|
|
4
|
+
name: string;
|
|
5
|
+
embedding: boolean;
|
|
6
|
+
};
|
|
7
|
+
export type OnboardingRuntime = {
|
|
8
|
+
listOllamaModels(baseUrl: string): Promise<OllamaModel[] | undefined>;
|
|
9
|
+
pullOllamaModel(model: string): Promise<boolean>;
|
|
10
|
+
validateEmbedding(config: {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
model: string;
|
|
13
|
+
apiKeyEnv?: string;
|
|
14
|
+
}): Promise<boolean>;
|
|
15
|
+
validateDecision(config: {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
model: string;
|
|
18
|
+
apiKeyEnv?: string;
|
|
19
|
+
}): Promise<boolean>;
|
|
20
|
+
};
|
|
21
|
+
export declare const defaultOnboardingRuntime: OnboardingRuntime;
|
|
22
|
+
export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion, runtime?: OnboardingRuntime): Promise<string[]>;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { loadSettings, saveSettingsSection } from "./settings.js";
|
|
3
|
+
import { DEFAULT_SEMANTIC_CONFIG, createOllamaEmbeddingProvider } from "./semantic.js";
|
|
4
|
+
import { DEFAULT_DECISION_CONFIG, createOllamaDecisionProvider } from "./decision.js";
|
|
5
|
+
const OLLAMA_BASE_URL = "http://localhost:11434";
|
|
6
|
+
function isEmbeddingModel(name, families) {
|
|
7
|
+
if (name.toLowerCase().includes("embed"))
|
|
8
|
+
return true;
|
|
9
|
+
return families.some((family) => family.toLowerCase() === "embedding");
|
|
10
|
+
}
|
|
11
|
+
export const defaultOnboardingRuntime = {
|
|
12
|
+
async listOllamaModels(baseUrl) {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(`${baseUrl}/api/tags`);
|
|
15
|
+
if (!response.ok)
|
|
16
|
+
return undefined;
|
|
17
|
+
const body = (await response.json());
|
|
18
|
+
const models = (body.models ?? [])
|
|
19
|
+
.filter((entry) => typeof entry?.name === "string")
|
|
20
|
+
.map((entry) => ({
|
|
21
|
+
name: entry.name,
|
|
22
|
+
embedding: isEmbeddingModel(entry.name, entry.details?.families ?? []),
|
|
23
|
+
}))
|
|
24
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
return models;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
pullOllamaModel(model) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
execFile("ollama", ["pull", model], { timeout: 10 * 60_000 }, (error) => resolve(error === null));
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
async validateEmbedding(config) {
|
|
37
|
+
try {
|
|
38
|
+
const provider = createOllamaEmbeddingProvider({
|
|
39
|
+
enabled: true,
|
|
40
|
+
model: config.model,
|
|
41
|
+
baseUrl: config.baseUrl,
|
|
42
|
+
chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
|
|
43
|
+
topK: DEFAULT_SEMANTIC_CONFIG.topK,
|
|
44
|
+
excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
|
|
45
|
+
...(config.apiKeyEnv ? { apiKeyEnv: config.apiKeyEnv } : {}),
|
|
46
|
+
});
|
|
47
|
+
if (!(await provider.available()))
|
|
48
|
+
return false;
|
|
49
|
+
const vectors = await provider.embed(["aiw onboarding validation"]);
|
|
50
|
+
return vectors.length === 1 && vectors[0].length > 0;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async validateDecision(config) {
|
|
57
|
+
try {
|
|
58
|
+
const provider = createOllamaDecisionProvider({
|
|
59
|
+
enabled: true,
|
|
60
|
+
model: config.model,
|
|
61
|
+
baseUrl: config.baseUrl,
|
|
62
|
+
...(config.apiKeyEnv ? { apiKeyEnv: config.apiKeyEnv } : {}),
|
|
63
|
+
});
|
|
64
|
+
return await provider.available();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
async function askYesNo(answer, question) {
|
|
72
|
+
const response = (await answer(question)).trim().toLowerCase();
|
|
73
|
+
return response === "y" || response === "yes";
|
|
74
|
+
}
|
|
75
|
+
async function pickFromList(answer, label, options, fallback) {
|
|
76
|
+
const menu = options.map((option, index) => `${index + 1}) ${option}`).join("\n");
|
|
77
|
+
const response = (await answer(`${menu}\nSelect ${label} [${fallback}]: `)).trim();
|
|
78
|
+
if (response === "")
|
|
79
|
+
return fallback;
|
|
80
|
+
const numeric = Number.parseInt(response, 10);
|
|
81
|
+
if (Number.isInteger(numeric) && numeric >= 1 && numeric <= options.length)
|
|
82
|
+
return options[numeric - 1];
|
|
83
|
+
return options.includes(response) ? response : fallback;
|
|
84
|
+
}
|
|
85
|
+
function isValidEnvName(value) {
|
|
86
|
+
return /^[A-Z_][A-Z0-9_]*$/.test(value);
|
|
87
|
+
}
|
|
88
|
+
async function gatherCapabilitySetup(answer, runtime, options) {
|
|
89
|
+
const models = await runtime.listOllamaModels(OLLAMA_BASE_URL);
|
|
90
|
+
const ollamaUp = models !== undefined;
|
|
91
|
+
const provider = ollamaUp
|
|
92
|
+
? (await pickFromList(answer, "provider", ["ollama", "external"], "ollama")) === "external"
|
|
93
|
+
? "external"
|
|
94
|
+
: "ollama"
|
|
95
|
+
: (await answer(`Ollama not reachable at ${OLLAMA_BASE_URL}. Use an external provider. [Enter] `),
|
|
96
|
+
"external");
|
|
97
|
+
if (provider === "ollama") {
|
|
98
|
+
let candidates = (models ?? []).filter((model) => options.onlyEmbedding ? model.embedding : true);
|
|
99
|
+
if (candidates.length === 0 && options.onlyEmbedding) {
|
|
100
|
+
const pull = await askYesNo(answer, `No embedding models found. Pull ${options.pullModel}? [y/N] `);
|
|
101
|
+
if (pull) {
|
|
102
|
+
await answer(`Pulling ${options.pullModel}... [Enter] `);
|
|
103
|
+
const pulled = await runtime.pullOllamaModel(options.pullModel);
|
|
104
|
+
if (pulled) {
|
|
105
|
+
const refreshed = await runtime.listOllamaModels(OLLAMA_BASE_URL);
|
|
106
|
+
candidates = (refreshed ?? []).filter((model) => model.embedding);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
await answer("Pull failed. Enter a model manually. [Enter] ");
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (candidates.length === 0) {
|
|
113
|
+
const manual = (await answer(`Model name to use [${options.pullModel}]: `)).trim();
|
|
114
|
+
if (manual.length === 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
return { baseUrl: OLLAMA_BASE_URL, model: manual };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const names = candidates.map((candidate) => candidate.name);
|
|
120
|
+
const fallback = names.includes(options.fallbackModel) ? options.fallbackModel : names[0];
|
|
121
|
+
const model = await pickFromList(answer, `${options.shortLabel} model`, names, fallback);
|
|
122
|
+
return { baseUrl: OLLAMA_BASE_URL, model };
|
|
123
|
+
}
|
|
124
|
+
const endpoint = (await answer(`Endpoint URL: `)).trim();
|
|
125
|
+
if (!/^https?:\/\//i.test(endpoint)) {
|
|
126
|
+
await answer("Invalid URL. [Enter] ");
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const model = (await answer(`Model name: `)).trim();
|
|
130
|
+
if (model.length === 0) {
|
|
131
|
+
await answer("Model name required. [Enter] ");
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const rawEnv = (await answer(`Token env var (empty for none): `)).trim();
|
|
135
|
+
if (rawEnv.length > 0 && !isValidEnvName(rawEnv)) {
|
|
136
|
+
await answer("Invalid env var name. [Enter] ");
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return { baseUrl: endpoint, model, ...(rawEnv ? { apiKeyEnv: rawEnv } : {}) };
|
|
140
|
+
}
|
|
141
|
+
async function configureCapability(fs, aiwPath, answer, runtime, options) {
|
|
142
|
+
const wants = await askYesNo(answer, `Configure ${options.label}? [y/N] `);
|
|
143
|
+
if (!wants)
|
|
144
|
+
return [];
|
|
145
|
+
for (;;) {
|
|
146
|
+
const setup = await gatherCapabilitySetup(answer, runtime, options);
|
|
147
|
+
if (!setup)
|
|
148
|
+
return [];
|
|
149
|
+
const valid = options.section === "semantic"
|
|
150
|
+
? await runtime.validateEmbedding(setup)
|
|
151
|
+
: await runtime.validateDecision(setup);
|
|
152
|
+
if (valid) {
|
|
153
|
+
saveSettingsSection(fs, aiwPath, options.section, options.serialize(setup));
|
|
154
|
+
return [
|
|
155
|
+
`${options.section} (${setup.model}${setup.apiKeyEnv ? ` via ${setup.baseUrl}` : ""})`,
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
const retry = await askYesNo(answer, `Validation failed for ${setup.model}. Retry? [y/N] (No = skip for now) `);
|
|
159
|
+
if (!retry)
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export async function configureOptionalModels(fs, aiwPath, answer, runtime = defaultOnboardingRuntime) {
|
|
164
|
+
const enabled = [];
|
|
165
|
+
const settings = loadSettings(fs, aiwPath) ?? {};
|
|
166
|
+
if (!("semantic" in settings))
|
|
167
|
+
enabled.push(...(await configureCapability(fs, aiwPath, answer, runtime, {
|
|
168
|
+
section: "semantic",
|
|
169
|
+
label: "semantic embedding",
|
|
170
|
+
shortLabel: "embedding",
|
|
171
|
+
onlyEmbedding: true,
|
|
172
|
+
fallbackModel: DEFAULT_SEMANTIC_CONFIG.model,
|
|
173
|
+
pullModel: DEFAULT_SEMANTIC_CONFIG.model,
|
|
174
|
+
serialize: (setup) => ({
|
|
175
|
+
enabled: true,
|
|
176
|
+
model: setup.model,
|
|
177
|
+
baseUrl: setup.baseUrl,
|
|
178
|
+
chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
|
|
179
|
+
topK: DEFAULT_SEMANTIC_CONFIG.topK,
|
|
180
|
+
excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
|
|
181
|
+
...(setup.apiKeyEnv ? { apiKeyEnv: setup.apiKeyEnv } : {}),
|
|
182
|
+
}),
|
|
183
|
+
})));
|
|
184
|
+
if (!("decision" in settings))
|
|
185
|
+
enabled.push(...(await configureCapability(fs, aiwPath, answer, runtime, {
|
|
186
|
+
section: "decision",
|
|
187
|
+
label: "advisory decision triage",
|
|
188
|
+
shortLabel: "decision",
|
|
189
|
+
onlyEmbedding: false,
|
|
190
|
+
fallbackModel: DEFAULT_DECISION_CONFIG.model,
|
|
191
|
+
pullModel: DEFAULT_DECISION_CONFIG.model,
|
|
192
|
+
serialize: (setup) => ({
|
|
193
|
+
enabled: true,
|
|
194
|
+
model: setup.model,
|
|
195
|
+
baseUrl: setup.baseUrl,
|
|
196
|
+
...(setup.apiKeyEnv ? { apiKeyEnv: setup.apiKeyEnv } : {}),
|
|
197
|
+
}),
|
|
198
|
+
})));
|
|
199
|
+
return enabled;
|
|
200
|
+
}
|
package/dist/semantic.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type SemanticConfig = {
|
|
|
6
6
|
chunkChars: number;
|
|
7
7
|
topK: number;
|
|
8
8
|
excludeLockfiles: boolean;
|
|
9
|
+
apiKeyEnv?: string;
|
|
9
10
|
};
|
|
10
11
|
export type SemanticIndexChunk = {
|
|
11
12
|
file: string;
|
|
@@ -47,7 +48,7 @@ export declare function serializeSemanticConfig(config: SemanticConfig): string;
|
|
|
47
48
|
export declare function loadSemanticConfig(fs: FileSystem, aiwPath: string): SemanticConfig | undefined;
|
|
48
49
|
export declare function parseSemanticConfig(text: string): SemanticConfig;
|
|
49
50
|
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
50
|
-
export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike): EmbeddingProvider;
|
|
51
|
+
export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike, env?: Readonly<Record<string, string | undefined>>): EmbeddingProvider;
|
|
51
52
|
export declare function chunkText(content: string, chunkChars: number): string[];
|
|
52
53
|
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
53
54
|
export declare function buildSemanticIndex(files: string[], readContent: (file: string) => Promise<string | undefined>, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticIndex>;
|
package/dist/semantic.js
CHANGED
|
@@ -40,6 +40,7 @@ export function parseSemanticMapping(raw) {
|
|
|
40
40
|
const chunkChars = raw2.chunkChars;
|
|
41
41
|
const topK = raw2.topK;
|
|
42
42
|
const rawExclude = raw2.excludeLockfiles;
|
|
43
|
+
const apiKeyEnv = raw2.apiKeyEnv;
|
|
43
44
|
if (typeof enabled !== "boolean")
|
|
44
45
|
throw new Error("semantic.enabled must be a boolean");
|
|
45
46
|
const excludeLockfiles = rawExclude === undefined ? true : rawExclude;
|
|
@@ -47,6 +48,9 @@ export function parseSemanticMapping(raw) {
|
|
|
47
48
|
return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false, excludeLockfiles };
|
|
48
49
|
if (rawExclude !== undefined && typeof rawExclude !== "boolean")
|
|
49
50
|
throw new Error("semantic.excludeLockfiles must be a boolean");
|
|
51
|
+
if (apiKeyEnv !== undefined &&
|
|
52
|
+
(typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
|
|
53
|
+
throw new Error("semantic.apiKeyEnv must be an uppercase environment variable name");
|
|
50
54
|
if (typeof model !== "string" || model.trim() === "")
|
|
51
55
|
throw new Error("semantic.model must be a non-empty string when semantic mapping is enabled");
|
|
52
56
|
if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
|
|
@@ -64,10 +68,11 @@ export function parseSemanticMapping(raw) {
|
|
|
64
68
|
chunkChars: parsedChunk,
|
|
65
69
|
topK: parsedTopK,
|
|
66
70
|
excludeLockfiles,
|
|
71
|
+
...(apiKeyEnv ? { apiKeyEnv } : {}),
|
|
67
72
|
};
|
|
68
73
|
}
|
|
69
74
|
export function serializeSemanticConfig(config) {
|
|
70
|
-
return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n excludeLockfiles: ${config.excludeLockfiles}\n`;
|
|
75
|
+
return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n excludeLockfiles: ${config.excludeLockfiles}${config.apiKeyEnv ? `\n apiKeyEnv: ${config.apiKeyEnv}` : ""}\n`;
|
|
71
76
|
}
|
|
72
77
|
export function loadSemanticConfig(fs, aiwPath) {
|
|
73
78
|
const settings = loadSettings(fs, aiwPath);
|
|
@@ -89,7 +94,44 @@ export function parseSemanticConfig(text) {
|
|
|
89
94
|
return parseSemanticDocument(text);
|
|
90
95
|
}
|
|
91
96
|
const EMBED_BATCH_SIZE = 64;
|
|
92
|
-
export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init)) {
|
|
97
|
+
export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
|
|
98
|
+
const externalToken = config.apiKeyEnv ? env[config.apiKeyEnv] : undefined;
|
|
99
|
+
const isExternal = config.apiKeyEnv !== undefined;
|
|
100
|
+
const authHeaders = (extra = {}) => externalToken ? { ...extra, authorization: `Bearer ${externalToken}` } : extra;
|
|
101
|
+
if (isExternal)
|
|
102
|
+
return {
|
|
103
|
+
async available() {
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetchImpl(`${config.baseUrl}/v1/models`, {
|
|
106
|
+
headers: authHeaders(),
|
|
107
|
+
});
|
|
108
|
+
return response.ok;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
async embed(texts) {
|
|
115
|
+
const vectors = [];
|
|
116
|
+
for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
|
|
117
|
+
const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
|
|
118
|
+
const response = await fetchImpl(`${config.baseUrl}/v1/embeddings`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: authHeaders({ "content-type": "application/json" }),
|
|
121
|
+
body: JSON.stringify({ model: config.model, input: batch }),
|
|
122
|
+
});
|
|
123
|
+
if (!response.ok)
|
|
124
|
+
throw new Error(`Embedding request failed for model ${config.model}`);
|
|
125
|
+
const body = (await response.json());
|
|
126
|
+
const rows = body.data ?? [];
|
|
127
|
+
if (rows.length !== batch.length ||
|
|
128
|
+
rows.some((row) => !Array.isArray(row.embedding) || row.embedding.some((v) => typeof v !== "number")))
|
|
129
|
+
throw new Error(`Embedding model ${config.model} returned invalid vectors`);
|
|
130
|
+
vectors.push(...rows.map((row) => row.embedding));
|
|
131
|
+
}
|
|
132
|
+
return vectors;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
93
135
|
return {
|
|
94
136
|
async available() {
|
|
95
137
|
try {
|
package/dist/workflow.d.ts
CHANGED
|
@@ -2,6 +2,3 @@ import type { CommandResult, FileSystem } from "./types.js";
|
|
|
2
2
|
import type { WorkflowDependencies } from "./services.js";
|
|
3
3
|
export type WorkflowServices = WorkflowDependencies;
|
|
4
4
|
export declare function runCommand(args: string[], root: string, fs: FileSystem, services?: WorkflowServices): Promise<CommandResult>;
|
|
5
|
-
export type ConfirmQuestion = (question: string) => Promise<string>;
|
|
6
|
-
export type ModelProbe = (baseUrl: string) => Promise<string[] | undefined>;
|
|
7
|
-
export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion, listModels?: ModelProbe): Promise<string[]>;
|
package/dist/workflow.js
CHANGED
|
@@ -7,6 +7,7 @@ import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomical
|
|
|
7
7
|
import { parseProjectProfile, profileProject } from "./profile.js";
|
|
8
8
|
import { applyOverrides } from "./confirmation.js";
|
|
9
9
|
import { mergeFacts } from "./intelligence.js";
|
|
10
|
+
import { configureOptionalModels } from "./model-onboarding.js";
|
|
10
11
|
import { createInterpretationRequest, createOpenAiCompatibleProvider, createProjectInterpreter, buildInterpreterQuery, loadAiProviderConfig, selectInterpreterKeyFiles, } from "./interpreter.js";
|
|
11
12
|
import { recommendCapabilities, rankRecommendations, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
|
|
12
13
|
import { selectRecommendations } from "./selection.js";
|
|
@@ -20,9 +21,9 @@ import { validatePackageContract } from "./package-contract.js";
|
|
|
20
21
|
import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
|
|
21
22
|
import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
|
|
22
23
|
import { scanProject } from "./scanner.js";
|
|
23
|
-
import { readFactOverrides, saveSettingOverrides, saveSettingsSection
|
|
24
|
-
import { buildEvidenceTriageRequest, buildRecommendationRankingRequest, buildSemanticRerankRequest, createOllamaDecisionProvider,
|
|
25
|
-
import { buildSemanticIndex, createOllamaEmbeddingProvider,
|
|
24
|
+
import { readFactOverrides, saveSettingOverrides, saveSettingsSection } from "./settings.js";
|
|
25
|
+
import { buildEvidenceTriageRequest, buildRecommendationRankingRequest, buildSemanticRerankRequest, createOllamaDecisionProvider, loadDecisionConfig, } from "./decision.js";
|
|
26
|
+
import { buildSemanticIndex, createOllamaEmbeddingProvider, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, rerankSemanticMatches, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
|
|
26
27
|
import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
|
|
27
28
|
import { planPackageUpdate } from "./package-updates.js";
|
|
28
29
|
import { searchRegistry, serializeRegistry } from "./registry.js";
|
|
@@ -442,11 +443,17 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
442
443
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
443
444
|
const profile = await (services.profiler?.profile(root) ?? profileProject(root));
|
|
444
445
|
let interpreter = services.interpreter;
|
|
446
|
+
let interpreterModel;
|
|
445
447
|
if (!interpreter) {
|
|
446
448
|
const aiConfig = loadAiProviderConfig(fs, aiw);
|
|
447
|
-
if (aiConfig?.enabled)
|
|
449
|
+
if (aiConfig?.enabled) {
|
|
448
450
|
interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
|
|
451
|
+
interpreterModel = aiConfig.model;
|
|
452
|
+
}
|
|
449
453
|
}
|
|
454
|
+
else
|
|
455
|
+
interpreterModel = "host-injected";
|
|
456
|
+
const modelUsage = [];
|
|
450
457
|
if (interpreter) {
|
|
451
458
|
const scan = await (services.scanner?.scan(root) ?? scanProject(root));
|
|
452
459
|
let scopedFiles = scan.files;
|
|
@@ -466,6 +473,11 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
466
473
|
];
|
|
467
474
|
const allowed = new Set(priorityFiles);
|
|
468
475
|
scopedFiles = scan.files.filter((file) => allowed.has(file));
|
|
476
|
+
modelUsage.push({
|
|
477
|
+
label: "embedding",
|
|
478
|
+
tokens: Math.ceil(buildInterpreterQuery(profile).length / 4),
|
|
479
|
+
detail: `${contextConfig.model} — scoped interpreter context to ${matches.length} semantic matches`,
|
|
480
|
+
});
|
|
469
481
|
}
|
|
470
482
|
}
|
|
471
483
|
}
|
|
@@ -473,8 +485,14 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
473
485
|
// An unreadable index keeps the scan deterministic with alphabetical scoping.
|
|
474
486
|
}
|
|
475
487
|
}
|
|
476
|
-
const
|
|
488
|
+
const interpretationRequest = createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles);
|
|
489
|
+
const inferred = await interpreter.interpret(interpretationRequest);
|
|
477
490
|
profile.facts = mergeFacts(profile.facts, inferred);
|
|
491
|
+
modelUsage.push({
|
|
492
|
+
label: "interpreter",
|
|
493
|
+
tokens: interpretationRequest.estimatedTokens,
|
|
494
|
+
detail: `${interpreterModel} — ${inferred.length} facts from ${interpretationRequest.files.length} files`,
|
|
495
|
+
});
|
|
478
496
|
}
|
|
479
497
|
const overrides = readFactOverrides(fs, aiw);
|
|
480
498
|
const rejectedKeys = new Set(overrides
|
|
@@ -507,8 +525,34 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
507
525
|
}, provider, semanticConfig);
|
|
508
526
|
fs.mkdir(join(aiw, "semantic"));
|
|
509
527
|
fs.write(join(aiw, SEMANTIC_INDEX_PATH), serializeSemanticIndex(index));
|
|
528
|
+
const indexedChars = index.chunks.reduce((total, chunk) => total + chunk.text.length, 0);
|
|
529
|
+
modelUsage.push({
|
|
530
|
+
label: "embedding",
|
|
531
|
+
tokens: Math.ceil(indexedChars / 4),
|
|
532
|
+
detail: `${semanticConfig.model} — indexed ${index.chunks.length} chunks in ${Math.ceil(index.chunks.length / 64)} batch requests`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (modelUsage.length > 0) {
|
|
536
|
+
const usagePath = join(aiw, "usage.yml");
|
|
537
|
+
const entries = fs.exists(usagePath) ? parseTokenUsage(fs.read(usagePath)) : [];
|
|
538
|
+
let next = entries;
|
|
539
|
+
for (const usage of modelUsage)
|
|
540
|
+
next = recordTokenUsage(next, {
|
|
541
|
+
stage: `scan.${usage.label}`,
|
|
542
|
+
budget: usage.tokens,
|
|
543
|
+
used: usage.tokens,
|
|
544
|
+
});
|
|
545
|
+
fs.write(usagePath, serializeTokenUsage(next));
|
|
510
546
|
}
|
|
511
|
-
return {
|
|
547
|
+
return {
|
|
548
|
+
exitCode: 0,
|
|
549
|
+
output: "Project profile generated." +
|
|
550
|
+
(modelUsage.length > 0
|
|
551
|
+
? `\nModels used:\n${modelUsage
|
|
552
|
+
.map((usage) => `- ${usage.label}: ${usage.detail} (~${usage.tokens.toLocaleString("en-US")} tokens)`)
|
|
553
|
+
.join("\n")}`
|
|
554
|
+
: "\nModels used: none (deterministic scan)."),
|
|
555
|
+
};
|
|
512
556
|
}
|
|
513
557
|
if (command === "decision") {
|
|
514
558
|
const usage = "Usage: aiw decision triage --artifact=<path> | aiw decision status";
|
|
@@ -1899,85 +1943,6 @@ function approvalArtifactPath(aiw, stage) {
|
|
|
1899
1943
|
};
|
|
1900
1944
|
return resolve(aiw, paths[stage]);
|
|
1901
1945
|
}
|
|
1902
|
-
async function defaultModelProbe(baseUrl) {
|
|
1903
|
-
try {
|
|
1904
|
-
const response = await fetch(`${baseUrl}/api/tags`);
|
|
1905
|
-
if (!response.ok)
|
|
1906
|
-
return undefined;
|
|
1907
|
-
const body = (await response.json());
|
|
1908
|
-
const names = [
|
|
1909
|
-
...new Set((body.models ?? [])
|
|
1910
|
-
.map((entry) => entry.name)
|
|
1911
|
-
.filter((name) => typeof name === "string")),
|
|
1912
|
-
];
|
|
1913
|
-
return names.length > 0 ? names.sort() : undefined;
|
|
1914
|
-
}
|
|
1915
|
-
catch {
|
|
1916
|
-
return undefined;
|
|
1917
|
-
}
|
|
1918
|
-
}
|
|
1919
|
-
function defaultModelFor(available, fallback) {
|
|
1920
|
-
if (!available || available.length === 0)
|
|
1921
|
-
return fallback;
|
|
1922
|
-
return available.includes(fallback) ? fallback : available[0];
|
|
1923
|
-
}
|
|
1924
|
-
export async function configureOptionalModels(fs, aiwPath, answer, listModels = defaultModelProbe) {
|
|
1925
|
-
const enabled = [];
|
|
1926
|
-
const settings = loadSettings(fs, aiwPath) ?? {};
|
|
1927
|
-
let availableModels;
|
|
1928
|
-
if (!("semantic" in settings) || !("decision" in settings))
|
|
1929
|
-
availableModels = await listModels(DEFAULT_SEMANTIC_CONFIG.baseUrl);
|
|
1930
|
-
if (!("semantic" in settings)) {
|
|
1931
|
-
const defaultEmbedding = defaultModelFor(availableModels, DEFAULT_SEMANTIC_CONFIG.model);
|
|
1932
|
-
const catalog = availableModels && availableModels.length > 0
|
|
1933
|
-
? ` Available embedding models: ${availableModels.join(", ")}.`
|
|
1934
|
-
: "";
|
|
1935
|
-
const response = (await answer(`Enable semantic mapping with a local embedding model (default: ${defaultEmbedding} at ${DEFAULT_SEMANTIC_CONFIG.baseUrl})?${catalog} [y/N] `))
|
|
1936
|
-
.trim()
|
|
1937
|
-
.toLowerCase();
|
|
1938
|
-
if (response === "y" || response === "yes") {
|
|
1939
|
-
let model = defaultEmbedding;
|
|
1940
|
-
if (availableModels && availableModels.length > 0) {
|
|
1941
|
-
const selection = (await answer(`Use which embedding model? [${defaultEmbedding}] `)).trim();
|
|
1942
|
-
if (selection.length > 0 && availableModels.includes(selection))
|
|
1943
|
-
model = selection;
|
|
1944
|
-
}
|
|
1945
|
-
saveSettingsSection(fs, aiwPath, "semantic", {
|
|
1946
|
-
enabled: true,
|
|
1947
|
-
model,
|
|
1948
|
-
baseUrl: DEFAULT_SEMANTIC_CONFIG.baseUrl,
|
|
1949
|
-
chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
|
|
1950
|
-
topK: DEFAULT_SEMANTIC_CONFIG.topK,
|
|
1951
|
-
excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
|
|
1952
|
-
});
|
|
1953
|
-
enabled.push(`semantic (${model})`);
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
if (!("decision" in settings)) {
|
|
1957
|
-
const defaultDecision = defaultModelFor(availableModels, DEFAULT_DECISION_CONFIG.model);
|
|
1958
|
-
const catalog = availableModels && availableModels.length > 0
|
|
1959
|
-
? ` Available decision models: ${availableModels.join(", ")}.`
|
|
1960
|
-
: "";
|
|
1961
|
-
const response = (await answer(`Enable advisory decision triage with a local decision model (default: ${defaultDecision} at ${DEFAULT_DECISION_CONFIG.baseUrl})?${catalog} [y/N] `))
|
|
1962
|
-
.trim()
|
|
1963
|
-
.toLowerCase();
|
|
1964
|
-
if (response === "y" || response === "yes") {
|
|
1965
|
-
let model = defaultDecision;
|
|
1966
|
-
if (availableModels && availableModels.length > 0) {
|
|
1967
|
-
const selection = (await answer(`Use which decision model? [${defaultDecision}] `)).trim();
|
|
1968
|
-
if (selection.length > 0 && availableModels.includes(selection))
|
|
1969
|
-
model = selection;
|
|
1970
|
-
}
|
|
1971
|
-
saveSettingsSection(fs, aiwPath, "decision", {
|
|
1972
|
-
enabled: true,
|
|
1973
|
-
model,
|
|
1974
|
-
baseUrl: DEFAULT_DECISION_CONFIG.baseUrl,
|
|
1975
|
-
});
|
|
1976
|
-
enabled.push(`decision (${model})`);
|
|
1977
|
-
}
|
|
1978
|
-
}
|
|
1979
|
-
return enabled;
|
|
1980
|
-
}
|
|
1981
1946
|
function formatConfidence(confidence) {
|
|
1982
1947
|
return confidence === undefined ? "unknown" : confidence.toFixed(3);
|
|
1983
1948
|
}
|
package/docs/README.md
CHANGED
|
@@ -13,6 +13,11 @@ AI Workflow is a portable, spec-driven development system for AI coding agents.
|
|
|
13
13
|
- [Quality and testing](quality.md)
|
|
14
14
|
- [Token efficiency](token-efficiency.md)
|
|
15
15
|
- [Project intelligence](project-intelligence.md)
|
|
16
|
+
- [Semantic mapping](semantic-mapping.md)
|
|
17
|
+
- [Decision model evaluation](decision-model-evaluation.md)
|
|
18
|
+
- [Telemetry and privacy](telemetry.md)
|
|
19
|
+
- [Multi-agent orchestration](orchestration.md)
|
|
20
|
+
- [Plugin authoring](plugin-authoring.md)
|
|
16
21
|
- [Release process](release-process.md)
|
|
17
22
|
- [Release backlog](release-backlog.md)
|
|
18
23
|
- [Backlog](backlog.md)
|
|
@@ -27,7 +27,7 @@ Clef Flash answers typed questions over a supplied state in a single non-autoreg
|
|
|
27
27
|
|
|
28
28
|
The earlier blocker (no exposed endpoint in Ollama 0.40.1) is resolved by the `/v1/systemone` endpoint documented at <https://ollama.com/library/clef-flash>. AI Workflow now ships the advisory layer:
|
|
29
29
|
|
|
30
|
-
- `src/decision.ts`: `DecisionConfig` (`.aiw/decision.yml`, flag-gated: `enabled` / `model` / `baseUrl`), a `DecisionProvider` with availability verification (`/api/tags`), typed question/response contracts, and `buildEvidenceTriageRequest` for pre-gate triage.
|
|
30
|
+
- `src/decision.ts`: `DecisionConfig` (`.aiw/decision.yml`, flag-gated: `enabled` / `model` / `baseUrl` / optional `apiKeyEnv` for an external OpenAI-compatible endpoint with bearer authentication), a `DecisionProvider` with availability verification (`/api/tags` for Ollama, `/v1/models` with auth for external), typed question/response contracts, and `buildEvidenceTriageRequest` for pre-gate triage.
|
|
31
31
|
- `aiw decision status` and `aiw decision triage --artifact=<path>`: advisory `evidence_complete` (noul) and `gate` (`ready` / `needs-work`) decisions over any project artifact, with explicit confidence reporting.
|
|
32
32
|
|
|
33
33
|
Observed behavior on real plans: an evidence-free plan scored `gate: ready` with confidence 0.044 and `evidence_complete` 0.260, while a fully evidenced plan scored confidence 0.718 and `evidence_complete` 0.924 — low confidence signals an inconclusive decision rather than approval.
|
package/docs/release-process.md
CHANGED
|
@@ -35,4 +35,4 @@ For the first publication only, create a short-lived granular npm token scoped t
|
|
|
35
35
|
|
|
36
36
|
After `0.1.0` exists, configure organization `MultiLeaf`, repository `aiw`, workflow `release.yml`, and environment `npm` as a trusted publisher in the package settings. Publish a subsequent release through OIDC and verify its provenance on npm before deleting the GitHub bootstrap secret and revoking the granular token. A saved publisher configuration alone is insufficient evidence because npm validates it only during publication.
|
|
37
37
|
|
|
38
|
-
The workflow uses Node 24 and npm 11.5.1 or newer, strictly validates Semantic Versioning, checks that the tag is `v<package-version>`, requires the approved license, and verifies ancestry from `origin/main`. Publish each tag only once; never reuse, move, or overwrite a published version.
|
|
38
|
+
The workflow uses Node 24 and npm 11.5.1 or newer, strictly validates Semantic Versioning, checks that the tag is `v<package-version>`, requires the approved license, and verifies ancestry from `origin/main`. After publishing, a `github-release` job creates the GitHub Release for the tag from the matching `CHANGELOG.md` section, so the signed Git tag, GitHub release, and npm package always agree on the immutable version. Publish each tag only once; never reuse, move, or overwrite a published version.
|
package/docs/semantic-mapping.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Semantic Mapping
|
|
2
2
|
|
|
3
|
-
AI Workflow can build a semantic index of the scanned project with a local embedding model such as `embeddinggemma-2` served by Ollama. Semantic mapping is opt-in and flag-gated: it activates only when a valid configuration exists, and every scan and flow query re-reads the flag. Configuration lives in the local user settings file `.aiw/settings.yml` under the `semantic` section; the legacy `.aiw/semantic.yml` file is still read when `settings.yml` does not exist.
|
|
3
|
+
AI Workflow can build a semantic index of the scanned project with a local embedding model such as `embeddinggemma-2` served by Ollama. Semantic mapping is opt-in and flag-gated: it activates only when a valid configuration exists, and every scan and flow query re-reads the flag. Configuration lives in the local user settings file `.aiw/settings.yml` under the `semantic` section; the legacy `.aiw/semantic.yml` file is still read when `settings.yml` does not exist.
|
|
4
|
+
|
|
5
|
+
The provider can be Ollama (default, `http://localhost:11434`) or any OpenAI-compatible embeddings endpoint: set `apiKeyEnv` to the name of the environment variable holding the bearer token, and requests go to `<baseUrl>/v1/embeddings` with `Authorization: Bearer <token>` instead of Ollama's `/api/embed`. The token is read from the environment at call time and never stored in settings.
|
|
6
|
+
|
|
7
|
+
During an interactive `aiw install`, the installer walks through provider selection: it probes the configured Ollama endpoint (`GET /api/tags`), offers Ollama or an external provider (external only, when Ollama is unreachable), lists the locally available embedding models — filtering to embedding-type models — and asks permission to run `ollama pull embeddinggemma-2` when none exist. External configuration asks for the endpoint URL, model name, and an optional token environment variable. The chosen setup is validated with a live request before being recorded in `.aiw/settings.yml`; a failed validation can be retried or skipped, and the next capability (decision triage) follows the same flow. Answering `n`, or installing non-interactively, leaves the section absent.
|
|
4
8
|
|
|
5
9
|
## Configuration
|
|
6
10
|
|
|
@@ -14,6 +18,7 @@ semantic:
|
|
|
14
18
|
chunkChars: 1200 # optional, minimum 200
|
|
15
19
|
topK: 8 # optional, results per query
|
|
16
20
|
excludeLockfiles: true # optional, skip lockfiles and generated vendor manifests (default: true)
|
|
21
|
+
apiKeyEnv: AIW_EMBED_KEY # optional, external OpenAI-compatible endpoint bearer token
|
|
17
22
|
```
|
|
18
23
|
|
|
19
24
|
- `enabled: false` (or a missing file) disables semantic mapping entirely; scans stay deterministic and make no network request.
|
|
@@ -30,6 +35,19 @@ When semantic mapping is enabled, `aiw scan` additionally:
|
|
|
30
35
|
|
|
31
36
|
The index stores the model name; queries against an index built with a different model fail explicitly.
|
|
32
37
|
|
|
38
|
+
## Usage visibility
|
|
39
|
+
|
|
40
|
+
Every `aiw scan` reports the models it actually used, for example:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
Project profile generated.
|
|
44
|
+
Models used:
|
|
45
|
+
- embedding: embeddinggemma-2:latest — indexed 824 chunks in 13 batch requests (~45,000 tokens)
|
|
46
|
+
- interpreter: gpt-oss:20b — 5 facts from 12 files (~3,800 tokens)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Token estimates are also recorded per stage in `.aiw/usage.yml` (`scan.embedding`, `scan.interpreter`; the latest scan overwrites its stage entry) and can be inspected with `aiw token-usage`. Scans that use no models report `Models used: none (deterministic scan).`
|
|
50
|
+
|
|
33
51
|
## Queries during the flow
|
|
34
52
|
|
|
35
53
|
`aiw semantic query --text="<natural language>" [--rerank]` ranks indexed chunks by cosine similarity and returns the top `topK` matches with file, score, and excerpt. With `--rerank`, and only when a decision model is also enabled and available, the decision model additionally scores each retrieved candidate 0–1 for query relevance and the output is reordered by that advisory score; matches without a decision score keep their cosine order. The command reports the ranking influence (how many positions were reordered) and the per-match `rerank` score; cosine recall itself is unchanged and reranking never creates confirmed facts. `aiw semantic status` reports whether mapping is configured and whether the index is built.
|