@multileaf/ai-workflow 0.4.1 → 0.5.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 +16 -0
- package/dist/decision.d.ts +11 -0
- package/dist/decision.js +25 -0
- package/dist/interpreter.d.ts +17 -1
- package/dist/interpreter.js +116 -6
- package/dist/recommendations.d.ts +16 -1
- package/dist/recommendations.js +33 -2
- package/dist/semantic.d.ts +18 -1
- package/dist/semantic.js +69 -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 +2 -1
- package/dist/workflow.js +190 -31
- package/docs/backlog.md +23 -0
- package/docs/decision-model-evaluation.md +1 -1
- package/docs/project-intelligence.md +13 -1
- package/docs/semantic-mapping.md +15 -4
- package/docs/telemetry.md +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. The format foll
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.5.0] - 2026-10-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 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).
|
|
12
|
+
- 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.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- GitHub Actions now use Node 24-native `actions/checkout@v5` and `actions/setup-node@v5`, and CI runs on Node 24.
|
|
7
23
|
## [0.4.1] - 2026-10-08
|
|
8
24
|
|
|
9
25
|
### Fixed
|
package/dist/decision.d.ts
CHANGED
|
@@ -48,4 +48,15 @@ export declare function parseDecisionConfig(text: string): DecisionConfig;
|
|
|
48
48
|
export declare function loadDecisionConfig(fs: FileSystem, aiwPath: string): DecisionConfig | undefined;
|
|
49
49
|
export declare function parseDecisionResponse(text: string): DecisionResponse;
|
|
50
50
|
export declare function createOllamaDecisionProvider(config: DecisionConfig): DecisionProvider;
|
|
51
|
+
export type RecommendationRankingCandidate = {
|
|
52
|
+
id: string;
|
|
53
|
+
rationale: string;
|
|
54
|
+
evidence: string[];
|
|
55
|
+
};
|
|
56
|
+
export declare function buildRecommendationRankingRequest(profileSummary: string, candidates: RecommendationRankingCandidate[]): DecisionRequest;
|
|
57
|
+
export type SemanticRerankCandidate = {
|
|
58
|
+
id: string;
|
|
59
|
+
text: string;
|
|
60
|
+
};
|
|
61
|
+
export declare function buildSemanticRerankRequest(query: string, candidates: SemanticRerankCandidate[]): DecisionRequest;
|
|
51
62
|
export declare function buildEvidenceTriageRequest(artifact: string): DecisionRequest;
|
package/dist/decision.js
CHANGED
|
@@ -96,6 +96,31 @@ export function createOllamaDecisionProvider(config) {
|
|
|
96
96
|
},
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
+
export function buildRecommendationRankingRequest(profileSummary, candidates) {
|
|
100
|
+
const questions = {};
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
questions[candidate.id] = {
|
|
103
|
+
type: "score",
|
|
104
|
+
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(", ")}`,
|
|
105
|
+
criteria: [
|
|
106
|
+
"0 = irrelevant to the confirmed profile",
|
|
107
|
+
"1 = highly relevant to the confirmed profile",
|
|
108
|
+
],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return { state: `Capability recommendation ranking for: ${profileSummary}`, questions };
|
|
112
|
+
}
|
|
113
|
+
export function buildSemanticRerankRequest(query, candidates) {
|
|
114
|
+
const questions = {};
|
|
115
|
+
for (const candidate of candidates) {
|
|
116
|
+
questions[candidate.id] = {
|
|
117
|
+
type: "score",
|
|
118
|
+
instructions: `Rate from 0 to 1 how well this retrieval candidate answers the user query "${query}".\n\nCandidate:\n${candidate.text}`,
|
|
119
|
+
criteria: ["0 = irrelevant to the query", "1 = directly and completely answers the query"],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { state: `Semantic retrieval rerank for query: ${query}`, questions };
|
|
123
|
+
}
|
|
99
124
|
export function buildEvidenceTriageRequest(artifact) {
|
|
100
125
|
return {
|
|
101
126
|
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 {
|
|
@@ -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;
|
package/dist/recommendations.js
CHANGED
|
@@ -241,6 +241,32 @@ export function recommendCapabilities(profile) {
|
|
|
241
241
|
: [];
|
|
242
242
|
});
|
|
243
243
|
}
|
|
244
|
+
export async function rankRecommendations(items, profile, ranker) {
|
|
245
|
+
if (items.length === 0)
|
|
246
|
+
return { items: [], ranking: [], changedPositions: 0 };
|
|
247
|
+
const answers = await ranker(profile, items);
|
|
248
|
+
const ranking = [];
|
|
249
|
+
const relevanceById = new Map();
|
|
250
|
+
for (const item of items) {
|
|
251
|
+
const answer = answers[item.id];
|
|
252
|
+
const relevance = answer?.relevance !== undefined && Number.isFinite(answer.relevance)
|
|
253
|
+
? answer.relevance
|
|
254
|
+
: undefined;
|
|
255
|
+
relevanceById.set(item.id, relevance);
|
|
256
|
+
if (relevance !== undefined)
|
|
257
|
+
ranking.push({
|
|
258
|
+
id: item.id,
|
|
259
|
+
relevance,
|
|
260
|
+
...(answer?.confidence !== undefined ? { confidence: answer.confidence } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const ordered = [...items].sort((a, b) => (relevanceById.get(b.id) ?? -1) - (relevanceById.get(a.id) ?? -1));
|
|
264
|
+
let changedPositions = 0;
|
|
265
|
+
for (let index = 0; index < ordered.length; index += 1)
|
|
266
|
+
if (ordered[index].id !== items[index].id)
|
|
267
|
+
changedPositions += 1;
|
|
268
|
+
return { items: ordered, ranking, changedPositions };
|
|
269
|
+
}
|
|
244
270
|
export function resourcesForCapabilities(ids) {
|
|
245
271
|
const normalizedIds = ids.map((id) => (id === "vitest-testing" ? "verification" : id));
|
|
246
272
|
const selectedCapabilities = new Set(CATALOG.filter(({ id }) => normalizedIds.includes(id)).map(({ id }) => id));
|
|
@@ -252,9 +278,14 @@ export function resourcesForCapabilities(ids) {
|
|
|
252
278
|
...new Map(resources.map((resource) => [`${resource.type}/${resource.id}`, resource])).values(),
|
|
253
279
|
];
|
|
254
280
|
}
|
|
255
|
-
export function serializeRecommendations(items, selected = []) {
|
|
281
|
+
export function serializeRecommendations(items, selected = [], ranking = []) {
|
|
282
|
+
const rankingById = new Map(ranking.map((entry) => [entry.id, entry]));
|
|
256
283
|
return `schema: 1\nrecommendations:\n${items
|
|
257
|
-
.map((item) => ` - id: ${item.id}\n provider: ${item.provider}\n confidence: ${item.confidence}
|
|
284
|
+
.map((item) => ` - id: ${item.id}\n provider: ${item.provider}\n confidence: ${item.confidence}${rankingById.has(item.id)
|
|
285
|
+
? `\n decision_relevance: ${rankingById.get(item.id)?.relevance}${rankingById.get(item.id)?.confidence !== undefined
|
|
286
|
+
? `\n decision_confidence: ${rankingById.get(item.id)?.confidence}`
|
|
287
|
+
: ""}`
|
|
288
|
+
: ""}\n selected: ${selected.includes(item.id)}\n requires: [${(item.requires ?? []).join(", ")}]\n resources: [${item.resources.map(({ type, id }) => `${type}/${id}`).join(", ")}]\n selected_resources: [${item.resources
|
|
258
289
|
.filter(({ type, id }) => selected.includes(`${type}/${id}`))
|
|
259
290
|
.map(({ type, id }) => `${type}/${id}`)
|
|
260
291
|
.join(", ")}]\n evidence: [${item.evidence.map((evidence) => `"${evidence}"`).join(", ")}]\n rationale: ${item.rationale}`)
|
package/dist/semantic.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export type SemanticConfig = {
|
|
|
5
5
|
baseUrl: string;
|
|
6
6
|
chunkChars: number;
|
|
7
7
|
topK: number;
|
|
8
|
+
excludeLockfiles: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type SemanticIndexChunk = {
|
|
10
11
|
file: string;
|
|
@@ -21,6 +22,17 @@ export type SemanticMatch = {
|
|
|
21
22
|
score: number;
|
|
22
23
|
excerpt: string;
|
|
23
24
|
};
|
|
25
|
+
export type RerankedMatch = SemanticMatch & {
|
|
26
|
+
rerankScore?: number;
|
|
27
|
+
};
|
|
28
|
+
export type RerankOutcome = {
|
|
29
|
+
matches: RerankedMatch[];
|
|
30
|
+
changedPositions: number;
|
|
31
|
+
};
|
|
32
|
+
export type DecisionReranker = (query: string, candidates: {
|
|
33
|
+
id: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}[]) => Promise<Record<string, number | undefined>>;
|
|
24
36
|
export type EmbeddingProvider = {
|
|
25
37
|
available(): Promise<boolean>;
|
|
26
38
|
embed(texts: string[]): Promise<number[][]>;
|
|
@@ -28,14 +40,19 @@ export type EmbeddingProvider = {
|
|
|
28
40
|
export declare const SEMANTIC_CONFIG_PATH = "semantic.yml";
|
|
29
41
|
export declare const SEMANTIC_INDEX_PATH = "semantic/index.json";
|
|
30
42
|
export declare const DEFAULT_SEMANTIC_CONFIG: SemanticConfig;
|
|
43
|
+
export declare const SEMANTIC_LOCKFILE_NAMES: Set<string>;
|
|
44
|
+
export declare function isExcludedIndexFile(file: string): boolean;
|
|
31
45
|
export declare function parseSemanticMapping(raw: unknown): SemanticConfig;
|
|
32
46
|
export declare function serializeSemanticConfig(config: SemanticConfig): string;
|
|
33
47
|
export declare function loadSemanticConfig(fs: FileSystem, aiwPath: string): SemanticConfig | undefined;
|
|
34
48
|
export declare function parseSemanticConfig(text: string): SemanticConfig;
|
|
35
|
-
|
|
49
|
+
type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
50
|
+
export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike): EmbeddingProvider;
|
|
36
51
|
export declare function chunkText(content: string, chunkChars: number): string[];
|
|
37
52
|
export declare function cosineSimilarity(a: number[], b: number[]): number;
|
|
38
53
|
export declare function buildSemanticIndex(files: string[], readContent: (file: string) => Promise<string | undefined>, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticIndex>;
|
|
39
54
|
export declare function serializeSemanticIndex(index: SemanticIndex): string;
|
|
40
55
|
export declare function parseSemanticIndex(text: string): SemanticIndex;
|
|
41
56
|
export declare function querySemanticIndex(index: SemanticIndex, query: string, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticMatch[]>;
|
|
57
|
+
export declare function rerankSemanticMatches(matches: SemanticMatch[], query: string, reranker: DecisionReranker): Promise<RerankOutcome>;
|
|
58
|
+
export {};
|
package/dist/semantic.js
CHANGED
|
@@ -8,7 +8,28 @@ export const DEFAULT_SEMANTIC_CONFIG = {
|
|
|
8
8
|
baseUrl: "http://localhost:11434",
|
|
9
9
|
chunkChars: 1200,
|
|
10
10
|
topK: 8,
|
|
11
|
+
excludeLockfiles: true,
|
|
11
12
|
};
|
|
13
|
+
export const SEMANTIC_LOCKFILE_NAMES = new Set([
|
|
14
|
+
"bun.lock",
|
|
15
|
+
"bun.lockb",
|
|
16
|
+
"cargo.lock",
|
|
17
|
+
"composer.lock",
|
|
18
|
+
"deno.lock",
|
|
19
|
+
"flake.lock",
|
|
20
|
+
"gemfile.lock",
|
|
21
|
+
"go.sum",
|
|
22
|
+
"npm-shrinkwrap.json",
|
|
23
|
+
"package-lock.json",
|
|
24
|
+
"pipfile.lock",
|
|
25
|
+
"pnpm-lock.yaml",
|
|
26
|
+
"poetry.lock",
|
|
27
|
+
"uv.lock",
|
|
28
|
+
"yarn.lock",
|
|
29
|
+
]);
|
|
30
|
+
export function isExcludedIndexFile(file) {
|
|
31
|
+
return SEMANTIC_LOCKFILE_NAMES.has(file.split("/").pop()?.toLowerCase() ?? "");
|
|
32
|
+
}
|
|
12
33
|
export function parseSemanticMapping(raw) {
|
|
13
34
|
if (!raw || typeof raw !== "object")
|
|
14
35
|
throw new Error("semantic must be a YAML mapping");
|
|
@@ -18,10 +39,14 @@ export function parseSemanticMapping(raw) {
|
|
|
18
39
|
const baseUrl = raw2.baseUrl;
|
|
19
40
|
const chunkChars = raw2.chunkChars;
|
|
20
41
|
const topK = raw2.topK;
|
|
42
|
+
const rawExclude = raw2.excludeLockfiles;
|
|
21
43
|
if (typeof enabled !== "boolean")
|
|
22
44
|
throw new Error("semantic.enabled must be a boolean");
|
|
45
|
+
const excludeLockfiles = rawExclude === undefined ? true : rawExclude;
|
|
23
46
|
if (!enabled)
|
|
24
|
-
return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false };
|
|
47
|
+
return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false, excludeLockfiles };
|
|
48
|
+
if (rawExclude !== undefined && typeof rawExclude !== "boolean")
|
|
49
|
+
throw new Error("semantic.excludeLockfiles must be a boolean");
|
|
25
50
|
if (typeof model !== "string" || model.trim() === "")
|
|
26
51
|
throw new Error("semantic.model must be a non-empty string when semantic mapping is enabled");
|
|
27
52
|
if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
|
|
@@ -32,10 +57,17 @@ export function parseSemanticMapping(raw) {
|
|
|
32
57
|
throw new Error("semantic.chunkChars must be an integer of at least 200");
|
|
33
58
|
if (typeof parsedTopK !== "number" || !Number.isInteger(parsedTopK) || parsedTopK < 1)
|
|
34
59
|
throw new Error("semantic.topK must be a positive integer");
|
|
35
|
-
return {
|
|
60
|
+
return {
|
|
61
|
+
enabled: true,
|
|
62
|
+
model,
|
|
63
|
+
baseUrl,
|
|
64
|
+
chunkChars: parsedChunk,
|
|
65
|
+
topK: parsedTopK,
|
|
66
|
+
excludeLockfiles,
|
|
67
|
+
};
|
|
36
68
|
}
|
|
37
69
|
export function serializeSemanticConfig(config) {
|
|
38
|
-
return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n`;
|
|
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`;
|
|
39
71
|
}
|
|
40
72
|
export function loadSemanticConfig(fs, aiwPath) {
|
|
41
73
|
const settings = loadSettings(fs, aiwPath);
|
|
@@ -56,8 +88,8 @@ function parseSemanticDocument(text) {
|
|
|
56
88
|
export function parseSemanticConfig(text) {
|
|
57
89
|
return parseSemanticDocument(text);
|
|
58
90
|
}
|
|
59
|
-
|
|
60
|
-
|
|
91
|
+
const EMBED_BATCH_SIZE = 64;
|
|
92
|
+
export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init)) {
|
|
61
93
|
return {
|
|
62
94
|
async available() {
|
|
63
95
|
try {
|
|
@@ -76,18 +108,21 @@ export function createOllamaEmbeddingProvider(config) {
|
|
|
76
108
|
},
|
|
77
109
|
async embed(texts) {
|
|
78
110
|
const vectors = [];
|
|
79
|
-
for (
|
|
80
|
-
const
|
|
111
|
+
for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
|
|
112
|
+
const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
|
|
113
|
+
const response = await fetchImpl(`${config.baseUrl}/api/embed`, {
|
|
81
114
|
method: "POST",
|
|
82
115
|
headers: { "content-type": "application/json" },
|
|
83
|
-
body: JSON.stringify({ model: config.model,
|
|
116
|
+
body: JSON.stringify({ model: config.model, input: batch }),
|
|
84
117
|
});
|
|
85
118
|
if (!response.ok)
|
|
86
119
|
throw new Error(`Embedding request failed for model ${config.model}`);
|
|
87
120
|
const body = (await response.json());
|
|
88
|
-
if (!Array.isArray(body.
|
|
89
|
-
|
|
90
|
-
|
|
121
|
+
if (!Array.isArray(body.embeddings) ||
|
|
122
|
+
body.embeddings.length !== batch.length ||
|
|
123
|
+
body.embeddings.some((vector) => !Array.isArray(vector) || vector.some((v) => typeof v !== "number")))
|
|
124
|
+
throw new Error(`Embedding model ${config.model} returned invalid vectors`);
|
|
125
|
+
vectors.push(...body.embeddings);
|
|
91
126
|
}
|
|
92
127
|
return vectors;
|
|
93
128
|
},
|
|
@@ -127,6 +162,8 @@ export function cosineSimilarity(a, b) {
|
|
|
127
162
|
export async function buildSemanticIndex(files, readContent, provider, config) {
|
|
128
163
|
const entries = [];
|
|
129
164
|
for (const file of files) {
|
|
165
|
+
if (config.excludeLockfiles && isExcludedIndexFile(file))
|
|
166
|
+
continue;
|
|
130
167
|
const content = await readContent(file);
|
|
131
168
|
if (content === undefined)
|
|
132
169
|
continue;
|
|
@@ -173,3 +210,24 @@ export async function querySemanticIndex(index, query, provider, config) {
|
|
|
173
210
|
.sort((a, b) => b.score - a.score)
|
|
174
211
|
.slice(0, config.topK);
|
|
175
212
|
}
|
|
213
|
+
export async function rerankSemanticMatches(matches, query, reranker) {
|
|
214
|
+
if (matches.length === 0)
|
|
215
|
+
return { matches: [], changedPositions: 0 };
|
|
216
|
+
const candidates = matches.map((match, index) => ({
|
|
217
|
+
id: `match_${index}`,
|
|
218
|
+
text: `${match.file}\n${match.excerpt}`,
|
|
219
|
+
}));
|
|
220
|
+
const scores = await reranker(query, candidates);
|
|
221
|
+
const reranked = matches.map((match, index) => {
|
|
222
|
+
const rerankScore = scores[`match_${index}`];
|
|
223
|
+
return Number.isFinite(rerankScore)
|
|
224
|
+
? { ...match, rerankScore: rerankScore }
|
|
225
|
+
: { ...match };
|
|
226
|
+
});
|
|
227
|
+
reranked.sort((a, b) => (b.rerankScore ?? b.score) - (a.rerankScore ?? a.score));
|
|
228
|
+
let changedPositions = 0;
|
|
229
|
+
for (let index = 0; index < reranked.length; index += 1)
|
|
230
|
+
if (reranked[index].file !== matches[index].file)
|
|
231
|
+
changedPositions += 1;
|
|
232
|
+
return { matches: reranked, changedPositions };
|
|
233
|
+
}
|
package/dist/services.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ProjectProfile } from "./profile.js";
|
|
2
|
+
import type { EmbeddingProvider } from "./semantic.js";
|
|
2
3
|
import type { ProjectScan } from "./scanner.js";
|
|
3
4
|
import type { CapabilityRecommendation } from "./recommendations.js";
|
|
4
|
-
import type { Interpreter } from "./interpreter.js";
|
|
5
|
+
import type { Interpreter, AiProvider } from "./interpreter.js";
|
|
5
6
|
import type { CommandExecutor } from "./vercel-skills.js";
|
|
6
7
|
import type { CommandResult, FileSystem } from "./types.js";
|
|
7
8
|
import type { PackageSourceLoader } from "./providers.js";
|
|
8
9
|
import type { PrivateRegistryClient } from "./team-configuration.js";
|
|
9
10
|
import type { PolicyTracking } from "./policy-gate.js";
|
|
11
|
+
import type { DecisionProvider } from "./decision.js";
|
|
10
12
|
import type { TelemetryClient } from "./telemetry.js";
|
|
11
13
|
import type { DashboardHandle } from "./dashboard.js";
|
|
12
14
|
import type { AgentOrchestrator } from "./orchestration.js";
|
|
@@ -35,6 +37,9 @@ export interface WorkflowDependencies {
|
|
|
35
37
|
start(root: string, port: number): Promise<DashboardHandle>;
|
|
36
38
|
};
|
|
37
39
|
orchestrator?: AgentOrchestrator;
|
|
40
|
+
embeddingProvider?: EmbeddingProvider;
|
|
41
|
+
aiProvider?: AiProvider;
|
|
42
|
+
decisionProvider?: DecisionProvider;
|
|
38
43
|
}
|
|
39
44
|
export interface WorkflowService {
|
|
40
45
|
execute(args: string[], root: string, fs: FileSystem): Promise<CommandResult>;
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -14,10 +14,18 @@ export interface TelemetryClient {
|
|
|
14
14
|
record(event: TelemetryEvent): Promise<void>;
|
|
15
15
|
}
|
|
16
16
|
export declare const DEFAULT_TELEMETRY_CONFIG: TelemetryConfig;
|
|
17
|
+
export declare const TELEMETRY_CONSENT_PROMPT = "Enable anonymous telemetry? Each event contains only the command name and its outcome (success or failure) - never arguments, file paths, or file contents. [y/N] ";
|
|
18
|
+
export declare function offerTelemetryConsent(fs: FileSystem, aiwPath: string, answer: (question: string) => Promise<string>): Promise<boolean>;
|
|
17
19
|
export declare function parseTelemetryMapping(raw: unknown): TelemetryConfig;
|
|
18
20
|
export declare function serializeTelemetryMapping(config: TelemetryConfig): unknown;
|
|
19
21
|
export declare function loadTelemetryConfig(fs: FileSystem, aiwPath: string): TelemetryConfig;
|
|
20
22
|
export declare function parseTelemetryConfig(content: string): TelemetryConfig;
|
|
21
23
|
export declare function serializeTelemetryConfig(config: TelemetryConfig): string;
|
|
22
24
|
export declare function createTelemetryEvent(command: string | undefined, result: CommandResult, config: TelemetryConfig): TelemetryEvent | undefined;
|
|
25
|
+
export declare const TELEMETRY_BUFFER_FILE = "telemetry-buffer.jsonl";
|
|
26
|
+
export declare const TELEMETRY_BUFFER_CAP = 200;
|
|
27
|
+
export declare function telemetryBufferPath(aiwPath: string): string;
|
|
28
|
+
export declare function readTelemetryBuffer(fs: FileSystem, aiwPath: string): TelemetryEvent[];
|
|
29
|
+
export declare function appendTelemetryBuffer(fs: FileSystem, aiwPath: string, event: TelemetryEvent): void;
|
|
30
|
+
export declare function purgeTelemetryBuffer(fs: FileSystem, aiwPath: string): void;
|
|
23
31
|
export declare function recordConfiguredTelemetry(fs: FileSystem, aiwPath: string, client: TelemetryClient | undefined, command: string | undefined, result: CommandResult): Promise<void>;
|
package/dist/telemetry.js
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import { loadSettings } from "./settings.js";
|
|
1
|
+
import { loadSettings, saveSettingsSection } from "./settings.js";
|
|
2
2
|
export const DEFAULT_TELEMETRY_CONFIG = {
|
|
3
3
|
schema: 1,
|
|
4
4
|
enabled: false,
|
|
5
5
|
includeCommand: true,
|
|
6
6
|
includeOutcome: true,
|
|
7
7
|
};
|
|
8
|
+
export const TELEMETRY_CONSENT_PROMPT = "Enable anonymous telemetry? Each event contains only the command name and its outcome (success or failure) - never arguments, file paths, or file contents. [y/N] ";
|
|
9
|
+
export async function offerTelemetryConsent(fs, aiwPath, answer) {
|
|
10
|
+
const settings = loadSettings(fs, aiwPath);
|
|
11
|
+
if (settings !== undefined && "telemetry" in settings)
|
|
12
|
+
return loadTelemetryConfig(fs, aiwPath).enabled;
|
|
13
|
+
const response = (await answer(TELEMETRY_CONSENT_PROMPT)).trim().toLowerCase();
|
|
14
|
+
const enabled = response === "y" || response === "yes";
|
|
15
|
+
saveSettingsSection(fs, aiwPath, "telemetry", serializeTelemetryMapping({
|
|
16
|
+
schema: 1,
|
|
17
|
+
enabled,
|
|
18
|
+
includeCommand: true,
|
|
19
|
+
includeOutcome: true,
|
|
20
|
+
}));
|
|
21
|
+
return enabled;
|
|
22
|
+
}
|
|
8
23
|
const TELEMETRY_COMMANDS = new Set([
|
|
9
24
|
"adr",
|
|
10
25
|
"audit-package",
|
|
@@ -110,9 +125,54 @@ export function createTelemetryEvent(command, result, config) {
|
|
|
110
125
|
...(config.includeOutcome ? { outcome: result.exitCode === 0 ? "success" : "failure" } : {}),
|
|
111
126
|
};
|
|
112
127
|
}
|
|
128
|
+
export const TELEMETRY_BUFFER_FILE = "telemetry-buffer.jsonl";
|
|
129
|
+
export const TELEMETRY_BUFFER_CAP = 200;
|
|
130
|
+
export function telemetryBufferPath(aiwPath) {
|
|
131
|
+
return `${aiwPath}/${TELEMETRY_BUFFER_FILE}`;
|
|
132
|
+
}
|
|
133
|
+
export function readTelemetryBuffer(fs, aiwPath) {
|
|
134
|
+
const path = telemetryBufferPath(aiwPath);
|
|
135
|
+
if (!fs.exists(path))
|
|
136
|
+
return [];
|
|
137
|
+
return fs
|
|
138
|
+
.read(path)
|
|
139
|
+
.split(/\r?\n/)
|
|
140
|
+
.filter((line) => line.trim().length > 0)
|
|
141
|
+
.map((line) => {
|
|
142
|
+
const parsed = JSON.parse(line);
|
|
143
|
+
if (parsed.schema !== 1)
|
|
144
|
+
throw new Error("Invalid buffered telemetry event");
|
|
145
|
+
return parsed;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export function appendTelemetryBuffer(fs, aiwPath, event) {
|
|
149
|
+
const buffered = [...readTelemetryBuffer(fs, aiwPath), event];
|
|
150
|
+
const rotated = buffered.slice(Math.max(0, buffered.length - TELEMETRY_BUFFER_CAP));
|
|
151
|
+
fs.write(telemetryBufferPath(aiwPath), `${rotated.map((item) => JSON.stringify(item)).join("\n")}\n`);
|
|
152
|
+
}
|
|
153
|
+
export function purgeTelemetryBuffer(fs, aiwPath) {
|
|
154
|
+
const path = telemetryBufferPath(aiwPath);
|
|
155
|
+
if (fs.exists(path) && fs.remove)
|
|
156
|
+
fs.remove(path);
|
|
157
|
+
}
|
|
113
158
|
export async function recordConfiguredTelemetry(fs, aiwPath, client, command, result) {
|
|
114
|
-
if (!client)
|
|
159
|
+
if (!client) {
|
|
160
|
+
try {
|
|
161
|
+
const config = loadTelemetryConfig(fs, aiwPath);
|
|
162
|
+
const settings = loadSettings(fs, aiwPath);
|
|
163
|
+
const configured = (settings !== undefined && "telemetry" in settings) ||
|
|
164
|
+
fs.exists(`${aiwPath}/telemetry.yml`);
|
|
165
|
+
if (configured) {
|
|
166
|
+
const event = createTelemetryEvent(command, result, config);
|
|
167
|
+
if (event)
|
|
168
|
+
appendTelemetryBuffer(fs, aiwPath, event);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Telemetry must never affect the requested workflow operation.
|
|
173
|
+
}
|
|
115
174
|
return;
|
|
175
|
+
}
|
|
116
176
|
try {
|
|
117
177
|
const config = loadTelemetryConfig(fs, aiwPath);
|
|
118
178
|
const settings = loadSettings(fs, aiwPath);
|
package/dist/workflow.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ 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
5
|
export type ConfirmQuestion = (question: string) => Promise<string>;
|
|
6
|
-
export
|
|
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,8 +7,8 @@ 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 { createInterpretationRequest } from "./interpreter.js";
|
|
11
|
-
import { recommendCapabilities, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
|
|
10
|
+
import { createInterpretationRequest, createOpenAiCompatibleProvider, createProjectInterpreter, buildInterpreterQuery, loadAiProviderConfig, selectInterpreterKeyFiles, } from "./interpreter.js";
|
|
11
|
+
import { recommendCapabilities, rankRecommendations, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
|
|
12
12
|
import { selectRecommendations } from "./selection.js";
|
|
13
13
|
import { createInterface } from "node:readline/promises";
|
|
14
14
|
import { stdin, stdout } from "node:process";
|
|
@@ -21,8 +21,8 @@ import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
|
|
|
21
21
|
import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
|
|
22
22
|
import { scanProject } from "./scanner.js";
|
|
23
23
|
import { readFactOverrides, saveSettingOverrides, saveSettingsSection, loadSettings, } from "./settings.js";
|
|
24
|
-
import { buildEvidenceTriageRequest, createOllamaDecisionProvider, DEFAULT_DECISION_CONFIG, loadDecisionConfig, } from "./decision.js";
|
|
25
|
-
import { buildSemanticIndex, createOllamaEmbeddingProvider, DEFAULT_SEMANTIC_CONFIG, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
|
|
24
|
+
import { buildEvidenceTriageRequest, buildRecommendationRankingRequest, buildSemanticRerankRequest, createOllamaDecisionProvider, DEFAULT_DECISION_CONFIG, loadDecisionConfig, } from "./decision.js";
|
|
25
|
+
import { buildSemanticIndex, createOllamaEmbeddingProvider, DEFAULT_SEMANTIC_CONFIG, loadSemanticConfig, parseSemanticIndex, querySemanticIndex, rerankSemanticMatches, SEMANTIC_INDEX_PATH, serializeSemanticIndex, } from "./semantic.js";
|
|
26
26
|
import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
|
|
27
27
|
import { planPackageUpdate } from "./package-updates.js";
|
|
28
28
|
import { searchRegistry, serializeRegistry } from "./registry.js";
|
|
@@ -42,7 +42,7 @@ import { evaluateQualityGate } from "./quality-gates.js";
|
|
|
42
42
|
import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
|
|
43
43
|
import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
|
|
44
44
|
import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
|
|
45
|
-
import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, parseTelemetryConfig, serializeTelemetryMapping, } from "./telemetry.js";
|
|
45
|
+
import { DEFAULT_TELEMETRY_CONFIG, loadTelemetryConfig, offerTelemetryConsent, parseTelemetryConfig, purgeTelemetryBuffer, readTelemetryBuffer, serializeTelemetryMapping, TELEMETRY_BUFFER_CAP, TELEMETRY_BUFFER_FILE, } from "./telemetry.js";
|
|
46
46
|
import { createPluginScaffold, validatePluginDirectory, writePluginScaffold, } from "./plugin-sdk.js";
|
|
47
47
|
import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestrationPreview, serializeOrchestrationResults, validateOrchestrationParallelism, } from "./orchestration.js";
|
|
48
48
|
export async function runCommand(args, root, fs, services = {}) {
|
|
@@ -331,10 +331,12 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
331
331
|
pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
|
|
332
332
|
writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
|
|
333
333
|
let optionalModels = [];
|
|
334
|
+
let telemetryEnabled;
|
|
334
335
|
if (stdin.isTTY) {
|
|
335
336
|
const terminal = createInterface({ input: stdin, output: stdout });
|
|
336
337
|
try {
|
|
337
338
|
optionalModels = await configureOptionalModels(fs, aiw, (question) => terminal.question(question));
|
|
339
|
+
telemetryEnabled = await offerTelemetryConsent(fs, aiw, (question) => terminal.question(question));
|
|
338
340
|
}
|
|
339
341
|
finally {
|
|
340
342
|
terminal.close();
|
|
@@ -345,34 +347,49 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
345
347
|
output: `AI Workflow initialized for target: ${target} with only the ai-init skill. Run /ai-init to scan the project and choose which resources to install.` +
|
|
346
348
|
(optionalModels.length
|
|
347
349
|
? ` Optional models enabled: ${optionalModels.join(", ")}. They activate once the models are available.`
|
|
350
|
+
: "") +
|
|
351
|
+
(telemetryEnabled !== undefined
|
|
352
|
+
? telemetryEnabled
|
|
353
|
+
? " Telemetry enabled (command name and outcome only); disable anytime with `aiw telemetry disable`."
|
|
354
|
+
: " Telemetry disabled; enable anytime with `aiw telemetry enable`."
|
|
348
355
|
: ""),
|
|
349
356
|
};
|
|
350
357
|
}
|
|
351
358
|
if (command === "telemetry") {
|
|
352
359
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
353
360
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
354
|
-
const usage = "Usage: aiw telemetry <status|enable|disable> [--commands=include|exclude] [--outcomes=include|exclude]";
|
|
361
|
+
const usage = "Usage: aiw telemetry <status|enable|disable> [--verbose] [--commands=include|exclude] [--outcomes=include|exclude]";
|
|
355
362
|
const action = args[1];
|
|
356
363
|
if (!action)
|
|
357
364
|
return { exitCode: 1, error: usage };
|
|
358
365
|
const telemetryConfig = loadTelemetryConfig(fs, aiw);
|
|
366
|
+
const actionArgs = args.slice(2).filter((arg) => arg !== "--verbose");
|
|
367
|
+
const verbose = args.includes("--verbose");
|
|
359
368
|
if (action === "status") {
|
|
360
|
-
if (
|
|
369
|
+
if (actionArgs.length !== 0)
|
|
361
370
|
return { exitCode: 1, error: usage };
|
|
362
371
|
const config = telemetryConfig;
|
|
372
|
+
const buffered = verbose ? readTelemetryBuffer(fs, aiw) : undefined;
|
|
363
373
|
return {
|
|
364
374
|
exitCode: 0,
|
|
365
|
-
output: `Telemetry is ${config.enabled ? "enabled" : "disabled"}. Command collection: ${config.includeCommand ? "included" : "excluded"}. Outcome collection: ${config.includeOutcome ? "included" : "excluded"}
|
|
375
|
+
output: `Telemetry is ${config.enabled ? "enabled" : "disabled"}. Command collection: ${config.includeCommand ? "included" : "excluded"}. Outcome collection: ${config.includeOutcome ? "included" : "excluded"}.` +
|
|
376
|
+
(buffered !== undefined
|
|
377
|
+
? `\nLocal buffer: ${buffered.length} event${buffered.length === 1 ? "" : "s"} (cap ${TELEMETRY_BUFFER_CAP}, .aiw/${TELEMETRY_BUFFER_FILE}).` +
|
|
378
|
+
(buffered.length > 0
|
|
379
|
+
? `\n${buffered.map((event) => `- ${JSON.stringify(event)}`).join("\n")}`
|
|
380
|
+
: "")
|
|
381
|
+
: ""),
|
|
366
382
|
};
|
|
367
383
|
}
|
|
368
384
|
if (action === "disable") {
|
|
369
|
-
if (
|
|
385
|
+
if (actionArgs.length !== 0)
|
|
370
386
|
return { exitCode: 1, error: usage };
|
|
387
|
+
purgeTelemetryBuffer(fs, aiw);
|
|
371
388
|
saveSettingsSection(fs, aiw, "telemetry", serializeTelemetryMapping(DEFAULT_TELEMETRY_CONFIG));
|
|
372
389
|
return { exitCode: 0, output: "Telemetry disabled." };
|
|
373
390
|
}
|
|
374
391
|
if (action === "enable") {
|
|
375
|
-
const options = args.slice(2);
|
|
392
|
+
const options = args.slice(2).filter((arg) => arg !== "--verbose");
|
|
376
393
|
if (options.some((option) => !/^--(?:commands|outcomes)=(?:include|exclude)$/.test(option)) ||
|
|
377
394
|
new Set(options.map((option) => option.slice(0, option.indexOf("=")))).size !==
|
|
378
395
|
options.length)
|
|
@@ -424,9 +441,39 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
424
441
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
425
442
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
426
443
|
const profile = await (services.profiler?.profile(root) ?? profileProject(root));
|
|
427
|
-
|
|
444
|
+
let interpreter = services.interpreter;
|
|
445
|
+
if (!interpreter) {
|
|
446
|
+
const aiConfig = loadAiProviderConfig(fs, aiw);
|
|
447
|
+
if (aiConfig?.enabled)
|
|
448
|
+
interpreter = createProjectInterpreter(root, services.aiProvider ?? createOpenAiCompatibleProvider(aiConfig));
|
|
449
|
+
}
|
|
450
|
+
if (interpreter) {
|
|
428
451
|
const scan = await (services.scanner?.scan(root) ?? scanProject(root));
|
|
429
|
-
|
|
452
|
+
let scopedFiles = scan.files;
|
|
453
|
+
let priorityFiles;
|
|
454
|
+
const contextConfig = loadSemanticConfig(fs, aiw);
|
|
455
|
+
const contextIndexPath = join(aiw, SEMANTIC_INDEX_PATH);
|
|
456
|
+
if (contextConfig?.enabled && fs.exists(contextIndexPath)) {
|
|
457
|
+
try {
|
|
458
|
+
const index = parseSemanticIndex(fs.read(contextIndexPath));
|
|
459
|
+
if (index.model === contextConfig.model && index.chunks.length > 0) {
|
|
460
|
+
const embedding = services.embeddingProvider ?? createOllamaEmbeddingProvider(contextConfig);
|
|
461
|
+
if (await embedding.available()) {
|
|
462
|
+
const matches = await querySemanticIndex(index, buildInterpreterQuery(profile), embedding, contextConfig);
|
|
463
|
+
priorityFiles = [
|
|
464
|
+
...selectInterpreterKeyFiles(scan.files),
|
|
465
|
+
...matches.map((match) => match.file),
|
|
466
|
+
];
|
|
467
|
+
const allowed = new Set(priorityFiles);
|
|
468
|
+
scopedFiles = scan.files.filter((file) => allowed.has(file));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// An unreadable index keeps the scan deterministic with alphabetical scoping.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const inferred = await interpreter.interpret(createInterpretationRequest(profile, scopedFiles, 4000, priorityFiles));
|
|
430
477
|
profile.facts = mergeFacts(profile.facts, inferred);
|
|
431
478
|
}
|
|
432
479
|
const overrides = readFactOverrides(fs, aiw);
|
|
@@ -442,7 +489,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
442
489
|
fs.write(join(aiw, "profile.yml"), `schema: 1\nstatus: scanned\nfacts:\n${evidence}\nruntime:\n languages: [${profile.runtime.languages.join(", ")}]\nframeworks: [${profile.frameworks.join(", ")}]\npackage_manager: ${profile.packageManager}\nquality:\n linter: ${profile.quality.linter?.name ?? "unknown"}\n linter_command: ${profile.quality.linter?.command ?? "unknown"}\n formatter: ${profile.quality.formatter?.name ?? "unknown"}\n formatter_command: ${profile.quality.formatter?.command ?? "unknown"}\n typecheck: ${profile.quality.typecheck?.name ?? "unknown"}\n typecheck_command: ${profile.quality.typecheck?.command ?? "unknown"}\ntesting: ${profile.testing?.name ?? "unknown"}\ntesting_command: ${profile.testing?.command ?? "unknown"}\nci: [${profile.ci.join(", ")}]\nworkspaces: [${profile.workspaces.join(", ")}]\nproject_modules: ${JSON.stringify(profile.modules ?? [])}\npolicies:\n artifact_language: en\n`);
|
|
443
490
|
const semanticConfig = loadSemanticConfig(fs, aiw);
|
|
444
491
|
if (semanticConfig?.enabled) {
|
|
445
|
-
const provider = createOllamaEmbeddingProvider(semanticConfig);
|
|
492
|
+
const provider = services.embeddingProvider ?? createOllamaEmbeddingProvider(semanticConfig);
|
|
446
493
|
if (!(await provider.available()))
|
|
447
494
|
return {
|
|
448
495
|
exitCode: 1,
|
|
@@ -511,7 +558,7 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
511
558
|
return { exitCode: 1, error: usage };
|
|
512
559
|
}
|
|
513
560
|
if (command === "semantic") {
|
|
514
|
-
const usage = "Usage: aiw semantic query --text=<query> | aiw semantic status";
|
|
561
|
+
const usage = "Usage: aiw semantic query --text=<query> [--rerank] | aiw semantic status";
|
|
515
562
|
const action = args[1];
|
|
516
563
|
if (action === "status") {
|
|
517
564
|
const config = loadSemanticConfig(fs, aiw);
|
|
@@ -530,7 +577,9 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
530
577
|
};
|
|
531
578
|
}
|
|
532
579
|
if (action === "query") {
|
|
533
|
-
const
|
|
580
|
+
const queryArgs = args.slice(2).filter((arg) => arg !== "--rerank");
|
|
581
|
+
const rerankRequested = args.slice(2).includes("--rerank");
|
|
582
|
+
const parsed = parseNamedOptions(queryArgs, ["text"]);
|
|
534
583
|
if (!parsed?.text)
|
|
535
584
|
return { exitCode: 1, error: usage };
|
|
536
585
|
const config = loadSemanticConfig(fs, aiw);
|
|
@@ -549,13 +598,38 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
549
598
|
error: `Semantic model ${config.model} is not available at ${config.baseUrl}.`,
|
|
550
599
|
};
|
|
551
600
|
const matches = await querySemanticIndex(parseSemanticIndex(fs.read(indexPath)), parsed.text, provider, config);
|
|
601
|
+
let ranked = matches.map((match) => ({ ...match }));
|
|
602
|
+
let influence;
|
|
603
|
+
if (rerankRequested) {
|
|
604
|
+
const decisionConfig = loadDecisionConfig(fs, aiw);
|
|
605
|
+
if (!decisionConfig?.enabled)
|
|
606
|
+
return {
|
|
607
|
+
exitCode: 1,
|
|
608
|
+
error: "Decision model is disabled. Set decision.enabled=true in .aiw/settings.yml to use --rerank.",
|
|
609
|
+
};
|
|
610
|
+
const decisionProvider = createOllamaDecisionProvider(decisionConfig);
|
|
611
|
+
if (!(await decisionProvider.available()))
|
|
612
|
+
return {
|
|
613
|
+
exitCode: 1,
|
|
614
|
+
error: `Decision model ${decisionConfig.model} is not available at ${decisionConfig.baseUrl}.`,
|
|
615
|
+
};
|
|
616
|
+
const outcome = await rerankSemanticMatches(matches, parsed.text, async (query, candidates) => {
|
|
617
|
+
const response = await decisionProvider.decide(buildSemanticRerankRequest(query, candidates));
|
|
618
|
+
const scores = {};
|
|
619
|
+
for (const [id, answer] of Object.entries(response.answers))
|
|
620
|
+
scores[id] = typeof answer.score === "number" ? answer.score : undefined;
|
|
621
|
+
return scores;
|
|
622
|
+
});
|
|
623
|
+
ranked = outcome.matches;
|
|
624
|
+
influence = `rerank influence: ${outcome.changedPositions}/${outcome.matches.length} positions reordered (advisory; cosine recall unchanged)`;
|
|
625
|
+
}
|
|
552
626
|
return {
|
|
553
627
|
exitCode: 0,
|
|
554
|
-
output:
|
|
628
|
+
output: ranked.length === 0
|
|
555
629
|
? "No semantic matches."
|
|
556
|
-
:
|
|
557
|
-
.map((match) => `- ${match.file} (score ${match.score.toFixed(3)})\n ${match.excerpt}`)
|
|
558
|
-
.join("\n")
|
|
630
|
+
: `${ranked
|
|
631
|
+
.map((match) => `- ${match.file} (score ${match.score.toFixed(3)}${match.rerankScore !== undefined ? `, rerank ${match.rerankScore.toFixed(3)}` : ""})\n ${match.excerpt}`)
|
|
632
|
+
.join("\n")}${influence ? `\n${influence}` : ""}`,
|
|
559
633
|
};
|
|
560
634
|
}
|
|
561
635
|
return { exitCode: 1, error: usage };
|
|
@@ -922,6 +996,33 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
922
996
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
923
997
|
const profile = await (services.profiler?.profile(root) ?? profileProject(root));
|
|
924
998
|
const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
|
|
999
|
+
let ordered = recommendations;
|
|
1000
|
+
let ranked;
|
|
1001
|
+
const rankingConfig = loadDecisionConfig(fs, aiw);
|
|
1002
|
+
if (rankingConfig?.enabled) {
|
|
1003
|
+
const decisionProvider = services.decisionProvider ?? createOllamaDecisionProvider(rankingConfig);
|
|
1004
|
+
if (!(await decisionProvider.available()))
|
|
1005
|
+
return {
|
|
1006
|
+
exitCode: 1,
|
|
1007
|
+
error: `Decision model ${rankingConfig.model} is not available at ${rankingConfig.baseUrl}.`,
|
|
1008
|
+
};
|
|
1009
|
+
ranked = await rankRecommendations(recommendations, profile, async (scoped, recs) => {
|
|
1010
|
+
const response = await decisionProvider.decide(buildRecommendationRankingRequest(profileSummaryForRanking(scoped), recs));
|
|
1011
|
+
const answers = {};
|
|
1012
|
+
for (const [id, answer] of Object.entries(response.answers))
|
|
1013
|
+
answers[id] =
|
|
1014
|
+
typeof answer.score === "number"
|
|
1015
|
+
? {
|
|
1016
|
+
relevance: answer.score,
|
|
1017
|
+
...(typeof answer.confidence === "number"
|
|
1018
|
+
? { confidence: answer.confidence }
|
|
1019
|
+
: {}),
|
|
1020
|
+
}
|
|
1021
|
+
: {};
|
|
1022
|
+
return answers;
|
|
1023
|
+
});
|
|
1024
|
+
ordered = ranked.items;
|
|
1025
|
+
}
|
|
925
1026
|
const selectedOption = args.find((arg) => arg.startsWith("--select="));
|
|
926
1027
|
const selectedArg = selectedOption?.slice("--select=".length) ?? "";
|
|
927
1028
|
let selection;
|
|
@@ -931,15 +1032,18 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
931
1032
|
selection = [];
|
|
932
1033
|
else {
|
|
933
1034
|
const terminal = createInterface({ input: stdin, output: stdout });
|
|
934
|
-
selection = await selectRecommendations(
|
|
1035
|
+
selection = await selectRecommendations(ordered, (question) => terminal.question(question));
|
|
935
1036
|
terminal.close();
|
|
936
1037
|
}
|
|
937
|
-
fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(
|
|
1038
|
+
fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(ordered, selection, ranked?.ranking));
|
|
1039
|
+
const rankingNote = ranked
|
|
1040
|
+
? ` Advisory decision-model ranking applied (${ranked.changedPositions} positions reordered); evidence states are unchanged.`
|
|
1041
|
+
: "";
|
|
938
1042
|
return {
|
|
939
1043
|
exitCode: 0,
|
|
940
|
-
output: selectedOption === undefined && !stdin.isTTY
|
|
941
|
-
?
|
|
942
|
-
:
|
|
1044
|
+
output: `${ordered.length} recommendations generated${selectedOption === undefined && !stdin.isTTY
|
|
1045
|
+
? " without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id."
|
|
1046
|
+
: "."}${rankingNote}`,
|
|
943
1047
|
};
|
|
944
1048
|
}
|
|
945
1049
|
if (command === "skills") {
|
|
@@ -1795,35 +1899,81 @@ function approvalArtifactPath(aiw, stage) {
|
|
|
1795
1899
|
};
|
|
1796
1900
|
return resolve(aiw, paths[stage]);
|
|
1797
1901
|
}
|
|
1798
|
-
|
|
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) {
|
|
1799
1925
|
const enabled = [];
|
|
1800
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);
|
|
1801
1930
|
if (!("semantic" in settings)) {
|
|
1802
|
-
const
|
|
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] `))
|
|
1803
1936
|
.trim()
|
|
1804
1937
|
.toLowerCase();
|
|
1805
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
|
+
}
|
|
1806
1945
|
saveSettingsSection(fs, aiwPath, "semantic", {
|
|
1807
1946
|
enabled: true,
|
|
1808
|
-
model
|
|
1947
|
+
model,
|
|
1809
1948
|
baseUrl: DEFAULT_SEMANTIC_CONFIG.baseUrl,
|
|
1810
1949
|
chunkChars: DEFAULT_SEMANTIC_CONFIG.chunkChars,
|
|
1811
1950
|
topK: DEFAULT_SEMANTIC_CONFIG.topK,
|
|
1951
|
+
excludeLockfiles: DEFAULT_SEMANTIC_CONFIG.excludeLockfiles,
|
|
1812
1952
|
});
|
|
1813
|
-
enabled.push(`semantic (${
|
|
1953
|
+
enabled.push(`semantic (${model})`);
|
|
1814
1954
|
}
|
|
1815
1955
|
}
|
|
1816
1956
|
if (!("decision" in settings)) {
|
|
1817
|
-
const
|
|
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] `))
|
|
1818
1962
|
.trim()
|
|
1819
1963
|
.toLowerCase();
|
|
1820
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
|
+
}
|
|
1821
1971
|
saveSettingsSection(fs, aiwPath, "decision", {
|
|
1822
1972
|
enabled: true,
|
|
1823
|
-
model
|
|
1973
|
+
model,
|
|
1824
1974
|
baseUrl: DEFAULT_DECISION_CONFIG.baseUrl,
|
|
1825
1975
|
});
|
|
1826
|
-
enabled.push(`decision (${
|
|
1976
|
+
enabled.push(`decision (${model})`);
|
|
1827
1977
|
}
|
|
1828
1978
|
}
|
|
1829
1979
|
return enabled;
|
|
@@ -2193,6 +2343,15 @@ function safeProjectSignal(value) {
|
|
|
2193
2343
|
.replace(/\s+/g, " ")
|
|
2194
2344
|
.slice(0, 180);
|
|
2195
2345
|
}
|
|
2346
|
+
function profileSummaryForRanking(profile) {
|
|
2347
|
+
return [
|
|
2348
|
+
`languages: ${profile.runtime.languages.join(", ") || "none"}`,
|
|
2349
|
+
`frameworks: ${profile.frameworks.join(", ") || "none"}`,
|
|
2350
|
+
`package-manager: ${profile.packageManager}`,
|
|
2351
|
+
`testing: ${profile.testing?.name ?? "none"}`,
|
|
2352
|
+
`linter: ${profile.quality.linter?.name ?? "none"}`,
|
|
2353
|
+
].join("; ");
|
|
2354
|
+
}
|
|
2196
2355
|
function readSelectedRecommendations(fs, aiw) {
|
|
2197
2356
|
if (!fs.exists(join(aiw, "recommendations.yml")))
|
|
2198
2357
|
return [];
|
package/docs/backlog.md
CHANGED
|
@@ -29,8 +29,10 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
|
|
|
29
29
|
- [x] **SCAN-006** Implement scoped AI project interpreter. _Acceptance:_ interpreter receives filtered context and produces structured insights requiring confirmation when uncertain.
|
|
30
30
|
- [x] **SCAN-007** Add interpreter privacy and token guardrails. _Acceptance:_ secrets, dependencies, generated files, Git internals, and checkpoints are excluded and token usage is measured.
|
|
31
31
|
- [x] **SCAN-004** Add interactive confirmation and overrides. _Acceptance:_ users can accept, reject, or edit inferred facts.
|
|
32
|
+
- [x] **INT-001** Ship a built-in OpenAI-compatible `AiProvider`. _Acceptance:_ an `aiw` settings section configures a local or remote OpenAI-compatible endpoint (for example Ollama `gpt-oss:20b`) so the scoped AI interpreter works without a host-injected provider; secrets redaction, token ceilings, and scoped-context validation still apply; missing configuration keeps scans deterministic and offline.
|
|
32
33
|
- [x] **REC-001** Build capability recommendation engine. _Acceptance:_ recommendations include rationale, evidence, permissions, conflicts, and confidence.
|
|
33
34
|
- [x] **REC-002** Add interactive capability selection. _Acceptance:_ users select packages and individual resources before installation.
|
|
35
|
+
- [x] **REC-003** Rank capability recommendations with a decision model. _Acceptance:_ when a decision model is enabled in settings, recommendations are ordered by decision-model relevance to the confirmed profile with confidence and rationale; ranking is advisory and never changes evidence states. See `docs/decision-model-evaluation.md`.
|
|
34
36
|
- [x] **GEN-001** Generate project-specific rules, agents, hooks, and context. _Acceptance:_ generated content uses confirmed profile data and English artifacts.
|
|
35
37
|
- [x] **GEN-002** Preserve manual overrides and detect drift. _Acceptance:_ regeneration never silently overwrites user changes.
|
|
36
38
|
|
|
@@ -70,6 +72,23 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
|
|
|
70
72
|
|
|
71
73
|
- [x] **SEM-001** Add flag-gated semantic mapping to scan and retrieval. _Acceptance:_ `.aiw/semantic.yml` with `enabled: true` verifies model availability before indexing, builds a versioned chunk index during `aiw scan`, and `aiw semantic query` returns ranked matches; missing or disabled configuration keeps scans fully deterministic and offline; semantic results are retrieval aids, never confirmed facts.
|
|
72
74
|
- [x] **SEM-002** Integrate a decision model (jev/clef-flash via Ollama `/v1/systemone`) for advisory gate triage. _Acceptance:_ `.aiw/decision.yml` with `enabled: true` verifies model availability before use; `aiw decision triage` returns advisory `evidence_complete` and `gate` decisions with confidence; disabled or missing configuration makes no network request; advisory output never replaces human approval. See `docs/decision-model-evaluation.md`.
|
|
75
|
+
- [x] **SEM-003** Embed chunks in batches. _Acceptance:_ indexing uses the batch embeddings endpoint (for example Ollama `/api/embed`) instead of one request per chunk, measurably reducing scan time on this repository (baseline: ~34s for 824 chunks) with identical index output.
|
|
76
|
+
- [x] **SEM-004** Exclude lockfiles and generated vendor manifests from the semantic index. _Acceptance:_ files such as `package-lock.json` (observed contributing 75 noise chunks) are skipped during indexing by default with an explicit, documented override.
|
|
77
|
+
- [x] **SEM-005** Re-rank semantic recall with a decision model. _Acceptance:_ when both semantic mapping and a decision model are enabled, `aiw semantic query` optionally re-ranks embedding matches with the decision model, reporting the ranking influence; recall without a decision model is unchanged.
|
|
78
|
+
- [x] **SEM-006** Scope AI interpreter context with semantic retrieval. _Acceptance:_ when semantic mapping is enabled and an index exists, the interpreter receives deterministic key files plus the top semantically relevant chunks for the active analysis question instead of alphabetically ordered files up to the token ceiling; measured token usage decreases and inferred-fact quality does not regress against the current baseline; scans without semantic mapping are unchanged.
|
|
79
|
+
|
|
80
|
+
## Onboarding — P1
|
|
81
|
+
|
|
82
|
+
- [x] **TEL-001** Offer telemetry consent during installation. _Acceptance:_ interactive install asks about telemetry after the model questions with an explicit default of No, states the exact event payload (command name and outcome only, no arguments, paths, or contents), records the decision in `.aiw/settings.yml`, and remains reversible through `aiw telemetry enable/disable`; non-interactive installs skip the prompt; the prompt itself never emits an event.
|
|
83
|
+
|
|
84
|
+
## Telemetry — P2
|
|
85
|
+
|
|
86
|
+
- [x] **TEL-002** Add an inspectable local telemetry buffer. _Acceptance:_ when telemetry is enabled and a host client is absent, events accumulate in a rotated local buffer under `.aiw/` with a documented retention cap; `aiw telemetry status --verbose` displays buffered events; `aiw telemetry disable` purges the buffer; no network transport is introduced by this item.
|
|
87
|
+
|
|
88
|
+
- [x] **ONB-001** Detect and offer real local models during installation. _Acceptance:_ interactive install 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, the installer reports the optional capability and continues without network assumptions; non-interactive installs are unchanged.
|
|
89
|
+
|
|
90
|
+
## Context and cost — P2 (continued)
|
|
91
|
+
|
|
73
92
|
- [x] **CTX-001** Build layered project context store.
|
|
74
93
|
- [x] **CTX-002** Add task-scoped context retrieval and delta loading.
|
|
75
94
|
- [x] **CTX-003** Add summary cache and freshness invalidation.
|
|
@@ -86,6 +105,10 @@ Priority levels: **P0** blocks the product or makes it unsafe; **P1** is require
|
|
|
86
105
|
- [x] **ENT-006** Add web UI for project profile, recommendations, traceability, and migrations. _Acceptance:_ `aiw ui` serves a local-only, responsive and accessible dashboard from packaged project state; profile, recommendations, and requirement evidence are visible; migrations require explicit confirmation and delegate to the existing workflow.
|
|
87
106
|
- [x] **ENT-007** Add multi-agent orchestration and parallel task execution. _Acceptance:_ strict versioned task graphs support deterministic preview, explicit adapter-backed execution, bounded parallelism, dependency-aware failure isolation, and immutable checkpoint evidence.
|
|
88
107
|
|
|
108
|
+
## Engineering — P2
|
|
109
|
+
|
|
110
|
+
- [x] **ENG-001** Update GitHub Actions to Node 24-native versions. _Acceptance:_ `actions/checkout` and `actions/setup-node` upgraded past v4 so release workflows no longer emit Node.js 20 deprecation warnings; CI and Release workflows pass on `ubuntu-latest` before and after the October 19, 2026 Ubuntu 26 runner migration.
|
|
111
|
+
|
|
89
112
|
## Backlog rules
|
|
90
113
|
|
|
91
114
|
- [x] **FND-009** Add self-validation workflow. _Acceptance:_ changes affecting AIW behavior execute against this repository and record generated-output evidence.
|
|
@@ -34,7 +34,7 @@ Observed behavior on real plans: an evidence-free plan scored `gate: ready` with
|
|
|
34
34
|
|
|
35
35
|
## Future integration points (advisory)
|
|
36
36
|
|
|
37
|
-
- **Recommendation ranking**: order capability recommendations by decision-model relevance to the confirmed profile.
|
|
37
|
+
- **Recommendation ranking**: order capability recommendations by decision-model relevance to the confirmed profile. _Status:_ implemented 2026-10-08 behind `decision.enabled` — `aiw recommend` sends one `score` question per recommendation (with the confirmed profile summary and the recommendation rationale/evidence) through `/v1/systemone`, orders the output by advisory relevance, and serializes `decision_relevance` / `decision_confidence` per item. The ranking is advisory: evidence, confidence, and selection logic are unchanged, and without a decision model the deterministic order is preserved.
|
|
38
38
|
- **Semantic re-ranking**: combine embedding recall (`aiw semantic query`) with clef re-ranking for sharper context selection during interpretation.
|
|
39
39
|
|
|
40
40
|
## Hard boundary (unchanged)
|
|
@@ -58,4 +58,16 @@ Before a provider call, the interpreter:
|
|
|
58
58
|
- redacts common credential assignments, bearer tokens, and private keys;
|
|
59
59
|
- rejects inferred evidence that cites any file outside the scoped request.
|
|
60
60
|
|
|
61
|
-
The workflow accepts an injected interpreter service.
|
|
61
|
+
The workflow accepts an injected interpreter service. Additionally, a built-in OpenAI-compatible provider can be configured through an `ai` section in `.aiw/settings.yml` so the interpreter works without a host-injected service:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
ai:
|
|
65
|
+
enabled: true
|
|
66
|
+
model: gpt-oss:20b # any model served by an OpenAI-compatible endpoint
|
|
67
|
+
baseUrl: http://localhost:11434 # local (Ollama) or remote
|
|
68
|
+
apiKeyEnv: AIW_AI_KEY # optional; bearer token read from this environment variable
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The provider posts to `<baseUrl>/v1/chat/completions` with a JSON response format and the interpreter's token ceiling; all existing guardrails (secrets redaction, scoped-context validation, binary and sensitive-file exclusion) apply unchanged because the same interpreter pipeline is used. An injected interpreter service always takes precedence. Without an `ai` section, scans remain deterministic and do not make a network request.
|
|
72
|
+
|
|
73
|
+
A configured interpreter runs during `aiw scan`, and its structured facts are stored in `.aiw/profile.yml` alongside deterministic facts. Facts below the confidence threshold are marked `requires_confirmation: true`.
|
package/docs/semantic-mapping.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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. During an interactive `aiw install`, the installer offers to create this configuration
|
|
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. During an interactive `aiw install`, the installer probes the configured Ollama endpoint (`GET /api/tags`), lists the models actually available, and offers to create this configuration — the user's selected model is recorded in `.aiw/settings.yml`; when no server or models are found, the installer reports the optional capability and continues with defaults. Answering `n`, or installing non-interactively, leaves the section absent.
|
|
4
4
|
|
|
5
5
|
## Configuration
|
|
6
6
|
|
|
@@ -13,6 +13,7 @@ semantic:
|
|
|
13
13
|
baseUrl: http://localhost:11434
|
|
14
14
|
chunkChars: 1200 # optional, minimum 200
|
|
15
15
|
topK: 8 # optional, results per query
|
|
16
|
+
excludeLockfiles: true # optional, skip lockfiles and generated vendor manifests (default: true)
|
|
16
17
|
```
|
|
17
18
|
|
|
18
19
|
- `enabled: false` (or a missing file) disables semantic mapping entirely; scans stay deterministic and make no network request.
|
|
@@ -22,18 +23,28 @@ semantic:
|
|
|
22
23
|
|
|
23
24
|
When semantic mapping is enabled, `aiw scan` additionally:
|
|
24
25
|
|
|
25
|
-
1. Reads the scanned files with strict UTF-8 decoding (unreadable files are skipped).
|
|
26
|
+
1. Reads the scanned files with strict UTF-8 decoding (unreadable files are skipped). Lockfiles and generated vendor manifests (`package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `go.sum`, `Cargo.lock`, `poetry.lock`, `Gemfile.lock`, `composer.lock`, `deno.lock`, `bun.lock`, `bun.lockb`, `uv.lock`, `Pipfile.lock`, `flake.lock`, `npm-shrinkwrap.json`) are skipped by default because they contribute noise chunks without retrieval value; set `excludeLockfiles: false` to index them explicitly.
|
|
26
27
|
2. Splits each file into line-aligned chunks of at most `chunkChars` characters.
|
|
27
|
-
3. Embeds
|
|
28
|
+
3. Embeds chunks in batches of up to 64 per request with `POST /api/embed` (Ollama batch embeddings endpoint) instead of one request per chunk.
|
|
28
29
|
4. Writes the versioned index to `.aiw/semantic/index.json` (`schema: 1`).
|
|
29
30
|
|
|
30
31
|
The index stores the model name; queries against an index built with a different model fail explicitly.
|
|
31
32
|
|
|
32
33
|
## Queries during the flow
|
|
33
34
|
|
|
34
|
-
`aiw semantic query --text="<natural language>"` ranks indexed chunks by cosine similarity and returns the top `topK` matches with file, score, and excerpt. `aiw semantic status` reports whether mapping is configured and whether the index is built.
|
|
35
|
+
`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.
|
|
35
36
|
|
|
36
37
|
## Boundaries
|
|
37
38
|
|
|
38
39
|
- Semantic results are retrieval aids, not evidence. They never create confirmed project facts and never bypass human approval gates.
|
|
39
40
|
- The index is derived state; delete and rebuild it with `aiw scan` after structural changes.
|
|
41
|
+
|
|
42
|
+
## Interpreter scoping
|
|
43
|
+
|
|
44
|
+
When semantic mapping is enabled and a valid index built with the active model exists, `aiw scan` scopes the AI interpreter's context semantically instead of alphabetically:
|
|
45
|
+
|
|
46
|
+
1. A deterministic set of key files (README, contributing/architecture docs, and primary package manifests such as `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`) is always included first, in a fixed priority order.
|
|
47
|
+
2. The remaining context is the top semantically relevant files for a deterministic analysis query derived from the detected profile (languages, frameworks, package manager), ranked through the existing embedding index.
|
|
48
|
+
3. Everything else is excluded, so measured token usage decreases while inferred-fact evidence still points at real scoped files.
|
|
49
|
+
|
|
50
|
+
Without semantic mapping (or when the index is missing, built with another model, or the embedding model is unavailable), the interpreter keeps the previous deterministic alphabetical scoping and scans make no network request for scoping purposes.
|
package/docs/telemetry.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
AI Workflow telemetry is disabled by default. No event is recorded unless a user explicitly runs `aiw telemetry enable` and a host application provides a telemetry client.
|
|
4
4
|
|
|
5
|
+
## Installation consent
|
|
6
|
+
|
|
7
|
+
During an interactive `aiw install`, after the optional model questions, the installer asks whether to enable anonymous telemetry with an explicit default of **No**. The question states the exact event payload: the command name and its outcome only — never arguments, file paths, or file contents. The answer is recorded in `.aiw/settings.yml` under the `telemetry` section (declining is recorded too, so the question is never repeated), and the decision is reversible at any time with `aiw telemetry enable` / `aiw telemetry disable`. Non-interactive installs skip the prompt entirely and leave the section absent, and the prompt itself never emits a telemetry event.
|
|
8
|
+
|
|
5
9
|
## Controls
|
|
6
10
|
|
|
7
11
|
```bash
|
|
@@ -15,6 +19,14 @@ Preferences are stored in the local user settings file `.aiw/settings.yml` under
|
|
|
15
19
|
|
|
16
20
|
Rejected telemetry administration commands never produce telemetry events, even when a previous valid configuration enabled collection. This keeps malformed consent or privacy changes outside the telemetry stream.
|
|
17
21
|
|
|
22
|
+
## Local buffer
|
|
23
|
+
|
|
24
|
+
When telemetry is enabled but no host application provides a telemetry client, events accumulate in a rotated local buffer at `.aiw/telemetry-buffer.jsonl` (one JSON event per line, retention cap of 200 events — the oldest events are dropped first). This introduces no network transport: the buffer is written only to the local `.aiw/` directory.
|
|
25
|
+
|
|
26
|
+
- `aiw telemetry status --verbose` displays the number of buffered events, the retention cap, and each buffered event verbatim.
|
|
27
|
+
- `aiw telemetry disable` purges the buffer.
|
|
28
|
+
- When a host client is present, events go to the client and the local buffer stays untouched.
|
|
29
|
+
|
|
18
30
|
## Data contract
|
|
19
31
|
|
|
20
32
|
An event contains only:
|