selftune 0.2.6 → 0.2.9

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 (119) hide show
  1. package/README.md +1 -0
  2. package/apps/local-dashboard/dist/assets/index-Bs3Y4ixf.css +1 -0
  3. package/apps/local-dashboard/dist/assets/index-C4UYGWKr.js +15 -0
  4. package/apps/local-dashboard/dist/assets/vendor-react-BQH_6WrG.js +60 -0
  5. package/apps/local-dashboard/dist/assets/{vendor-table-B7VF2Ipl.js → vendor-table-dK1QMLq9.js} +1 -1
  6. package/apps/local-dashboard/dist/assets/{vendor-ui-r2k_Ku_V.js → vendor-ui-CO2mrx6e.js} +60 -65
  7. package/apps/local-dashboard/dist/index.html +5 -5
  8. package/cli/selftune/activation-rules.ts +57 -18
  9. package/cli/selftune/agent-guidance.ts +96 -0
  10. package/cli/selftune/alpha-identity.ts +156 -0
  11. package/cli/selftune/alpha-upload/build-payloads.ts +151 -0
  12. package/cli/selftune/alpha-upload/client.ts +113 -0
  13. package/cli/selftune/alpha-upload/flush.ts +191 -0
  14. package/cli/selftune/alpha-upload/index.ts +194 -0
  15. package/cli/selftune/alpha-upload/queue.ts +252 -0
  16. package/cli/selftune/alpha-upload/stage-canonical.ts +251 -0
  17. package/cli/selftune/alpha-upload-contract.ts +52 -0
  18. package/cli/selftune/auth/device-code.ts +110 -0
  19. package/cli/selftune/auto-update.ts +130 -0
  20. package/cli/selftune/badge/badge.ts +19 -9
  21. package/cli/selftune/canonical-export.ts +16 -3
  22. package/cli/selftune/constants.ts +28 -8
  23. package/cli/selftune/contribute/bundle.ts +33 -5
  24. package/cli/selftune/dashboard-contract.ts +32 -1
  25. package/cli/selftune/dashboard-server.ts +215 -693
  26. package/cli/selftune/dashboard.ts +1 -1
  27. package/cli/selftune/eval/baseline.ts +11 -7
  28. package/cli/selftune/eval/hooks-to-evals.ts +39 -15
  29. package/cli/selftune/eval/synthetic-evals.ts +54 -1
  30. package/cli/selftune/evolution/audit.ts +24 -19
  31. package/cli/selftune/evolution/constitutional.ts +176 -0
  32. package/cli/selftune/evolution/evidence.ts +18 -13
  33. package/cli/selftune/evolution/evolve-body.ts +104 -7
  34. package/cli/selftune/evolution/evolve.ts +195 -22
  35. package/cli/selftune/evolution/propose-body.ts +18 -1
  36. package/cli/selftune/evolution/propose-description.ts +27 -2
  37. package/cli/selftune/evolution/rollback.ts +11 -15
  38. package/cli/selftune/export.ts +84 -0
  39. package/cli/selftune/grading/auto-grade.ts +14 -4
  40. package/cli/selftune/grading/grade-session.ts +17 -6
  41. package/cli/selftune/hooks/auto-activate.ts +5 -0
  42. package/cli/selftune/hooks/evolution-guard.ts +25 -11
  43. package/cli/selftune/hooks/prompt-log.ts +23 -9
  44. package/cli/selftune/hooks/session-stop.ts +78 -15
  45. package/cli/selftune/hooks/skill-eval.ts +189 -10
  46. package/cli/selftune/index.ts +274 -2
  47. package/cli/selftune/ingestors/claude-replay.ts +48 -21
  48. package/cli/selftune/init.ts +260 -49
  49. package/cli/selftune/last.ts +7 -7
  50. package/cli/selftune/localdb/db.ts +90 -10
  51. package/cli/selftune/localdb/direct-write.ts +573 -0
  52. package/cli/selftune/localdb/materialize.ts +296 -42
  53. package/cli/selftune/localdb/queries.ts +482 -32
  54. package/cli/selftune/localdb/schema.ts +153 -1
  55. package/cli/selftune/monitoring/watch.ts +27 -8
  56. package/cli/selftune/normalization.ts +88 -15
  57. package/cli/selftune/observability.ts +257 -5
  58. package/cli/selftune/orchestrate.ts +176 -53
  59. package/cli/selftune/quickstart.ts +34 -10
  60. package/cli/selftune/repair/skill-usage.ts +15 -2
  61. package/cli/selftune/routes/actions.ts +77 -0
  62. package/cli/selftune/routes/badge.ts +66 -0
  63. package/cli/selftune/routes/doctor.ts +12 -0
  64. package/cli/selftune/routes/index.ts +14 -0
  65. package/cli/selftune/routes/orchestrate-runs.ts +13 -0
  66. package/cli/selftune/routes/overview.ts +14 -0
  67. package/cli/selftune/routes/report.ts +293 -0
  68. package/cli/selftune/routes/skill-report.ts +230 -0
  69. package/cli/selftune/status.ts +203 -7
  70. package/cli/selftune/sync.ts +14 -1
  71. package/cli/selftune/types.ts +52 -2
  72. package/cli/selftune/utils/jsonl.ts +58 -1
  73. package/cli/selftune/utils/selftune-meta.ts +38 -0
  74. package/cli/selftune/utils/skill-log.ts +30 -4
  75. package/cli/selftune/utils/transcript.ts +15 -0
  76. package/cli/selftune/workflows/workflows.ts +7 -6
  77. package/package.json +11 -6
  78. package/packages/telemetry-contract/fixtures/complete-push.ts +184 -0
  79. package/packages/telemetry-contract/fixtures/evidence-only-push.ts +58 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +1 -0
  81. package/packages/telemetry-contract/fixtures/index.ts +4 -0
  82. package/packages/telemetry-contract/fixtures/partial-push-no-sessions.ts +40 -0
  83. package/packages/telemetry-contract/fixtures/partial-push-unresolved-parents.ts +79 -0
  84. package/packages/telemetry-contract/package.json +6 -1
  85. package/packages/telemetry-contract/src/schemas.ts +196 -0
  86. package/packages/telemetry-contract/src/types.ts +3 -1
  87. package/packages/telemetry-contract/src/validators.ts +3 -1
  88. package/packages/telemetry-contract/tests/compatibility.test.ts +144 -0
  89. package/packages/ui/package.json +4 -0
  90. package/packages/ui/src/components/ActivityTimeline.tsx +61 -29
  91. package/packages/ui/src/components/section-cards.tsx +31 -14
  92. package/packages/ui/src/types.ts +1 -0
  93. package/skill/SKILL.md +214 -174
  94. package/skill/Workflows/AlphaUpload.md +45 -0
  95. package/skill/Workflows/Baseline.md +18 -12
  96. package/skill/Workflows/Composability.md +3 -3
  97. package/skill/Workflows/Dashboard.md +39 -91
  98. package/skill/Workflows/Doctor.md +93 -66
  99. package/skill/Workflows/Evals.md +49 -40
  100. package/skill/Workflows/Evolve.md +76 -28
  101. package/skill/Workflows/EvolveBody.md +37 -38
  102. package/skill/Workflows/Initialize.md +145 -26
  103. package/skill/Workflows/Orchestrate.md +11 -2
  104. package/skill/Workflows/Sync.md +23 -0
  105. package/skill/Workflows/Watch.md +2 -5
  106. package/skill/agents/diagnosis-analyst.md +163 -0
  107. package/skill/agents/evolution-reviewer.md +149 -0
  108. package/skill/agents/integration-guide.md +154 -0
  109. package/skill/agents/pattern-analyst.md +149 -0
  110. package/skill/assets/multi-skill-settings.json +1 -1
  111. package/skill/assets/single-skill-settings.json +1 -1
  112. package/skill/references/interactive-config.md +39 -0
  113. package/skill/references/invocation-taxonomy.md +34 -0
  114. package/skill/references/logs.md +15 -1
  115. package/skill/references/setup-patterns.md +3 -3
  116. package/skill/settings_snippet.json +1 -1
  117. package/apps/local-dashboard/dist/assets/index-C75H1Q3n.css +0 -1
  118. package/apps/local-dashboard/dist/assets/index-axE4kz3Q.js +0 -15
  119. 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-axE4kz3Q.js"></script>
