@multileaf/ai-workflow 0.4.1 → 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 +29 -0
- package/README.md +20 -3
- package/dist/decision.d.ts +15 -1
- package/dist/decision.js +43 -6
- package/dist/interpreter.d.ts +17 -1
- package/dist/interpreter.js +116 -6
- package/dist/model-onboarding.d.ts +22 -0
- package/dist/model-onboarding.js +200 -0
- package/dist/recommendations.d.ts +16 -1
- package/dist/recommendations.js +33 -2
- package/dist/semantic.d.ts +19 -1
- package/dist/semantic.js +111 -11
- package/dist/services.d.ts +6 -1
- package/dist/telemetry.d.ts +8 -0
- package/dist/telemetry.js +62 -2
- package/dist/workflow.d.ts +0 -2
- package/dist/workflow.js +183 -59
- package/docs/README.md +5 -0
- package/docs/backlog.md +23 -0
- package/docs/decision-model-evaluation.md +2 -2
- package/docs/project-intelligence.md +13 -1
- package/docs/release-process.md +1 -1
- package/docs/semantic-mapping.md +33 -4
- package/docs/telemetry.md +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ 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.
|
|
20
|
+
## [0.5.0] - 2026-10-08
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Batched semantic indexing: embedding requests now use the Ollama batch endpoint (`POST /api/embed`) with up to 64 chunks per request instead of one request per chunk, and lockfiles and generated vendor manifests (`package-lock.json`, `yarn.lock`, `go.sum`, and others) are excluded from the index by default (`semantic.excludeLockfiles` overrides).
|
|
25
|
+
- Optional decision-model re-ranking for `aiw semantic query --rerank`: when a decision model is enabled, each retrieved candidate is scored 0-1 for query relevance and the output is reordered with a reported ranking influence; cosine recall without a decision model is unchanged.
|
|
26
|
+
- Semantic interpreter scoping during `aiw scan`: when semantic mapping is enabled and an index exists, the AI interpreter receives deterministic key files plus the top semantically relevant files for a profile-derived query instead of the alphabetically ordered file list, reducing token usage.
|
|
27
|
+
- Built-in OpenAI-compatible `AiProvider` configured through an `ai` section in `.aiw/settings.yml` (for example Ollama `gpt-oss:20b`), so the scoped interpreter works without a host-injected provider; secrets redaction, token ceilings, and scoped-context validation apply unchanged, and missing configuration keeps scans deterministic and offline.
|
|
28
|
+
- Advisory decision-model ranking for `aiw recommend`: when a decision model is enabled, recommendations are ordered by relevance to the confirmed profile and serialized with `decision_relevance` / `decision_confidence`; evidence states never change.
|
|
29
|
+
- Telemetry consent during interactive `aiw install`, asked after the optional model questions with an explicit default of No, stating the exact event payload (command name and outcome only); the decision is recorded in `.aiw/settings.yml` and reversible through `aiw telemetry enable/disable`. Non-interactive installs skip the prompt.
|
|
30
|
+
- Local telemetry buffer: when telemetry is enabled and no host client is present, events accumulate in a rotated `.aiw/telemetry-buffer.jsonl` (retention cap of 200 events); `aiw telemetry status --verbose` displays buffered events and `aiw telemetry disable` purges the buffer. No network transport was added.
|
|
31
|
+
- Local model detection during interactive install: the installer probes the configured Ollama endpoint (`/api/tags`), lists the embedding and decision models actually available, and records the user's selection in `.aiw/settings.yml`; when no server or models are found it continues with defaults.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- GitHub Actions now use Node 24-native `actions/checkout@v5` and `actions/setup-node@v5`, and CI runs on Node 24.
|
|
7
36
|
## [0.4.1] - 2026-10-08
|
|
8
37
|
|
|
9
38
|
### Fixed
|
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,5 +48,18 @@ 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;
|
|
53
|
+
export type RecommendationRankingCandidate = {
|
|
54
|
+
id: string;
|
|
55
|
+
rationale: string;
|
|
56
|
+
evidence: string[];
|
|
57
|
+
};
|
|
58
|
+
export declare function buildRecommendationRankingRequest(profileSummary: string, candidates: RecommendationRankingCandidate[]): DecisionRequest;
|
|
59
|
+
export type SemanticRerankCandidate = {
|
|
60
|
+
id: string;
|
|
61
|
+
text: string;
|
|
62
|
+
};
|
|
63
|
+
export declare function buildSemanticRerankRequest(query: string, candidates: SemanticRerankCandidate[]): DecisionRequest;
|
|
51
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,
|
|
@@ -96,6 +108,31 @@ export function createOllamaDecisionProvider(config) {
|
|
|
96
108
|
},
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
export function buildRecommendationRankingRequest(profileSummary, candidates) {
|
|
112
|
+
const questions = {};
|
|
113
|
+
for (const candidate of candidates) {
|
|
114
|
+
questions[candidate.id] = {
|
|
115
|
+
type: "score",
|
|
116
|
+
instructions: `Confirmed project profile: ${profileSummary}.\nRate from 0 to 1 how relevant this capability recommendation is to that profile.\n\nRecommendation ${candidate.id}: ${candidate.rationale}\nEvidence: ${candidate.evidence.join(", ")}`,
|
|
117
|
+
criteria: [
|
|
118
|
+
"0 = irrelevant to the confirmed profile",
|
|
119
|
+
"1 = highly relevant to the confirmed profile",
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { state: `Capability recommendation ranking for: ${profileSummary}`, questions };
|
|
124
|
+
}
|
|
125
|
+
export function buildSemanticRerankRequest(query, candidates) {
|
|
126
|
+
const questions = {};
|
|
127
|
+
for (const candidate of candidates) {
|
|
128
|
+
questions[candidate.id] = {
|
|
129
|
+
type: "score",
|
|
130
|
+
instructions: `Rate from 0 to 1 how well this retrieval candidate answers the user query "${query}".\n\nCandidate:\n${candidate.text}`,
|
|
131
|
+
criteria: ["0 = irrelevant to the query", "1 = directly and completely answers the query"],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { state: `Semantic retrieval rerank for query: ${query}`, questions };
|
|
135
|
+
}
|
|
99
136
|
export function buildEvidenceTriageRequest(artifact) {
|
|
100
137
|
return {
|
|
101
138
|
state: artifact,
|
package/dist/interpreter.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EvidenceFact } from "./evidence.js";
|
|
2
2
|
import type { ProjectProfile } from "./profile.js";
|
|
3
|
+
import type { FileSystem } from "./types.js";
|
|
3
4
|
export type InterpretationRequest = {
|
|
4
5
|
profile: ProjectProfile;
|
|
5
6
|
files: string[];
|
|
@@ -19,5 +20,20 @@ export type AiProvider = {
|
|
|
19
20
|
generate(request: AiProviderRequest): Promise<string>;
|
|
20
21
|
};
|
|
21
22
|
export type ContextReader = (absolutePath: string) => Promise<string>;
|
|
22
|
-
export
|
|
23
|
+
export type AiProviderConfig = {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
model: string;
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
apiKeyEnv?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare const DEFAULT_AI_PROVIDER_CONFIG: AiProviderConfig;
|
|
30
|
+
export declare function parseAiProviderMapping(raw: unknown): AiProviderConfig;
|
|
31
|
+
export declare function createInterpretationRequest(profile: ProjectProfile, files: string[], maxTokens?: number, priorityFiles?: string[]): InterpretationRequest;
|
|
32
|
+
export declare const INTERPRETER_KEY_FILE_NAMES: string[];
|
|
33
|
+
export declare function selectInterpreterKeyFiles(files: string[]): string[];
|
|
34
|
+
export declare function buildInterpreterQuery(profile: ProjectProfile): string;
|
|
35
|
+
type AiFetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
36
|
+
export declare function loadAiProviderConfig(fs: FileSystem, aiwPath: string): AiProviderConfig | undefined;
|
|
37
|
+
export declare function createOpenAiCompatibleProvider(config: AiProviderConfig, fetchImpl?: AiFetchLike, env?: Readonly<Record<string, string | undefined>>): AiProvider;
|
|
23
38
|
export declare function createProjectInterpreter(root: string, provider: AiProvider, contextReader?: ContextReader): Interpreter;
|
|
39
|
+
export {};
|
package/dist/interpreter.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
import { lstat, readFile } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { loadSettings } from "./settings.js";
|
|
4
|
+
export const DEFAULT_AI_PROVIDER_CONFIG = {
|
|
5
|
+
enabled: false,
|
|
6
|
+
model: "gpt-oss:20b",
|
|
7
|
+
baseUrl: "http://localhost:11434",
|
|
8
|
+
};
|
|
9
|
+
export function parseAiProviderMapping(raw) {
|
|
10
|
+
if (!raw || typeof raw !== "object")
|
|
11
|
+
throw new Error("ai must be a YAML mapping");
|
|
12
|
+
const record = raw;
|
|
13
|
+
const enabled = record.enabled;
|
|
14
|
+
const model = record.model;
|
|
15
|
+
const baseUrl = record.baseUrl;
|
|
16
|
+
const apiKeyEnv = record.apiKeyEnv;
|
|
17
|
+
if (typeof enabled !== "boolean")
|
|
18
|
+
throw new Error("ai.enabled must be a boolean");
|
|
19
|
+
if (!enabled)
|
|
20
|
+
return { ...DEFAULT_AI_PROVIDER_CONFIG, enabled: false };
|
|
21
|
+
if (typeof model !== "string" || model.trim() === "")
|
|
22
|
+
throw new Error("ai.model must be a non-empty string when the AI provider is enabled");
|
|
23
|
+
if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
|
|
24
|
+
throw new Error("ai.baseUrl must be an http(s) URL when the AI provider is enabled");
|
|
25
|
+
if (apiKeyEnv !== undefined &&
|
|
26
|
+
(typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
|
|
27
|
+
throw new Error("ai.apiKeyEnv must be an uppercase environment variable name");
|
|
28
|
+
return { enabled: true, model, baseUrl, ...(apiKeyEnv ? { apiKeyEnv } : {}) };
|
|
29
|
+
}
|
|
3
30
|
const EXCLUDED_SEGMENT = /(^|\/)(\.git|node_modules|vendor|\.venv|venv|dist|build|coverage|out|target|\.next|\.nuxt|\.output)(\/|$)/i;
|
|
4
31
|
const EXCLUDED_AIW = /(^|\/)\.aiw\/(generated|checkpoints)(\/|$)/i;
|
|
5
32
|
const SENSITIVE = /(^|\/)\.env(?:\.[^/]*)?$|\.(pem|key|p12|pfx|secret)$/i;
|
|
@@ -58,19 +85,62 @@ function isBinary(content) {
|
|
|
58
85
|
(code >= 14 && code <= 31));
|
|
59
86
|
});
|
|
60
87
|
}
|
|
61
|
-
export function createInterpretationRequest(profile, files, maxTokens = 4000) {
|
|
88
|
+
export function createInterpretationRequest(profile, files, maxTokens = 4000, priorityFiles) {
|
|
62
89
|
if (!Number.isInteger(maxTokens) || maxTokens <= 0)
|
|
63
90
|
throw new Error("maxTokens must be a positive integer");
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
const safe = [...new Set(files.filter(safeFile).map((file) => file.replaceAll("\\", "/")))];
|
|
92
|
+
const priority = priorityFiles?.map((file) => file.replaceAll("\\", "/"));
|
|
93
|
+
const ordered = priority
|
|
94
|
+
? [
|
|
95
|
+
...new Set(priority.filter((file) => safe.includes(file))),
|
|
96
|
+
...safe.filter((file) => !priority.includes(file)).sort(),
|
|
97
|
+
]
|
|
98
|
+
: [...safe].sort();
|
|
67
99
|
return {
|
|
68
100
|
profile,
|
|
69
|
-
files:
|
|
101
|
+
files: ordered,
|
|
70
102
|
maxTokens,
|
|
71
|
-
estimatedTokens: Math.min(maxTokens, JSON.stringify(profile).length +
|
|
103
|
+
estimatedTokens: Math.min(maxTokens, JSON.stringify(profile).length + ordered.join("\n").length),
|
|
72
104
|
};
|
|
73
105
|
}
|
|
106
|
+
export const INTERPRETER_KEY_FILE_NAMES = [
|
|
107
|
+
"readme.md",
|
|
108
|
+
"contributing.md",
|
|
109
|
+
"architecture.md",
|
|
110
|
+
"package.json",
|
|
111
|
+
"pyproject.toml",
|
|
112
|
+
"requirements.txt",
|
|
113
|
+
"go.mod",
|
|
114
|
+
"cargo.toml",
|
|
115
|
+
"pom.xml",
|
|
116
|
+
"build.gradle",
|
|
117
|
+
"build.gradle.kts",
|
|
118
|
+
"composer.json",
|
|
119
|
+
"gemfile",
|
|
120
|
+
"deno.json",
|
|
121
|
+
"mix.exs",
|
|
122
|
+
];
|
|
123
|
+
export function selectInterpreterKeyFiles(files) {
|
|
124
|
+
const byBasename = new Map();
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const normalized = file.replaceAll("\\", "/");
|
|
127
|
+
const basename = normalized.split("/").pop()?.toLowerCase() ?? "";
|
|
128
|
+
if (basename && !byBasename.has(basename))
|
|
129
|
+
byBasename.set(basename, normalized);
|
|
130
|
+
}
|
|
131
|
+
return INTERPRETER_KEY_FILE_NAMES.flatMap((name) => {
|
|
132
|
+
const match = byBasename.get(name);
|
|
133
|
+
return match ? [match] : [];
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export function buildInterpreterQuery(profile) {
|
|
137
|
+
const parts = [
|
|
138
|
+
profile.runtime.languages.join(", "),
|
|
139
|
+
profile.frameworks.join(", "),
|
|
140
|
+
profile.packageManager,
|
|
141
|
+
].filter((part) => part && part !== "unknown");
|
|
142
|
+
return `Project conventions, architecture, and coding standards${parts.length > 0 ? ` for ${parts.join(" with ")}` : ""}`;
|
|
143
|
+
}
|
|
74
144
|
function parseFacts(output, allowedFiles) {
|
|
75
145
|
let value;
|
|
76
146
|
try {
|
|
@@ -109,6 +179,46 @@ function parseFacts(output, allowedFiles) {
|
|
|
109
179
|
};
|
|
110
180
|
});
|
|
111
181
|
}
|
|
182
|
+
export function loadAiProviderConfig(fs, aiwPath) {
|
|
183
|
+
const settings = loadSettings(fs, aiwPath);
|
|
184
|
+
if (settings === undefined || !("ai" in settings))
|
|
185
|
+
return undefined;
|
|
186
|
+
return parseAiProviderMapping(settings.ai);
|
|
187
|
+
}
|
|
188
|
+
export function createOpenAiCompatibleProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
|
|
189
|
+
return {
|
|
190
|
+
async generate(request) {
|
|
191
|
+
const headers = { "content-type": "application/json" };
|
|
192
|
+
if (config.apiKeyEnv) {
|
|
193
|
+
const key = env[config.apiKeyEnv];
|
|
194
|
+
if (key)
|
|
195
|
+
headers.authorization = `Bearer ${key}`;
|
|
196
|
+
}
|
|
197
|
+
const response = await fetchImpl(`${config.baseUrl}/v1/chat/completions`, {
|
|
198
|
+
method: "POST",
|
|
199
|
+
headers,
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
model: config.model,
|
|
202
|
+
messages: [
|
|
203
|
+
{ role: "system", content: request.system },
|
|
204
|
+
{ role: "user", content: request.prompt },
|
|
205
|
+
],
|
|
206
|
+
max_tokens: request.maxTokens,
|
|
207
|
+
...(request.responseFormat === "json"
|
|
208
|
+
? { response_format: { type: "json_object" } }
|
|
209
|
+
: {}),
|
|
210
|
+
}),
|
|
211
|
+
});
|
|
212
|
+
if (!response.ok)
|
|
213
|
+
throw new Error(`AI provider request failed for model ${config.model}: ${await response.text()}`);
|
|
214
|
+
const body = (await response.json());
|
|
215
|
+
const content = body.choices?.[0]?.message?.content;
|
|
216
|
+
if (typeof content !== "string")
|
|
217
|
+
throw new Error(`AI provider ${config.model} returned no message content`);
|
|
218
|
+
return content;
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
112
222
|
export function createProjectInterpreter(root, provider, contextReader = async (path) => new TextDecoder("utf-8", { fatal: true }).decode(await readFile(path))) {
|
|
113
223
|
const projectRoot = resolve(root);
|
|
114
224
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -16,5 +16,20 @@ export type CapabilityRecommendation = {
|
|
|
16
16
|
requires?: string[];
|
|
17
17
|
};
|
|
18
18
|
export declare function recommendCapabilities(profile: ProjectProfile): CapabilityRecommendation[];
|
|
19
|
+
export type RecommendationRanking = {
|
|
20
|
+
id: string;
|
|
21
|
+
relevance: number;
|
|
22
|
+
confidence?: number;
|
|
23
|
+
};
|
|
24
|
+
export type RankedRecommendations = {
|
|
25
|
+
items: CapabilityRecommendation[];
|
|
26
|
+
ranking: RecommendationRanking[];
|
|
27
|
+
changedPositions: number;
|
|
28
|
+
};
|
|
29
|
+
export type DecisionRanker = (profile: ProjectProfile, recommendations: CapabilityRecommendation[]) => Promise<Record<string, {
|
|
30
|
+
relevance?: number;
|
|
31
|
+
confidence?: number;
|
|
32
|
+
}>>;
|
|
33
|
+
export declare function rankRecommendations(items: CapabilityRecommendation[], profile: ProjectProfile, ranker: DecisionRanker): Promise<RankedRecommendations>;
|
|
19
34
|
export declare function resourcesForCapabilities(ids: string[]): RecommendedResource[];
|
|
20
|
-
export declare function serializeRecommendations(items: CapabilityRecommendation[], selected?: string[]): string;
|
|
35
|
+
export declare function serializeRecommendations(items: CapabilityRecommendation[], selected?: string[], ranking?: RecommendationRanking[]): string;
|