gestalt-mobile 0.35.5 → 0.36.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/README.md +60 -3
- package/dist/client/assets/index-9XMv2BvD.js +38 -0
- package/dist/client/assets/{index-CPQBJDsg.css → index-Bxv5Z2w4.css} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/client/features/chat/activity-summary.js +71 -0
- package/dist/server/client/features/chat/chat-projection.js +722 -0
- package/dist/server/client/features/chat/message-store.js +30 -0
- package/dist/server/server/app.js +1 -1
- package/dist/server/server/cli.js +8 -0
- package/dist/server/server/composition.contracts.js +158 -6
- package/dist/server/server/composition.js +266 -56
- package/dist/server/server/features/autopilot/register-routes.js +2 -2
- package/dist/server/server/features/autopilot/toggle/endpoint.js +5 -1
- package/dist/server/server/features/catalog/get-bootstrap/use-case.js +5 -3
- package/dist/server/server/features/sessions/get-session/endpoint.js +1 -1
- package/dist/server/server/features/sessions/list-models/endpoint.js +16 -0
- package/dist/server/server/features/sessions/list-recent-threads/endpoint.js +8 -3
- package/dist/server/server/features/sessions/list-sessions/endpoint.js +1 -1
- package/dist/server/server/features/sessions/model/relay-session.js +2 -0
- package/dist/server/server/features/sessions/promote-recent-thread/endpoint.js +6 -2
- package/dist/server/server/features/sessions/promote-recent-thread/use-case.js +7 -2
- package/dist/server/server/features/sessions/register-routes.js +8 -3
- package/dist/server/server/features/sessions/respond-interaction/endpoint.js +1 -1
- package/dist/server/server/features/sessions/select-model/endpoint.js +1 -1
- package/dist/server/server/features/sessions/start-session/request.js +1 -0
- package/dist/server/server/features/sessions/start-session/use-case.js +21 -7
- package/dist/server/server/features/skills/list-available/endpoint.js +4 -2
- package/dist/server/server/platform/catalog/provider-model-catalog.js +15 -0
- package/dist/server/server/platform/kimi/kimi-activity-facts.js +88 -0
- package/dist/server/server/platform/kimi/kimi-errors.js +30 -0
- package/dist/server/server/platform/kimi/kimi-model-catalog.js +63 -0
- package/dist/server/server/platform/kimi/kimi-normalizer.js +196 -0
- package/dist/server/server/platform/kimi/kimi-recent-session-lister.js +68 -0
- package/dist/server/server/platform/kimi/kimi-server-request.js +143 -0
- package/dist/server/server/platform/kimi/kimi-session-runtime.js +653 -0
- package/dist/server/server/platform/kimi/kimi-share-dir.js +65 -0
- package/dist/server/server/platform/kimi/kimi-skill-catalog.js +79 -0
- package/dist/server/server/platform/kimi/kimi-skills-materializer.js +41 -0
- package/dist/server/server/platform/kimi/kimi-web-client.js +63 -0
- package/dist/server/server/platform/kimi/kimi-web-server-manager.js +215 -0
- package/dist/server/server/platform/kimi/kimi-ws-client.js +154 -0
- package/dist/server/server/platform/persistence/migrate.js +2 -0
- package/dist/server/server/platform/persistence/sqlite-session-repository.js +3 -2
- package/dist/server/server/platform/skills/cached-skill-catalog.js +0 -0
- package/dist/server/shared/contracts/llm-provider.js +6 -0
- package/package.json +4 -3
- package/dist/client/assets/index-SZM7lpix.js +0 -38
|
@@ -8,13 +8,17 @@ import { RecentThreadHistoryUnavailable } from './use-case.js';
|
|
|
8
8
|
const requestSchema = z.object({
|
|
9
9
|
threadId: z.string().min(1),
|
|
10
10
|
cwd: z.string().startsWith('/'),
|
|
11
|
+
// Requests written before provider support remain Codex-compatible.
|
|
12
|
+
provider: z.enum(['codex', 'kimi']).optional().default('codex'),
|
|
11
13
|
});
|
|
12
14
|
export function registerPromoteRecentThread(app, deps) {
|
|
13
15
|
app.post('/api/sessions/recent-threads/open', { bodyLimit: 8 * 1024 }, async (request, reply) => {
|
|
14
16
|
const parsed = requestSchema.safeParse(request.body);
|
|
15
17
|
if (!parsed.success)
|
|
16
18
|
return reply.code(400).send({ code: 'INVALID_RECENT_THREAD' });
|
|
17
|
-
const thread = (await deps.list()).find((candidate) => candidate.id === parsed.data.threadId &&
|
|
19
|
+
const thread = (await deps.list()).find((candidate) => candidate.id === parsed.data.threadId &&
|
|
20
|
+
candidate.cwd === parsed.data.cwd &&
|
|
21
|
+
(candidate.provider ?? 'codex') === parsed.data.provider);
|
|
18
22
|
if (!thread)
|
|
19
23
|
return reply.code(404).send({ code: 'RECENT_THREAD_NOT_FOUND' });
|
|
20
24
|
try {
|
|
@@ -27,7 +31,7 @@ export function registerPromoteRecentThread(app, deps) {
|
|
|
27
31
|
? 'RECENT_THREAD_HISTORY_UNAVAILABLE'
|
|
28
32
|
: 'RECENT_THREAD_OPEN_FAILED',
|
|
29
33
|
detail: historyUnavailable
|
|
30
|
-
? 'The selected thread history is currently unavailable. Retry after
|
|
34
|
+
? 'The selected thread history is currently unavailable. Retry after its provider is available.'
|
|
31
35
|
: 'The selected thread could not be opened. Retry shortly.',
|
|
32
36
|
});
|
|
33
37
|
}
|
|
@@ -11,7 +11,8 @@ export class RecentThreadHistoryUnavailable extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
const inFlightPromotions = new Map();
|
|
13
13
|
export async function promoteRecentThread(thread, deps) {
|
|
14
|
-
const
|
|
14
|
+
const provider = thread.provider ?? 'codex';
|
|
15
|
+
const key = `${provider}:${thread.profile}:${thread.cwd}:${thread.id}`;
|
|
15
16
|
const ongoing = inFlightPromotions.get(key);
|
|
16
17
|
if (ongoing)
|
|
17
18
|
return ongoing;
|
|
@@ -26,7 +27,10 @@ export async function promoteRecentThread(thread, deps) {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
async function promote(thread, deps) {
|
|
29
|
-
const
|
|
30
|
+
const provider = thread.provider ?? 'codex';
|
|
31
|
+
const existing = deps
|
|
32
|
+
.list()
|
|
33
|
+
.find((session) => session.threadId === thread.id && (session.provider ?? 'codex') === provider);
|
|
30
34
|
if (existing) {
|
|
31
35
|
const executionPolicy = existing.executionPolicy
|
|
32
36
|
? undefined
|
|
@@ -51,6 +55,7 @@ async function promote(thread, deps) {
|
|
|
51
55
|
id: deps.createId(),
|
|
52
56
|
workspaceId: thread.cwd,
|
|
53
57
|
workspacePath: thread.cwd,
|
|
58
|
+
provider,
|
|
54
59
|
profile: thread.profile,
|
|
55
60
|
threadId: thread.id,
|
|
56
61
|
...(executionPolicy === undefined ? {} : { executionPolicy }),
|
|
@@ -17,6 +17,7 @@ import { registerReleaseSession } from './release-session/endpoint.js';
|
|
|
17
17
|
import { registerRestoreSession } from './restore-session/endpoint.js';
|
|
18
18
|
import { registerRespondInteraction } from './respond-interaction/endpoint.js';
|
|
19
19
|
import { registerSelectModel } from './select-model/endpoint.js';
|
|
20
|
+
import { registerListSessionModels } from './list-models/endpoint.js';
|
|
20
21
|
import { registerSessionEvents } from './session-events/endpoint.js';
|
|
21
22
|
import { registerStartSession } from './start-session/endpoint.js';
|
|
22
23
|
import { registerStartTurn } from './start-turn/endpoint.js';
|
|
@@ -27,8 +28,10 @@ export function registerSessionRoutes(app, deps) {
|
|
|
27
28
|
if (deps.recentThreads) {
|
|
28
29
|
registerListRecentThreads(app, {
|
|
29
30
|
...deps.recentThreads,
|
|
30
|
-
metadata: (threadId) => {
|
|
31
|
-
const session = sessions
|
|
31
|
+
metadata: (threadId, provider) => {
|
|
32
|
+
const session = sessions
|
|
33
|
+
?.list?.()
|
|
34
|
+
.find((candidate) => candidate.threadId === threadId && (candidate.provider ?? 'codex') === provider);
|
|
32
35
|
if (!session)
|
|
33
36
|
return null;
|
|
34
37
|
return {
|
|
@@ -44,6 +47,8 @@ export function registerSessionRoutes(app, deps) {
|
|
|
44
47
|
});
|
|
45
48
|
}
|
|
46
49
|
if (sessions) {
|
|
50
|
+
if (sessions.sessionModels)
|
|
51
|
+
registerListSessionModels(app, sessions.sessionModels);
|
|
47
52
|
registerStartSession(app, {
|
|
48
53
|
...sessions,
|
|
49
54
|
reportFailure: (operation, error) => deps.logger.error(`${operation} failed: ${safeErrorLabel(error)}`),
|
|
@@ -84,7 +89,7 @@ export function registerSessionRoutes(app, deps) {
|
|
|
84
89
|
if (sessions.models)
|
|
85
90
|
registerSelectModel(app, {
|
|
86
91
|
find: sessions.find,
|
|
87
|
-
models: () => sessions.models.list(),
|
|
92
|
+
models: (provider) => sessions.models.list(provider),
|
|
88
93
|
now: sessions.now,
|
|
89
94
|
save: sessions.save,
|
|
90
95
|
});
|
|
@@ -18,7 +18,7 @@ export function registerRespondInteraction(app, deps) {
|
|
|
18
18
|
return reply.code(409).send({ code: 'INTERACTION_NOT_PENDING' });
|
|
19
19
|
if (deps.validate && !deps.validate(id, requestId, value))
|
|
20
20
|
return reply.code(400).send({ code: 'INTERACTION_RESPONSE_INVALID' });
|
|
21
|
-
const delivery = deps.reply(id, requestId, value);
|
|
21
|
+
const delivery = await deps.reply(id, requestId, value);
|
|
22
22
|
if (delivery === false || delivery === 'unavailable')
|
|
23
23
|
return reply.code(409).send({ code: 'INTERACTION_DELIVERY_UNAVAILABLE' });
|
|
24
24
|
if (delivery === 'cleared') {
|
|
@@ -12,7 +12,7 @@ export function registerSelectModel(app, deps) {
|
|
|
12
12
|
const model = request.body.model;
|
|
13
13
|
if (typeof model !== 'string')
|
|
14
14
|
return reply.code(400).send({ code: 'MODEL_REQUIRED' });
|
|
15
|
-
if (!(await deps.models()).includes(model))
|
|
15
|
+
if (!(await deps.models(session.provider)).includes(model))
|
|
16
16
|
return reply.code(400).send({ code: 'MODEL_UNAVAILABLE' });
|
|
17
17
|
if (session.state !== 'ready')
|
|
18
18
|
return reply.code(409).send({ code: 'SESSION_NOT_READY' });
|
|
@@ -8,15 +8,28 @@ import { DEFAULT_SESSION_MODEL } from '../application/start-settings.js';
|
|
|
8
8
|
import { reconcileSkillSelectionSnapshot, } from '../../skills/model/skill-profile.js';
|
|
9
9
|
import { SkillProfileError } from '../../skills/model/errors.js';
|
|
10
10
|
export async function startSession(input, deps) {
|
|
11
|
-
|
|
11
|
+
// Codex launcher profiles select the app-server skill directory. Kimi owns
|
|
12
|
+
// its isolated profile lifecycle, so it must not require a Codex launcher
|
|
13
|
+
// profile before its own catalog/runtime can be selected.
|
|
12
14
|
const [workspace] = await Promise.all([
|
|
13
15
|
deps.workspaces.resolve(input.workspaceId),
|
|
14
|
-
deps.profiles.require(input.profile),
|
|
16
|
+
...(input.provider === 'codex' ? [deps.profiles.require(input.profile)] : []),
|
|
15
17
|
]);
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
let model = input.model;
|
|
19
|
+
const modelCatalog = deps.sessionModels ?? deps.models;
|
|
20
|
+
if (modelCatalog) {
|
|
21
|
+
const models = await modelCatalog.list(input.provider);
|
|
22
|
+
// An omitted model resolves to the provider's own default: the relay-wide
|
|
23
|
+
// default when that provider serves it, otherwise the provider's first
|
|
24
|
+
// available model, so a kimi request never inherits the codex default.
|
|
25
|
+
model ??= models.includes(DEFAULT_SESSION_MODEL)
|
|
26
|
+
? DEFAULT_SESSION_MODEL
|
|
27
|
+
: (models[0] ?? DEFAULT_SESSION_MODEL);
|
|
18
28
|
if (!models.includes(model))
|
|
19
|
-
throw new Error('CODEX_MODEL_UNAVAILABLE');
|
|
29
|
+
throw new Error(input.provider === 'codex' ? 'CODEX_MODEL_UNAVAILABLE' : 'KIMI_MODEL_UNAVAILABLE');
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
model ??= DEFAULT_SESSION_MODEL;
|
|
20
33
|
}
|
|
21
34
|
const selectedProfile = input.skillProfile
|
|
22
35
|
? await deps.skillProfiles.readGlobalProfile(input.skillProfile)
|
|
@@ -25,7 +38,7 @@ export async function startSession(input, deps) {
|
|
|
25
38
|
throw new SkillProfileError('UNKNOWN_SKILL_PROFILE', 'The selected skill profile does not exist.');
|
|
26
39
|
const [projectProfile, catalog] = await Promise.all([
|
|
27
40
|
deps.skillProfiles.readWorkspaceDefault(workspace.realPath),
|
|
28
|
-
deps.skillCatalog(input.profile).list(workspace.realPath),
|
|
41
|
+
deps.skillCatalog(input.provider, input.profile).list(workspace.realPath),
|
|
29
42
|
]);
|
|
30
43
|
const sourceProfile = selectedProfile ?? projectProfile;
|
|
31
44
|
const reconciledSkills = reconcileSkillSelectionSnapshot(catalog.skills, sourceProfile?.skills);
|
|
@@ -46,6 +59,7 @@ export async function startSession(input, deps) {
|
|
|
46
59
|
id: deps.createId(),
|
|
47
60
|
workspaceId: workspace.id,
|
|
48
61
|
workspacePath: workspace.realPath,
|
|
62
|
+
provider: input.provider,
|
|
49
63
|
profile: input.profile,
|
|
50
64
|
model,
|
|
51
65
|
...(branch ? { branch } : {}),
|
|
@@ -57,7 +71,7 @@ export async function startSession(input, deps) {
|
|
|
57
71
|
deps.save(session);
|
|
58
72
|
if (!deps.activate)
|
|
59
73
|
return session;
|
|
60
|
-
const active = await deps.activate(session, {});
|
|
74
|
+
const active = await deps.activate(session, { provider: session.provider });
|
|
61
75
|
deps.save(active);
|
|
62
76
|
return active;
|
|
63
77
|
}
|
|
@@ -11,6 +11,7 @@ const querySchema = z
|
|
|
11
11
|
.object({
|
|
12
12
|
workspaceId: z.string().min(1),
|
|
13
13
|
profile: z.string().min(1),
|
|
14
|
+
provider: z.enum(['codex', 'kimi']).optional(),
|
|
14
15
|
refresh: z.enum(['true']).optional(),
|
|
15
16
|
})
|
|
16
17
|
.strict();
|
|
@@ -28,10 +29,11 @@ export function registerListAvailableSkills(app, deps) {
|
|
|
28
29
|
deps.workspaces.resolve(parsed.data.workspaceId),
|
|
29
30
|
deps.profiles.require(parsed.data.profile),
|
|
30
31
|
]);
|
|
32
|
+
const provider = parsed.data.provider ?? 'codex';
|
|
31
33
|
const [discovered, project] = await Promise.all([
|
|
32
34
|
parsed.data.refresh
|
|
33
|
-
? deps.catalog.refresh(profile.name, workspace.realPath)
|
|
34
|
-
: deps.catalog.list(profile.name, workspace.realPath),
|
|
35
|
+
? deps.catalog.refresh(provider, profile.name, workspace.realPath)
|
|
36
|
+
: deps.catalog.list(provider, profile.name, workspace.realPath),
|
|
35
37
|
deps.selections.readWorkspaceDefault(workspace.realPath),
|
|
36
38
|
]);
|
|
37
39
|
const skills = applySkillSelectionSnapshot(discovered.skills, project?.skills);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
/** Routes each provider's model query to that provider's own catalog adapter. */
|
|
7
|
+
export class ProviderModelCatalog {
|
|
8
|
+
catalogs;
|
|
9
|
+
constructor(catalogs) {
|
|
10
|
+
this.catalogs = catalogs;
|
|
11
|
+
}
|
|
12
|
+
list(provider) {
|
|
13
|
+
return this.catalogs[provider].list();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Decodes bounded activity evidence from kimi web events. Turn lifecycle is
|
|
8
|
+
* prompt-based; `agent.status.updated` supplies context usage and
|
|
9
|
+
* awaiting-human transitions (kimi 2.0.2 has no dedicated interaction events —
|
|
10
|
+
* pending approvals/questions arrive through status changes plus REST lists).
|
|
11
|
+
*/
|
|
12
|
+
export function decodeKimiActivityFacts(sessionId, occurredAt, event, context) {
|
|
13
|
+
const payload = record(event.payload);
|
|
14
|
+
if (!payload || typeof payload.type !== 'string')
|
|
15
|
+
return [];
|
|
16
|
+
switch (payload.type) {
|
|
17
|
+
case 'turn.started': {
|
|
18
|
+
const agentId = safeId(payload.agentId);
|
|
19
|
+
const turnNumber = safeTurn(payload.turnId);
|
|
20
|
+
if (!agentId || turnNumber === null)
|
|
21
|
+
return [];
|
|
22
|
+
const turnId = context.resolveTurnId(agentId, turnNumber);
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
sessionId,
|
|
26
|
+
occurredAt,
|
|
27
|
+
kind: 'turnStarted',
|
|
28
|
+
...(event.session_id ? { threadId: event.session_id } : {}),
|
|
29
|
+
...(turnId ? { turnId } : {}),
|
|
30
|
+
...(context.isChildAgent(agentId) ? { childThreadId: agentId } : {}),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
case 'prompt.completed':
|
|
35
|
+
case 'prompt.aborted': {
|
|
36
|
+
const agentId = safeId(payload.agentId);
|
|
37
|
+
const promptId = safeId(payload.promptId);
|
|
38
|
+
if (!agentId || !promptId || context.isChildAgent(agentId))
|
|
39
|
+
return [];
|
|
40
|
+
return [
|
|
41
|
+
{
|
|
42
|
+
sessionId,
|
|
43
|
+
occurredAt,
|
|
44
|
+
kind: 'turnCompleted',
|
|
45
|
+
...(event.session_id ? { threadId: event.session_id } : {}),
|
|
46
|
+
turnId: promptId,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
case 'agent.status.updated': {
|
|
51
|
+
const status = typeof payload.status === 'string' ? payload.status : null;
|
|
52
|
+
const facts = [];
|
|
53
|
+
const usage = typeof payload.contextUsage === 'number' ? payload.contextUsage : null;
|
|
54
|
+
if (usage !== null && usage >= 0) {
|
|
55
|
+
facts.push({
|
|
56
|
+
sessionId,
|
|
57
|
+
occurredAt,
|
|
58
|
+
kind: 'contextUsage',
|
|
59
|
+
...(event.session_id ? { threadId: event.session_id } : {}),
|
|
60
|
+
contextUsedPercent: usage <= 1 ? Math.round(usage * 100) : Math.min(100, Math.round(usage)),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (status === 'awaiting_approval' || status === 'awaiting_question') {
|
|
64
|
+
facts.push({
|
|
65
|
+
sessionId,
|
|
66
|
+
occurredAt,
|
|
67
|
+
kind: 'interactionPending',
|
|
68
|
+
...(event.session_id ? { threadId: event.session_id } : {}),
|
|
69
|
+
attentionReason: status === 'awaiting_approval' ? 'permissionRequired' : 'materialAmbiguity',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return facts;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function record(value) {
|
|
79
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
80
|
+
? value
|
|
81
|
+
: null;
|
|
82
|
+
}
|
|
83
|
+
function safeId(value) {
|
|
84
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null;
|
|
85
|
+
}
|
|
86
|
+
function safeTurn(value) {
|
|
87
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Error surfaced by the kimi web client. `code` is the protocol business code
|
|
8
|
+
* (0 means success on the wire, so a thrown error always has code !== 0);
|
|
9
|
+
* HTTP-level failures are normalized to code -1 with the transport message.
|
|
10
|
+
*/
|
|
11
|
+
export class KimiWebError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
requestId;
|
|
14
|
+
constructor(code, message, requestId) {
|
|
15
|
+
super(boundMessage(message));
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.requestId = requestId;
|
|
18
|
+
this.name = 'KimiWebError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function isKimiWebError(error) {
|
|
22
|
+
return error instanceof KimiWebError;
|
|
23
|
+
}
|
|
24
|
+
/** Caps and redacts wire messages before they enter logs or client payloads. */
|
|
25
|
+
export function boundMessage(message) {
|
|
26
|
+
return message
|
|
27
|
+
.replace(/((?:authorization|token|api[_ -]?key|password)\s*[:=]\s*)\S+/gi, '$1[REDACTED]')
|
|
28
|
+
.replace(/(authentication failed:\s*).*/i, '$1[REDACTED]')
|
|
29
|
+
.slice(0, 256);
|
|
30
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const MODELS_TIMEOUT_MS = 5_000;
|
|
7
|
+
/**
|
|
8
|
+
* Lists kimi model aliases from a running gestalt-owned kimi web server
|
|
9
|
+
* (`GET /api/v1/models`) when a Kimi profile server is already running.
|
|
10
|
+
* Discovery is deliberately read-only: bootstrap must not spawn or wait for
|
|
11
|
+
* Kimi on behalf of an otherwise ordinary Codex client. Explicit Kimi session
|
|
12
|
+
* activation owns server startup; any discovery failure degrades to an empty
|
|
13
|
+
* list, matching the catalog contract.
|
|
14
|
+
*/
|
|
15
|
+
export class KimiModelCatalog {
|
|
16
|
+
servers;
|
|
17
|
+
available;
|
|
18
|
+
constructor(servers, available) {
|
|
19
|
+
this.servers = servers;
|
|
20
|
+
this.available = available;
|
|
21
|
+
}
|
|
22
|
+
async list() {
|
|
23
|
+
if (!this.available || !this.servers)
|
|
24
|
+
return [];
|
|
25
|
+
try {
|
|
26
|
+
const handle = this.servers.list()[0];
|
|
27
|
+
if (!handle)
|
|
28
|
+
return [];
|
|
29
|
+
return await this.read(handle);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Explicit Kimi session setup may start its isolated default server. */
|
|
36
|
+
async listForSession() {
|
|
37
|
+
if (!this.available || !this.servers)
|
|
38
|
+
return [];
|
|
39
|
+
try {
|
|
40
|
+
return await this.read(await this.servers.ensure('default', []));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async read(handle) {
|
|
47
|
+
const data = await withTimeout(handle.client.get('/api/v1/models'), MODELS_TIMEOUT_MS, 'kimi model list timed out');
|
|
48
|
+
const items = Array.isArray(data?.items)
|
|
49
|
+
? data.items
|
|
50
|
+
: [];
|
|
51
|
+
return [
|
|
52
|
+
...new Set(items.flatMap((item) => typeof item.model === 'string' && item.model.length > 0 && item.model.length <= 128
|
|
53
|
+
? [item.model]
|
|
54
|
+
: [])),
|
|
55
|
+
].sort();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
59
|
+
return Promise.race([
|
|
60
|
+
promise,
|
|
61
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error(message)), timeoutMs).unref()),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Maps kimi web session events onto the relay's provider-neutral event types.
|
|
8
|
+
* The relay's turn lifecycle is prompt-based (`prompt.completed`/`aborted`),
|
|
9
|
+
* because a gestalt turn id is the kimi prompt id; `turn.started` exists only
|
|
10
|
+
* to bind kimi per-agent turn numbers to prompt ids. Unknown future event
|
|
11
|
+
* types deliberately become `null`, mirroring the codex normalizer.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeKimiEvent(sessionId, sequence, occurredAt, event, context = {
|
|
14
|
+
resolveTurnId: () => null,
|
|
15
|
+
isChildAgent: () => false,
|
|
16
|
+
}) {
|
|
17
|
+
const payload = record(event.payload);
|
|
18
|
+
if (!payload || typeof payload.type !== 'string')
|
|
19
|
+
return null;
|
|
20
|
+
switch (payload.type) {
|
|
21
|
+
case 'assistant.delta': {
|
|
22
|
+
const agentId = safeId(payload.agentId);
|
|
23
|
+
const turnNumber = safeTurn(payload.turnId);
|
|
24
|
+
const delta = bounded(payload.delta, 64_000);
|
|
25
|
+
if (!agentId || turnNumber === null || delta === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (context.isChildAgent(agentId))
|
|
28
|
+
return null;
|
|
29
|
+
const turnId = context.resolveTurnId(agentId, turnNumber) ?? `kimi:${agentId}:${turnNumber}`;
|
|
30
|
+
return {
|
|
31
|
+
sessionId,
|
|
32
|
+
sequence,
|
|
33
|
+
occurredAt,
|
|
34
|
+
type: 'agentMessageDelta',
|
|
35
|
+
payload: { text: delta, itemId: `kimi:${agentId}:${turnNumber}`, turnId },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
case 'tool.call.started': {
|
|
39
|
+
const toolCallId = safeId(payload.toolCallId);
|
|
40
|
+
const agentId = safeId(payload.agentId) ?? 'main';
|
|
41
|
+
const turnNumber = safeTurn(payload.turnId);
|
|
42
|
+
if (!toolCallId || turnNumber === null)
|
|
43
|
+
return null;
|
|
44
|
+
const display = record(payload.display);
|
|
45
|
+
const kind = typeof display?.kind === 'string' ? display.kind : 'generic';
|
|
46
|
+
const name = safeId(payload.name);
|
|
47
|
+
const description = bounded(payload.description, 500);
|
|
48
|
+
const detail = (typeof display?.command === 'string' ? bounded(display.command, 500) : null) ??
|
|
49
|
+
(typeof display?.path === 'string' ? bounded(display.path, 500) : null) ??
|
|
50
|
+
description ??
|
|
51
|
+
name ??
|
|
52
|
+
kind;
|
|
53
|
+
const label = kind === 'command' || kind === 'bash'
|
|
54
|
+
? 'Command'
|
|
55
|
+
: kind === 'file_io'
|
|
56
|
+
? 'File'
|
|
57
|
+
: `Tool · ${name ?? kind}`;
|
|
58
|
+
const owner = ownership(agentId, turnNumber, context);
|
|
59
|
+
return {
|
|
60
|
+
sessionId,
|
|
61
|
+
sequence,
|
|
62
|
+
occurredAt,
|
|
63
|
+
type: 'activity.updated',
|
|
64
|
+
payload: { id: toolCallId, label, detail, ...owner },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
case 'tool.result': {
|
|
68
|
+
const toolCallId = safeId(payload.toolCallId);
|
|
69
|
+
const agentId = safeId(payload.agentId) ?? 'main';
|
|
70
|
+
const turnNumber = safeTurn(payload.turnId);
|
|
71
|
+
if (!toolCallId || turnNumber === null)
|
|
72
|
+
return null;
|
|
73
|
+
const isError = payload.isError === true;
|
|
74
|
+
const owner = ownership(agentId, turnNumber, context);
|
|
75
|
+
return {
|
|
76
|
+
sessionId,
|
|
77
|
+
sequence,
|
|
78
|
+
occurredAt,
|
|
79
|
+
type: 'activity.updated',
|
|
80
|
+
payload: {
|
|
81
|
+
id: toolCallId,
|
|
82
|
+
label: isError ? 'Tool error' : 'Tool result',
|
|
83
|
+
detail: bounded(safeJson(payload.output), 400) ?? (isError ? 'failed' : 'done'),
|
|
84
|
+
...owner,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
case 'subagent.spawned': {
|
|
89
|
+
const subagentId = safeId(payload.subagentId);
|
|
90
|
+
if (!subagentId)
|
|
91
|
+
return null;
|
|
92
|
+
const name = bounded(payload.subagentName, 128);
|
|
93
|
+
const description = bounded(payload.description, 300);
|
|
94
|
+
return {
|
|
95
|
+
sessionId,
|
|
96
|
+
sequence,
|
|
97
|
+
occurredAt,
|
|
98
|
+
type: 'activity.updated',
|
|
99
|
+
payload: {
|
|
100
|
+
id: subagentId,
|
|
101
|
+
label: 'Subagent',
|
|
102
|
+
detail: [name, description].filter(Boolean).join(' · '),
|
|
103
|
+
...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case 'subagent.completed':
|
|
108
|
+
case 'subagent.failed': {
|
|
109
|
+
const subagentId = safeId(payload.subagentId);
|
|
110
|
+
if (!subagentId)
|
|
111
|
+
return null;
|
|
112
|
+
const failed = payload.type === 'subagent.failed';
|
|
113
|
+
return {
|
|
114
|
+
sessionId,
|
|
115
|
+
sequence,
|
|
116
|
+
occurredAt,
|
|
117
|
+
type: 'activity.updated',
|
|
118
|
+
payload: {
|
|
119
|
+
id: subagentId,
|
|
120
|
+
label: failed ? 'Subagent failed' : 'Subagent completed',
|
|
121
|
+
detail: bounded(payload.resultSummary, 500) ?? '',
|
|
122
|
+
...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
case 'prompt.completed': {
|
|
127
|
+
const agentId = safeId(payload.agentId);
|
|
128
|
+
const promptId = safeId(payload.promptId);
|
|
129
|
+
if (!agentId || !promptId || context.isChildAgent(agentId))
|
|
130
|
+
return null;
|
|
131
|
+
return {
|
|
132
|
+
sessionId,
|
|
133
|
+
sequence,
|
|
134
|
+
occurredAt,
|
|
135
|
+
type: 'turnCompleted',
|
|
136
|
+
payload: {
|
|
137
|
+
turn: {
|
|
138
|
+
id: promptId,
|
|
139
|
+
...(typeof payload.reason === 'string' ? { status: bounded(payload.reason, 64) } : {}),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
case 'prompt.aborted': {
|
|
145
|
+
const agentId = safeId(payload.agentId);
|
|
146
|
+
const promptId = safeId(payload.promptId);
|
|
147
|
+
if (!agentId || !promptId || context.isChildAgent(agentId))
|
|
148
|
+
return null;
|
|
149
|
+
return {
|
|
150
|
+
sessionId,
|
|
151
|
+
sequence,
|
|
152
|
+
occurredAt,
|
|
153
|
+
type: 'turnInterrupted',
|
|
154
|
+
payload: { turn: { id: promptId } },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
default:
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function ownership(agentId, turnNumber, context) {
|
|
162
|
+
if (context.isChildAgent(agentId)) {
|
|
163
|
+
return {
|
|
164
|
+
...(context.activeTurnId ? { turnId: context.activeTurnId } : {}),
|
|
165
|
+
actorTurnId: `${agentId}:${turnNumber}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const turnId = context.resolveTurnId(agentId, turnNumber);
|
|
169
|
+
return turnId ? { turnId } : context.activeTurnId ? { turnId: context.activeTurnId } : {};
|
|
170
|
+
}
|
|
171
|
+
function record(value) {
|
|
172
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
173
|
+
? value
|
|
174
|
+
: null;
|
|
175
|
+
}
|
|
176
|
+
function safeId(value) {
|
|
177
|
+
return typeof value === 'string' && value.length > 0 && value.length <= 256 ? value : null;
|
|
178
|
+
}
|
|
179
|
+
function safeTurn(value) {
|
|
180
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
181
|
+
}
|
|
182
|
+
function bounded(value, max) {
|
|
183
|
+
return typeof value === 'string' ? value.slice(0, max) : null;
|
|
184
|
+
}
|
|
185
|
+
function safeJson(value) {
|
|
186
|
+
if (value === undefined || value === null)
|
|
187
|
+
return null;
|
|
188
|
+
if (typeof value === 'string')
|
|
189
|
+
return value.slice(0, 400);
|
|
190
|
+
try {
|
|
191
|
+
return JSON.stringify(value).slice(0, 400);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (C) 2026 Dyne.org foundation
|
|
3
|
+
* Designed by Denis Roio <jaromil@dyne.org>
|
|
4
|
+
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
*/
|
|
6
|
+
const LIST_PATH = '/api/v2/sessions?page_size=50&sort=meta.updated_at_desc';
|
|
7
|
+
const LIST_TIMEOUT_MS = 5_000;
|
|
8
|
+
/**
|
|
9
|
+
* Lists recent kimi web sessions across every running gestalt-owned server,
|
|
10
|
+
* for parity with the codex recent-thread lister. Each skill profile owns an
|
|
11
|
+
* isolated `kimi web` process and state directory, so a profile's sessions
|
|
12
|
+
* only exist on that profile's server; only already-running servers are
|
|
13
|
+
* queried, never spawned just to list. kimi timestamps arrive as epoch
|
|
14
|
+
* seconds (or, defensively, epoch millis), normalized to seconds.
|
|
15
|
+
*/
|
|
16
|
+
export function createKimiRecentSessionLister(deps) {
|
|
17
|
+
return {
|
|
18
|
+
async list() {
|
|
19
|
+
if (!deps.available || !deps.servers)
|
|
20
|
+
return [];
|
|
21
|
+
const listings = await Promise.all(deps.servers.list().map(listFromServer));
|
|
22
|
+
return listings.flat().sort((a, b) => (b.recencyAt ?? 0) - (a.recencyAt ?? 0));
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
async function listFromServer(handle) {
|
|
27
|
+
try {
|
|
28
|
+
const data = (await withTimeout(handle.client.get(LIST_PATH), LIST_TIMEOUT_MS, 'kimi session list timed out'));
|
|
29
|
+
const items = Array.isArray(data.items) ? data.items : [];
|
|
30
|
+
return items.flatMap((item) => {
|
|
31
|
+
if (!item || typeof item !== 'object')
|
|
32
|
+
return [];
|
|
33
|
+
const workspace = item.workspace;
|
|
34
|
+
const cwd = workspace && typeof workspace === 'object'
|
|
35
|
+
? workspace.cwd
|
|
36
|
+
: undefined;
|
|
37
|
+
if (typeof item.id !== 'string' || typeof cwd !== 'string' || !cwd.startsWith('/'))
|
|
38
|
+
return [];
|
|
39
|
+
const updatedAt = item.meta && typeof item.meta === 'object'
|
|
40
|
+
? item.meta.updated_at
|
|
41
|
+
: undefined;
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
id: item.id,
|
|
45
|
+
cwd,
|
|
46
|
+
profile: handle.profileKey,
|
|
47
|
+
recencyAt: toEpochSeconds(updatedAt),
|
|
48
|
+
provider: 'kimi',
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function toEpochSeconds(value) {
|
|
58
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
59
|
+
return null;
|
|
60
|
+
// Epoch millis are roughly 1e12+ today; seconds stay below 1e11 for decades.
|
|
61
|
+
return value > 1e11 ? Math.floor(value / 1000) : Math.floor(value);
|
|
62
|
+
}
|
|
63
|
+
function withTimeout(promise, timeoutMs, message) {
|
|
64
|
+
return Promise.race([
|
|
65
|
+
promise,
|
|
66
|
+
new Promise((_resolve, reject) => setTimeout(() => reject(new Error(message)), timeoutMs).unref()),
|
|
67
|
+
]);
|
|
68
|
+
}
|