kimaki 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/agent-model.e2e.test.js +90 -10
  2. package/dist/analytics.js +238 -0
  3. package/dist/analytics.test.js +130 -0
  4. package/dist/channel-management.js +40 -2
  5. package/dist/cli-commands/project.js +10 -0
  6. package/dist/cli-commands/send.js +34 -6
  7. package/dist/cli-commands/task.js +80 -7
  8. package/dist/cli-parsing.test.js +22 -0
  9. package/dist/cli-runner.js +8 -8
  10. package/dist/cli.js +4 -8
  11. package/dist/commands/add-project.js +1 -0
  12. package/dist/commands/create-new-project.js +12 -4
  13. package/dist/commands/gemini-apikey.js +52 -18
  14. package/dist/commands/model-variant.js +20 -23
  15. package/dist/commands/model.js +93 -4
  16. package/dist/commands/queue.js +65 -71
  17. package/dist/commands/screenshare.js +30 -26
  18. package/dist/commands/unset-model.js +19 -26
  19. package/dist/commands/vscode.js +29 -3
  20. package/dist/commands/worktrees.js +95 -28
  21. package/dist/context-awareness-plugin.js +23 -0
  22. package/dist/discord-bot.js +13 -1
  23. package/dist/discord-command-registration.js +13 -43
  24. package/dist/discord-utils.js +41 -0
  25. package/dist/interaction-handler.js +3 -26
  26. package/dist/session-handler/thread-runtime-state.js +24 -0
  27. package/dist/session-handler/thread-session-runtime.js +96 -4
  28. package/dist/system-message.js +66 -5
  29. package/dist/system-message.test.js +54 -7
  30. package/dist/task-runner.js +21 -6
  31. package/dist/test-utils.js +12 -1
  32. package/dist/thread-message-queue.e2e.test.js +18 -16
  33. package/dist/voice-handler.js +34 -23
  34. package/dist/voice-message.e2e.test.js +75 -0
  35. package/package.json +8 -7
  36. package/skills/sigillo/SKILL.md +26 -0
  37. package/src/agent-model.e2e.test.ts +102 -10
  38. package/src/analytics.test.ts +160 -0
  39. package/src/analytics.ts +295 -0
  40. package/src/channel-management.ts +55 -0
  41. package/src/cli-commands/project.ts +14 -0
  42. package/src/cli-commands/send.ts +44 -6
  43. package/src/cli-commands/task.ts +116 -7
  44. package/src/cli-parsing.test.ts +29 -0
  45. package/src/cli-runner.ts +13 -8
  46. package/src/cli.ts +10 -8
  47. package/src/commands/add-project.ts +1 -0
  48. package/src/commands/create-new-project.ts +20 -7
  49. package/src/commands/gemini-apikey.ts +84 -15
  50. package/src/commands/model-variant.ts +27 -28
  51. package/src/commands/model.ts +135 -18
  52. package/src/commands/queue.ts +91 -90
  53. package/src/commands/screenshare.ts +54 -32
  54. package/src/commands/unset-model.ts +26 -32
  55. package/src/commands/vscode.ts +38 -2
  56. package/src/commands/worktrees.ts +137 -27
  57. package/src/context-awareness-plugin.ts +27 -0
  58. package/src/discord-bot.ts +18 -1
  59. package/src/discord-command-registration.ts +26 -47
  60. package/src/discord-utils.ts +51 -0
  61. package/src/interaction-handler.ts +1 -34
  62. package/src/session-handler/thread-runtime-state.ts +36 -0
  63. package/src/session-handler/thread-session-runtime.ts +137 -3
  64. package/src/store.ts +2 -0
  65. package/src/system-message.test.ts +79 -6
  66. package/src/system-message.ts +99 -6
  67. package/src/task-runner.ts +29 -9
  68. package/src/test-utils.ts +16 -1
  69. package/src/thread-message-queue.e2e.test.ts +23 -16
  70. package/src/voice-handler.ts +36 -22
  71. package/src/voice-message.e2e.test.ts +89 -0
  72. package/dist/commands/worktree-settings.js +0 -41
  73. package/skills/batch/SKILL.md +0 -87
  74. package/skills/security-review/SKILL.md +0 -208
  75. package/skills/simplify/SKILL.md +0 -58
  76. package/src/commands/worktree-settings.ts +0 -68
