pikiloop 0.4.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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,1206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session workspace management, metadata persistence, classification, and export/import.
|
|
3
|
+
*/
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { dedupeStrings, shortValue, firstNonEmptyLine, normalizeErrorMessage, normalizeStreamPreviewPlan, isPendingSessionId, agentLog, } from './utils.js';
|
|
8
|
+
import { getDriver } from './driver.js';
|
|
9
|
+
import { collapseSkillPrompt } from './skills.js';
|
|
10
|
+
import { SESSION_RUNNING_THRESHOLD_MS } from '../core/constants.js';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Private helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function ensureDir(dirPath) { fs.mkdirSync(dirPath, { recursive: true }); }
|
|
15
|
+
function readJsonFile(filePath, fallback) {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function writeJsonFile(filePath, value) {
|
|
24
|
+
ensureDir(path.dirname(filePath));
|
|
25
|
+
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
26
|
+
fs.writeFileSync(tmpPath, JSON.stringify(value, null, 2));
|
|
27
|
+
fs.renameSync(tmpPath, filePath);
|
|
28
|
+
}
|
|
29
|
+
function removeFileIfExists(filePath) { try {
|
|
30
|
+
fs.rmSync(filePath, { force: true });
|
|
31
|
+
}
|
|
32
|
+
catch { } }
|
|
33
|
+
function trimSessionText(value, max = 24_000) {
|
|
34
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
35
|
+
if (!text)
|
|
36
|
+
return null;
|
|
37
|
+
if (text.length <= max)
|
|
38
|
+
return text;
|
|
39
|
+
return `${text.slice(0, Math.max(0, max - 3)).trimEnd()}...`;
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Constants
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const PIKILOOP_DIR = '.pikiloop';
|
|
45
|
+
const PIKILOOP_SESSIONS_DIR = path.join(PIKILOOP_DIR, 'sessions');
|
|
46
|
+
const PIKILOOP_SESSION_INDEX = path.join(PIKILOOP_SESSIONS_DIR, 'index.json');
|
|
47
|
+
const PIKILOOP_LEGACY_WORKSPACES_DIR = path.join(PIKILOOP_DIR, 'workspaces');
|
|
48
|
+
const SESSION_WORKSPACE_DIR = 'workspace';
|
|
49
|
+
const SESSION_META_FILE = 'session.json';
|
|
50
|
+
// return.json and artifact constants removed — file return is now handled by MCP bridge
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Path helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function sessionIndexPath(workdir) { return path.join(workdir, PIKILOOP_SESSION_INDEX); }
|
|
55
|
+
function sessionDirPath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOP_SESSIONS_DIR, agent, sessionId); }
|
|
56
|
+
function legacySessionWorkspacePath(workdir, agent, sessionId) { return path.join(workdir, PIKILOOP_LEGACY_WORKSPACES_DIR, agent, sessionId); }
|
|
57
|
+
function sessionWorkspacePath(workdir, agent, sessionId) { return path.join(sessionDirPath(workdir, agent, sessionId), SESSION_WORKSPACE_DIR); }
|
|
58
|
+
function sessionRootFromWorkspacePath(workspacePath) {
|
|
59
|
+
const resolved = path.resolve(workspacePath);
|
|
60
|
+
return path.basename(resolved) === SESSION_WORKSPACE_DIR ? path.dirname(resolved) : resolved;
|
|
61
|
+
}
|
|
62
|
+
function sessionMetaPath(workspacePath) { return path.join(sessionRootFromWorkspacePath(workspacePath), SESSION_META_FILE); }
|
|
63
|
+
function legacySessionMetaPath(workspacePath) { return path.join(workspacePath, PIKILOOP_DIR, SESSION_META_FILE); }
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// ID helpers
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/** Generate a temporary session ID for new sessions before the agent assigns one. */
|
|
68
|
+
function nextPendingSessionId() { return `pending_${crypto.randomBytes(6).toString('hex')}`; }
|
|
69
|
+
function nextThreadId() { return `thread_${crypto.randomBytes(6).toString('hex')}`; }
|
|
70
|
+
function legacyThreadId(agent, sessionId) { return `legacy:${agent}:${sessionId}`; }
|
|
71
|
+
function normalizeThreadId(value) {
|
|
72
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Run state helpers
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
function normalizeSessionRunState(rawState) {
|
|
78
|
+
const state = typeof rawState === 'string' ? rawState.trim().toLowerCase() : '';
|
|
79
|
+
if (state === 'completed' || state === 'incomplete' || state === 'running')
|
|
80
|
+
return state;
|
|
81
|
+
return 'completed';
|
|
82
|
+
}
|
|
83
|
+
function normalizeSessionRunDetail(_rawState, rawDetail) {
|
|
84
|
+
const detail = typeof rawDetail === 'string' ? rawDetail.trim() : '';
|
|
85
|
+
if (detail)
|
|
86
|
+
return shortValue(detail, 180);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
function normalizeSessionRunUpdatedAt(rawUpdatedAt, fallback) {
|
|
90
|
+
return typeof rawUpdatedAt === 'string' && rawUpdatedAt.trim() ? rawUpdatedAt : fallback;
|
|
91
|
+
}
|
|
92
|
+
export function setSessionRunState(record, runState, runDetail, runUpdatedAt) {
|
|
93
|
+
record.runState = runState;
|
|
94
|
+
record.runDetail = runDetail ? shortValue(runDetail, 180) : null;
|
|
95
|
+
record.runUpdatedAt = runUpdatedAt || new Date().toISOString();
|
|
96
|
+
record.runPid = runState === 'running' ? process.pid : null;
|
|
97
|
+
}
|
|
98
|
+
function incompleteRunDetail(result) {
|
|
99
|
+
if (result.stopReason === 'interrupted')
|
|
100
|
+
return 'Interrupted by user.';
|
|
101
|
+
if (result.stopReason === 'timeout')
|
|
102
|
+
return 'Timed out before completion.';
|
|
103
|
+
if (result.stopReason === 'max_tokens')
|
|
104
|
+
return 'Stopped before completion: max tokens reached.';
|
|
105
|
+
const error = normalizeErrorMessage(result.error);
|
|
106
|
+
if (error)
|
|
107
|
+
return shortValue(error, 180);
|
|
108
|
+
const stopReason = normalizeErrorMessage(result.stopReason);
|
|
109
|
+
if (stopReason)
|
|
110
|
+
return `Stopped before completion: ${shortValue(stopReason, 120)}`;
|
|
111
|
+
const message = firstNonEmptyLine(result.message || '');
|
|
112
|
+
return message ? shortValue(message, 180) : 'Last run did not complete.';
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check whether a process is still alive. Returns true when the PID exists and we can
|
|
116
|
+
* signal it, false when the process is definitively gone, and null when we cannot tell
|
|
117
|
+
* (e.g. owned by a different user — permission denied).
|
|
118
|
+
*/
|
|
119
|
+
export function isProcessAlive(pid) {
|
|
120
|
+
if (!pid || !Number.isFinite(pid) || pid <= 0)
|
|
121
|
+
return null;
|
|
122
|
+
try {
|
|
123
|
+
process.kill(pid, 0);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
if (err?.code === 'ESRCH')
|
|
128
|
+
return false;
|
|
129
|
+
if (err?.code === 'EPERM')
|
|
130
|
+
return true;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Heuristic staleness check for a session record marked 'running'. Returns true when
|
|
136
|
+
* the record should be downgraded to 'incomplete' — i.e. the owning process is gone,
|
|
137
|
+
* or (if PID is missing) the last update is older than `ageThresholdMs`.
|
|
138
|
+
*
|
|
139
|
+
* Returns false if the session might still be live and should be left alone.
|
|
140
|
+
*/
|
|
141
|
+
export function isRunningSessionStale(record, ageThresholdMs) {
|
|
142
|
+
if (record.runState !== 'running')
|
|
143
|
+
return false;
|
|
144
|
+
const alive = isProcessAlive(record.runPid ?? null);
|
|
145
|
+
if (alive === false)
|
|
146
|
+
return true;
|
|
147
|
+
if (alive === true)
|
|
148
|
+
return false;
|
|
149
|
+
const age = record.runUpdatedAt ? Date.now() - Date.parse(record.runUpdatedAt) : Infinity;
|
|
150
|
+
return age > ageThresholdMs;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Scan the session index for a workdir and downgrade any 'running' record whose
|
|
154
|
+
* owning process is no longer alive (or that has gone stale past `ageThresholdMs`).
|
|
155
|
+
* Returns the number of records downgraded. Safe to call at startup and periodically.
|
|
156
|
+
*/
|
|
157
|
+
export function reconcileOrphanedRunningSessions(workdir, ageThresholdMs = 30 * 60_000) {
|
|
158
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
159
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
160
|
+
const downgraded = [];
|
|
161
|
+
for (const record of index.sessions) {
|
|
162
|
+
if (!isRunningSessionStale(record, ageThresholdMs))
|
|
163
|
+
continue;
|
|
164
|
+
setSessionRunState(record, 'incomplete', 'Process exited before reporting completion.');
|
|
165
|
+
downgraded.push(record);
|
|
166
|
+
}
|
|
167
|
+
if (downgraded.length > 0) {
|
|
168
|
+
writeSessionIndex(resolvedWorkdir, index.sessions);
|
|
169
|
+
for (const record of downgraded) {
|
|
170
|
+
try {
|
|
171
|
+
writeSessionMeta(record);
|
|
172
|
+
}
|
|
173
|
+
catch { }
|
|
174
|
+
}
|
|
175
|
+
agentLog(`[sessions] reconciled ${downgraded.length} orphaned running session(s) in ${resolvedWorkdir}`);
|
|
176
|
+
}
|
|
177
|
+
return downgraded.length;
|
|
178
|
+
}
|
|
179
|
+
export function applySessionRunResult(record, result) {
|
|
180
|
+
if (result.ok && !result.incomplete) {
|
|
181
|
+
setSessionRunState(record, 'completed', null);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
setSessionRunState(record, 'incomplete', incompleteRunDetail(result));
|
|
185
|
+
}
|
|
186
|
+
// Auto-classify the stream result
|
|
187
|
+
const classification = classifySession({ ...result, activity: result.activity ?? null });
|
|
188
|
+
record.classification = classification;
|
|
189
|
+
// Only set userStatus if not manually overridden by the user
|
|
190
|
+
if (!record.userStatus) {
|
|
191
|
+
record.userStatus = deriveUserStatus(classification.outcome);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function normalizeHandoverRef(value) {
|
|
195
|
+
if (!value || typeof value !== 'object')
|
|
196
|
+
return null;
|
|
197
|
+
const v = value;
|
|
198
|
+
const agent = typeof v.agent === 'string' ? v.agent.trim() : '';
|
|
199
|
+
const sessionId = typeof v.sessionId === 'string' ? v.sessionId.trim() : '';
|
|
200
|
+
if (!agent || !sessionId)
|
|
201
|
+
return null;
|
|
202
|
+
return { agent: agent, sessionId };
|
|
203
|
+
}
|
|
204
|
+
function normalizeSessionRecord(raw, workdir) {
|
|
205
|
+
// Support both new format (sessionId) and legacy format (localSessionId + engineSessionId)
|
|
206
|
+
const sessionId = typeof raw?.sessionId === 'string' ? raw.sessionId.trim()
|
|
207
|
+
: typeof raw?.engineSessionId === 'string' && raw.engineSessionId.trim() ? raw.engineSessionId.trim()
|
|
208
|
+
: typeof raw?.localSessionId === 'string' ? raw.localSessionId.trim()
|
|
209
|
+
: '';
|
|
210
|
+
const agent = typeof raw?.agent === 'string' ? raw.agent.trim() : null;
|
|
211
|
+
if (!sessionId || !agent)
|
|
212
|
+
return null;
|
|
213
|
+
const workspacePath = typeof raw?.workspacePath === 'string' && raw.workspacePath.trim()
|
|
214
|
+
? path.resolve(raw.workspacePath)
|
|
215
|
+
: sessionWorkspacePath(workdir, agent, sessionId);
|
|
216
|
+
return {
|
|
217
|
+
sessionId, agent, workdir,
|
|
218
|
+
workspacePath,
|
|
219
|
+
threadId: normalizeThreadId(raw?.threadId) || legacyThreadId(agent, sessionId),
|
|
220
|
+
createdAt: typeof raw?.createdAt === 'string' && raw.createdAt.trim() ? raw.createdAt : new Date().toISOString(),
|
|
221
|
+
updatedAt: typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString(),
|
|
222
|
+
title: typeof raw?.title === 'string' && raw.title.trim() ? raw.title.trim() : null,
|
|
223
|
+
model: typeof raw?.model === 'string' && raw.model.trim() ? raw.model.trim() : null,
|
|
224
|
+
thinkingEffort: typeof raw?.thinkingEffort === 'string' && raw.thinkingEffort.trim() ? raw.thinkingEffort.trim() : null,
|
|
225
|
+
profileId: typeof raw?.profileId === 'string' && raw.profileId.trim() ? raw.profileId.trim() : null,
|
|
226
|
+
stagedFiles: Array.isArray(raw?.stagedFiles) ? dedupeStrings(raw.stagedFiles.filter((v) => typeof v === 'string')) : [],
|
|
227
|
+
lastUserAttachments: Array.isArray(raw?.lastUserAttachments)
|
|
228
|
+
? dedupeStrings(raw.lastUserAttachments.filter((v) => typeof v === 'string'))
|
|
229
|
+
: [],
|
|
230
|
+
runState: normalizeSessionRunState(raw?.runState),
|
|
231
|
+
runDetail: normalizeSessionRunDetail(raw?.runState, raw?.runDetail),
|
|
232
|
+
runUpdatedAt: normalizeSessionRunUpdatedAt(raw?.runUpdatedAt, typeof raw?.updatedAt === 'string' && raw.updatedAt.trim() ? raw.updatedAt : new Date().toISOString()),
|
|
233
|
+
runPid: typeof raw?.runPid === 'number' && Number.isFinite(raw.runPid) ? raw.runPid : null,
|
|
234
|
+
classification: raw?.classification ?? null,
|
|
235
|
+
userStatus: raw?.userStatus ?? null,
|
|
236
|
+
userNote: typeof raw?.userNote === 'string' ? raw.userNote : null,
|
|
237
|
+
lastQuestion: typeof raw?.lastQuestion === 'string' ? raw.lastQuestion : null,
|
|
238
|
+
lastAnswer: typeof raw?.lastAnswer === 'string' ? raw.lastAnswer : null,
|
|
239
|
+
lastMessageText: typeof raw?.lastMessageText === 'string' ? raw.lastMessageText : null,
|
|
240
|
+
lastThinking: trimSessionText(raw?.lastThinking),
|
|
241
|
+
lastPlan: normalizeStreamPreviewPlan(raw?.lastPlan),
|
|
242
|
+
migratedFrom: raw?.migratedFrom ?? null,
|
|
243
|
+
migratedTo: raw?.migratedTo ?? null,
|
|
244
|
+
linkedSessions: Array.isArray(raw?.linkedSessions) ? raw.linkedSessions : [],
|
|
245
|
+
handoverFrom: normalizeHandoverRef(raw?.handoverFrom),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Index persistence
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/**
|
|
252
|
+
* Parsed-index cache keyed by index-file identity (mtime + size). loadSessionIndex
|
|
253
|
+
* sits on the per-turn read path (getSessionStoredConfig), every dashboard session
|
|
254
|
+
* read, and is hit several times within a single save flow — each call otherwise
|
|
255
|
+
* does readFileSync + JSON.parse + a per-record normalize pass. A cache hit costs
|
|
256
|
+
* one statSync. writeSessionIndex invalidates the entry, so a write is always
|
|
257
|
+
* re-read fresh; every writer mutates records then writes, so the shared cache is
|
|
258
|
+
* never left serving a half-mutated record.
|
|
259
|
+
*/
|
|
260
|
+
const sessionIndexCache = new Map();
|
|
261
|
+
/** Sort session records newest-first, parsing each `updatedAt` only once. */
|
|
262
|
+
function sortByUpdatedAtDesc(records) {
|
|
263
|
+
const at = new Map(records.map(r => [r, Date.parse(r.updatedAt) || 0]));
|
|
264
|
+
return records.sort((a, b) => at.get(b) - at.get(a));
|
|
265
|
+
}
|
|
266
|
+
function loadSessionIndex(workdir) {
|
|
267
|
+
const filePath = sessionIndexPath(workdir);
|
|
268
|
+
let stat = null;
|
|
269
|
+
try {
|
|
270
|
+
stat = fs.statSync(filePath);
|
|
271
|
+
}
|
|
272
|
+
catch { }
|
|
273
|
+
if (stat) {
|
|
274
|
+
const cached = sessionIndexCache.get(filePath);
|
|
275
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size)
|
|
276
|
+
return cached.data;
|
|
277
|
+
}
|
|
278
|
+
const parsed = readJsonFile(filePath, { version: 1, sessions: [] });
|
|
279
|
+
const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : [];
|
|
280
|
+
const data = {
|
|
281
|
+
version: 1,
|
|
282
|
+
sessions: sessions
|
|
283
|
+
.map((entry) => normalizeSessionRecord(entry, workdir))
|
|
284
|
+
.filter((entry) => !!entry)
|
|
285
|
+
.filter((entry) => !isPendingSessionId(entry.sessionId) || fs.existsSync(sessionRootFromWorkspacePath(entry.workspacePath))),
|
|
286
|
+
};
|
|
287
|
+
if (stat)
|
|
288
|
+
sessionIndexCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, data });
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
function writeSessionIndex(workdir, sessions) {
|
|
292
|
+
const filePath = sessionIndexPath(workdir);
|
|
293
|
+
writeJsonFile(filePath, { version: 1, sessions });
|
|
294
|
+
sessionIndexCache.delete(filePath);
|
|
295
|
+
}
|
|
296
|
+
function writeSessionMeta(record) {
|
|
297
|
+
writeJsonFile(sessionMetaPath(record.workspacePath), {
|
|
298
|
+
sessionId: record.sessionId, agent: record.agent, workdir: record.workdir,
|
|
299
|
+
workspacePath: record.workspacePath,
|
|
300
|
+
threadId: record.threadId,
|
|
301
|
+
createdAt: record.createdAt, updatedAt: record.updatedAt,
|
|
302
|
+
title: record.title, model: record.model, thinkingEffort: record.thinkingEffort, stagedFiles: record.stagedFiles,
|
|
303
|
+
runState: record.runState, runDetail: record.runDetail, runUpdatedAt: record.runUpdatedAt,
|
|
304
|
+
runPid: record.runPid,
|
|
305
|
+
classification: record.classification,
|
|
306
|
+
userStatus: record.userStatus,
|
|
307
|
+
userNote: record.userNote,
|
|
308
|
+
lastQuestion: record.lastQuestion,
|
|
309
|
+
lastAnswer: record.lastAnswer,
|
|
310
|
+
lastMessageText: record.lastMessageText,
|
|
311
|
+
lastThinking: record.lastThinking,
|
|
312
|
+
lastPlan: record.lastPlan,
|
|
313
|
+
migratedFrom: record.migratedFrom,
|
|
314
|
+
migratedTo: record.migratedTo,
|
|
315
|
+
linkedSessions: record.linkedSessions,
|
|
316
|
+
handoverFrom: record.handoverFrom ?? null,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// File / directory helpers
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
function copyPath(sourcePath, targetPath) {
|
|
323
|
+
const stat = fs.statSync(sourcePath);
|
|
324
|
+
if (stat.isDirectory()) {
|
|
325
|
+
fs.cpSync(sourcePath, targetPath, { recursive: true, force: true });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
ensureDir(path.dirname(targetPath));
|
|
329
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
330
|
+
}
|
|
331
|
+
function createSessionDirAlias(aliasPath, targetPath) {
|
|
332
|
+
if (fs.existsSync(aliasPath) || !fs.existsSync(targetPath))
|
|
333
|
+
return;
|
|
334
|
+
try {
|
|
335
|
+
ensureDir(path.dirname(aliasPath));
|
|
336
|
+
const relativeTarget = path.relative(path.dirname(aliasPath), targetPath) || '.';
|
|
337
|
+
fs.symlinkSync(relativeTarget, aliasPath, process.platform === 'win32' ? 'junction' : 'dir');
|
|
338
|
+
}
|
|
339
|
+
catch { }
|
|
340
|
+
}
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Migration
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
function migrateSessionLayout(workdir, record) {
|
|
345
|
+
const targetSessionDir = sessionDirPath(workdir, record.agent, record.sessionId);
|
|
346
|
+
const targetWorkspacePath = sessionWorkspacePath(workdir, record.agent, record.sessionId);
|
|
347
|
+
const currentWorkspacePath = path.resolve(record.workspacePath || targetWorkspacePath);
|
|
348
|
+
const legacyWp = path.resolve(legacySessionWorkspacePath(workdir, record.agent, record.sessionId));
|
|
349
|
+
ensureDir(targetSessionDir);
|
|
350
|
+
ensureDir(targetWorkspacePath);
|
|
351
|
+
for (const sourceWorkspacePath of dedupeStrings([currentWorkspacePath, legacyWp])) {
|
|
352
|
+
if (sourceWorkspacePath === targetWorkspacePath || !fs.existsSync(sourceWorkspacePath))
|
|
353
|
+
continue;
|
|
354
|
+
if (!fs.statSync(sourceWorkspacePath).isDirectory())
|
|
355
|
+
continue;
|
|
356
|
+
for (const entry of fs.readdirSync(sourceWorkspacePath)) {
|
|
357
|
+
if (entry === PIKILOOP_DIR)
|
|
358
|
+
continue;
|
|
359
|
+
copyPath(path.join(sourceWorkspacePath, entry), path.join(targetWorkspacePath, entry));
|
|
360
|
+
}
|
|
361
|
+
if (sourceWorkspacePath === legacyWp)
|
|
362
|
+
fs.rmSync(sourceWorkspacePath, { recursive: true, force: true });
|
|
363
|
+
}
|
|
364
|
+
record.workspacePath = path.resolve(targetWorkspacePath);
|
|
365
|
+
return record;
|
|
366
|
+
}
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Save / update
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
export function saveSessionRecord(workdir, record) {
|
|
371
|
+
record = migrateSessionLayout(workdir, record);
|
|
372
|
+
ensureDir(sessionDirPath(workdir, record.agent, record.sessionId));
|
|
373
|
+
ensureDir(record.workspacePath);
|
|
374
|
+
const index = loadSessionIndex(workdir);
|
|
375
|
+
record.threadId = normalizeThreadId(record.threadId) || legacyThreadId(record.agent, record.sessionId);
|
|
376
|
+
record.updatedAt = new Date().toISOString();
|
|
377
|
+
const pos = index.sessions.findIndex(entry => entry.agent === record.agent && entry.sessionId === record.sessionId);
|
|
378
|
+
if (pos >= 0)
|
|
379
|
+
index.sessions[pos] = record;
|
|
380
|
+
else
|
|
381
|
+
index.sessions.unshift(record);
|
|
382
|
+
sortByUpdatedAtDesc(index.sessions);
|
|
383
|
+
writeSessionIndex(workdir, index.sessions);
|
|
384
|
+
writeSessionMeta(record);
|
|
385
|
+
return record;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Update mutable session metadata (classification, userStatus, userNote, links, migration)
|
|
389
|
+
* for an existing pikiloop-managed session. Returns true if the record was found and updated.
|
|
390
|
+
*/
|
|
391
|
+
export function updateSessionMeta(workdir, agent, sessionId, patch) {
|
|
392
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
393
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
394
|
+
const record = index.sessions.find(s => s.sessionId === sessionId && s.agent === agent);
|
|
395
|
+
if (!record)
|
|
396
|
+
return false;
|
|
397
|
+
if (patch.userStatus !== undefined)
|
|
398
|
+
record.userStatus = patch.userStatus;
|
|
399
|
+
if (patch.userNote !== undefined)
|
|
400
|
+
record.userNote = patch.userNote;
|
|
401
|
+
if (patch.classification !== undefined)
|
|
402
|
+
record.classification = patch.classification;
|
|
403
|
+
if (patch.migratedFrom !== undefined)
|
|
404
|
+
record.migratedFrom = patch.migratedFrom;
|
|
405
|
+
if (patch.migratedTo !== undefined)
|
|
406
|
+
record.migratedTo = patch.migratedTo;
|
|
407
|
+
if (patch.addLink) {
|
|
408
|
+
if (!record.linkedSessions)
|
|
409
|
+
record.linkedSessions = [];
|
|
410
|
+
const exists = record.linkedSessions.some(l => l.agent === patch.addLink.agent && l.sessionId === patch.addLink.sessionId);
|
|
411
|
+
if (!exists)
|
|
412
|
+
record.linkedSessions.push(patch.addLink);
|
|
413
|
+
}
|
|
414
|
+
record.updatedAt = new Date().toISOString();
|
|
415
|
+
writeSessionIndex(resolvedWorkdir, index.sessions);
|
|
416
|
+
writeSessionMeta(record);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Promote a pending session to a real session ID. Renames the workspace directory
|
|
421
|
+
* and updates the index. Called after the first stream returns the agent's native ID.
|
|
422
|
+
*/
|
|
423
|
+
export function promoteSessionId(workdir, agent, pendingId, nativeId) {
|
|
424
|
+
if (!isPendingSessionId(pendingId) || !nativeId.trim())
|
|
425
|
+
return;
|
|
426
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
427
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
428
|
+
const record = index.sessions.find(entry => entry.sessionId === pendingId && entry.agent === agent);
|
|
429
|
+
if (!record)
|
|
430
|
+
return;
|
|
431
|
+
const oldDir = sessionDirPath(resolvedWorkdir, agent, pendingId);
|
|
432
|
+
const newDir = sessionDirPath(resolvedWorkdir, agent, nativeId);
|
|
433
|
+
// Move workspace directory if it exists
|
|
434
|
+
if (fs.existsSync(oldDir) && !fs.existsSync(newDir)) {
|
|
435
|
+
try {
|
|
436
|
+
fs.renameSync(oldDir, newDir);
|
|
437
|
+
}
|
|
438
|
+
catch { /* cross-device: copy+delete */
|
|
439
|
+
try {
|
|
440
|
+
fs.cpSync(oldDir, newDir, { recursive: true });
|
|
441
|
+
fs.rmSync(oldDir, { recursive: true, force: true });
|
|
442
|
+
}
|
|
443
|
+
catch { }
|
|
444
|
+
}
|
|
445
|
+
createSessionDirAlias(oldDir, newDir);
|
|
446
|
+
}
|
|
447
|
+
writeSessionIndex(resolvedWorkdir, index.sessions.filter(entry => entry.agent !== agent || (entry.sessionId !== pendingId && entry.sessionId !== nativeId)));
|
|
448
|
+
record.sessionId = nativeId;
|
|
449
|
+
record.workspacePath = sessionWorkspacePath(resolvedWorkdir, agent, nativeId);
|
|
450
|
+
saveSessionRecord(resolvedWorkdir, record);
|
|
451
|
+
}
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
// Fork lineage
|
|
454
|
+
// ---------------------------------------------------------------------------
|
|
455
|
+
/**
|
|
456
|
+
* Record a fork relationship between two pikiloop-managed sessions.
|
|
457
|
+
*
|
|
458
|
+
* Sets `migratedFrom` (with kind='fork' + forkedAtTurn) on the child and
|
|
459
|
+
* appends the reverse link on the parent's `linkedSessions`. Both sides also
|
|
460
|
+
* get `migratedTo` set on the parent so the child is a discoverable twin.
|
|
461
|
+
*
|
|
462
|
+
* No-op if either record is missing — call sites are expected to ensure both
|
|
463
|
+
* managed records exist (the child is created via the fork stream completion).
|
|
464
|
+
*/
|
|
465
|
+
export function recordFork(workdir, opts) {
|
|
466
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
467
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
468
|
+
const parent = index.sessions.find(e => e.agent === opts.parent.agent && e.sessionId === opts.parent.sessionId);
|
|
469
|
+
const child = index.sessions.find(e => e.agent === opts.child.agent && e.sessionId === opts.child.sessionId);
|
|
470
|
+
if (!parent || !child)
|
|
471
|
+
return;
|
|
472
|
+
child.migratedFrom = {
|
|
473
|
+
agent: parent.agent,
|
|
474
|
+
sessionId: parent.sessionId,
|
|
475
|
+
kind: 'fork',
|
|
476
|
+
forkedAtTurn: opts.atTurn,
|
|
477
|
+
};
|
|
478
|
+
if (!parent.linkedSessions)
|
|
479
|
+
parent.linkedSessions = [];
|
|
480
|
+
const childRef = { agent: child.agent, sessionId: child.sessionId, kind: 'fork', forkedAtTurn: opts.atTurn };
|
|
481
|
+
if (!parent.linkedSessions.some(l => l.agent === child.agent && l.sessionId === child.sessionId)) {
|
|
482
|
+
parent.linkedSessions.push(childRef);
|
|
483
|
+
}
|
|
484
|
+
child.updatedAt = new Date().toISOString();
|
|
485
|
+
parent.updatedAt = new Date().toISOString();
|
|
486
|
+
writeSessionIndex(resolvedWorkdir, index.sessions);
|
|
487
|
+
writeSessionMeta(parent);
|
|
488
|
+
writeSessionMeta(child);
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Identity sync
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
export function syncManagedSessionIdentity(session, workdir, nativeId) {
|
|
494
|
+
const resolvedId = nativeId.trim();
|
|
495
|
+
if (!resolvedId || session.sessionId === resolvedId)
|
|
496
|
+
return false;
|
|
497
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
498
|
+
const previousId = session.sessionId;
|
|
499
|
+
if (isPendingSessionId(previousId)) {
|
|
500
|
+
// Pending → native: move the workspace dir into the native slot and
|
|
501
|
+
// remove the pending index entry (handled by promoteSessionId).
|
|
502
|
+
promoteSessionId(resolvedWorkdir, session.record.agent, previousId, resolvedId);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
// Native → native rotation (Claude `--resume` can rewrite the session id
|
|
506
|
+
// mid-stream). Drop the old index entry so the dashboard does not show a
|
|
507
|
+
// stale duplicate; both jsonl files stay on disk and the workspace stays
|
|
508
|
+
// under its original native id (the next saveSessionRecord will lay down
|
|
509
|
+
// a fresh dir under the new id).
|
|
510
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
511
|
+
const filtered = index.sessions.filter(e => !(e.agent === session.record.agent && e.sessionId === previousId));
|
|
512
|
+
if (filtered.length !== index.sessions.length)
|
|
513
|
+
writeSessionIndex(resolvedWorkdir, filtered);
|
|
514
|
+
}
|
|
515
|
+
session.sessionId = resolvedId;
|
|
516
|
+
session.workspacePath = sessionWorkspacePath(resolvedWorkdir, session.record.agent, resolvedId);
|
|
517
|
+
session.record.sessionId = resolvedId;
|
|
518
|
+
session.record.workspacePath = session.workspacePath;
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// Title / filename helpers
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
export function summarizePromptTitle(prompt) {
|
|
525
|
+
const raw = String(prompt || '').replace(/\r\n?/g, '\n');
|
|
526
|
+
const text = firstNonEmptyLine(raw).replace(/\s+/g, ' ').trim()
|
|
527
|
+
|| raw.replace(/\s+/g, ' ').trim();
|
|
528
|
+
if (!text)
|
|
529
|
+
return null;
|
|
530
|
+
return text.length <= 120 ? text : `${text.slice(0, 117).trimEnd()}...`;
|
|
531
|
+
}
|
|
532
|
+
function safeWorkspaceFilename(filename) {
|
|
533
|
+
const base = path.basename(filename || 'file');
|
|
534
|
+
const sanitized = base.replace(/[^\w.\- ]+/g, '_').replace(/^\.+/, '').trim();
|
|
535
|
+
return sanitized || `file-${Date.now()}`;
|
|
536
|
+
}
|
|
537
|
+
function uniqueWorkspaceFilename(workspacePath, desiredName) {
|
|
538
|
+
const ext = path.extname(desiredName);
|
|
539
|
+
const stem = ext ? desiredName.slice(0, -ext.length) : desiredName;
|
|
540
|
+
let candidate = desiredName;
|
|
541
|
+
let index = 2;
|
|
542
|
+
while (fs.existsSync(path.join(workspacePath, candidate))) {
|
|
543
|
+
candidate = `${stem}-${index}${ext}`;
|
|
544
|
+
index++;
|
|
545
|
+
}
|
|
546
|
+
return candidate;
|
|
547
|
+
}
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
// Workspace file import
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
export function importFilesIntoWorkspace(workspacePath, files) {
|
|
552
|
+
const imported = [];
|
|
553
|
+
const realWorkspace = fs.realpathSync(workspacePath);
|
|
554
|
+
for (const filePath of files) {
|
|
555
|
+
const sourcePath = path.resolve(filePath);
|
|
556
|
+
if (!fs.existsSync(sourcePath) || !fs.statSync(sourcePath).isFile())
|
|
557
|
+
continue;
|
|
558
|
+
let relPath = path.relative(realWorkspace, sourcePath);
|
|
559
|
+
if (relPath && !relPath.startsWith('..') && !path.isAbsolute(relPath)) {
|
|
560
|
+
imported.push(relPath.split(path.sep).join(path.posix.sep));
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const targetName = uniqueWorkspaceFilename(workspacePath, safeWorkspaceFilename(path.basename(sourcePath)));
|
|
564
|
+
fs.copyFileSync(sourcePath, path.join(workspacePath, targetName));
|
|
565
|
+
imported.push(targetName);
|
|
566
|
+
}
|
|
567
|
+
return dedupeStrings(imported);
|
|
568
|
+
}
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// Ensure session workspace
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
export function ensureSessionWorkspace(opts) {
|
|
573
|
+
const workdir = path.resolve(opts.workdir);
|
|
574
|
+
const index = loadSessionIndex(workdir);
|
|
575
|
+
let record = index.sessions.find(entry => entry.agent === opts.agent && opts.sessionId && entry.sessionId === opts.sessionId)
|
|
576
|
+
|| null;
|
|
577
|
+
if (!record) {
|
|
578
|
+
const sessionId = opts.sessionId?.trim() || nextPendingSessionId();
|
|
579
|
+
const threadId = normalizeThreadId(opts.threadId)
|
|
580
|
+
|| (opts.sessionId ? legacyThreadId(opts.agent, sessionId) : nextThreadId());
|
|
581
|
+
record = {
|
|
582
|
+
sessionId, agent: opts.agent, workdir,
|
|
583
|
+
workspacePath: sessionWorkspacePath(workdir, opts.agent, sessionId),
|
|
584
|
+
threadId,
|
|
585
|
+
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
|
586
|
+
title: summarizePromptTitle(opts.title) || null, model: null, thinkingEffort: null, profileId: null, stagedFiles: [], lastUserAttachments: [],
|
|
587
|
+
runState: 'completed', runDetail: null, runUpdatedAt: new Date().toISOString(),
|
|
588
|
+
runPid: null,
|
|
589
|
+
classification: null, userStatus: null, userNote: null,
|
|
590
|
+
lastQuestion: null, lastAnswer: null, lastMessageText: null,
|
|
591
|
+
lastThinking: null, lastPlan: null,
|
|
592
|
+
migratedFrom: null, migratedTo: null, linkedSessions: [],
|
|
593
|
+
handoverFrom: normalizeHandoverRef(opts.handoverFrom),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
if (!record.threadId)
|
|
597
|
+
record.threadId = normalizeThreadId(opts.threadId) || legacyThreadId(record.agent, record.sessionId);
|
|
598
|
+
// Backfill handoverFrom on first staging only — never overwrite an existing one.
|
|
599
|
+
if (!record.handoverFrom)
|
|
600
|
+
record.handoverFrom = normalizeHandoverRef(opts.handoverFrom);
|
|
601
|
+
if (!record.title && opts.title)
|
|
602
|
+
record.title = summarizePromptTitle(opts.title);
|
|
603
|
+
record.workspacePath = path.resolve(record.workspacePath);
|
|
604
|
+
saveSessionRecord(workdir, record);
|
|
605
|
+
return { sessionId: record.sessionId, workspacePath: record.workspacePath, record };
|
|
606
|
+
}
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// Record to SessionInfo
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
function managedRecordToSessionInfo(record) {
|
|
611
|
+
// Collapse pre-fix records that stored the canonical skill expansion as the
|
|
612
|
+
// title / lastQuestion / lastMessageText. New records get collapsed at write
|
|
613
|
+
// time in `prepareStreamOpts`; this read-time pass keeps existing sessions
|
|
614
|
+
// from showing the long instruction in the sidebar after the fix lands.
|
|
615
|
+
const title = collapseSkillPrompt(record.title) ?? record.title;
|
|
616
|
+
const lastQuestion = collapseSkillPrompt(record.lastQuestion) ?? record.lastQuestion;
|
|
617
|
+
const lastMessageText = collapseSkillPrompt(record.lastMessageText) ?? record.lastMessageText;
|
|
618
|
+
return {
|
|
619
|
+
sessionId: record.sessionId,
|
|
620
|
+
agent: record.agent,
|
|
621
|
+
workdir: record.workdir,
|
|
622
|
+
workspacePath: record.workspacePath,
|
|
623
|
+
threadId: record.threadId,
|
|
624
|
+
model: record.model,
|
|
625
|
+
thinkingEffort: record.thinkingEffort,
|
|
626
|
+
profileId: record.profileId ?? null,
|
|
627
|
+
createdAt: record.createdAt,
|
|
628
|
+
title,
|
|
629
|
+
running: record.runState === 'running',
|
|
630
|
+
runState: record.runState,
|
|
631
|
+
runDetail: record.runDetail,
|
|
632
|
+
runUpdatedAt: record.runUpdatedAt,
|
|
633
|
+
runPid: record.runPid,
|
|
634
|
+
classification: record.classification,
|
|
635
|
+
userStatus: record.userStatus,
|
|
636
|
+
userNote: record.userNote,
|
|
637
|
+
lastQuestion,
|
|
638
|
+
lastAnswer: record.lastAnswer,
|
|
639
|
+
lastMessageText,
|
|
640
|
+
migratedFrom: record.migratedFrom,
|
|
641
|
+
migratedTo: record.migratedTo,
|
|
642
|
+
linkedSessions: record.linkedSessions,
|
|
643
|
+
numTurns: record.numTurns ?? null,
|
|
644
|
+
handoverFrom: record.handoverFrom ?? null,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// Public session queries
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// Exported for drivers
|
|
651
|
+
export function listPikiloopSessions(workdir, agent, limit) {
|
|
652
|
+
const records = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.agent === agent));
|
|
653
|
+
return typeof limit === 'number' ? records.slice(0, limit) : records;
|
|
654
|
+
}
|
|
655
|
+
export function findPikiloopSession(workdir, agent, sessionId) {
|
|
656
|
+
return listPikiloopSessions(workdir, agent).find(entry => entry.sessionId === sessionId) || null;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Delete a pikiloop-managed session. Two scopes:
|
|
660
|
+
* - default: drop the index entry + recursively delete the per-session dir
|
|
661
|
+
* under `<workdir>/.pikiloop/sessions/<agent>/<sessionId>/` (and the legacy
|
|
662
|
+
* `workspaces/` path). Native agent transcript is left in place so the
|
|
663
|
+
* user can still resume the conversation outside pikiloop.
|
|
664
|
+
* - `purgeNative: true`: also call the driver's `deleteNativeSession` to
|
|
665
|
+
* remove the underlying jsonl/rollout file.
|
|
666
|
+
*
|
|
667
|
+
* Refuses to delete a session whose record is currently marked running and
|
|
668
|
+
* not stale (active process or recent mtime) — caller should stop the
|
|
669
|
+
* stream first.
|
|
670
|
+
*
|
|
671
|
+
* Sessions that exist only in the agent's native store (no pikiloop record)
|
|
672
|
+
* are still purgeable when `purgeNative` is set.
|
|
673
|
+
*/
|
|
674
|
+
export async function deleteAgentSession(opts) {
|
|
675
|
+
const resolvedWorkdir = path.resolve(opts.workdir);
|
|
676
|
+
const { agent, sessionId } = opts;
|
|
677
|
+
const result = {
|
|
678
|
+
ok: false,
|
|
679
|
+
recordRemoved: false,
|
|
680
|
+
pikiloopPathsRemoved: [],
|
|
681
|
+
nativePathsRemoved: [],
|
|
682
|
+
refusedReason: null,
|
|
683
|
+
};
|
|
684
|
+
const index = loadSessionIndex(resolvedWorkdir);
|
|
685
|
+
const recordIdx = index.sessions.findIndex(s => s.agent === agent && s.sessionId === sessionId);
|
|
686
|
+
const record = recordIdx >= 0 ? index.sessions[recordIdx] : null;
|
|
687
|
+
if (record && record.runState === 'running' && !isRunningSessionStale(record, SESSION_RUNNING_THRESHOLD_MS)) {
|
|
688
|
+
result.refusedReason = 'session-running';
|
|
689
|
+
return result;
|
|
690
|
+
}
|
|
691
|
+
if (record) {
|
|
692
|
+
index.sessions.splice(recordIdx, 1);
|
|
693
|
+
writeSessionIndex(resolvedWorkdir, index.sessions);
|
|
694
|
+
result.recordRemoved = true;
|
|
695
|
+
}
|
|
696
|
+
for (const dir of [sessionDirPath(resolvedWorkdir, agent, sessionId), legacySessionWorkspacePath(resolvedWorkdir, agent, sessionId)]) {
|
|
697
|
+
if (!fs.existsSync(dir))
|
|
698
|
+
continue;
|
|
699
|
+
try {
|
|
700
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
701
|
+
result.pikiloopPathsRemoved.push(dir);
|
|
702
|
+
}
|
|
703
|
+
catch (err) {
|
|
704
|
+
agentLog(`[sessions] failed to remove ${dir}: ${err.message}`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (opts.purgeNative) {
|
|
708
|
+
try {
|
|
709
|
+
const driver = getDriver(agent);
|
|
710
|
+
if (typeof driver.deleteNativeSession === 'function') {
|
|
711
|
+
const removed = await driver.deleteNativeSession(resolvedWorkdir, sessionId);
|
|
712
|
+
result.nativePathsRemoved = Array.isArray(removed) ? removed : [];
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch (err) {
|
|
716
|
+
agentLog(`[sessions] native session purge failed for ${agent}/${sessionId}: ${err.message}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
result.ok = true;
|
|
720
|
+
return result;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Look up the persisted model, thinkingEffort, and bound profileId for an
|
|
724
|
+
* existing session. Returns null values when the session is not found or
|
|
725
|
+
* fields are not set.
|
|
726
|
+
*/
|
|
727
|
+
export function getSessionStoredConfig(workdir, agent, sessionId) {
|
|
728
|
+
const record = findPikiloopSession(workdir, agent, sessionId);
|
|
729
|
+
return {
|
|
730
|
+
model: record?.model ?? null,
|
|
731
|
+
thinkingEffort: record?.thinkingEffort ?? null,
|
|
732
|
+
profileId: record?.profileId ?? null,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
export function ensureManagedSession(opts) {
|
|
736
|
+
const session = ensureSessionWorkspace({
|
|
737
|
+
agent: opts.agent,
|
|
738
|
+
workdir: opts.workdir,
|
|
739
|
+
sessionId: opts.sessionId,
|
|
740
|
+
title: opts.title,
|
|
741
|
+
threadId: opts.threadId,
|
|
742
|
+
});
|
|
743
|
+
if (!session.record.title && opts.title)
|
|
744
|
+
session.record.title = summarizePromptTitle(opts.title);
|
|
745
|
+
if (!session.record.model && opts.model)
|
|
746
|
+
session.record.model = opts.model.trim() || null;
|
|
747
|
+
if (!session.record.thinkingEffort && opts.thinkingEffort) {
|
|
748
|
+
session.record.thinkingEffort = opts.thinkingEffort.trim().toLowerCase() || null;
|
|
749
|
+
}
|
|
750
|
+
if (!session.record.profileId && opts.profileId) {
|
|
751
|
+
session.record.profileId = opts.profileId.trim() || null;
|
|
752
|
+
}
|
|
753
|
+
saveSessionRecord(opts.workdir, session.record);
|
|
754
|
+
return managedRecordToSessionInfo(session.record);
|
|
755
|
+
}
|
|
756
|
+
export function findManagedThreadSession(workdir, threadId, agent) {
|
|
757
|
+
const record = sortByUpdatedAtDesc(loadSessionIndex(path.resolve(workdir)).sessions.filter(entry => entry.threadId === threadId && entry.agent === agent))[0] || null;
|
|
758
|
+
return record ? managedRecordToSessionInfo(record) : null;
|
|
759
|
+
}
|
|
760
|
+
export function stageSessionFiles(opts) {
|
|
761
|
+
const session = ensureSessionWorkspace({
|
|
762
|
+
agent: opts.agent,
|
|
763
|
+
workdir: opts.workdir,
|
|
764
|
+
sessionId: opts.sessionId,
|
|
765
|
+
title: opts.title,
|
|
766
|
+
threadId: opts.threadId,
|
|
767
|
+
handoverFrom: opts.handoverFrom,
|
|
768
|
+
});
|
|
769
|
+
const importedFiles = importFilesIntoWorkspace(session.workspacePath, opts.files);
|
|
770
|
+
if (importedFiles.length) {
|
|
771
|
+
session.record.stagedFiles = dedupeStrings([...session.record.stagedFiles, ...importedFiles]);
|
|
772
|
+
/* title will be set when the first text prompt arrives */
|
|
773
|
+
saveSessionRecord(opts.workdir, session.record);
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
sessionId: session.sessionId,
|
|
777
|
+
workspacePath: session.workspacePath,
|
|
778
|
+
threadId: session.record.threadId,
|
|
779
|
+
importedFiles,
|
|
780
|
+
handoverFrom: session.record.handoverFrom ?? null,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
// ---------------------------------------------------------------------------
|
|
784
|
+
// Merge managed and native sessions
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
function sessionTimelineAt(session) {
|
|
787
|
+
const ts = Date.parse(session.runUpdatedAt || session.createdAt || '');
|
|
788
|
+
return Number.isFinite(ts) ? ts : Number.NEGATIVE_INFINITY;
|
|
789
|
+
}
|
|
790
|
+
function preferNativeSessionTimeline(managed, native) {
|
|
791
|
+
const managedTs = sessionTimelineAt(managed);
|
|
792
|
+
const nativeTs = sessionTimelineAt(native);
|
|
793
|
+
return nativeTs > managedTs;
|
|
794
|
+
}
|
|
795
|
+
export function mergeManagedAndNativeSessions(managedSessions, nativeSessions) {
|
|
796
|
+
const managedById = new Map();
|
|
797
|
+
const merged = [];
|
|
798
|
+
const seen = new Set();
|
|
799
|
+
for (const session of managedSessions) {
|
|
800
|
+
if (!session.sessionId || isPendingSessionId(session.sessionId))
|
|
801
|
+
continue;
|
|
802
|
+
managedById.set(session.sessionId, session);
|
|
803
|
+
}
|
|
804
|
+
for (const native of nativeSessions) {
|
|
805
|
+
const sessionId = native.sessionId;
|
|
806
|
+
if (sessionId)
|
|
807
|
+
seen.add(sessionId);
|
|
808
|
+
const managed = sessionId ? managedById.get(sessionId) : null;
|
|
809
|
+
if (!managed) {
|
|
810
|
+
merged.push(native);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
const useNativeTimeline = preferNativeSessionTimeline(managed, native);
|
|
814
|
+
merged.push({
|
|
815
|
+
...managed,
|
|
816
|
+
...native,
|
|
817
|
+
workdir: native.workdir || managed.workdir,
|
|
818
|
+
workspacePath: managed.workspacePath || native.workspacePath,
|
|
819
|
+
threadId: managed.threadId ?? native.threadId ?? null,
|
|
820
|
+
running: managed.running || native.running,
|
|
821
|
+
runState: managed.runState === 'running'
|
|
822
|
+
? managed.runState
|
|
823
|
+
: (useNativeTimeline ? native.runState : managed.runState),
|
|
824
|
+
runDetail: useNativeTimeline ? (native.runDetail ?? managed.runDetail) : (managed.runDetail ?? native.runDetail),
|
|
825
|
+
runUpdatedAt: useNativeTimeline ? (native.runUpdatedAt ?? managed.runUpdatedAt) : (managed.runUpdatedAt ?? native.runUpdatedAt),
|
|
826
|
+
title: native.title || managed.title,
|
|
827
|
+
model: native.model || managed.model,
|
|
828
|
+
createdAt: native.createdAt || managed.createdAt,
|
|
829
|
+
classification: managed.classification ?? native.classification ?? null,
|
|
830
|
+
userStatus: managed.userStatus ?? native.userStatus ?? null,
|
|
831
|
+
userNote: managed.userNote ?? native.userNote ?? null,
|
|
832
|
+
lastQuestion: useNativeTimeline
|
|
833
|
+
? (native.lastQuestion ?? managed.lastQuestion ?? null)
|
|
834
|
+
: (managed.lastQuestion ?? native.lastQuestion ?? null),
|
|
835
|
+
lastAnswer: useNativeTimeline
|
|
836
|
+
? (native.lastAnswer ?? managed.lastAnswer ?? null)
|
|
837
|
+
: (managed.lastAnswer ?? native.lastAnswer ?? null),
|
|
838
|
+
lastMessageText: useNativeTimeline
|
|
839
|
+
? (native.lastMessageText ?? managed.lastMessageText ?? native.lastAnswer ?? native.lastQuestion ?? managed.lastAnswer ?? managed.lastQuestion ?? null)
|
|
840
|
+
: (managed.lastMessageText ?? native.lastMessageText ?? managed.lastAnswer ?? managed.lastQuestion ?? native.lastAnswer ?? native.lastQuestion ?? null),
|
|
841
|
+
migratedFrom: managed.migratedFrom ?? native.migratedFrom ?? null,
|
|
842
|
+
migratedTo: managed.migratedTo ?? native.migratedTo ?? null,
|
|
843
|
+
linkedSessions: managed.linkedSessions?.length ? managed.linkedSessions : (native.linkedSessions ?? []),
|
|
844
|
+
numTurns: useNativeTimeline ? (native.numTurns ?? managed.numTurns ?? null) : (managed.numTurns ?? native.numTurns ?? null),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
for (const managed of managedSessions) {
|
|
848
|
+
if (!managed.sessionId || isPendingSessionId(managed.sessionId) || seen.has(managed.sessionId))
|
|
849
|
+
continue;
|
|
850
|
+
merged.push(managed);
|
|
851
|
+
}
|
|
852
|
+
merged.sort((a, b) => Date.parse(b.createdAt || '') - Date.parse(a.createdAt || ''));
|
|
853
|
+
return merged;
|
|
854
|
+
}
|
|
855
|
+
// ---------------------------------------------------------------------------
|
|
856
|
+
// getSessions / getSessionTail / getSessionMessages
|
|
857
|
+
// ---------------------------------------------------------------------------
|
|
858
|
+
export function getSessions(opts) {
|
|
859
|
+
const workdir = path.resolve(opts.workdir);
|
|
860
|
+
agentLog(`[sessions] request agent=${opts.agent} workdir=${workdir} limit=${opts.limit ?? 'all'}`);
|
|
861
|
+
return getDriver(opts.agent).getSessions(workdir, opts.limit).then(result => {
|
|
862
|
+
agentLog(`[sessions] result agent=${opts.agent} ok=${result.ok} count=${result.sessions.length} error=${result.error || '(none)'}`);
|
|
863
|
+
return result;
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
export function getSessionTail(opts) {
|
|
867
|
+
return getDriver(opts.agent).getSessionTail(opts);
|
|
868
|
+
}
|
|
869
|
+
export function getSessionMessages(opts) {
|
|
870
|
+
return getDriver(opts.agent).getSessionMessages(opts);
|
|
871
|
+
}
|
|
872
|
+
// ---------------------------------------------------------------------------
|
|
873
|
+
// Turn windowing
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
875
|
+
function normalizeTurnWindowValue(value, fallback) {
|
|
876
|
+
if (!Number.isFinite(value) || value == null)
|
|
877
|
+
return fallback;
|
|
878
|
+
return Math.max(0, Math.floor(value));
|
|
879
|
+
}
|
|
880
|
+
/** Slice messages by turn window and count total turns. Exported for drivers. */
|
|
881
|
+
export function applyTurnWindow(allMsgs, opts = {}, richMsgs) {
|
|
882
|
+
let totalTurns = 0;
|
|
883
|
+
const turnStartIndexes = [];
|
|
884
|
+
for (let i = 0; i < allMsgs.length; i++) {
|
|
885
|
+
if (allMsgs[i].role === 'user') {
|
|
886
|
+
turnStartIndexes.push(i);
|
|
887
|
+
totalTurns++;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
// If no rich messages provided, synthesize from plain messages so the
|
|
891
|
+
// API always returns a consistent richMessages array.
|
|
892
|
+
const rich = richMsgs ?? allMsgs.map(m => ({ role: m.role, text: m.text, blocks: [{ type: 'text', content: m.text }] }));
|
|
893
|
+
if (totalTurns <= 0) {
|
|
894
|
+
return {
|
|
895
|
+
ok: true,
|
|
896
|
+
messages: allMsgs,
|
|
897
|
+
richMessages: rich,
|
|
898
|
+
totalTurns,
|
|
899
|
+
window: {
|
|
900
|
+
offset: 0,
|
|
901
|
+
limit: 0,
|
|
902
|
+
returnedTurns: 0,
|
|
903
|
+
totalTurns: 0,
|
|
904
|
+
hasOlder: false,
|
|
905
|
+
hasNewer: false,
|
|
906
|
+
startTurn: 0,
|
|
907
|
+
endTurn: 0,
|
|
908
|
+
},
|
|
909
|
+
error: null,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const offset = normalizeTurnWindowValue(opts.turnOffset, 0);
|
|
913
|
+
const availableTurns = Math.max(0, totalTurns - offset);
|
|
914
|
+
const rawLimit = normalizeTurnWindowValue(opts.turnLimit ?? opts.lastNTurns, availableTurns);
|
|
915
|
+
const limit = rawLimit > 0 ? Math.min(rawLimit, availableTurns) : availableTurns;
|
|
916
|
+
if (limit <= 0 || availableTurns <= 0) {
|
|
917
|
+
const emptyTurn = Math.max(0, totalTurns - offset);
|
|
918
|
+
return {
|
|
919
|
+
ok: true,
|
|
920
|
+
messages: [],
|
|
921
|
+
richMessages: [],
|
|
922
|
+
totalTurns,
|
|
923
|
+
window: {
|
|
924
|
+
offset,
|
|
925
|
+
limit,
|
|
926
|
+
returnedTurns: 0,
|
|
927
|
+
totalTurns,
|
|
928
|
+
hasOlder: emptyTurn > 0,
|
|
929
|
+
hasNewer: offset > 0,
|
|
930
|
+
startTurn: emptyTurn,
|
|
931
|
+
endTurn: emptyTurn,
|
|
932
|
+
},
|
|
933
|
+
error: null,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
const endTurn = Math.max(0, totalTurns - offset);
|
|
937
|
+
const startTurn = Math.max(0, endTurn - limit);
|
|
938
|
+
const startIdx = turnStartIndexes[startTurn] ?? 0;
|
|
939
|
+
const endIdx = endTurn < totalTurns ? (turnStartIndexes[endTurn] ?? allMsgs.length) : allMsgs.length;
|
|
940
|
+
return {
|
|
941
|
+
ok: true,
|
|
942
|
+
messages: allMsgs.slice(startIdx, endIdx),
|
|
943
|
+
richMessages: rich.slice(startIdx, endIdx),
|
|
944
|
+
totalTurns,
|
|
945
|
+
window: {
|
|
946
|
+
offset,
|
|
947
|
+
limit,
|
|
948
|
+
returnedTurns: endTurn - startTurn,
|
|
949
|
+
totalTurns,
|
|
950
|
+
hasOlder: startTurn > 0,
|
|
951
|
+
hasNewer: endTurn < totalTurns,
|
|
952
|
+
startTurn,
|
|
953
|
+
endTurn,
|
|
954
|
+
},
|
|
955
|
+
error: null,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
/** Filter messages to last N turns and count total turns. Exported for drivers. */
|
|
959
|
+
export function applyTurnFilter(allMsgs, lastNTurns, richMsgs) {
|
|
960
|
+
return applyTurnWindow(allMsgs, { lastNTurns }, richMsgs);
|
|
961
|
+
}
|
|
962
|
+
// ---------------------------------------------------------------------------
|
|
963
|
+
// Session classification
|
|
964
|
+
// ---------------------------------------------------------------------------
|
|
965
|
+
const PROPOSAL_PATTERNS = /方案|option[s ]?[A-C]|plan|approach|建议|recommend|alternatively|trade-?off|pros?\s+(and|&)\s+cons?|选择|比较/i;
|
|
966
|
+
const IMPLEMENTATION_PATTERNS = /已完成|committed|done|implemented|fixed|created|wrote|修复|完成|写入|提交|applied|updated|modified|refactored/i;
|
|
967
|
+
const BLOCKED_PATTERNS = /error|failed|permission denied|cannot|无法|失败|报错|blocked|timed?\s*out/i;
|
|
968
|
+
export function classifySession(result) {
|
|
969
|
+
const now = new Date().toISOString();
|
|
970
|
+
const message = result.message || '';
|
|
971
|
+
const firstLine = message.split('\n').find(l => l.trim())?.trim() || '';
|
|
972
|
+
const summaryText = firstLine.length > 120 ? firstLine.slice(0, 117) + '...' : firstLine;
|
|
973
|
+
// 1. Structural signals from StreamResult
|
|
974
|
+
if (result.incomplete) {
|
|
975
|
+
return {
|
|
976
|
+
outcome: 'partial',
|
|
977
|
+
suggestedNextAction: result.stopReason === 'interrupted' ? 'Resume or restart the interrupted task' : 'Continue the incomplete task',
|
|
978
|
+
summary: summaryText || 'Task did not complete',
|
|
979
|
+
classifiedAt: now,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
if (!result.ok) {
|
|
983
|
+
const errorDetail = result.error || result.stopReason || 'unknown error';
|
|
984
|
+
return {
|
|
985
|
+
outcome: 'blocked',
|
|
986
|
+
suggestedNextAction: `Resolve error: ${errorDetail.slice(0, 100)}`,
|
|
987
|
+
summary: summaryText || `Failed: ${errorDetail.slice(0, 100)}`,
|
|
988
|
+
classifiedAt: now,
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
// 2. Activity signals (tool use indicates implementation)
|
|
992
|
+
const activity = result.activity || '';
|
|
993
|
+
if (/\b(Edit|Write|Bash)\b/.test(activity)) {
|
|
994
|
+
return {
|
|
995
|
+
outcome: 'implementation',
|
|
996
|
+
suggestedNextAction: 'Verify the changes made',
|
|
997
|
+
summary: summaryText,
|
|
998
|
+
classifiedAt: now,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
// 3. Content-based classification
|
|
1002
|
+
if (BLOCKED_PATTERNS.test(message.slice(0, 500))) {
|
|
1003
|
+
return {
|
|
1004
|
+
outcome: 'blocked',
|
|
1005
|
+
suggestedNextAction: 'Review the error and provide guidance',
|
|
1006
|
+
summary: summaryText,
|
|
1007
|
+
classifiedAt: now,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
if (PROPOSAL_PATTERNS.test(message.slice(0, 1000))) {
|
|
1011
|
+
return {
|
|
1012
|
+
outcome: 'proposal',
|
|
1013
|
+
suggestedNextAction: 'Review the proposal and decide on next steps',
|
|
1014
|
+
summary: summaryText,
|
|
1015
|
+
classifiedAt: now,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
if (IMPLEMENTATION_PATTERNS.test(message.slice(0, 500))) {
|
|
1019
|
+
return {
|
|
1020
|
+
outcome: 'implementation',
|
|
1021
|
+
suggestedNextAction: 'Verify the changes made',
|
|
1022
|
+
summary: summaryText,
|
|
1023
|
+
classifiedAt: now,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
// 4. Default: informational answer
|
|
1027
|
+
return {
|
|
1028
|
+
outcome: 'answer',
|
|
1029
|
+
suggestedNextAction: null,
|
|
1030
|
+
summary: summaryText,
|
|
1031
|
+
classifiedAt: now,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
/** Derive a default userStatus from classification outcome */
|
|
1035
|
+
export function deriveUserStatus(outcome) {
|
|
1036
|
+
switch (outcome) {
|
|
1037
|
+
case 'answer': return 'done';
|
|
1038
|
+
case 'partial': return 'active';
|
|
1039
|
+
default: return 'review';
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
// ---------------------------------------------------------------------------
|
|
1043
|
+
// Session export/import
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
export async function exportSession(opts) {
|
|
1046
|
+
try {
|
|
1047
|
+
// Rich mode so we can include image blocks in the export. The session
|
|
1048
|
+
// pipeline always returns plain messages even when rich is set; rich is
|
|
1049
|
+
// additive.
|
|
1050
|
+
const result = await getSessionMessages({ ...opts, agent: opts.agent, rich: true });
|
|
1051
|
+
if (!result.ok)
|
|
1052
|
+
return { ok: false, content: '', filename: '', error: result.error };
|
|
1053
|
+
const messages = result.messages;
|
|
1054
|
+
const richMessages = result.richMessages;
|
|
1055
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
1056
|
+
let content;
|
|
1057
|
+
let ext;
|
|
1058
|
+
switch (opts.format) {
|
|
1059
|
+
case 'json': {
|
|
1060
|
+
// Materialize image bytes into inline data URLs so the JSON is a
|
|
1061
|
+
// self-contained artefact (no dangling filesystem references).
|
|
1062
|
+
const { materializeImage } = await import('./images.js');
|
|
1063
|
+
const enrichedRichMessages = richMessages?.map(message => ({
|
|
1064
|
+
...message,
|
|
1065
|
+
blocks: message.blocks.map(block => {
|
|
1066
|
+
if (block.type !== 'image')
|
|
1067
|
+
return block;
|
|
1068
|
+
const resolved = materializeImage(block);
|
|
1069
|
+
if (!resolved)
|
|
1070
|
+
return block;
|
|
1071
|
+
return {
|
|
1072
|
+
...block,
|
|
1073
|
+
content: `data:${resolved.mime};base64,${resolved.bytes.toString('base64')}`,
|
|
1074
|
+
};
|
|
1075
|
+
}),
|
|
1076
|
+
}));
|
|
1077
|
+
content = JSON.stringify({
|
|
1078
|
+
agent: opts.agent,
|
|
1079
|
+
sessionId: opts.sessionId,
|
|
1080
|
+
exportedAt: new Date().toISOString(),
|
|
1081
|
+
messages,
|
|
1082
|
+
richMessages: enrichedRichMessages,
|
|
1083
|
+
}, null, 2);
|
|
1084
|
+
ext = 'json';
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
case 'text':
|
|
1088
|
+
content = messages.map(m => `[${m.role}]\n${m.text}`).join('\n\n---\n\n');
|
|
1089
|
+
ext = 'txt';
|
|
1090
|
+
break;
|
|
1091
|
+
case 'markdown':
|
|
1092
|
+
default:
|
|
1093
|
+
content = await renderMarkdownExport(opts.agent, timestamp, messages, richMessages);
|
|
1094
|
+
ext = 'md';
|
|
1095
|
+
break;
|
|
1096
|
+
}
|
|
1097
|
+
const filename = `session-${opts.agent}-${opts.sessionId.slice(0, 8)}-${timestamp}.${ext}`;
|
|
1098
|
+
return { ok: true, content, filename, error: null };
|
|
1099
|
+
}
|
|
1100
|
+
catch (e) {
|
|
1101
|
+
return { ok: false, content: '', filename: '', error: e.message };
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Render an export-friendly markdown view. Each turn renders the role header,
|
|
1106
|
+
* the text body, and (for image blocks) an inlined `` ref
|
|
1107
|
+
* so the markdown is self-contained and renders correctly in any viewer
|
|
1108
|
+
* (VSCode preview, GitHub, etc.) without external file lookups.
|
|
1109
|
+
*/
|
|
1110
|
+
async function renderMarkdownExport(agent, timestamp, messages, richMessages) {
|
|
1111
|
+
const lines = [`# Session Export (${agent}, ${timestamp})`, ''];
|
|
1112
|
+
const { materializeImage } = await import('./images.js');
|
|
1113
|
+
// Walk by index so we can pair messages[i] with richMessages[i] when present.
|
|
1114
|
+
const indexed = richMessages?.length === messages.length ? richMessages : null;
|
|
1115
|
+
const sections = [];
|
|
1116
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1117
|
+
const m = messages[i];
|
|
1118
|
+
const sectionHeader = `## ${m.role === 'user' ? 'User' : 'Assistant'}`;
|
|
1119
|
+
const sectionParts = [sectionHeader, '', m.text];
|
|
1120
|
+
const rich = indexed?.[i];
|
|
1121
|
+
if (rich) {
|
|
1122
|
+
for (const block of rich.blocks) {
|
|
1123
|
+
if (block.type !== 'image')
|
|
1124
|
+
continue;
|
|
1125
|
+
const resolved = materializeImage(block);
|
|
1126
|
+
if (!resolved)
|
|
1127
|
+
continue;
|
|
1128
|
+
const altText = (block.imageCaption || '').replace(/[\r\n]+/g, ' ').slice(0, 120);
|
|
1129
|
+
const dataUrl = `data:${resolved.mime};base64,${resolved.bytes.toString('base64')}`;
|
|
1130
|
+
sectionParts.push('', ``);
|
|
1131
|
+
if (block.imageCaption)
|
|
1132
|
+
sectionParts.push('', `_${altText}_`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
sections.push(sectionParts.join('\n'));
|
|
1136
|
+
}
|
|
1137
|
+
return lines.join('\n') + sections.join('\n\n---\n\n');
|
|
1138
|
+
}
|
|
1139
|
+
export function importSession(opts) {
|
|
1140
|
+
try {
|
|
1141
|
+
const format = opts.format || detectImportFormat(opts.content);
|
|
1142
|
+
let messages;
|
|
1143
|
+
switch (format) {
|
|
1144
|
+
case 'json': {
|
|
1145
|
+
const parsed = JSON.parse(opts.content);
|
|
1146
|
+
messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed) ? parsed : [];
|
|
1147
|
+
break;
|
|
1148
|
+
}
|
|
1149
|
+
case 'markdown': {
|
|
1150
|
+
messages = parseMarkdownConversation(opts.content);
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
case 'text':
|
|
1154
|
+
default: {
|
|
1155
|
+
messages = parseTextConversation(opts.content);
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return { ok: true, messages, error: null };
|
|
1160
|
+
}
|
|
1161
|
+
catch (e) {
|
|
1162
|
+
return { ok: false, messages: [], error: e.message };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
function detectImportFormat(content) {
|
|
1166
|
+
const trimmed = content.trimStart();
|
|
1167
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('['))
|
|
1168
|
+
return 'json';
|
|
1169
|
+
if (trimmed.startsWith('#'))
|
|
1170
|
+
return 'markdown';
|
|
1171
|
+
return 'text';
|
|
1172
|
+
}
|
|
1173
|
+
function parseMarkdownConversation(content) {
|
|
1174
|
+
const messages = [];
|
|
1175
|
+
const sections = content.split(/^## /m).slice(1);
|
|
1176
|
+
for (const section of sections) {
|
|
1177
|
+
const firstLine = section.split('\n')[0].trim().toLowerCase();
|
|
1178
|
+
const role = firstLine.includes('user') ? 'user' : 'assistant';
|
|
1179
|
+
// Strip inlined image data URLs (``) so
|
|
1180
|
+
// the imported text body stays readable. The base64 payload itself isn't
|
|
1181
|
+
// re-attached as a MessageBlock because the import API returns plain
|
|
1182
|
+
// TailMessages; downstream agents that re-process the export will see the
|
|
1183
|
+
// alt text "[image: alt]" placeholder where the markdown image stood.
|
|
1184
|
+
const stripped = section
|
|
1185
|
+
.split('\n')
|
|
1186
|
+
.slice(1)
|
|
1187
|
+
.join('\n')
|
|
1188
|
+
.replace(/^---\s*$/m, '')
|
|
1189
|
+
.replace(/!\[([^\]]*)\]\(data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\)/g, (_, alt) => alt ? `[image: ${alt}]` : '[image]')
|
|
1190
|
+
.trim();
|
|
1191
|
+
if (stripped)
|
|
1192
|
+
messages.push({ role, text: stripped });
|
|
1193
|
+
}
|
|
1194
|
+
return messages;
|
|
1195
|
+
}
|
|
1196
|
+
function parseTextConversation(content) {
|
|
1197
|
+
const messages = [];
|
|
1198
|
+
const blocks = content.split(/\n---\n/).map(b => b.trim()).filter(Boolean);
|
|
1199
|
+
for (const block of blocks) {
|
|
1200
|
+
const match = block.match(/^\[(user|assistant)\]\n([\s\S]+)$/i);
|
|
1201
|
+
if (match) {
|
|
1202
|
+
messages.push({ role: match[1].toLowerCase(), text: match[2].trim() });
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return messages;
|
|
1206
|
+
}
|