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.
- package/dist/agent-model.e2e.test.js +90 -10
- package/dist/analytics.js +238 -0
- package/dist/analytics.test.js +130 -0
- package/dist/channel-management.js +40 -2
- package/dist/cli-commands/project.js +10 -0
- package/dist/cli-commands/send.js +34 -6
- package/dist/cli-commands/task.js +80 -7
- package/dist/cli-parsing.test.js +22 -0
- package/dist/cli-runner.js +8 -8
- package/dist/cli.js +4 -8
- package/dist/commands/add-project.js +1 -0
- package/dist/commands/create-new-project.js +12 -4
- package/dist/commands/gemini-apikey.js +52 -18
- package/dist/commands/model-variant.js +20 -23
- package/dist/commands/model.js +93 -4
- package/dist/commands/queue.js +65 -71
- package/dist/commands/screenshare.js +30 -26
- package/dist/commands/unset-model.js +19 -26
- package/dist/commands/vscode.js +29 -3
- package/dist/commands/worktrees.js +95 -28
- package/dist/context-awareness-plugin.js +23 -0
- package/dist/discord-bot.js +13 -1
- package/dist/discord-command-registration.js +13 -43
- package/dist/discord-utils.js +41 -0
- package/dist/interaction-handler.js +3 -26
- package/dist/session-handler/thread-runtime-state.js +24 -0
- package/dist/session-handler/thread-session-runtime.js +96 -4
- package/dist/system-message.js +66 -5
- package/dist/system-message.test.js +54 -7
- package/dist/task-runner.js +21 -6
- package/dist/test-utils.js +12 -1
- package/dist/thread-message-queue.e2e.test.js +18 -16
- package/dist/voice-handler.js +34 -23
- package/dist/voice-message.e2e.test.js +75 -0
- package/package.json +8 -7
- package/skills/sigillo/SKILL.md +26 -0
- package/src/agent-model.e2e.test.ts +102 -10
- package/src/analytics.test.ts +160 -0
- package/src/analytics.ts +295 -0
- package/src/channel-management.ts +55 -0
- package/src/cli-commands/project.ts +14 -0
- package/src/cli-commands/send.ts +44 -6
- package/src/cli-commands/task.ts +116 -7
- package/src/cli-parsing.test.ts +29 -0
- package/src/cli-runner.ts +13 -8
- package/src/cli.ts +10 -8
- package/src/commands/add-project.ts +1 -0
- package/src/commands/create-new-project.ts +20 -7
- package/src/commands/gemini-apikey.ts +84 -15
- package/src/commands/model-variant.ts +27 -28
- package/src/commands/model.ts +135 -18
- package/src/commands/queue.ts +91 -90
- package/src/commands/screenshare.ts +54 -32
- package/src/commands/unset-model.ts +26 -32
- package/src/commands/vscode.ts +38 -2
- package/src/commands/worktrees.ts +137 -27
- package/src/context-awareness-plugin.ts +27 -0
- package/src/discord-bot.ts +18 -1
- package/src/discord-command-registration.ts +26 -47
- package/src/discord-utils.ts +51 -0
- package/src/interaction-handler.ts +1 -34
- package/src/session-handler/thread-runtime-state.ts +36 -0
- package/src/session-handler/thread-session-runtime.ts +137 -3
- package/src/store.ts +2 -0
- package/src/system-message.test.ts +79 -6
- package/src/system-message.ts +99 -6
- package/src/task-runner.ts +29 -9
- package/src/test-utils.ts +16 -1
- package/src/thread-message-queue.e2e.test.ts +23 -16
- package/src/voice-handler.ts +36 -22
- package/src/voice-message.e2e.test.ts +89 -0
- package/dist/commands/worktree-settings.js +0 -41
- package/skills/batch/SKILL.md +0 -87
- package/skills/security-review/SKILL.md +0 -208
- package/skills/simplify/SKILL.md +0 -58
- 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 [
|
|
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 =
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|