@@ -63,6 +63,8 @@ function createDiscordJsClient({ restUrl }) {
63
63
  },
64
64
  });
65
65
  }
66
+ const COMMAND_SYSTEM_CHECK_NAME = 'sys-cmd-check';
67
+ const COMMAND_SYSTEM_CHECK_TEMPLATE = 'Reply with exactly: command-system-check';
66
68
  function createDeterministicMatchers() {
67
69
  const systemContextMatcher = {
68
70
  id: 'system-context-check',
@@ -91,6 +93,37 @@ function createDeterministicMatchers() {
91
93
  partDelaysMs: [0, 100, 0, 0, 0],
92
94
  },
93
95
  };
96
+ // session.command has no system field. Match an operational kimaki system
97
+ // instruction (upload helper) so we know the real session system prompt was
98
+ // injected — not just any string that happens to mention kimaki.dev.
99
+ // Without the fix this never fires and the bot replies "ok" from the fallback.
100
+ const commandSystemMatcher = {
101
+ id: 'command-system-check',
102
+ priority: 25,
103
+ when: {
104
+ lastMessageRole: 'user',
105
+ latestUserTextIncludes: COMMAND_SYSTEM_CHECK_TEMPLATE,
106
+ promptTextIncludes: 'kimaki upload-to-discord --session',
107
+ },
108
+ then: {
109
+ parts: [
110
+ { type: 'stream-start', warnings: [] },
111
+ { type: 'text-start', id: 'command-system-reply' },
112
+ {
113
+ type: 'text-delta',
114
+ id: 'command-system-reply',
115
+ delta: 'command-system-ok',
116
+ },
117
+ { type: 'text-end', id: 'command-system-reply' },
118
+ {
119
+ type: 'finish',
120
+ finishReason: 'stop',
121
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
122
+ },
123
+ ],
124
+ partDelaysMs: [0, 100, 0, 0, 0],
125
+ },
126
+ };
94
127
  const replyContextMatcher = {
95
128
  id: 'reply-context-check',
96
129
  priority: 15,
@@ -140,7 +173,12 @@ function createDeterministicMatchers() {
140
173
  partDelaysMs: [0, 100, 0, 0, 0],
141
174
  },
142
175
  };
143
- return [systemContextMatcher, replyContextMatcher, userReplyMatcher];
176
+ return [
177
+ commandSystemMatcher,
178
+ systemContextMatcher,
179
+ replyContextMatcher,
180
+ userReplyMatcher,
181
+ ];
144
182
  }
