@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.
@@ -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}\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
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}`)
@@ -5,6 +5,8 @@ export type SemanticConfig = {
5
5
  baseUrl: string;
6
6
  chunkChars: number;
7
7
  topK: number;
8
+ excludeLockfiles: boolean;
9
+ apiKeyEnv?: string;
8
10
  };
9
11
  export type SemanticIndexChunk = {
10
12
  file: string;
@@ -21,6 +23,17 @@ export type SemanticMatch = {
21
23
  score: number;
22
24
  excerpt: string;
23
25
  };
26
+ export type RerankedMatch = SemanticMatch & {
27
+ rerankScore?: number;
28
+ };
29
+ export type RerankOutcome = {
30
+ matches: RerankedMatch[];
31
+ changedPositions: number;
32
+ };
33
+ export type DecisionReranker = (query: string, candidates: {
34
+ id: string;
35
+ text: string;
36
+ }[]) => Promise<Record<string, number | undefined>>;
24
37
  export type EmbeddingProvider = {
25
38
  available(): Promise<boolean>;
26
39
  embed(texts: string[]): Promise<number[][]>;
@@ -28,14 +41,19 @@ export type EmbeddingProvider = {
28
41
  export declare const SEMANTIC_CONFIG_PATH = "semantic.yml";
29
42
  export declare const SEMANTIC_INDEX_PATH = "semantic/index.json";
30
43
  export declare const DEFAULT_SEMANTIC_CONFIG: SemanticConfig;
44
+ export declare const SEMANTIC_LOCKFILE_NAMES: Set<string>;
45
+ export declare function isExcludedIndexFile(file: string): boolean;
31
46
  export declare function parseSemanticMapping(raw: unknown): SemanticConfig;
32
47
  export declare function serializeSemanticConfig(config: SemanticConfig): string;
33
48
  export declare function loadSemanticConfig(fs: FileSystem, aiwPath: string): SemanticConfig | undefined;
34
49
  export declare function parseSemanticConfig(text: string): SemanticConfig;
35
- export declare function createOllamaEmbeddingProvider(config: SemanticConfig): EmbeddingProvider;
50
+ type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
51
+ export declare function createOllamaEmbeddingProvider(config: SemanticConfig, fetchImpl?: FetchLike, env?: Readonly<Record<string, string | undefined>>): EmbeddingProvider;
36
52
  export declare function chunkText(content: string, chunkChars: number): string[];
37
53
  export declare function cosineSimilarity(a: number[], b: number[]): number;
38
54
  export declare function buildSemanticIndex(files: string[], readContent: (file: string) => Promise<string | undefined>, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticIndex>;
39
55
  export declare function serializeSemanticIndex(index: SemanticIndex): string;
40
56
  export declare function parseSemanticIndex(text: string): SemanticIndex;
41
57
  export declare function querySemanticIndex(index: SemanticIndex, query: string, provider: EmbeddingProvider, config: SemanticConfig): Promise<SemanticMatch[]>;
58
+ export declare function rerankSemanticMatches(matches: SemanticMatch[], query: string, reranker: DecisionReranker): Promise<RerankOutcome>;
59
+ 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,18 @@ 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;
43
+ const apiKeyEnv = raw2.apiKeyEnv;
21
44
  if (typeof enabled !== "boolean")
22
45
  throw new Error("semantic.enabled must be a boolean");
46
+ const excludeLockfiles = rawExclude === undefined ? true : rawExclude;
23
47
  if (!enabled)
24
- return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false };
48
+ return { ...DEFAULT_SEMANTIC_CONFIG, enabled: false, excludeLockfiles };
49
+ if (rawExclude !== undefined && typeof rawExclude !== "boolean")
50
+ throw new Error("semantic.excludeLockfiles must be a boolean");
51
+ if (apiKeyEnv !== undefined &&
52
+ (typeof apiKeyEnv !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)))
53
+ throw new Error("semantic.apiKeyEnv must be an uppercase environment variable name");
25
54
  if (typeof model !== "string" || model.trim() === "")
26
55
  throw new Error("semantic.model must be a non-empty string when semantic mapping is enabled");
27
56
  if (typeof baseUrl !== "string" || !/^https?:\/\//i.test(baseUrl))
@@ -32,10 +61,18 @@ export function parseSemanticMapping(raw) {
32
61
  throw new Error("semantic.chunkChars must be an integer of at least 200");
33
62
  if (typeof parsedTopK !== "number" || !Number.isInteger(parsedTopK) || parsedTopK < 1)
34
63
  throw new Error("semantic.topK must be a positive integer");
35
- return { enabled: true, model, baseUrl, chunkChars: parsedChunk, topK: parsedTopK };
64
+ return {
65
+ enabled: true,
66
+ model,
67
+ baseUrl,
68
+ chunkChars: parsedChunk,
69
+ topK: parsedTopK,
70
+ excludeLockfiles,
71
+ ...(apiKeyEnv ? { apiKeyEnv } : {}),
72
+ };
36
73
  }
37
74
  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`;
75
+ return `semantic:\n enabled: ${config.enabled}\n model: ${config.model}\n baseUrl: ${config.baseUrl}\n chunkChars: ${config.chunkChars}\n topK: ${config.topK}\n excludeLockfiles: ${config.excludeLockfiles}${config.apiKeyEnv ? `\n apiKeyEnv: ${config.apiKeyEnv}` : ""}\n`;
39
76
  }