9
- <link rel="modulepreload" crossorigin href="/assets/vendor-react-U7zYD9Rg.js">
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-ui-r2k_Ku_V.js">
11
- <link rel="modulepreload" crossorigin href="/assets/vendor-table-B7VF2Ipl.js">
12
- <link rel="stylesheet" crossorigin href="/assets/index-C75H1Q3n.css">
8
+ <script type="module" crossorigin src="/assets/index-C4UYGWKr.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-Bs3Y4ixf.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -2,37 +2,67 @@
2
2
  * Default activation rules for the auto-activate hook.
3
3
  *
4
4
  * Each rule evaluates session context and returns a suggestion string
5
- * (or null if the rule doesn't fire). Rules must be pure functions
6
- * that read from the filesystem — no network calls, no imports from
7
- * evolution/monitoring/grading layers.
5
+ * (or null if the rule doesn't fire). Rules must be pure functions
6
+ * no network calls, no imports from evolution/monitoring/grading layers.
7
+ *
8
+ * SQLite is the default read path for log data. JSONL fallback is used
9
+ * only when context paths differ from the well-known constants
10
+ * (test/custom-path override).
8
11
  */
9
12
 
10
13
  import { existsSync, readdirSync, readFileSync } from "node:fs";
11
14
  import { dirname, join } from "node:path";
15
+ import { EVOLUTION_AUDIT_LOG, QUERY_LOG } from "./constants.js";
16
+ import { getDb } from "./localdb/db.js";
17
+ import { queryEvolutionAudit, queryQueryLog, querySkillUsageRecords } from "./localdb/queries.js";
12
18
  import type { ActivationContext, ActivationRule } from "./types.js";
13
19
  import { readJsonl } from "./utils/jsonl.js";
14
20
 
15
21
  // ---------------------------------------------------------------------------
16
- // Rule: post-session diagnostic
22
+ // Rule: post-session diagnostic (SQLite-first; JSONL for test/custom paths)
17
23
  // ---------------------------------------------------------------------------
18
24
 
19
25
  const postSessionDiagnostic: ActivationRule = {
20
26
  id: "post-session-diagnostic",
21
27
  description: "Suggest `selftune last` when session has >2 unmatched queries",
22
28
  evaluate(ctx: ActivationContext): string | null {
23
- // Count queries for this session
24
- const queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path);
29
+ // Count queries for this session — SQLite is the default path
30
+ let queries: Array<{ session_id: string; query: string }>;
31
+ if (ctx.query_log_path === QUERY_LOG) {
32
+ try {
33
+ const db = getDb();
34
+ queries = queryQueryLog(db) as Array<{ session_id: string; query: string }>;
35
+ } catch {
36
+ return null;
37
+ }
38
+ } else {
39
+ // test/custom-path fallback
40
+ queries = readJsonl<{ session_id: string; query: string }>(ctx.query_log_path);
41
+ }
25
42
  const sessionQueries = queries.filter((q) => q.session_id === ctx.session_id);
26
43
 
27
44
  if (sessionQueries.length === 0) return null;
28
45
 
29
- // Count skill usages for this session (skill log is in the same dir as query log)
30
- const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl");
31
- const skillUsages = existsSync(skillLogPath)
32
- ? readJsonl<{ session_id: string }>(skillLogPath).filter(
46
+ // Count skill usages for this session SQLite is the default path
47
+ let skillUsages: Array<{ session_id: string }>;
48
+ if (ctx.query_log_path === QUERY_LOG) {
49
+ try {
50
+ const db = getDb();
51
+ skillUsages = (querySkillUsageRecords(db) as Array<{ session_id: string }>).filter(
33
52
  (s) => s.session_id === ctx.session_id,
34
- )
35
- : [];
53
+ );
54
+ } catch {
55
+ return null;
56
+ }
57
+ } else {
58
+ // test/custom-path fallback
59
+ const skillLogPath = join(dirname(ctx.query_log_path), "skill_usage_log.jsonl");
60
+ skillUsages = existsSync(skillLogPath)
61
+ ? readJsonl<{ session_id: string }>(skillLogPath).filter(
62
+ (s) => s.session_id === ctx.session_id,
63
+ )
64
+ : [];
65
+ }
36
66
 
37
67
  const unmatchedCount = sessionQueries.length - skillUsages.length;
38
68
 
@@ -83,7 +113,7 @@ const gradingThresholdBreach: ActivationRule = {
83
113
  };
84
114
 
85
115
  // ---------------------------------------------------------------------------
86
- // Rule: stale evolution
116
+ // Rule: stale evolution (SQLite-first; JSONL for test/custom paths)
87
117
  // ---------------------------------------------------------------------------
88
118
 
89
119
  const staleEvolution: ActivationRule = {
@@ -93,10 +123,19 @@ const staleEvolution: ActivationRule = {
93
123
  evaluate(ctx: ActivationContext): string | null {
94
124
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
95
125
 
96
- // Check last evolution timestamp
97
- const auditEntries = readJsonl<{ timestamp: string; action: string }>(
98
- ctx.evolution_audit_log_path,
99
- );
126
+ // Check last evolution timestamp — SQLite is the default path
127
+ let auditEntries: Array<{ timestamp: string; action: string }>;
128
+ if (ctx.evolution_audit_log_path === EVOLUTION_AUDIT_LOG) {
129
+ try {
130
+ const db = getDb();
131
+ auditEntries = queryEvolutionAudit(db) as Array<{ timestamp: string; action: string }>;
132
+ } catch {
133
+ return null;
134
+ }
135
+ } else {
136
+ // test/custom-path fallback
137
+ auditEntries = readJsonl<{ timestamp: string; action: string }>(ctx.evolution_audit_log_path);
138
+ }
100
139
 
101
140
  if (auditEntries.length === 0) {
102
141
  // No evolution has ever run — check for false negatives
@@ -105,7 +144,7 @@ const staleEvolution: ActivationRule = {
105
144
  : null;
106
145
  }
107
146
 
108
- const lastEntry = auditEntries[auditEntries.length - 1];
147
+ const lastEntry = auditEntries[0]; // queryEvolutionAudit returns DESC order
109
148
  const lastTimestamp = new Date(lastEntry.timestamp).getTime();
110
149
  const ageMs = Date.now() - lastTimestamp;
111
150
 
@@ -0,0 +1,96 @@
1
+ import { getAlphaLinkState } from "./alpha-identity.js";
2
+ import type { AgentCommandGuidance, AlphaIdentity, AlphaLinkState } from "./types.js";
3
+
4
+ function sanitizeAlphaEmail(email?: string): string | null {
5
+ const trimmed = email?.trim();
6
+ if (!trimmed) return null;
7
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return null;
8
+ return trimmed;
9
+ }
10
+
11
+ function buildAlphaInitCommand(options?: { email?: string; force?: boolean }): string {
12
+ const parts = ["selftune", "init", "--alpha"];
13
+ const email = sanitizeAlphaEmail(options?.email);
14
+ if (email) {
15
+ parts.push("--alpha-email", email);
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. Run the init command with --alpha to authenticate via browser.",
48
+ buildAlphaInitCommand({ email: options?.email }),
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. Re-run init with --alpha to complete enrollment via browser.",
56
+ buildAlphaInitCommand({ email: options?.email, 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. Re-run init with --alpha to re-authenticate via browser.",
64
+ buildAlphaInitCommand({ email: options?.email, 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,156 @@
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 device-code flow sets both api_key and cloud_user_id simultaneously.
93
+ */
94
+ export function getAlphaLinkState(identity: AlphaIdentity | null): AlphaLinkState {
95
+ if (!identity) return "not_linked";
96
+ if (!identity.enrolled) return identity.cloud_user_id ? "linked_not_enrolled" : "not_linked";
97
+ if (!identity.api_key || !isValidApiKeyFormat(identity.api_key)) return "enrolled_no_credential";
98
+ // Enrolled + valid key = ready (cloud_user_id is bonus, not gate)
99
+ return "ready";
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Migration helper
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Detect legacy local-only alpha blocks and mark them as needing cloud link.
108
+ * A legacy identity has email + user_id but no cloud_user_id.
109
+ */
110
+ export function migrateLocalIdentity(identity: AlphaIdentity): {
111
+ needsCloudLink: boolean;
112
+ identity: AlphaIdentity;
113
+ } {
114
+ if (identity.cloud_user_id) {
115
+ return { needsCloudLink: false, identity };
116
+ }
117
+ // Legacy: has local user_id but no cloud link
118
+ return { needsCloudLink: true, identity };
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Consent notice
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export const ALPHA_CONSENT_NOTICE = `
126
+ ========================================
127
+ selftune Alpha Program
128
+ ========================================
129
+
130
+ You are enrolling in the selftune alpha program.
131
+
132
+ WHAT IS COLLECTED:
133
+ - Skill invocations and trigger metadata
134
+ - Session metadata (timestamps, tool counts, error counts)
135
+ - Evolution outcomes (proposals, pass rates, deployments)
136
+ - Raw user prompt/query text submitted during captured sessions
137
+
138
+ WHAT IS NOT COLLECTED:
139
+ - File contents or source code
140
+ - Full transcript bodies beyond the captured prompt/query text
141
+ - Structured repository names or file paths as separate fields
142
+
143
+ IMPORTANT:
144
+ Raw prompt/query text is uploaded unchanged for the friendly alpha cohort.
145
+ If your prompt includes repository names, file paths, or secrets, that text
146
+ may be included in the alpha data you choose to share.
147
+
148
+ Your alpha identity (email, display name, and any upload API key)
149
+ is stored locally in ~/.selftune/config.json and used for alpha coordination
150
+ and authenticated uploads.
151
+
152
+ TO UNENROLL:
153
+ selftune init --no-alpha
154
+
155
+ ========================================
156
+ `;
@@ -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
+ }