145
183
  /**
146
184
  * Create an opencode agent .md file that uses a specific model.
@@ -201,16 +239,25 @@ describe('agent model resolution', () => {
201
239
  .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
202
240
  .toString();
203
241
  // Build base config with default model
204
- const opencodeConfig = buildDeterministicOpencodeConfig({
205
- providerName: PROVIDER_NAME,
206
- providerNpm,
207
- model: DEFAULT_MODEL,
208
- smallModel: DEFAULT_MODEL,
209
- settings: {
210
- strict: false,
211
- matchers: createDeterministicMatchers(),
242
+ const opencodeConfig = {
243
+ ...buildDeterministicOpencodeConfig({
244
+ providerName: PROVIDER_NAME,
245
+ providerNpm,
246
+ model: DEFAULT_MODEL,
247
+ smallModel: DEFAULT_MODEL,
248
+ settings: {
249
+ strict: false,
250
+ matchers: createDeterministicMatchers(),
251
+ },
252
+ }),
253
+ // OpenCode command used to verify session.command still gets kimaki system
254
+ command: {
255
+ [COMMAND_SYSTEM_CHECK_NAME]: {
256
+ description: 'Test command for kimaki system prompt injection',
257
+ template: COMMAND_SYSTEM_CHECK_TEMPLATE,
258
+ },
212
259
  },
213
- });
260
+ };
214
261
  // Add extra models to the provider so opencode accepts them
215
262
  const providerConfig = opencodeConfig.provider[PROVIDER_NAME];
216
263
  if (!providerConfig) {
@@ -220,6 +267,17 @@ describe('agent model resolution', () => {
220
267
  providerConfig.models[PLAN_AGENT_MODEL] = { name: PLAN_AGENT_MODEL };
221
268
  providerConfig.models[CHANNEL_MODEL] = { name: CHANNEL_MODEL };
222
269
  fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
270
+ // Leading /command detection only rewrites when registeredUserCommands is set
271
+ store.setState({
272
+ registeredUserCommands: [
273
+ {
274
+ name: COMMAND_SYSTEM_CHECK_NAME,
275
+ discordCommandName: `${COMMAND_SYSTEM_CHECK_NAME}-cmd`,
276
+ description: 'Test command for kimaki system prompt injection',
277
+ source: 'command',
278
+ },
279
+ ],
280
+ });
223
281
  // Create agent .md files with custom models
224
282
  createAgentFile({
225
283
  projectDirectory: directories.projectDirectory,
@@ -387,6 +445,28 @@ describe('agent model resolution', () => {
387
445
  *project ā‹… main ā‹… Ns ā‹… N% ā‹… agent-model-v2 ā‹… **test-agent***"
388
446
  `);
389
447
  }, 15_000);
448
+ test('session.command path includes kimaki system prompt on first message', async () => {
449
+ // Leading /command is rewritten to session.command. Without system
450
+ // injection the matcher requiring "kimaki upload-to-discord --session"
451
+ // never matches and the bot falls through to the generic "ok" reply.
452
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
453
+ content: `/${COMMAND_SYSTEM_CHECK_NAME}`,
454
+ });
455
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
456
+ timeout: 4_000,
457
+ predicate: (t) => {
458
+ return t.name === `/${COMMAND_SYSTEM_CHECK_NAME}`;
459
+ },
460
+ });
461
+ await waitForBotMessageContaining({
462
+ discord,
463
+ threadId: thread.id,
464
+ userId: TEST_USER_ID,
465
+ text: 'command-system-ok',
466
+ timeout: 4_000,
467
+ });
468
+ expect(await discord.thread(thread.id).text()).toContain('command-system-ok');
469
+ }, 15_000);
390
470
  test('reply message injects replied-message context', async () => {
391
471
  const db = await getDb();
392
472
  await db.delete(schema.channel_agents).where(orm.eq(schema.channel_agents.channel_id, TEXT_CHANNEL_ID));
@@ -0,0 +1,238 @@
1
+ // Anonymous product analytics via Strada (OpenTelemetry).
2
+ // Tracks install-level usage only: no Discord IDs, paths, prompts, or secrets.
3
+ // A random install id is stored in {dataDir}/install-id for DAU-style queries.
4
+ //
5
+ // Metrics are "active installs", not people. Multiple --data-dir values count
6
+ // as separate installs. Query with ServiceName = 'kimaki-cli' and
7
+ // LogAttributes['custom.install_id'] / LogAttributes['event.name'].
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import crypto from 'node:crypto';
11
+ import { createRequire } from 'node:module';
12
+ import { initStrada, track, flush } from '@strada.sh/sdk';
13
+ import { getDataDir } from './config.js';
14
+ import { createLogger, LogPrefix } from './logger.js';
15
+ import { store } from './store.js';
16
+ const logger = createLogger(LogPrefix.CLI);
17
+ // Public Strada project for production kimaki usage (write-only ingest token).
18
+ // Override with KIMAKI_STRADA_* for local/dev against kimaki-local.
19
+ // Disable with --no-analytics or KIMAKI_STRADA_ENABLED=0.
20
+ const DEFAULT_STRADA_PROJECT_ID = '01KYX3X6FEBBV5JV6Q8M97988C';
21
+ const DEFAULT_STRADA_TOKEN = 'str_9eee60d24a444da78107f8780fe965c5f8cae422def44dcb9ee94d8035a5a14f';
22
+ const INSTALL_ID_FILENAME = 'install-id';
23
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
24
+ const SCHEMA_VERSION = 1;
25
+ const INSTALL_ID_FILENAME_EXPORT = INSTALL_ID_FILENAME;
26
+ let initialized = false;
27
+ let identityFailed = false;
28
+ let installIdCache = null;
29
+ let botModeOverride = null;
30
+ /** Captured events when tests call _enableAnalyticsTestCapture(). */
31
+ let testCapture = null;
32
+ function isAnalyticsDisabled() {
33
+ if (testCapture)
34
+ return false;
35
+ if (process.env.KIMAKI_VITEST)
36
+ return true;
37
+ if (process.env.KIMAKI_STRADA_ENABLED === '0')
38
+ return true;
39
+ if (process.env.KIMAKI_STRADA_ENABLED === 'false')
40
+ return true;
41
+ return false;
42
+ }
43
+ function getKimakiVersion() {
44
+ const require = createRequire(import.meta.url);
45
+ const pkg = require('../package.json');
46
+ return pkg.version;
47
+ }
48
+ function isValidInstallId(value) {
49
+ return UUID_RE.test(value);
50
+ }
51
+ function resolveBotMode() {
52
+ if (botModeOverride)
53
+ return botModeOverride;
54
+ if (store.getState().discordBaseUrl !== 'https://discord.com') {
55
+ return 'gateway';
56
+ }
57
+ return 'self_hosted';
58
+ }
59
+ /**
60
+ * Set bot mode once credentials are known (gateway vs self-hosted).
61
+ * Applied as a common property on every subsequent event.
62
+ */
63
+ export function setAnalyticsBotMode(mode) {
64
+ botModeOverride = mode;
65
+ }
66
+ /**
67
+ * Stable anonymous id for this data dir. Created once and reused forever.
68
+ * Returns null when the id cannot be read or persisted (fail closed).
69
+ */
70
+ export function getInstallId() {
71
+ if (identityFailed)
72
+ return null;
73
+ if (installIdCache)
74
+ return installIdCache;
75
+ const filePath = path.join(getDataDir(), INSTALL_ID_FILENAME);
76
+ const existing = (() => {
77
+ try {
78
+ if (!fs.existsSync(filePath))
79
+ return null;
80
+ const value = fs.readFileSync(filePath, 'utf8').trim();
81
+ if (!value || !isValidInstallId(value))
82
+ return null;
83
+ return value;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ })();
89
+ if (existing) {
90
+ installIdCache = existing;
91
+ return existing;
92
+ }
93
+ const id = crypto.randomUUID();
94
+ try {
95
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
96
+ // Use plain write so a corrupt/non-uuid file is replaced. Concurrent
97
+ // writers may race; we re-read and only accept a valid uuid afterward.
98
+ fs.writeFileSync(filePath, `${id}\n`, { encoding: 'utf8' });
99
+ const verified = fs.readFileSync(filePath, 'utf8').trim();
100
+ if (!isValidInstallId(verified)) {
101
+ identityFailed = true;
102
+ logger.warn('analytics disabled: install-id file is not a valid uuid');
103
+ return null;
104
+ }
105
+ installIdCache = verified;
106
+ return verified;
107
+ }
108
+ catch {
109
+ identityFailed = true;
110
+ logger.warn('analytics disabled: could not persist install-id (data dir not writable?)');
111
+ return null;
112
+ }
113
+ }
114
+ /** Common low-cardinality props on every product event. */
115
+ export function commonAnalyticsProps(extra) {
116
+ const installId = getInstallId();
117
+ if (!installId)
118
+ return null;
119
+ return {
120
+ ...(extra ?? {}),
121
+ install_id: installId,
122
+ schema_version: SCHEMA_VERSION,
123
+ bot_mode: resolveBotMode(),
124
+ platform: process.platform,
125
+ arch: process.arch,
126
+ };
127
+ }
128
+ /**
129
+ * Initialize Strada. Safe to call once from the bot or short-lived CLI paths
130
+ * that emit product events. No-ops under tests and when disabled.
131
+ */
132
+ export function initAnalytics() {
133
+ if (initialized)
134
+ return;
135
+ if (isAnalyticsDisabled() && !testCapture) {
136
+ initialized = true;
137
+ return;
138
+ }
139
+ if (testCapture) {
140
+ initialized = true;
141
+ return;
142
+ }
143
+ const installId = getInstallId();
144
+ if (!installId) {
145
+ initialized = true;
146
+ return;
147
+ }
148
+ const projectId = process.env.KIMAKI_STRADA_PROJECT_ID || DEFAULT_STRADA_PROJECT_ID;
149
+ const token = process.env.KIMAKI_STRADA_TOKEN || DEFAULT_STRADA_TOKEN;
150
+ const environment = process.env.KIMAKI_STRADA_ENVIRONMENT ||
151
+ process.env.NODE_ENV ||
152
+ 'production';
153
+ try {
154
+ initStrada({
155
+ projectId,
156
+ token,
157
+ service: 'kimaki-cli',
158
+ environment,
159
+ version: getKimakiVersion(),
160
+ userId: installId,
161
+ });
162
+ initialized = true;
163
+ }
164
+ catch (error) {
165
+ logger.warn('Failed to init analytics:', error instanceof Error ? error.message : String(error));
166
+ initialized = true;
167
+ }
168
+ }
169
+ /**
170
+ * Fire-and-forget product event. Never throws.
171
+ * install_id / schema_version / bot_mode / platform / arch always win over
172
+ * caller props so identity invariants cannot be overridden.
173
+ */
174
+ export function trackEvent(name, properties) {
175
+ if (!initialized && !testCapture)
176
+ return;
177
+ if (isAnalyticsDisabled() && !testCapture)
178
+ return;
179
+ if (identityFailed)
180
+ return;
181
+ const props = commonAnalyticsProps(properties);
182
+ if (!props)
183
+ return;
184
+ if (testCapture) {
185
+ testCapture.push({ name, properties: props });
186
+ return;
187
+ }
188
+ try {
189
+ track(name, props);
190
+ }
191
+ catch (error) {
192
+ logger.warn(`trackEvent(${name}) failed:`, error instanceof Error ? error.message : String(error));
193
+ }
194
+ }
195
+ /**
196
+ * Flush buffered telemetry. Call before process.exit on short CLI paths and
197
+ * on graceful bot shutdown.
198
+ */
199
+ export async function flushAnalytics() {
200
+ if (testCapture)
201
+ return;
202
+ if (!initialized || isAnalyticsDisabled() || identityFailed)
203
+ return;
204
+ try {
205
+ await flush();
206
+ }
207
+ catch (error) {
208
+ logger.warn('flushAnalytics failed:', error instanceof Error ? error.message : String(error));
209
+ }
210
+ }
211
+ /** @deprecated Use commonAnalyticsProps / trackEvent common fields. */
212
+ export function baseRuntimeProps(extra) {
213
+ return {
214
+ version: getKimakiVersion(),
215
+ ...extra,
216
+ };
217
+ }
218
+ /** Test helper: reset module state between unit tests. */
219
+ export function _resetAnalyticsForTests() {
220
+ initialized = false;
221
+ identityFailed = false;
222
+ installIdCache = null;
223
+ botModeOverride = null;
224
+ testCapture = null;
225
+ }
226
+ /**
227
+ * Test helper: enable in-process event capture instead of Strada export.
228
+ * Returns the mutable capture array.
229
+ */
230
+ export function _enableAnalyticsTestCapture(opts) {
231
+ testCapture = [];
232
+ initialized = true;
233
+ identityFailed = false;
234
+ installIdCache = opts?.installId ?? '11111111-1111-4111-8111-111111111111';
235
+ botModeOverride = opts?.botMode ?? 'self_hosted';
236
+ return testCapture;
237
+ }
238
+ export { INSTALL_ID_FILENAME_EXPORT as INSTALL_ID_FILENAME };
@@ -0,0 +1,130 @@
1
+ // Unit tests for anonymous install id and product event construction.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6
+ import { setDataDir } from './config.js';
7
+ import { _enableAnalyticsTestCapture, _resetAnalyticsForTests, commonAnalyticsProps, getInstallId, initAnalytics, INSTALL_ID_FILENAME, setAnalyticsBotMode, trackEvent, } from './analytics.js';
8
+ describe('analytics', () => {
9
+ let tmpDir;
10
+ beforeEach(() => {
11
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-analytics-'));
12
+ setDataDir(tmpDir);
13
+ _resetAnalyticsForTests();
14
+ });
15
+ afterEach(() => {
16
+ _resetAnalyticsForTests();
17
+ fs.rmSync(tmpDir, { recursive: true, force: true });
18
+ });
19
+ it('creates and reuses install-id', () => {
20
+ const first = getInstallId();
21
+ expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
22
+ expect(fs.readFileSync(path.join(tmpDir, INSTALL_ID_FILENAME), 'utf8').trim()).toBe(first);
23
+ _resetAnalyticsForTests();
24
+ setDataDir(tmpDir);
25
+ expect(getInstallId()).toBe(first);
26
+ });
27
+ it('replaces invalid install-id file contents', () => {
28
+ fs.writeFileSync(path.join(tmpDir, INSTALL_ID_FILENAME), 'not-a-uuid\n');
29
+ const id = getInstallId();
30
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
31
+ expect(id).not.toBe('not-a-uuid');
32
+ expect(fs.readFileSync(path.join(tmpDir, INSTALL_ID_FILENAME), 'utf8').trim()).toBe(id);
33
+ });
34
+ it('no-ops trackEvent under vitest without capture', () => {
35
+ initAnalytics();
36
+ expect(() => {
37
+ trackEvent('bot_started', { guild_count: 1 });
38
+ }).not.toThrow();
39
+ });
40
+ it('KIMAKI_STRADA_ENABLED=0 keeps initAnalytics from throwing', () => {
41
+ const prev = process.env.KIMAKI_STRADA_ENABLED;
42
+ process.env.KIMAKI_STRADA_ENABLED = '0';
43
+ try {
44
+ initAnalytics();
45
+ expect(() => {
46
+ trackEvent('bot_started', { guild_count: 1 });
47
+ }).not.toThrow();
48
+ }
49
+ finally {
50
+ if (prev === undefined) {
51
+ delete process.env.KIMAKI_STRADA_ENABLED;
52
+ }
53
+ else {
54
+ process.env.KIMAKI_STRADA_ENABLED = prev;
55
+ }
56
+ }
57
+ });
58
+ it('builds common props and install_id always wins', () => {
59
+ setAnalyticsBotMode('gateway');
60
+ const captured = _enableAnalyticsTestCapture({
61
+ installId: '22222222-2222-4222-8222-222222222222',
62
+ botMode: 'gateway',
63
+ });
64
+ trackEvent('turn_started', {
65
+ install_id: 'should-not-win',
66
+ input_kind: 'prompt',
67
+ ingress_mode: 'direct',
68
+ source: 'discord',
69
+ uses_custom_agent: false,
70
+ });
71
+ expect(captured).toHaveLength(1);
72
+ expect(captured[0].name).toBe('turn_started');
73
+ expect(captured[0].properties).toMatchObject({
74
+ install_id: '22222222-2222-4222-8222-222222222222',
75
+ schema_version: 1,
76
+ bot_mode: 'gateway',
77
+ platform: process.platform,
78
+ arch: process.arch,
79
+ input_kind: 'prompt',
80
+ ingress_mode: 'direct',
81
+ source: 'discord',
82
+ uses_custom_agent: false,
83
+ });
84
+ expect(captured[0].properties.install_id).not.toBe('should-not-win');
85
+ const common = commonAnalyticsProps({ foo: 'bar' });
86
+ expect(common).toMatchObject({
87
+ foo: 'bar',
88
+ install_id: '22222222-2222-4222-8222-222222222222',
89
+ schema_version: 1,
90
+ bot_mode: 'gateway',
91
+ });
92
+ });
93
+ it('emits project_registered session_created turn_completed shapes', () => {
94
+ const captured = _enableAnalyticsTestCapture({
95
+ installId: '33333333-3333-4333-8333-333333333333',
96
+ botMode: 'self_hosted',
97
+ });
98
+ trackEvent('project_registered', {
99
+ project_kind: 'user',
100
+ source: 'cli',
101
+ user_project_count: 2,
102
+ });
103
+ trackEvent('session_created', {
104
+ has_worktree: true,
105
+ source: 'discord',
106
+ });
107
+ trackEvent('turn_completed', {
108
+ duration_sec: 42,
109
+ });
110
+ expect(captured.map((e) => e.name)).toEqual([
111
+ 'project_registered',
112
+ 'session_created',
113
+ 'turn_completed',
114
+ ]);
115
+ expect(captured[0].properties).toMatchObject({
116
+ project_kind: 'user',
117
+ source: 'cli',
118
+ user_project_count: 2,
119
+ install_id: '33333333-3333-4333-8333-333333333333',
120
+ schema_version: 1,
121
+ });
122
+ expect(captured[1].properties).toMatchObject({
123
+ has_worktree: true,
124
+ source: 'discord',
125
+ });
126
+ expect(captured[2].properties).toMatchObject({
127
+ duration_sec: 42,
128
+ });
129
+ });
130
+ });
@@ -4,10 +4,40 @@
4
4
  import { ChannelType, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, } from './database.js';
7
+ import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, } from './database.js';
8
8
  import { getProjectsDir } from './config.js';
