@multileaf/ai-workflow 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +20 -3
- package/dist/decision.d.ts +15 -1
- package/dist/decision.js +43 -6
- package/dist/interpreter.d.ts +17 -1
- package/dist/interpreter.js +116 -6
- package/dist/model-onboarding.d.ts +22 -0
- package/dist/model-onboarding.js +200 -0
- package/dist/recommendations.d.ts +16 -1
- package/dist/recommendations.js +33 -2
- package/dist/semantic.d.ts +19 -1
- package/dist/semantic.js +111 -11
- package/dist/services.d.ts +6 -1
- package/dist/telemetry.d.ts +8 -0
- package/dist/telemetry.js +62 -2
- package/dist/workflow.d.ts +0 -2
- package/dist/workflow.js +183 -59
- package/docs/README.md +5 -0
- package/docs/backlog.md +23 -0
- package/docs/decision-model-evaluation.md +2 -2
- package/docs/project-intelligence.md +13 -1
- package/docs/release-process.md +1 -1
- package/docs/semantic-mapping.md +33 -4
- package/docs/telemetry.md +12 -0
- package/package.json +1 -1
package/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,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
|
-
|
|
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 {
|
|
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
|
-
|
|
60
|
-
|
|
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 (
|
|
80
|
-
const
|
|
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,
|
|
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.
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
}
|
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
|
@@ -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[]>;
|