40
77
  export function loadSemanticConfig(fs, aiwPath) {
41
78
  const settings = loadSettings(fs, aiwPath);
@@ -56,8 +93,45 @@ function parseSemanticDocument(text) {
56
93
  export function parseSemanticConfig(text) {
57
94
  return parseSemanticDocument(text);
58
95
  }
59
- export function createOllamaEmbeddingProvider(config) {
60
- const fetchImpl = (url, init) => fetch(url, init);
96
+ const EMBED_BATCH_SIZE = 64;
97
+ export function createOllamaEmbeddingProvider(config, fetchImpl = (url, init) => fetch(url, init), env = process.env) {
98
+ const externalToken = config.apiKeyEnv ? env[config.apiKeyEnv] : undefined;
99
+ const isExternal = config.apiKeyEnv !== undefined;
100
+ const authHeaders = (extra = {}) => externalToken ? { ...extra, authorization: `Bearer ${externalToken}` } : extra;
101
+ if (isExternal)
102
+ return {
103
+ async available() {
104
+ try {
105
+ const response = await fetchImpl(`${config.baseUrl}/v1/models`, {
106
+ headers: authHeaders(),
107
+ });
108
+ return response.ok;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ },
114
+ async embed(texts) {
115
+ const vectors = [];
116
+ for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
117
+ const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
118
+ const response = await fetchImpl(`${config.baseUrl}/v1/embeddings`, {
119
+ method: "POST",
120
+ headers: authHeaders({ "content-type": "application/json" }),
121
+ body: JSON.stringify({ model: config.model, input: batch }),
122
+ });
123
+ if (!response.ok)
124
+ throw new Error(`Embedding request failed for model ${config.model}`);
125
+ const body = (await response.json());
126
+ const rows = body.data ?? [];
127
+ if (rows.length !== batch.length ||
128
+ rows.some((row) => !Array.isArray(row.embedding) || row.embedding.some((v) => typeof v !== "number")))
129
+ throw new Error(`Embedding model ${config.model} returned invalid vectors`);
130
+ vectors.push(...rows.map((row) => row.embedding));
131
+ }
132
+ return vectors;
133
+ },
134
+ };
61
135
  return {
62
136
  async available() {
63
137
  try {
@@ -76,18 +150,21 @@ export function createOllamaEmbeddingProvider(config) {
76
150
  },
77
151
  async embed(texts) {
78
152
  const vectors = [];
79
- for (const text of texts) {
80
- const response = await fetchImpl(`${config.baseUrl}/api/embeddings`, {
153
+ for (let offset = 0; offset < texts.length; offset += EMBED_BATCH_SIZE) {
154
+ const batch = texts.slice(offset, offset + EMBED_BATCH_SIZE);
155
+ const response = await fetchImpl(`${config.baseUrl}/api/embed`, {
81
156
  method: "POST",
82
157
  headers: { "content-type": "application/json" },
83
- body: JSON.stringify({ model: config.model, prompt: text }),
158
+ body: JSON.stringify({ model: config.model, input: batch }),
84
159
  });
85
160
  if (!response.ok)
86
161
  throw new Error(`Embedding request failed for model ${config.model}`);
87
162
  const body = (await response.json());
88
- if (!Array.isArray(body.embedding) || body.embedding.some((v) => typeof v !== "number"))
89
- throw new Error(`Embedding model ${config.model} returned an invalid vector`);
90
- vectors.push(body.embedding);
163
+ if (!Array.isArray(body.embeddings) ||
164
+ body.embeddings.length !== batch.length ||
165
+ body.embeddings.some((vector) => !Array.isArray(vector) || vector.some((v) => typeof v !== "number")))
166
+ throw new Error(`Embedding model ${config.model} returned invalid vectors`);
167
+ vectors.push(...body.embeddings);
91
168
  }
92
169
  return vectors;
93
170
  },
@@ -127,6 +204,8 @@ export function cosineSimilarity(a, b) {
127
204
  export async function buildSemanticIndex(files, readContent, provider, config) {
128
205
  const entries = [];
129
206
  for (const file of files) {
207
+ if (config.excludeLockfiles && isExcludedIndexFile(file))
208
+ continue;
130
209
  const content = await readContent(file);
131
210
  if (content === undefined)
132
211
  continue;
@@ -173,3 +252,24 @@ export async function querySemanticIndex(index, query, provider, config) {
173
252
  .sort((a, b) => b.score - a.score)
174
253
  .slice(0, config.topK);
175
254
  }
255
+ export async function rerankSemanticMatches(matches, query, reranker) {
256
+ if (matches.length === 0)
257
+ return { matches: [], changedPositions: 0 };
258
+ const candidates = matches.map((match, index) => ({
259
+ id: `match_${index}`,
260
+ text: `${match.file}\n${match.excerpt}`,
261
+ }));
262
+ const scores = await reranker(query, candidates);
263
+ const reranked = matches.map((match, index) => {
264
+ const rerankScore = scores[`match_${index}`];
265
+ return Number.isFinite(rerankScore)
266
+ ? { ...match, rerankScore: rerankScore }
267
+ : { ...match };
268
+ });
269
+ reranked.sort((a, b) => (b.rerankScore ?? b.score) - (a.rerankScore ?? a.score));
270
+ let changedPositions = 0;
271
+ for (let index = 0; index < reranked.length; index += 1)
272
+ if (reranked[index].file !== matches[index].file)
273
+ changedPositions += 1;
274
+ return { matches: reranked, changedPositions };
275
+ }
@@ -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>;
@@ -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);
@@ -2,5 +2,3 @@ import type { CommandResult, FileSystem } from "./types.js";
2
2
  import type { WorkflowDependencies } from "./services.js";
3
3
  export type WorkflowServices = WorkflowDependencies;
4
4
  export declare function runCommand(args: string[], root: string, fs: FileSystem, services?: WorkflowServices): Promise<CommandResult>;
5
- export type ConfirmQuestion = (question: string) => Promise<string>;
6
- export declare function configureOptionalModels(fs: FileSystem, aiwPath: string, answer: ConfirmQuestion): Promise<string[]>;