9
9
  import { execAsync } from './worktrees.js';
10
10
  import { createLogger, LogPrefix } from './logger.js';
11
+ import { trackEvent, } from './analytics.js';
12
+ /**
13
+ * Distinct non-default project directories mapped as text channels.
14
+ * Returns null on query failure so callers omit the field instead of
15
+ * emitting a fabricated zero.
16
+ */
17
+ export async function getUserProjectCount() {
18
+ try {
19
+ const channels = await listTrackedTextChannels();
20
+ const defaultDir = path.resolve(getDefaultKimakiDirectory());
21
+ const dirs = new Set(channels
22
+ .map((row) => path.resolve(row.directory))
23
+ .filter((directory) => directory !== defaultDir));
24
+ return dirs.size;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ async function trackProjectRegistered({ projectKind, source, }) {
31
+ const userProjectCount = await getUserProjectCount();
32
+ const props = {
33
+ project_kind: projectKind,
34
+ source,
35
+ };
36
+ if (userProjectCount !== null) {
37
+ props.user_project_count = userProjectCount;
38
+ }
39
+ trackEvent('project_registered', props);
40
+ }
11
41
  const logger = createLogger(LogPrefix.CHANNEL);
12
42
  export async function ensureKimakiCategory(guild, botName) {
13
43
  // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
@@ -45,7 +75,7 @@ export async function ensureKimakiAudioCategory(guild, botName) {
45
75
  type: ChannelType.GuildCategory,
46
76
  });
47
77
  }
48
- export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, }) {
78
+ export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, analyticsSource = 'cli', }) {
49
79
  const baseName = path.basename(projectDirectory);
50
80
  const channelName = `${baseName}`
51
81
  .toLowerCase()
@@ -63,6 +93,10 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
63
93
  directory: projectDirectory,
64
94
  channelType: 'text',
65
95
  });
