selftune 0.2.18 → 0.2.19

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.
Files changed (65) hide show
  1. package/README.md +9 -4
  2. package/apps/local-dashboard/dist/assets/index-DnhnXQm6.js +60 -0
  3. package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +1 -0
  4. package/apps/local-dashboard/dist/assets/vendor-table-BIiI3YhS.js +1 -0
  5. package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +12 -0
  6. package/apps/local-dashboard/dist/index.html +5 -5
  7. package/cli/selftune/alpha-upload/stage-canonical.ts +7 -6
  8. package/cli/selftune/constants.ts +10 -0
  9. package/cli/selftune/contribute/contribute.ts +30 -2
  10. package/cli/selftune/contribution-config.ts +249 -0
  11. package/cli/selftune/contribution-relay.ts +177 -0
  12. package/cli/selftune/contribution-signals.ts +219 -0
  13. package/cli/selftune/contribution-staging.ts +147 -0
  14. package/cli/selftune/contributions.ts +532 -0
  15. package/cli/selftune/creator-contributions.ts +333 -0
  16. package/cli/selftune/dashboard-contract.ts +205 -1
  17. package/cli/selftune/dashboard-server.ts +45 -11
  18. package/cli/selftune/eval/family-overlap.ts +395 -0
  19. package/cli/selftune/eval/hooks-to-evals.ts +182 -28
  20. package/cli/selftune/eval/synthetic-evals.ts +298 -11
  21. package/cli/selftune/export.ts +2 -2
  22. package/cli/selftune/index.ts +41 -5
  23. package/cli/selftune/ingestors/codex-rollout.ts +31 -35
  24. package/cli/selftune/ingestors/codex-wrapper.ts +32 -24
  25. package/cli/selftune/localdb/db.ts +2 -2
  26. package/cli/selftune/localdb/queries.ts +701 -30
  27. package/cli/selftune/localdb/schema.ts +20 -0
  28. package/cli/selftune/recover.ts +153 -0
  29. package/cli/selftune/repair/skill-usage.ts +363 -4
  30. package/cli/selftune/routes/actions.ts +35 -1
  31. package/cli/selftune/routes/analytics.ts +14 -0
  32. package/cli/selftune/routes/index.ts +1 -0
  33. package/cli/selftune/routes/overview.ts +112 -4
  34. package/cli/selftune/routes/skill-report.ts +569 -10
  35. package/cli/selftune/status.ts +81 -2
  36. package/cli/selftune/sync.ts +56 -2
  37. package/cli/selftune/trust-model.ts +66 -0
  38. package/cli/selftune/types.ts +49 -0
  39. package/cli/selftune/utils/skill-detection.ts +43 -0
  40. package/cli/selftune/watchlist.ts +65 -0
  41. package/package.json +1 -1
  42. package/packages/ui/src/components/ActivityTimeline.tsx +165 -150
  43. package/packages/ui/src/components/EvidenceViewer.tsx +335 -144
  44. package/packages/ui/src/components/EvolutionTimeline.tsx +58 -28
  45. package/packages/ui/src/components/OrchestrateRunsPanel.tsx +33 -16
  46. package/packages/ui/src/components/RecentActivityFeed.tsx +72 -41
  47. package/packages/ui/src/components/section-cards.tsx +12 -9
  48. package/packages/ui/src/primitives/card.tsx +1 -1
  49. package/skill/SKILL.md +11 -1
  50. package/skill/Workflows/AlphaUpload.md +4 -0
  51. package/skill/Workflows/Composability.md +64 -0
  52. package/skill/Workflows/Contribute.md +6 -3
  53. package/skill/Workflows/Contributions.md +97 -0
  54. package/skill/Workflows/CreatorContributions.md +74 -0
  55. package/skill/Workflows/Dashboard.md +31 -0
  56. package/skill/Workflows/Evals.md +57 -8
  57. package/skill/Workflows/Ingest.md +7 -0
  58. package/skill/Workflows/Initialize.md +20 -1
  59. package/skill/Workflows/Recover.md +84 -0
  60. package/skill/Workflows/RepairSkillUsage.md +12 -4
  61. package/skill/Workflows/Sync.md +18 -12
  62. package/apps/local-dashboard/dist/assets/index-BMIS6uUh.css +0 -2
  63. package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +0 -16
  64. package/apps/local-dashboard/dist/assets/vendor-table-pHbDxq36.js +0 -8
  65. package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +0 -12
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * selftune contribute — opt-in export of anonymized skill observability data.
3
+ * selftune contribute — community export of anonymized skill observability data.
4
4
  *
