selftune 0.2.5 → 0.2.8
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 +1 -0
- package/apps/local-dashboard/dist/assets/index-Bk9vSHHd.js +15 -0
- package/apps/local-dashboard/dist/assets/index-CRtLkBTi.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
- package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
- package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
- package/apps/local-dashboard/dist/index.html +5 -5
- package/cli/selftune/activation-rules.ts +30 -9
- package/cli/selftune/agent-guidance.ts +96 -0
- package/cli/selftune/alpha-identity.ts +157 -0
- package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
- package/cli/selftune/alpha-upload/client.ts +113 -0
- package/cli/selftune/alpha-upload/flush.ts +191 -0
- package/cli/selftune/alpha-upload/index.ts +194 -0
- package/cli/selftune/alpha-upload/queue.ts +252 -0
- package/cli/selftune/alpha-upload/stage-canonical.ts +242 -0
- package/cli/selftune/alpha-upload-contract.ts +52 -0
- package/cli/selftune/auth/device-code.ts +110 -0
- package/cli/selftune/auto-update.ts +130 -0
- package/cli/selftune/badge/badge.ts +19 -9
- package/cli/selftune/canonical-export.ts +16 -3
- package/cli/selftune/constants.ts +28 -8
- package/cli/selftune/contribute/bundle.ts +32 -5
- package/cli/selftune/dashboard-contract.ts +32 -1
- package/cli/selftune/dashboard-server.ts +256 -692
- package/cli/selftune/dashboard.ts +1 -1
- package/cli/selftune/eval/baseline.ts +11 -7
- package/cli/selftune/eval/hooks-to-evals.ts +27 -9
- package/cli/selftune/eval/synthetic-evals.ts +54 -1
- package/cli/selftune/evolution/audit.ts +24 -19
- package/cli/selftune/evolution/constitutional.ts +176 -0
- package/cli/selftune/evolution/evidence.ts +18 -13
- package/cli/selftune/evolution/evolve-body.ts +104 -7
- package/cli/selftune/evolution/evolve.ts +195 -22
- package/cli/selftune/evolution/propose-body.ts +18 -1
- package/cli/selftune/evolution/propose-description.ts +27 -2
- package/cli/selftune/evolution/rollback.ts +11 -15
- package/cli/selftune/export.ts +84 -0
- package/cli/selftune/grading/auto-grade.ts +13 -4
- package/cli/selftune/grading/grade-session.ts +16 -6
- package/cli/selftune/hooks/evolution-guard.ts +26 -9
- package/cli/selftune/hooks/prompt-log.ts +23 -9
- package/cli/selftune/hooks/session-stop.ts +78 -15
- package/cli/selftune/hooks/skill-eval.ts +189 -10
- package/cli/selftune/index.ts +274 -2
- package/cli/selftune/ingestors/claude-replay.ts +48 -21
- package/cli/selftune/init.ts +249 -47
- package/cli/selftune/last.ts +7 -7
- package/cli/selftune/localdb/db.ts +90 -10
- package/cli/selftune/localdb/direct-write.ts +531 -0
- package/cli/selftune/localdb/materialize.ts +296 -42
- package/cli/selftune/localdb/queries.ts +325 -32
- package/cli/selftune/localdb/schema.ts +109 -0
- package/cli/selftune/monitoring/watch.ts +26 -8
- package/cli/selftune/normalization.ts +85 -15
- package/cli/selftune/observability.ts +248 -2
- package/cli/selftune/orchestrate.ts +165 -20
- package/cli/selftune/quickstart.ts +34 -10
- package/cli/selftune/repair/skill-usage.ts +12 -2
- package/cli/selftune/routes/actions.ts +77 -0
- package/cli/selftune/routes/badge.ts +66 -0
- package/cli/selftune/routes/doctor.ts +12 -0
- package/cli/selftune/routes/index.ts +14 -0
- package/cli/selftune/routes/orchestrate-runs.ts +13 -0
- package/cli/selftune/routes/overview.ts +14 -0
- package/cli/selftune/routes/report.ts +293 -0
- package/cli/selftune/routes/skill-report.ts +230 -0
- package/cli/selftune/status.ts +203 -7
- package/cli/selftune/sync.ts +13 -1
- package/cli/selftune/types.ts +50 -0
- package/cli/selftune/utils/jsonl.ts +58 -1
- package/cli/selftune/utils/selftune-meta.ts +38 -0
- package/cli/selftune/utils/skill-log.ts +30 -4
- package/cli/selftune/utils/transcript.ts +15 -0
- package/cli/selftune/workflows/workflows.ts +7 -6
- package/package.json +11 -7
- package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
- package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
- package/packages/telemetry-contract/fixtures/golden.json +1 -0
- package/packages/telemetry-contract/fixtures/index.ts +4 -0
- package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
- package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
- package/packages/telemetry-contract/package.json +6 -1
- package/packages/telemetry-contract/src/index.ts +1 -0
- package/packages/telemetry-contract/src/schemas.ts +215 -0
- package/packages/telemetry-contract/src/types.ts +3 -1
- package/packages/telemetry-contract/src/validators.ts +3 -1
- package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
- package/packages/ui/package.json +4 -0
- package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
- package/packages/ui/src/components/section-cards.tsx +31 -14
- package/packages/ui/src/types.ts +1 -0
- package/skill/SKILL.md +214 -174
- package/skill/Workflows/AlphaUpload.md +45 -0
- package/skill/Workflows/Baseline.md +18 -12
- package/skill/Workflows/Composability.md +3 -3
- package/skill/Workflows/Dashboard.md +44 -91
- package/skill/Workflows/Doctor.md +93 -66
- package/skill/Workflows/Evals.md +49 -40
- package/skill/Workflows/Evolve.md +76 -28
- package/skill/Workflows/EvolveBody.md +37 -38
- package/skill/Workflows/Initialize.md +172 -26
- package/skill/Workflows/Orchestrate.md +11 -2
- package/skill/Workflows/Sync.md +23 -0
- package/skill/Workflows/Watch.md +2 -5
- package/skill/agents/diagnosis-analyst.md +163 -0
- package/skill/agents/evolution-reviewer.md +149 -0
- package/skill/agents/integration-guide.md +154 -0
- package/skill/agents/pattern-analyst.md +149 -0
- package/skill/assets/multi-skill-settings.json +1 -1
- package/skill/assets/single-skill-settings.json +1 -1
- package/skill/references/interactive-config.md +39 -0
- package/skill/references/invocation-taxonomy.md +34 -0
- package/skill/references/logs.md +9 -1
- package/skill/references/setup-patterns.md +3 -3
- package/skill/settings_snippet.json +1 -1
- package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
- package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +0 -60
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>selftune — Dashboard</title>
|
|
7
7
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-react-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-
|
|
11
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-table-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-Bk9vSHHd.js"></script>
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BQH_6WrG.js">
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-ui-CO2mrx6e.js">
|
|
11
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-table-dK1QMLq9.js">
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CRtLkBTi.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
|
+
import { EVOLUTION_AUDIT_LOG, QUERY_LOG } from "./constants.js";
|
|
13
|
+
import { getDb } from "./localdb/db.js";
|
|
14
|
+
import { queryEvolutionAudit, queryQueryLog, querySkillUsageRecords } from "./localdb/queries.js";
|
|
12
15
|
import type { ActivationContext, ActivationRule } from "./types.js";
|
|
13
16
|
import { readJsonl } from "./utils/jsonl.js";
|
|
14
17
|
|
|
@@ -21,18 +24,32 @@ const postSessionDiagnostic: ActivationRule = {
|
|
|
21
24
|
description: "Suggest `selftune last` when session has >2 unmatched queries",
|
|
22
25
|
evaluate(ctx: ActivationContext): string | null {
|
|
23
26
|
// Count queries for this session
|
|
24
|
-
|
|
27
|
+
let queries: Array<{ session_id: string; query: string }>;
|
|
28
|
+
if (ctx.query_log_path === QUERY_LOG) {
|
|
29
|
+
const db = getDb();
|
|
30
|
+
queries = queryQueryLog(db) as Array<{ session_id: string; query: string }>;
|
|
31
|
+
} else {
|
|
32
|
+
queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path);
|
|
33
|
+
}
|
|
25
34
|
const sessionQueries = queries.filter((q) => q.session_id === ctx.session_id);
|
|
26
35
|
|
|
27
36
|
if (sessionQueries.length === 0) return null;
|
|
28
37
|
|
|
29
38
|
// Count skill usages for this session (skill log is in the same dir as query log)
|
|
30
39
|
const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl");
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
let skillUsages: Array<{ session_id: string }>;
|
|
41
|
+
if (ctx.query_log_path === QUERY_LOG) {
|
|
42
|
+
const db = getDb();
|
|
43
|
+
skillUsages = (querySkillUsageRecords(db) as Array<{ session_id: string }>).filter(
|
|
44
|
+
(s) => s.session_id === ctx.session_id,
|
|
45
|
+
);
|
|
46
|
+
} else {
|
|
47
|
+
skillUsages = existsSync(skillLogPath)
|
|
48
|
+
? readJsonl<{ session_id: string }>(skillLogPath).filter(
|
|
49
|
+
(s) => s.session_id === ctx.session_id,
|
|
50
|
+
)
|
|
51
|
+
: [];
|
|
52
|
+
}
|
|
36
53
|
|
|
37
54
|
const unmatchedCount = sessionQueries.length - skillUsages.length;
|
|
38
55
|
|
|
@@ -94,9 +111,13 @@ const staleEvolution: ActivationRule = {
|
|
|
94
111
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
|
95
112
|
|
|
96
113
|
// Check last evolution timestamp
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
let auditEntries: Array<{ timestamp: string; action: string }>;
|
|
115
|
+
if (ctx.evolution_audit_log_path === EVOLUTION_AUDIT_LOG) {
|
|
116
|
+
const db = getDb();
|
|
117
|
+
auditEntries = queryEvolutionAudit(db) as Array<{ timestamp: string; action: string }>;
|
|
118
|
+
} else {
|
|
119
|
+
auditEntries = readJsonl<{ timestamp: string; action: string }>(ctx.evolution_audit_log_path);
|
|
120
|
+
}
|
|
100
121
|
|
|
101
122
|
if (auditEntries.length === 0) {
|
|
102
123
|
// No evolution has ever run — check for false negatives
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { getAlphaLinkState } from "./alpha-identity.js";
|
|
2
|
+
import type { AgentCommandGuidance, AlphaIdentity, AlphaLinkState } from "./types.js";
|
|
3
|
+
|
|
4
|
+
function emailArg(email?: string): string {
|
|
5
|
+
return email?.trim() ? email : "<email>";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function buildAlphaInitCommand(options?: {
|
|
9
|
+
email?: string;
|
|
10
|
+
includeKey?: boolean;
|
|
11
|
+
force?: boolean;
|
|
12
|
+
}): string {
|
|
13
|
+
const parts = ["selftune", "init", "--alpha", "--alpha-email", emailArg(options?.email)];
|
|
14
|
+
if (options?.includeKey) {
|
|
15
|
+
parts.push("--alpha-key", "<st_live_key>");
|
|
16
|
+
}
|
|
17
|
+
if (options?.force) {
|
|
18
|
+
parts.push("--force");
|
|
19
|
+
}
|
|
20
|
+
return parts.join(" ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildGuidance(
|
|
24
|
+
code: string,
|
|
25
|
+
message: string,
|
|
26
|
+
nextCommand: string,
|
|
27
|
+
blocking: boolean,
|
|
28
|
+
suggestedCommands: string[],
|
|
29
|
+
): AgentCommandGuidance {
|
|
30
|
+
return {
|
|
31
|
+
code,
|
|
32
|
+
message,
|
|
33
|
+
next_command: nextCommand,
|
|
34
|
+
suggested_commands: suggestedCommands,
|
|
35
|
+
blocking,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getAlphaGuidanceForState(
|
|
40
|
+
state: AlphaLinkState,
|
|
41
|
+
options?: { email?: string },
|
|
42
|
+
): AgentCommandGuidance {
|
|
43
|
+
switch (state) {
|
|
44
|
+
case "not_linked":
|
|
45
|
+
return buildGuidance(
|
|
46
|
+
"alpha_cloud_link_required",
|
|
47
|
+
"Alpha upload is not linked. Sign in to app.selftune.dev, enroll in alpha, mint an st_live_* credential, then store it locally.",
|
|
48
|
+
buildAlphaInitCommand({ email: options?.email, includeKey: true }),
|
|
49
|
+
true,
|
|
50
|
+
["selftune status", "selftune doctor"],
|
|
51
|
+
);
|
|
52
|
+
case "linked_not_enrolled":
|
|
53
|
+
return buildGuidance(
|
|
54
|
+
"alpha_enrollment_incomplete",
|
|
55
|
+
"Cloud account is linked but alpha enrollment is incomplete. Finish enrollment in app.selftune.dev, then refresh the local credential.",
|
|
56
|
+
buildAlphaInitCommand({ email: options?.email, includeKey: true, force: true }),
|
|
57
|
+
true,
|
|
58
|
+
["selftune status", "selftune doctor"],
|
|
59
|
+
);
|
|
60
|
+
case "enrolled_no_credential":
|
|
61
|
+
return buildGuidance(
|
|
62
|
+
"alpha_credential_required",
|
|
63
|
+
"Alpha enrollment exists, but the local upload credential is missing or invalid.",
|
|
64
|
+
buildAlphaInitCommand({ email: options?.email, includeKey: true, force: true }),
|
|
65
|
+
true,
|
|
66
|
+
["selftune status", "selftune doctor"],
|
|
67
|
+
);
|
|
68
|
+
case "ready":
|
|
69
|
+
return buildGuidance(
|
|
70
|
+
"alpha_upload_ready",
|
|
71
|
+
"Alpha upload is configured and ready.",
|
|
72
|
+
"selftune alpha upload",
|
|
73
|
+
false,
|
|
74
|
+
["selftune status", "selftune doctor"],
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getAlphaGuidance(identity: AlphaIdentity | null): AgentCommandGuidance {
|
|
80
|
+
if (!identity) {
|
|
81
|
+
return getAlphaGuidanceForState("not_linked");
|
|
82
|
+
}
|
|
83
|
+
return getAlphaGuidanceForState(getAlphaLinkState(identity), { email: identity.email });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatGuidanceLines(
|
|
87
|
+
guidance: AgentCommandGuidance,
|
|
88
|
+
options?: { indent?: string },
|
|
89
|
+
): string[] {
|
|
90
|
+
const indent = options?.indent ?? " ";
|
|
91
|
+
const lines = [`${indent}Next command: ${guidance.next_command}`];
|
|
92
|
+
if (guidance.suggested_commands.length > 0) {
|
|
93
|
+
lines.push(`${indent}Suggested commands: ${guidance.suggested_commands.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
return lines;
|
|
96
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha program identity management — cached cloud identity model.
|
|
3
|
+
*
|
|
4
|
+
* Local config is a cache of cloud-linked identity, not the source of truth.
|
|
5
|
+
* The cloud_user_id field is the primary "linked" indicator. Legacy local-only
|
|
6
|
+
* identities (user_id without cloud_user_id) are detected by migrateLocalIdentity().
|
|
7
|
+
*
|
|
8
|
+
* Handles stable user identity generation, config persistence,
|
|
9
|
+
* and consent notice for the selftune alpha program.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
|
|
16
|
+
import type { AlphaIdentity, AlphaLinkState, SelftuneConfig } from "./types.js";
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// User ID generation
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** Generate a stable UUID for alpha user identity. */
|
|
23
|
+
export function generateUserId(): string {
|
|
24
|
+
return randomUUID();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Config read/write helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read the alpha identity block from the selftune config file.
|
|
33
|
+
* Returns null if config does not exist or has no alpha block.
|
|
34
|
+
*/
|
|
35
|
+
export function readAlphaIdentity(configPath: string): AlphaIdentity | null {
|
|
36
|
+
if (!existsSync(configPath)) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
40
|
+
const config = JSON.parse(raw) as SelftuneConfig;
|
|
41
|
+
return config.alpha ?? null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Write the alpha identity block into the selftune config file.
|
|
49
|
+
* Reads existing config, merges the alpha block, and writes back.
|
|
50
|
+
* Creates parent directories if needed.
|
|
51
|
+
*/
|
|
52
|
+
export function writeAlphaIdentity(configPath: string, identity: AlphaIdentity): void {
|
|
53
|
+
let config: Record<string, unknown> = {};
|
|
54
|
+
|
|
55
|
+
if (existsSync(configPath)) {
|
|
56
|
+
try {
|
|
57
|
+
config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Unable to update alpha identity: ${configPath} is not valid JSON (${message})`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
config.alpha = identity;
|
|
67
|
+
|
|
68
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
69
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Link state helper — cloud-first model
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Check if an API key has the expected st_live_ or st_test_ prefix. */
|
|
77
|
+
export function isValidApiKeyFormat(key: string): boolean {
|
|
78
|
+
return key.startsWith("st_live_") || key.startsWith("st_test_");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Derive the cloud link readiness state from an AlphaIdentity.
|
|
83
|
+
*
|
|
84
|
+
* State machine:
|
|
85
|
+
* null -> "not_linked"
|
|
86
|
+
* not enrolled, no cloud_user_id -> "not_linked"
|
|
87
|
+
* not enrolled, has cloud_user_id -> "linked_not_enrolled"
|
|
88
|
+
* enrolled, no valid api_key -> "enrolled_no_credential"
|
|
89
|
+
* enrolled, valid api_key -> "ready"
|
|
90
|
+
*
|
|
91
|
+
* cloud_user_id enriches the identity (confirms cloud link) but is not a gate.
|
|
92
|
+
* The direct-key path (--alpha-key) sets api_key without cloud_user_id, and
|
|
93
|
+
* that is a valid "ready" state. cloud_user_id can be backfilled later.
|
|
94
|
+
*/
|
|
95
|
+
export function getAlphaLinkState(identity: AlphaIdentity | null): AlphaLinkState {
|
|
96
|
+
if (!identity) return "not_linked";
|
|
97
|
+
if (!identity.enrolled) return identity.cloud_user_id ? "linked_not_enrolled" : "not_linked";
|
|
98
|
+
if (!identity.api_key || !isValidApiKeyFormat(identity.api_key)) return "enrolled_no_credential";
|
|
99
|
+
// Enrolled + valid key = ready (cloud_user_id is bonus, not gate)
|
|
100
|
+
return "ready";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Migration helper
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect legacy local-only alpha blocks and mark them as needing cloud link.
|
|
109
|
+
* A legacy identity has email + user_id but no cloud_user_id.
|
|
110
|
+
*/
|
|
111
|
+
export function migrateLocalIdentity(identity: AlphaIdentity): {
|
|
112
|
+
needsCloudLink: boolean;
|
|
113
|
+
identity: AlphaIdentity;
|
|
114
|
+
} {
|
|
115
|
+
if (identity.cloud_user_id) {
|
|
116
|
+
return { needsCloudLink: false, identity };
|
|
117
|
+
}
|
|
118
|
+
// Legacy: has local user_id but no cloud link
|
|
119
|
+
return { needsCloudLink: true, identity };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Consent notice
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export const ALPHA_CONSENT_NOTICE = `
|
|
127
|
+
========================================
|
|
128
|
+
selftune Alpha Program
|
|
129
|
+
========================================
|
|
130
|
+
|
|
131
|
+
You are enrolling in the selftune alpha program.
|
|
132
|
+
|
|
133
|
+
WHAT IS COLLECTED:
|
|
134
|
+
- Skill invocations and trigger metadata
|
|
135
|
+
- Session metadata (timestamps, tool counts, error counts)
|
|
136
|
+
- Evolution outcomes (proposals, pass rates, deployments)
|
|
137
|
+
- Raw user prompt/query text submitted during captured sessions
|
|
138
|
+
|
|
139
|
+
WHAT IS NOT COLLECTED:
|
|
140
|
+
- File contents or source code
|
|
141
|
+
- Full transcript bodies beyond the captured prompt/query text
|
|
142
|
+
- Structured repository names or file paths as separate fields
|
|
143
|
+
|
|
144
|
+
IMPORTANT:
|
|
145
|
+
Raw prompt/query text is uploaded unchanged for the friendly alpha cohort.
|
|
146
|
+
If your prompt includes repository names, file paths, or secrets, that text
|
|
147
|
+
may be included in the alpha data you choose to share.
|
|
148
|
+
|
|
149
|
+
Your alpha identity (email, display name, and any upload API key)
|
|
150
|
+
is stored locally in ~/.selftune/config.json and used for alpha coordination
|
|
151
|
+
and authenticated uploads.
|
|
152
|
+
|
|
153
|
+
TO UNENROLL:
|
|
154
|
+
selftune init --no-alpha
|
|
155
|
+
|
|
156
|
+
========================================
|
|
157
|
+
`;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2 canonical push payload builder (staging-based).
|
|
3
|
+
*
|
|
4
|
+
* Reads from the canonical_upload_staging table using a single monotonic
|
|
5
|
+
* cursor (local_seq). Each staged row contains the full canonical record
|
|
6
|
+
* JSON, so no fields are dropped or hardcoded during payload construction.
|
|
7
|
+
*
|
|
8
|
+
* Evolution evidence rows (record_kind = "evolution_evidence") are separated
|
|
9
|
+
* and placed in the canonical.evolution_evidence array.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Database } from "bun:sqlite";
|
|
13
|
+
import type { CanonicalRecord } from "@selftune/telemetry-contract";
|
|
14
|
+
import { buildPushPayloadV2 } from "../canonical-export.js";
|
|
15
|
+
import type { EvolutionEvidenceEntry } from "../types.js";
|
|
16
|
+
|
|
17
|
+
// -- Types --------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface BuildV2Result {
|
|
20
|
+
payload: Record<string, unknown>;
|
|
21
|
+
lastSeq: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// -- Constants ----------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const DEFAULT_LIMIT = 500;
|
|
27
|
+
|
|
28
|
+
// -- Helpers ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** Parse a JSON string, returning null on failure. */
|
|
31
|
+
function safeParseJson<T>(json: string | null): T | null {
|
|
32
|
+
if (!json) return null;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(json) as T;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// -- Main builder -------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a V2 canonical push payload from the staging table.
|
|
44
|
+
*
|
|
45
|
+
* Reads records from canonical_upload_staging WHERE local_seq > afterSeq,
|
|
46
|
+
* groups them by record_kind, and assembles a V2 push payload.
|
|
47
|
+
*
|
|
48
|
+
* Returns null when no new records exist after afterSeq.
|
|
49
|
+
*/
|
|
50
|
+
export function buildV2PushPayload(
|
|
51
|
+
db: Database,
|
|
52
|
+
afterSeq?: number,
|
|
53
|
+
limit: number = DEFAULT_LIMIT,
|
|
54
|
+
): BuildV2Result | null {
|
|
55
|
+
const whereClause = afterSeq !== undefined ? "WHERE local_seq > ?" : "";
|
|
56
|
+
const params = afterSeq !== undefined ? [afterSeq, limit] : [limit];
|
|
57
|
+
|
|
58
|
+
const sql = `
|
|
59
|
+
SELECT local_seq, record_kind, record_json
|
|
60
|
+
FROM canonical_upload_staging
|
|
61
|
+
${whereClause}
|
|
62
|
+
ORDER BY local_seq ASC
|
|
63
|
+
LIMIT ?
|
|
64
|
+
`;
|
|
65
|
+
|
|
66
|
+
const rows = db.query(sql).all(...params) as Array<{
|
|
67
|
+
local_seq: number;
|
|
68
|
+
record_kind: string;
|
|
69
|
+
record_json: string;
|
|
70
|
+
}>;
|
|
71
|
+
|
|
72
|
+
if (rows.length === 0) return null;
|
|
73
|
+
|
|
74
|
+
const canonicalRecords: CanonicalRecord[] = [];
|
|
75
|
+
const evidenceEntries: EvolutionEvidenceEntry[] = [];
|
|
76
|
+
const orchestrateRuns: Record<string, unknown>[] = [];
|
|
77
|
+
let lastParsedSeq: number | null = null;
|
|
78
|
+
let hitMalformedRow = false;
|
|
79
|
+
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
const parsed = safeParseJson<Record<string, unknown>>(row.record_json);
|
|
82
|
+
if (!parsed) {
|
|
83
|
+
hitMalformedRow = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (row.record_kind === "evolution_evidence") {
|
|
88
|
+
const timestamp =
|
|
89
|
+
typeof parsed.timestamp === "string" && parsed.timestamp.trim().length > 0
|
|
90
|
+
? parsed.timestamp
|
|
91
|
+
: null;
|
|
92
|
+
const proposalId =
|
|
93
|
+
typeof parsed.proposal_id === "string" && parsed.proposal_id.trim().length > 0
|
|
94
|
+
? parsed.proposal_id
|
|
95
|
+
: null;
|
|
96
|
+
if (!timestamp || !proposalId) {
|
|
97
|
+
hitMalformedRow = true;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Evolution evidence has its own shape
|
|
102
|
+
evidenceEntries.push({
|
|
103
|
+
timestamp,
|
|
104
|
+
proposal_id: proposalId,
|
|
105
|
+
skill_name: parsed.skill_name as string,
|
|
106
|
+
skill_path: (parsed.skill_path as string) ?? "",
|
|
107
|
+
target: (parsed.target as EvolutionEvidenceEntry["target"]) ?? "description",
|
|
108
|
+
stage: (parsed.stage as EvolutionEvidenceEntry["stage"]) ?? "created",
|
|
109
|
+
rationale: parsed.rationale as string | undefined,
|
|
110
|
+
confidence: parsed.confidence as number | undefined,
|
|
111
|
+
details: parsed.details as string | undefined,
|
|
112
|
+
original_text: parsed.original_text as string | undefined,
|
|
113
|
+
proposed_text: parsed.proposed_text as string | undefined,
|
|
114
|
+
eval_set: parsed.eval_set_json as EvolutionEvidenceEntry["eval_set"],
|
|
115
|
+
validation: parsed.validation_json as EvolutionEvidenceEntry["validation"],
|
|
116
|
+
evidence_id: parsed.evidence_id as string | undefined,
|
|
117
|
+
});
|
|
118
|
+
} else if (row.record_kind === "orchestrate_run") {
|
|
119
|
+
// Orchestrate run records -- pass through as-is
|
|
120
|
+
orchestrateRuns.push(parsed);
|
|
121
|
+
} else {
|
|
122
|
+
// Canonical telemetry records -- pass through as-is
|
|
123
|
+
canonicalRecords.push(parsed as unknown as CanonicalRecord);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
lastParsedSeq = row.local_seq;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// If nothing parsed successfully, return null
|
|
130
|
+
if (
|
|
131
|
+
canonicalRecords.length === 0 &&
|
|
132
|
+
evidenceEntries.length === 0 &&
|
|
133
|
+
orchestrateRuns.length === 0
|
|
134
|
+
) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const payload = buildPushPayloadV2(canonicalRecords, evidenceEntries, orchestrateRuns);
|
|
139
|
+
if (lastParsedSeq === null) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
const lastSeq = lastParsedSeq;
|
|
143
|
+
|
|
144
|
+
if (hitMalformedRow && (process.env.DEBUG || process.env.NODE_ENV === "development")) {
|
|
145
|
+
console.error(
|
|
146
|
+
"[alpha-upload/build-payloads] encountered malformed staged row; cursor held at last valid seq",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { payload, lastSeq };
|
|
151
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alpha upload HTTP client.
|
|
3
|
+
*
|
|
4
|
+
* POSTs V2 canonical push payloads to the cloud API's POST /api/v1/push.
|
|
5
|
+
* Uses native fetch (Bun built-in). Never throws -- returns a
|
|
6
|
+
* PushUploadResult indicating success or failure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PushUploadResult } from "../alpha-upload-contract.js";
|
|
10
|
+
import { getSelftuneVersion } from "../utils/selftune-meta.js";
|
|
11
|
+
|
|
12
|
+
function isPushUploadResult(value: unknown): value is PushUploadResult {
|
|
13
|
+
if (typeof value !== "object" || value === null) return false;
|
|
14
|
+
const record = value as Record<string, unknown>;
|
|
15
|
+
return (
|
|
16
|
+
typeof record.success === "boolean" &&
|
|
17
|
+
Array.isArray(record.errors) &&
|
|
18
|
+
record.errors.every((entry) => typeof entry === "string") &&
|
|
19
|
+
(record.push_id === undefined || typeof record.push_id === "string") &&
|
|
20
|
+
(record._status === undefined || typeof record._status === "number")
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isAcceptedPushResponse(value: unknown): value is { status: "accepted"; push_id: string } {
|
|
25
|
+
if (typeof value !== "object" || value === null) return false;
|
|
26
|
+
const record = value as Record<string, unknown>;
|
|
27
|
+
return record.status === "accepted" && typeof record.push_id === "string";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Upload a single V2 push payload to the given endpoint.
|
|
32
|
+
*
|
|
33
|
+
* Returns a typed result. Never throws -- network errors and HTTP
|
|
34
|
+
* failures are captured in the result.
|
|
35
|
+
*/
|
|
36
|
+
export async function uploadPushPayload(
|
|
37
|
+
payload: Record<string, unknown>,
|
|
38
|
+
endpoint: string,
|
|
39
|
+
apiKey?: string,
|
|
40
|
+
): Promise<PushUploadResult> {
|
|
41
|
+
try {
|
|
42
|
+
const headers: Record<string, string> = {
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"User-Agent": `selftune/${getSelftuneVersion()}`,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (apiKey) {
|
|
48
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const response = await fetch(endpoint, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers,
|
|
54
|
+
body: JSON.stringify(payload),
|
|
55
|
+
signal: AbortSignal.timeout(30_000),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (response.ok) {
|
|
59
|
+
// Read body as text first — Bun consumes the stream on .json(),
|
|
60
|
+
// so a failed .json() followed by .text() would throw.
|
|
61
|
+
const body = await response.text();
|
|
62
|
+
if (body.length === 0) {
|
|
63
|
+
return {
|
|
64
|
+
success: true,
|
|
65
|
+
push_id: (payload as { push_id?: string }).push_id,
|
|
66
|
+
errors: [],
|
|
67
|
+
_status: response.status,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const parsed: unknown = JSON.parse(body);
|
|
72
|
+
if (isPushUploadResult(parsed)) {
|
|
73
|
+
return { ...parsed, _status: parsed._status ?? response.status };
|
|
74
|
+
}
|
|
75
|
+
if (isAcceptedPushResponse(parsed)) {
|
|
76
|
+
return {
|
|
77
|
+
success: true,
|
|
78
|
+
push_id: parsed.push_id,
|
|
79
|
+
errors: [],
|
|
80
|
+
_status: response.status,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
success: false,
|
|
85
|
+
errors: ["Invalid JSON response shape for PushUploadResult"],
|
|
86
|
+
_status: response.status,
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return {
|
|
90
|
+
success: false,
|
|
91
|
+
errors: [`Unexpected non-JSON response body: ${body.slice(0, 200)}`],
|
|
92
|
+
_status: response.status,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Non-2xx response -- read error text for diagnostics
|
|
98
|
+
const errorText = await response.text().catch(() => "unknown error");
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
errors: [`HTTP ${response.status}: ${errorText.slice(0, 200)}`],
|
|
102
|
+
_status: response.status,
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// Network-level failure (DNS, timeout, connection refused, etc.)
|
|
106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
errors: [message],
|
|
110
|
+
_status: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|