96
+ await trackProjectRegistered({
97
+ projectKind: 'user',
98
+ source: analyticsSource,
99
+ });
66
100
  let voiceChannelId = null;
67
101
  if (enableVoiceChannels) {
68
102
  const kimakiAudioCategory = await ensureKimakiAudioCategory(guild, botName);
@@ -237,6 +271,10 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
237
271
  channelType: 'text',
238
272
  guildId: guild.id,
239
273
  });
274
+ await trackProjectRegistered({
275
+ projectKind: 'default',
276
+ source: 'onboarding',
277
+ });
240
278
  logger.log(`Created default kimaki channel: #${channelName} (${textChannel.id})`);
241
279
  return {
242
280
  textChannel,
@@ -25,6 +25,7 @@ import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
25
25
  import { upgrade, getCurrentVersion } from '../upgrade.js';
26
26
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
27
27
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
28
+ import { flushAnalytics, initAnalytics, setAnalyticsBotMode, } from '../analytics.js';
28
29
  const cliLogger = createLogger(LogPrefix.CLI);
29
30
  const cli = goke();
30
31
  cli
@@ -43,6 +44,9 @@ cli
43
44
  const { token: botToken, appId } = await resolveBotCredentials({
44
45
  appIdOverride: options.appId,
45
46
  });
47
+ const botRow = await getBotTokenWithMode();
48
+ setAnalyticsBotMode(botRow?.mode === 'gateway' ? 'gateway' : 'self_hosted');
49
+ initAnalytics();
46
50
  if (!appId) {
47
51
  cliLogger.error('App ID is required to create channels. Use --app-id or run `kimaki` first.');
48
52
  process.exit(EXIT_NO_RESTART);
@@ -86,6 +90,7 @@ cli
86
90
  guild,
87
91
  projectDirectory: absolutePath,
88
92
  botName: client.user?.username,
93
+ analyticsSource: 'cli',
89
94
  });