5
5
  * Usage:
6
6
  * bun run cli/selftune/contribute/contribute.ts --skill selftune [--preview] [--output file.json]
@@ -31,10 +31,38 @@ export async function cliMain(): Promise<void> {
31
31
  submit: { type: "boolean", default: false },
32
32
  endpoint: { type: "string", default: "https://selftune-api.fly.dev" },
33
33
  github: { type: "boolean", default: false },
34
+ help: { type: "boolean", short: "h", default: false },
34
35
  },
35
36
  strict: true,
36
37
  });
37
38
 
39
+ if (values.help) {
40
+ console.log(`selftune contribute — Export an anonymized community bundle
41
+
42
+ Usage:
43
+ selftune contribute --skill <name> [--preview] [--sanitize conservative|aggressive]
44
+ selftune contribute --skill <name> [--output <file>] [--submit]
45
+
46
+ Purpose:
47
+ Build a sanitized community contribution bundle from local SQLite data.
48
+ This is separate from:
49
+ selftune contributions Creator-directed sharing preferences
50
+ selftune alpha upload Personal cloud upload cycle
51
+
52
+ Options:
53
+ --skill <name> Skill to export
54
+ --preview Print the sanitized bundle instead of writing it
55
+ --sanitize conservative|aggressive
56
+ Choose the sanitization level
57
+ --output <file> Write the bundle to an explicit file path
58
+ --since <timestamp> Only include records on or after this time
59
+ --submit Submit the bundle after writing it
60
+ --endpoint <url> Override the default service endpoint
61
+ --github Submit via GitHub flow instead of the service
62
+ -h, --help Show this help`);
63
+ return;
64
+ }
65
+
38
66
  const skillName = values.skill ?? "selftune";
39
67
  const sanitizationLevel = values.sanitize === "aggressive" ? "aggressive" : "conservative";
40
68
 
