selftune 0.2.16 → 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.
- package/README.md +32 -22
- package/apps/local-dashboard/dist/assets/index-DnhnXQm6.js +60 -0
- package/apps/local-dashboard/dist/assets/index-_EcLywDg.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-table-BIiI3YhS.js +1 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-CGEmUayx.js +12 -0
- package/apps/local-dashboard/dist/index.html +5 -5
- package/cli/selftune/alpha-upload/build-payloads.ts +14 -1
- package/cli/selftune/alpha-upload/client.ts +51 -1
- package/cli/selftune/alpha-upload/flush.ts +46 -5
- package/cli/selftune/alpha-upload/stage-canonical.ts +32 -10
- package/cli/selftune/alpha-upload-contract.ts +9 -0
- package/cli/selftune/constants.ts +92 -5
- package/cli/selftune/contribute/contribute.ts +30 -2
- package/cli/selftune/contribute/sanitize.ts +52 -5
- package/cli/selftune/contribution-config.ts +249 -0
- package/cli/selftune/contribution-relay.ts +177 -0
- package/cli/selftune/contribution-signals.ts +219 -0
- package/cli/selftune/contribution-staging.ts +147 -0
- package/cli/selftune/contributions.ts +532 -0
- package/cli/selftune/creator-contributions.ts +333 -0
- package/cli/selftune/dashboard-contract.ts +305 -1
- package/cli/selftune/dashboard-server.ts +47 -13
- package/cli/selftune/eval/family-overlap.ts +395 -0
- package/cli/selftune/eval/hooks-to-evals.ts +182 -28
- package/cli/selftune/eval/synthetic-evals.ts +298 -11
- package/cli/selftune/evolution/description-quality.ts +12 -11
- package/cli/selftune/evolution/evolve.ts +214 -51
- package/cli/selftune/evolution/validate-proposal.ts +9 -6
- package/cli/selftune/export.ts +2 -2
- package/cli/selftune/grading/grade-session.ts +20 -0
- package/cli/selftune/hooks/commit-track.ts +188 -0
- package/cli/selftune/hooks/prompt-log.ts +10 -1
- package/cli/selftune/hooks/session-stop.ts +2 -2
- package/cli/selftune/hooks/skill-eval.ts +15 -1
- package/cli/selftune/hooks/stdin-preview.ts +32 -0
- package/cli/selftune/index.ts +41 -5
- package/cli/selftune/ingestors/codex-rollout.ts +31 -35
- package/cli/selftune/ingestors/codex-wrapper.ts +32 -24
- package/cli/selftune/localdb/db.ts +2 -2
- package/cli/selftune/localdb/direct-write.ts +69 -6
- package/cli/selftune/localdb/queries.ts +1253 -37
- package/cli/selftune/localdb/schema.ts +66 -0
- package/cli/selftune/orchestrate.ts +32 -4
- package/cli/selftune/recover.ts +153 -0
- package/cli/selftune/repair/skill-usage.ts +363 -4
- package/cli/selftune/routes/actions.ts +35 -1
- package/cli/selftune/routes/analytics.ts +14 -0
- package/cli/selftune/routes/index.ts +1 -0
- package/cli/selftune/routes/overview.ts +150 -4
- package/cli/selftune/routes/skill-report.ts +648 -18
- package/cli/selftune/status.ts +81 -2
- package/cli/selftune/sync.ts +56 -2
- package/cli/selftune/trust-model.ts +66 -0
- package/cli/selftune/types.ts +80 -0
- package/cli/selftune/utils/skill-detection.ts +43 -0
- package/cli/selftune/utils/transcript.ts +210 -1
- package/cli/selftune/watchlist.ts +65 -0
- package/node_modules/@selftune/telemetry-contract/src/types.ts +11 -0
- package/package.json +1 -1
- package/packages/telemetry-contract/src/types.ts +11 -0
- package/packages/ui/src/components/ActivityTimeline.tsx +165 -150
- package/packages/ui/src/components/EvidenceViewer.tsx +335 -144
- package/packages/ui/src/components/EvolutionTimeline.tsx +58 -28
- package/packages/ui/src/components/OrchestrateRunsPanel.tsx +33 -16
- package/packages/ui/src/components/RecentActivityFeed.tsx +72 -41
- package/packages/ui/src/components/section-cards.tsx +12 -9
- package/packages/ui/src/primitives/card.tsx +1 -1
- package/skill/SKILL.md +40 -2
- package/skill/Workflows/AlphaUpload.md +4 -0
- package/skill/Workflows/Composability.md +64 -0
- package/skill/Workflows/Contribute.md +6 -3
- package/skill/Workflows/Contributions.md +97 -0
- package/skill/Workflows/CreatorContributions.md +74 -0
- package/skill/Workflows/Dashboard.md +31 -0
- package/skill/Workflows/Evals.md +57 -8
- package/skill/Workflows/Evolve.md +31 -13
- package/skill/Workflows/ExportCanonical.md +121 -0
- package/skill/Workflows/Hook.md +131 -0
- package/skill/Workflows/Ingest.md +7 -0
- package/skill/Workflows/Initialize.md +29 -9
- package/skill/Workflows/Orchestrate.md +27 -5
- package/skill/Workflows/Quickstart.md +94 -0
- package/skill/Workflows/Recover.md +84 -0
- package/skill/Workflows/RepairSkillUsage.md +95 -0
- package/skill/Workflows/Sync.md +18 -12
- package/skill/Workflows/Uninstall.md +82 -0
- package/skill/settings_snippet.json +11 -0
- package/apps/local-dashboard/dist/assets/index-BMIS6uUh.css +0 -2
- package/apps/local-dashboard/dist/assets/index-DOu3iLD9.js +0 -16
- package/apps/local-dashboard/dist/assets/vendor-table-pHbDxq36.js +0 -8
- package/apps/local-dashboard/dist/assets/vendor-ui-DIwlrGlb.js +0 -12
|
@@ -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
|
+
}
|