90
95
  void client.destroy();
91
96
  if (textChannelId || voiceChannelId) {
@@ -94,6 +99,7 @@ cli
94
99
  const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
95
100
  note(`Created channels for project:\n\nšŸ“ Text: #${channelName}\nšŸ”Š Voice: #${channelName}\nšŸ“ Directory: ${absolutePath}\n\nURL: ${channelUrl}`, 'āœ… Success');
96
101
  cliLogger.log(channelUrl);
102
+ await flushAnalytics();
97
103
  process.exit(0);
98
104
  });
99
105
  cli
@@ -393,6 +399,8 @@ cli
393
399
  process.exit(EXIT_NO_RESTART);
394
400
  }
395
401
  const { token: botToken } = botRow;
402
+ setAnalyticsBotMode(botRow.mode === 'gateway' ? 'gateway' : 'self_hosted');
403
+ initAnalytics();
396
404
  const projectsDir = getProjectsDir();
397
405
  const projectDirectory = path.join(projectsDir, sanitizedName);
398
406
  if (!fs.existsSync(projectsDir)) {
@@ -420,11 +428,13 @@ cli
420
428
  guild,
421
429
  projectDirectory,
422
430
  botName: client.user?.username,
431
+ analyticsSource: 'cli',
423
432
  });
424
433
  void client.destroy();
425
434
  const channelUrl = `https://discord.com/channels/${guild.id}/${textChannelId}`;
426
435
  note(`Created project: ${sanitizedName}\n\nDirectory: ${projectDirectory}\nChannel: #${channelName}\nURL: ${channelUrl}`, 'āœ… Success');
427
436
  cliLogger.log(channelUrl);
437
+ await flushAnalytics();
428
438
  process.exit(0);
429
439
  });
430
440
  // Resolve the guild for project add/create commands. In gateway mode the