@@ -81,7 +109,7 @@ export async function cliMain(): Promise<void> {
81
109
  writeFileSync(outputPath, json, "utf-8");
82
110
 
83
111
  // 6. Summary
84
- console.log(`Contribution bundle written to: ${outputPath}`);
112
+ console.log(`Community contribution bundle written to: ${outputPath}`);
85
113
  console.log(` Queries: ${bundle.positive_queries.length}`);
86
114
  console.log(` Eval entries: ${bundle.eval_entries.length}`);
87
115
  console.log(` Sessions: ${bundle.session_metrics.total_sessions}`);
@@ -0,0 +1,249 @@
1
+ import { existsSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import {
5
+ findInstalledSkillPath,
6
+ findRepositoryClaudeSkillDirs,
7
+ findRepositorySkillDirs,
8
+ } from "./utils/skill-discovery.js";
9
+
10
+ export interface CreatorContributionConfig {
11
+ version: 1;
12
+ creator_id: string;
13
+ skill_name: string;
14
+ config_path: string;
15
+ skill_path: string;
16
+ contribution: {
17
+ enabled: boolean;
18
+ signals: string[];
19
+ message?: string;
20
+ privacy_url?: string;
21
+ };
22
+ }
23
+
24
+ export interface CreatorContributionConfigInput {
25
+ creator_id: string;
26
+ skill_name: string;
27
+ skill_path: string;
28
+ signals: string[];
29
+ message?: string;
30
+ privacy_url?: string;
31
+ }
32
+
33
+ interface ParsedContributionConfig {
34
+ version?: unknown;
35
+ creator_id?: unknown;
36
+ skill_name?: unknown;
37
+ contribution?: {
38
+ enabled?: unknown;
39
+ signals?: unknown;
40
+ message?: unknown;
41
+ privacy_url?: unknown;
42
+ };
43
+ }
44
+
45
+ function getOverrideRoots(): string[] {
46
+ const raw = process.env.SELFTUNE_SKILL_DIRS;
47
+ if (!raw) return [];
48
+ return raw
49
+ .split(process.platform === "win32" ? ";" : ":")
50
+ .map((part) => part.trim())
51
+ .filter(Boolean);
52
+ }
53
+
54
+ export function getContributionConfigSearchRoots(
55
+ cwd: string = process.cwd(),
56
+ homeDir: string = process.env.HOME ?? "",
57
+ codexHome: string = process.env.CODEX_HOME ?? join(homeDir, ".codex"),
58
+ ): string[] {
59
+ const overrideRoots = getOverrideRoots();
60
+ if (overrideRoots.length > 0) return overrideRoots;
61
+
62
+ const roots = [
63
+ ...findRepositorySkillDirs(cwd),
64
+ ...findRepositoryClaudeSkillDirs(cwd),
65
+ join(homeDir, ".agents", "skills"),
66
+ join(homeDir, ".claude", "skills"),
67
+ join(codexHome, "skills"),
68
+ ];
69
+
70
+ return [...new Set(roots)];
71
+ }
72
+
73
+ function normalizeContributionConfig(
74
+ raw: ParsedContributionConfig,
75
+ configPath: string,
76
+ skillPath: string,
77
+ ): CreatorContributionConfig | null {
78
+ const creatorId = typeof raw.creator_id === "string" ? raw.creator_id.trim() : "";
79
+ const skillName = typeof raw.skill_name === "string" ? raw.skill_name.trim() : "";
80
+ if (
81
+ raw.version !== 1 ||
82
+ !creatorId ||
83
+ !skillName ||
84
+ !raw.contribution ||
85
+ typeof raw.contribution !== "object" ||
86
+ raw.contribution.enabled !== true ||
87
+ !Array.isArray(raw.contribution.signals)
88
+ ) {
89
+ return null;
90
+ }
91
+
92
+ const signals = raw.contribution.signals
93
+ .filter((signal): signal is string => typeof signal === "string")
94
+ .map((signal) => signal.trim())
95
+ .filter(Boolean);
96
+ if (signals.length === 0) return null;
97
+
98
+ return {
99
+ version: 1,
100
+ creator_id: creatorId,
101
+ skill_name: skillName,
102
+ config_path: configPath,
103
+ skill_path: skillPath,
104
+ contribution: {
105
+ enabled: true,
106
+ signals: [...new Set(signals)],
107
+ message: typeof raw.contribution.message === "string" ? raw.contribution.message : undefined,
108
+ privacy_url:
109
+ typeof raw.contribution.privacy_url === "string" ? raw.contribution.privacy_url : undefined,
110
+ },
111
+ };
112
+ }
113
+
114
+ function readContributionConfig(skillDir: string): CreatorContributionConfig | null {
115
+ const skillPath = join(skillDir, "SKILL.md");
116
+ const configPath = join(skillDir, "selftune.contribute.json");
117
+ if (!existsSync(skillPath) || !existsSync(configPath)) return null;
118
+
119
+ try {
120
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8")) as ParsedContributionConfig;
121
+ return normalizeContributionConfig(parsed, configPath, skillPath);
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export function findCreatorContributionConfig(
128
+ skillName: string,
129
+ roots: string[] = getContributionConfigSearchRoots(),
130
+ ): CreatorContributionConfig | null {
131
+ return (
132
+ discoverCreatorContributionConfigs(roots).find((config) => config.skill_name === skillName) ??
133
+ null
134
+ );
135
+ }
136
+
137
+ export function resolveContributionSkillPath(
138
+ skillName: string,
139
+ explicitSkillPath?: string,
140
+ roots: string[] = getContributionConfigSearchRoots(),
141
+ ): string | null {
142
+ if (explicitSkillPath?.trim()) {
143
+ const trimmed = explicitSkillPath.trim();
144
+ if (trimmed.endsWith("SKILL.md")) return trimmed;
145
+ return join(trimmed, "SKILL.md");
146
+ }
147
+ return findInstalledSkillPath(skillName, roots) ?? null;
148
+ }
149
+
150
+ export function writeCreatorContributionConfig(
151
+ input: CreatorContributionConfigInput,
152
+ ): CreatorContributionConfig {
153
+ const normalized = normalizeContributionConfig(
154
+ {
155
+ version: 1,
156
+ creator_id: input.creator_id,
157
+ skill_name: input.skill_name,
158
+ contribution: {
159
+ enabled: true,
160
+ signals: input.signals,
161
+ message: input.message,
162
+ privacy_url: input.privacy_url,
163
+ },
164
+ },
165
+ join(dirname(input.skill_path), "selftune.contribute.json"),
166
+ input.skill_path,
167
+ );
168
+
169
+ if (!normalized) {
170
+ throw new Error("Invalid creator contribution config input");
171
+ }
172
+
173
+ writeFileSync(
174
+ normalized.config_path,
175
+ JSON.stringify(
176
+ {
177
+ version: normalized.version,
178
+ creator_id: normalized.creator_id,
179
+ skill_name: normalized.skill_name,
180
+ contribution: normalized.contribution,
181
+ },
182
+ null,
183
+ 2,
184
+ ),
185
+ "utf-8",
186
+ );
187
+
188
+ return normalized;
189
+ }
190
+
191
+ export function removeCreatorContributionConfig(skillPath: string): boolean {
192
+ const configPath = join(dirname(skillPath), "selftune.contribute.json");
193
+ if (!existsSync(configPath)) return false;
194
+ rmSync(configPath, { force: true });
195
+ return true;
196
+ }
197
+
198
+ function scanSkillRoot(root: string): CreatorContributionConfig[] {
199
+ if (!existsSync(root)) return [];
200
+
201
+ const discovered: CreatorContributionConfig[] = [];
202
+ for (const entry of readdirSync(root)) {
203
+ const entryPath = join(root, entry);
204
+ try {
205
+ if (!statSync(entryPath).isDirectory()) continue;
206
+ } catch {
207
+ continue;
208
+ }
209
+
210
+ const direct = readContributionConfig(entryPath);
211
+ if (direct) {
212
+ discovered.push(direct);
213
+ continue;
214
+ }
215
+
216
+ try {
217
+ for (const nestedEntry of readdirSync(entryPath)) {
218
+ const nestedPath = join(entryPath, nestedEntry);
219
+ try {
220
+ if (!statSync(nestedPath).isDirectory()) continue;
221
+ } catch {
222
+ continue;
223
+ }
224
+ const nested = readContributionConfig(nestedPath);
225
+ if (nested) discovered.push(nested);
226
+ }
227
+ } catch {
228
+ // Ignore unreadable nested skill registries.
229
+ }
230
+ }
231
+
232
+ return discovered;
233
+ }
234
+
235
+ export function discoverCreatorContributionConfigs(
236
+ roots: string[] = getContributionConfigSearchRoots(),
237
+ ): CreatorContributionConfig[] {
238
+ const bySkill = new Map<string, CreatorContributionConfig>();
239
+
240
+ for (const root of roots) {
241
+ for (const config of scanSkillRoot(root)) {
242
+ if (!bySkill.has(config.skill_name)) {
243
+ bySkill.set(config.skill_name, config);
244
+ }
245
+ }
246
+ }
247
+
248
+ return [...bySkill.values()].sort((a, b) => a.skill_name.localeCompare(b.skill_name));
249
+ }
@@ -0,0 +1,177 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ import { readAlphaIdentity } from "./alpha-identity.js";
4
+ import { CONTRIBUTION_RELAY_ENDPOINT, SELFTUNE_CONFIG_PATH } from "./constants.js";
5
+ import type { CreatorContributionRelayPayload } from "./contribution-signals.js";
6
+ import {
7
+ markCreatorContributionFailed,
8
+ markCreatorContributionSending,
9
+ markCreatorContributionSent,
10
+ requeueFailedCreatorContributionSignals,
11
+ requeueSendingCreatorContributionSignals,
12
+ } from "./contribution-staging.js";
13
+ import {
14
+ getCreatorContributionRelayStats,
15
+ getPendingCreatorContributionRows,
16
+ type CreatorContributionRelayStats,
17
+ } from "./localdb/queries.js";
18
+ import { getSelftuneVersion } from "./utils/selftune-meta.js";
19
+
20
+ export interface ContributionRelayUploadResult {
21
+ success: boolean;
22
+ errors: string[];
23
+ _status: number;
24
+ }
25
+
26
+ export interface FlushCreatorContributionSignalsOptions {
27
+ endpoint?: string;
28
+ apiKey?: string;
29
+ limit?: number;
30
+ dryRun?: boolean;
31
+ retryFailed?: boolean;
32
+ }
33
+
34
+ export interface FlushCreatorContributionSignalsResult {
35
+ endpoint: string;
36
+ attempted: number;
37
+ sent: number;
38
+ failed: number;
39
+ requeued: number;
40
+ retried_failed: number;
41
+ stats: CreatorContributionRelayStats;
42
+ dry_run: boolean;
43
+ }
44
+
45
+ function isAcceptedContributionResponse(
46
+ value: unknown,
47
+ ): value is { status: "accepted" | "duplicate" } {
48
+ if (typeof value !== "object" || value === null) return false;
49
+ const record = value as Record<string, unknown>;
50
+ return record.status === "accepted" || record.status === "duplicate";
51
+ }
52
+
53
+ export function resolveContributionRelayEndpoint(explicit?: string): string {
54
+ return explicit?.trim() || CONTRIBUTION_RELAY_ENDPOINT;
55
+ }
56
+
57
+ export function resolveContributionRelayApiKey(explicit?: string): string | null {
58
+ if (explicit?.trim()) return explicit.trim();
59
+ return readAlphaIdentity(SELFTUNE_CONFIG_PATH)?.api_key?.trim() || null;
60
+ }
61
+
62
+ export async function uploadContributionSignal(
63
+ payload: CreatorContributionRelayPayload,
64
+ endpoint: string,
65
+ apiKey: string,
66
+ ): Promise<ContributionRelayUploadResult> {
67
+ try {
68
+ const response = await fetch(endpoint, {
69
+ method: "POST",
70
+ headers: {
71
+ "Content-Type": "application/json",
72
+ "User-Agent": `selftune/${getSelftuneVersion()}`,
73
+ Authorization: `Bearer ${apiKey}`,
74
+ },
75
+ body: JSON.stringify(payload),
76
+ signal: AbortSignal.timeout(30_000),
77
+ });
78
+
79
+ if (response.ok || response.status === 409) {
80
+ const body = await response.text();
81
+ if (!body.trim()) return { success: true, errors: [], _status: response.status };
82
+ try {
83
+ const parsed: unknown = JSON.parse(body);
84
+ if (isAcceptedContributionResponse(parsed)) {
85
+ return { success: true, errors: [], _status: response.status };
86
+ }
87
+ } catch {
88
+ // Empty or non-JSON success bodies are still acceptable here.
89
+ }
90
+ return { success: true, errors: [], _status: response.status };
91
+ }
92
+
93
+ const errorText = await response.text().catch(() => "unknown error");
94
+ return {
95
+ success: false,
96
+ errors: [`HTTP ${response.status}: ${errorText.slice(0, 200)}`],
97
+ _status: response.status,
98
+ };
99
+ } catch (error) {
100
+ const message = error instanceof Error ? error.message : String(error);
101
+ return {
102
+ success: false,
103
+ errors: [message],
104
+ _status: 0,
105
+ };
106
+ }
107
+ }
108
+
109
+ export async function flushCreatorContributionSignals(
110
+ db: Database,
111
+ options: FlushCreatorContributionSignalsOptions = {},
112
+ ): Promise<FlushCreatorContributionSignalsResult> {
113
+ const endpoint = resolveContributionRelayEndpoint(options.endpoint);
114
+ const limit = Math.max(1, options.limit ?? 50);
115
+
116
+ if (options.dryRun) {
117
+ const pendingRows = getPendingCreatorContributionRows(db, limit);
118
+ return {
119
+ endpoint,
120
+ attempted: pendingRows.length,
121
+ sent: 0,
122
+ failed: 0,
123
+ requeued: 0,
124
+ retried_failed: 0,
125
+ stats: getCreatorContributionRelayStats(db),
126
+ dry_run: true,
127
+ };
128
+ }
129
+
130
+ const requeued = requeueSendingCreatorContributionSignals(db);
131
+ const retriedFailed = options.retryFailed ? requeueFailedCreatorContributionSignals(db) : 0;
132
+ const pendingRows = getPendingCreatorContributionRows(db, limit);
133
+
134
+ const apiKey = resolveContributionRelayApiKey(options.apiKey);
135
+ if (!apiKey) {
136
+ throw new Error(
137
+ "Creator contribution relay upload requires a cloud API key. Run `selftune init --alpha` or pass --api-key.",
138
+ );
139
+ }
140
+
141
+ let sent = 0;
142
+ let failed = 0;
143
+
144
+ for (const row of pendingRows) {
145
+ if (!markCreatorContributionSending(db, row.id)) continue;
146
+
147
+ let payload: CreatorContributionRelayPayload;
148
+ try {
149
+ payload = JSON.parse(row.payload_json) as CreatorContributionRelayPayload;
150
+ } catch {
151
+ markCreatorContributionFailed(db, row.id, "Invalid staged creator contribution payload JSON");
152
+ failed += 1;
153
+ continue;
154
+ }
155
+
156
+ const result = await uploadContributionSignal(payload, endpoint, apiKey);
157
+ if (result.success) {
158
+ markCreatorContributionSent(db, row.id);
159
+ sent += 1;
160
+ continue;
161
+ }
162
+
163
+ markCreatorContributionFailed(db, row.id, result.errors.join("; "));
164
+ failed += 1;
165
+ }
166
+
167
+ return {
168
+ endpoint,
169
+ attempted: pendingRows.length,
170
+ sent,
171
+ failed,
172
+ requeued,
173
+ retried_failed: retriedFailed,
174
+ stats: getCreatorContributionRelayStats(db),
175
+ dry_run: false,
176
+ };
177
+ }
@@ -0,0 +1,219 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { createHash } from "node:crypto";
3
+ import { hostname } from "node:os";
4
+
5
+ import { readAlphaIdentity } from "./alpha-identity.js";
6
+ import { SELFTUNE_CONFIG_PATH } from "./constants.js";
7
+ import type { CreatorContributionConfig } from "./contribution-config.js";
8
+ import { queryGradingResults, queryTrustedSkillObservationRows } from "./localdb/queries.js";
9
+
10
+ export type ContributionSignal = "trigger" | "grade" | "miss_category";
11
+
12
+ export interface CreatorContributionRelayPayload {
13
+ version: 1;
14
+ signal_type: "skill_session";
15
+ relay_destination: string;
16
+ skill_hash: string;
17
+ user_cohort: string;
18
+ signals: {
19
+ triggered?: boolean;
20
+ invocation_type?: "explicit" | "implicit" | "contextual" | "missed";
21
+ execution_grade?: "A" | "B" | "C" | "F";
22
+ query_bucket?: string;
23
+ miss_detected?: boolean;
24
+ };
25
+ timestamp_bucket: string;
26
+ client_version: string;
27
+ }
28
+
29
+ export interface CreatorContributionSignalRecord {
30
+ skill_name: string;
31
+ creator_id: string;
32
+ source_key: string;
33
+ payload: CreatorContributionRelayPayload;
34
+ }
35
+
36
+ export interface ContributionSignalBuildOptions {
37
+ now?: Date;
38
+ clientVersion?: string;
39
+ cohortSeed?: string;
40
+ }
41
+
42
+ function gradeBucket(value: number): "A" | "B" | "C" | "F" {
43
+ if (value >= 0.9) return "A";
44
+ if (value >= 0.75) return "B";
45
+ if (value >= 0.5) return "C";
46
+ return "F";
47
+ }
48
+
49
+ function bucketWeek(date: Date): string {
50
+ const utc = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
51
+ const day = utc.getUTCDay() || 7;
52
+ utc.setUTCDate(utc.getUTCDate() + 4 - day);
53
+ const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
54
+ const week = Math.ceil(((utc.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
55
+ return `${utc.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
56
+ }
57
+
58
+ function resolveContributionCohortSeed(explicitSeed?: string): string {
59
+ if (explicitSeed?.trim()) return explicitSeed.trim();
60
+ const alphaIdentity = readAlphaIdentity(SELFTUNE_CONFIG_PATH);
61
+ return alphaIdentity?.cloud_user_id || alphaIdentity?.user_id || hostname() || "selftune-local";
62
+ }
63
+
64
+ export function buildContributionUserCohort(now: Date = new Date(), explicitSeed?: string): string {
65
+ const monthBucket = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
66
+ const basis = `${resolveContributionCohortSeed(explicitSeed)}:${monthBucket}`;
67
+ return `uc_sha256_${createHash("sha256").update(basis).digest("hex").slice(0, 12)}`;
68
+ }
69
+
70
+ export function classifyContributionQueryBucket(query: string | null | undefined): string {
71
+ const text = (query ?? "").toLowerCase();
72
+ if (!text) return "other";
73
+
74
+ const patterns: Array<[string, RegExp]> = [
75
+ ["comparison", /\b(compare|comparison|versus|vs\b|trade[- ]?off|which is better)\b/],
76
+ ["troubleshooting", /\b(debug|debugging|fix|broken|not working|issue|error|troubleshoot)\b/],
77
+ ["migration", /\b(migrate|migration|upgrade|move from|switch to|convert)\b/],
78
+ ["configuration", /\b(config|configure|configuration|setup|set up)\b/],
79
+ ["analysis", /\b(analyze|analysis|evaluate|assess|review)\b/],
80
+ ["search", /\b(search|find|lookup)\b/],
81
+ ["generation", /\b(generate|create|write|draft)\b/],
82
+ ["testing", /\b(test|testing|spec|assert|regression)\b/],
83
+ ["refactoring", /\b(refactor|cleanup|clean up|restructure)\b/],
84
+ ["documentation", /\b(doc|docs|documentation|readme)\b/],
85
+ ];
86
+
87
+ for (const [bucket, pattern] of patterns) {
88
+ if (pattern.test(text)) return bucket;
89
+ }
90
+ return "other";
91
+ }
92
+
93
+ function invocationType(
94
+ invocationMode: string | null,
95
+ triggered: number,
96
+ ): "explicit" | "implicit" | "contextual" | "missed" {
97
+ if (triggered === 0) return "missed";
98
+ if (invocationMode === "explicit") return "explicit";
99
+ if (invocationMode === "implicit" || invocationMode === "inferred") return "implicit";
100
+ return "contextual";
101
+ }
102
+
103
+ function normalizeContributionSkillIdentifier(skillName: string): string {
104
+ return skillName.trim().toLowerCase();
105
+ }
106
+
107
+ function buildContributionSkillHash(skillName: string): string {
108
+ return `sk_sha256_${createHash("sha256").update(normalizeContributionSkillIdentifier(skillName)).digest("hex").slice(0, 12)}`;
109
+ }
110
+
111
+ export function buildCreatorDirectedContributionSignals(
112
+ db: Database,
113
+ configs: CreatorContributionConfig[],
114
+ options: ContributionSignalBuildOptions = {},
115
+ ): CreatorContributionSignalRecord[] {
116
+ const bySkill = new Map(configs.map((config) => [config.skill_name, config]));
117
+ const gradingBySession = new Map<string, "A" | "B" | "C" | "F">();
118
+ for (const row of queryGradingResults(db)) {
119
+ const source = typeof row.mean_score === "number" ? row.mean_score : row.pass_rate;
120
+ if (typeof source === "number" && !gradingBySession.has(row.session_id)) {
121
+ gradingBySession.set(row.session_id, gradeBucket(source));
122
+ }
123
+ }
124
+
125
+ const cohort = buildContributionUserCohort(options.now ?? new Date(), options.cohortSeed);
126
+ const clientVersion = options.clientVersion ?? "local-preview";
127
+
128
+ return queryTrustedSkillObservationRows(db)
129
+ .filter((row) => bySkill.has(row.skill_name))
130
+ .map((row) => {
131
+ const config = bySkill.get(row.skill_name)!;
132
+ const signals: CreatorContributionRelayPayload["signals"] = {};
133
+ if (config.contribution.signals.includes("trigger")) {
134
+ signals.triggered = row.triggered === 1;
135
+ signals.invocation_type = invocationType(row.invocation_mode, row.triggered);
136
+ signals.miss_detected = row.triggered === 0;
137
+ }
138
+ if (config.contribution.signals.includes("grade")) {
139
+ const grade = gradingBySession.get(row.session_id);
140
+ if (grade) signals.execution_grade = grade;
141
+ }
142
+ if (config.contribution.signals.includes("miss_category")) {
143
+ signals.query_bucket = classifyContributionQueryBucket(row.query_text);
144
+ }
145
+
146
+ return {
147
+ skill_name: row.skill_name,
148
+ creator_id: config.creator_id,
149
+ source_key: createHash("sha256")
150
+ .update(
151
+ [
152
+ row.skill_name,
153
+ row.session_id,
154
+ row.occurred_at ?? "",
155
+ row.query_text,
156
+ String(row.triggered),
157
+ row.invocation_mode ?? "",
158
+ ].join("::"),
159
+ )
160
+ .digest("hex")
161
+ .slice(0, 16),
162
+ payload: {
163
+ version: 1 as const,
164
+ signal_type: "skill_session" as const,
165
+ relay_destination: config.creator_id,
166
+ skill_hash: buildContributionSkillHash(config.skill_name),
167
+ user_cohort: cohort,
168
+ signals,
169
+ timestamp_bucket: bucketWeek(
170
+ row.occurred_at ? new Date(row.occurred_at) : (options.now ?? new Date()),
171
+ ),
172
+ client_version: clientVersion,
173
+ },
174
+ };
175
+ });
176
+ }
177
+
178
+ export function buildContributionPreview(
179
+ db: Database,
180
+ config: CreatorContributionConfig,
181
+ options: ContributionSignalBuildOptions = {},
182
+ ): {
183
+ observedCount: number;
184
+ triggerRate: number | null;
185
+ missRate: number | null;
186
+ gradedSessions: number;
187
+ samplePayload: CreatorContributionRelayPayload;
188
+ } {
189
+ const payloads = buildCreatorDirectedContributionSignals(db, [config], options);
190
+ const observedCount = payloads.length;
191
+ const triggeredCount = payloads.filter(
192
+ (record) => record.payload.signals.triggered === true,
193
+ ).length;
194
+ const missedCount = payloads.filter(
195
+ (record) => record.payload.signals.miss_detected === true,
196
+ ).length;
197
+ const gradedSessions = queryGradingResults(db).filter(
198
+ (row) => row.skill_name === config.skill_name,
199
+ ).length;
200
+
201
+ return {
202
+ observedCount,
203
+ triggerRate: observedCount > 0 ? Math.round((triggeredCount / observedCount) * 100) : null,
204
+ missRate: observedCount > 0 ? Math.round((missedCount / observedCount) * 100) : null,
205
+ gradedSessions,
206
+ samplePayload: payloads[0]?.payload ?? {
207
+ version: 1,
208
+ signal_type: "skill_session",
209
+ relay_destination: config.creator_id,
210
+ skill_hash: buildContributionSkillHash(config.skill_name),
211
+ user_cohort: buildContributionUserCohort(options.now ?? new Date(), options.cohortSeed),
212
+ signals: {
213
+ query_bucket: "other",
214
+ },
215
+ timestamp_bucket: bucketWeek(options.now ?? new Date()),
216
+ client_version: options.clientVersion ?? "local-preview",
217
+ },
218
+ };
219
+ }