@qiaolei81/copilot-session-viewer 0.3.1 → 0.3.2
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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/src/models/Session.js +12 -2
- package/src/services/sessionRepository.js +98 -108
- package/src/utils/fileUtils.js +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.2] - 2026-03-08
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **VSCode Session Duration** - Duration now uses the last `terminalCommandState.timestamp` (when the agent actually executed a command) instead of a `toolCount × 3500ms` heuristic. Long-running agentic sessions that span multiple hours are now measured correctly
|
|
12
|
+
- **VSCode Multi-Workspace Dedup** - `_findVsCodeSession` now collects candidates from all matching workspace hashes and returns the one with the latest effective end time (most complete data), instead of returning the first match found
|
|
13
|
+
- **Session `createdAt` in CI** - `Session.fromDirectory` now reads `startTime`/`endTime` from `workspace.yaml` (in addition to `created_at`/`updated_at`), fixing `createdAt` being undefined in environments where `stats.birthtime` is unavailable
|
|
14
|
+
|
|
15
|
+
### Refactored
|
|
16
|
+
- **`_buildVsCodeSession()`** - Extracted shared VSCode session construction logic into a single method used by both the main scan loop and `_findVsCodeSession`, eliminating duplicate `effectiveEnd2`/`toolCount2` variables
|
|
17
|
+
|
|
8
18
|
## [0.3.1] - 2026-03-07
|
|
9
19
|
|
|
10
20
|
### Fixed
|
package/package.json
CHANGED
package/src/models/Session.js
CHANGED
|
@@ -40,11 +40,21 @@ class Session {
|
|
|
40
40
|
* @returns {Session}
|
|
41
41
|
*/
|
|
42
42
|
static fromDirectory(dirPath, id, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus) {
|
|
43
|
+
const createdAt = workspace?.created_at
|
|
44
|
+
? new Date(workspace.created_at)
|
|
45
|
+
: workspace?.startTime
|
|
46
|
+
? new Date(workspace.startTime)
|
|
47
|
+
: stats.birthtime;
|
|
48
|
+
const updatedAt = workspace?.updated_at
|
|
49
|
+
? new Date(workspace.updated_at)
|
|
50
|
+
: workspace?.endTime
|
|
51
|
+
? new Date(workspace.endTime)
|
|
52
|
+
: stats.mtime;
|
|
43
53
|
return new Session(id, 'directory', {
|
|
44
54
|
directory: dirPath, // Add directory path
|
|
45
55
|
workspace: workspace,
|
|
46
|
-
createdAt
|
|
47
|
-
updatedAt
|
|
56
|
+
createdAt,
|
|
57
|
+
updatedAt,
|
|
48
58
|
summary: workspace?.summary || 'No summary',
|
|
49
59
|
hasEvents: eventCount > 0,
|
|
50
60
|
eventCount: eventCount,
|
|
@@ -512,6 +512,8 @@ class SessionRepository {
|
|
|
512
512
|
async _findVsCodeSession(sessionId, workspaceStorageDir) {
|
|
513
513
|
try {
|
|
514
514
|
const hashes = await fs.readdir(workspaceStorageDir);
|
|
515
|
+
const candidates = [];
|
|
516
|
+
|
|
515
517
|
for (const hash of hashes) {
|
|
516
518
|
const chatSessionsDir = path.join(workspaceStorageDir, hash, 'chatSessions');
|
|
517
519
|
try {
|
|
@@ -529,58 +531,24 @@ class SessionRepository {
|
|
|
529
531
|
sessionJson = JSON.parse(raw);
|
|
530
532
|
}
|
|
531
533
|
const requests = sessionJson.requests || [];
|
|
532
|
-
if (requests.length === 0)
|
|
533
|
-
|
|
534
|
-
const firstReq = requests[0];
|
|
535
|
-
const createdAt = sessionJson.creationDate
|
|
536
|
-
? new Date(sessionJson.creationDate)
|
|
537
|
-
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
538
|
-
const lastReqTime2 = requests[requests.length - 1].timestamp ? new Date(requests[requests.length - 1].timestamp) : null;
|
|
539
|
-
const updatedAt = sessionJson.lastMessageDate
|
|
540
|
-
? new Date(sessionJson.lastMessageDate)
|
|
541
|
-
: (lastReqTime2 || stats.mtime);
|
|
542
|
-
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
543
|
-
|
|
544
|
-
const copilotChatVersion = firstReq.agent?.extensionVersion || null;
|
|
545
|
-
const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
|
|
534
|
+
if (requests.length === 0) continue;
|
|
546
535
|
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
let effectiveEnd2;
|
|
555
|
-
if (toolCount2 > 10 && lastReqTime2) {
|
|
556
|
-
const estimatedDurationMs2 = Math.max(toolCount2 * 3500, 60000);
|
|
557
|
-
effectiveEnd2 = new Date(createdAt.getTime() + estimatedDurationMs2);
|
|
558
|
-
} else if (lastReqTime2) {
|
|
559
|
-
effectiveEnd2 = lastReqTime2;
|
|
560
|
-
} else {
|
|
561
|
-
effectiveEnd2 = updatedAt;
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return new Session(sessionId, 'file', {
|
|
565
|
-
source: 'vscode',
|
|
566
|
-
filePath: fullPath,
|
|
567
|
-
workspaceHash: hash,
|
|
568
|
-
createdAt,
|
|
569
|
-
updatedAt: effectiveEnd2,
|
|
570
|
-
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
571
|
-
hasEvents: true,
|
|
572
|
-
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
573
|
-
duration: effectiveEnd2.getTime() - createdAt.getTime(),
|
|
574
|
-
sessionStatus: ((Date.now() - effectiveEnd2.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
|
|
575
|
-
selectedModel: firstReq.modelId || null,
|
|
576
|
-
copilotVersion: copilotChatVersion,
|
|
577
|
-
workspace: { cwd: realWorkspacePath || path.join(workspaceStorageDir, hash) },
|
|
578
|
-
});
|
|
536
|
+
const realWorkspacePath = await this._resolveVsCodeWorkspacePath(path.join(workspaceStorageDir, hash));
|
|
537
|
+
const statsWithPath = { ...stats, filePath: fullPath };
|
|
538
|
+
candidates.push(this._buildVsCodeSession(
|
|
539
|
+
sessionId, requests, sessionJson, statsWithPath, hash,
|
|
540
|
+
realWorkspacePath || path.join(workspaceStorageDir, hash)
|
|
541
|
+
));
|
|
579
542
|
}
|
|
580
543
|
} catch {
|
|
581
544
|
// No chatSessions dir or can't read — skip
|
|
582
545
|
}
|
|
583
546
|
}
|
|
547
|
+
// Return the candidate with the latest effectiveEndTime (most complete data)
|
|
548
|
+
if (candidates.length > 0) {
|
|
549
|
+
candidates.sort((a, b) => (b.updatedAt?.getTime?.() ?? 0) - (a.updatedAt?.getTime?.() ?? 0));
|
|
550
|
+
return candidates[0];
|
|
551
|
+
}
|
|
584
552
|
} catch (err) {
|
|
585
553
|
console.error(`[VSCode findById] Error searching VSCode sessions: ${err.message}`, err.stack);
|
|
586
554
|
}
|
|
@@ -685,6 +653,15 @@ class SessionRepository {
|
|
|
685
653
|
if (!workspace.summary && optimizedMetadata.firstUserMessage) {
|
|
686
654
|
workspace.summary = optimizedMetadata.firstUserMessage;
|
|
687
655
|
}
|
|
656
|
+
|
|
657
|
+
// Use max of filesystem mtime and last event timestamp for updatedAt
|
|
658
|
+
if (optimizedMetadata.lastEventTime) {
|
|
659
|
+
const lastEventMs = new Date(optimizedMetadata.lastEventTime).getTime();
|
|
660
|
+
const mtimeMs = new Date(stats.mtime).getTime();
|
|
661
|
+
if (lastEventMs > mtimeMs) {
|
|
662
|
+
stats = { ...stats, mtime: new Date(lastEventMs) };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
688
665
|
}
|
|
689
666
|
|
|
690
667
|
const session = Session.fromDirectory(fullPath, entry, stats, workspace, eventCount, duration, isImported, hasInsight, copilotVersion, selectedModel, sessionStatus);
|
|
@@ -727,7 +704,7 @@ class SessionRepository {
|
|
|
727
704
|
return 'completed';
|
|
728
705
|
}
|
|
729
706
|
if (metadata.lastEventTime !== null && metadata.lastEventTime !== undefined) {
|
|
730
|
-
const WIP_THRESHOLD_MS =
|
|
707
|
+
const WIP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
731
708
|
if ((Date.now() - metadata.lastEventTime) < WIP_THRESHOLD_MS) {
|
|
732
709
|
return 'wip';
|
|
733
710
|
}
|
|
@@ -962,68 +939,11 @@ class SessionRepository {
|
|
|
962
939
|
const requests = sessionJson.requests || [];
|
|
963
940
|
if (requests.length === 0) continue;
|
|
964
941
|
|
|
965
|
-
const
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
970
|
-
const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
|
|
971
|
-
const updatedAt = sessionJson.lastMessageDate
|
|
972
|
-
? new Date(sessionJson.lastMessageDate)
|
|
973
|
-
: (lastReqTime || stats.mtime);
|
|
974
|
-
|
|
975
|
-
// Count tool invocations across all requests (must be before effectiveEndTime calc)
|
|
976
|
-
let toolCount = 0;
|
|
977
|
-
for (const req of requests) {
|
|
978
|
-
toolCount += (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length;
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
// For VSCode agentic sessions, file mtime may be more accurate than last request timestamp
|
|
982
|
-
// because sub-agents write incremental updates over time without new request timestamps.
|
|
983
|
-
// BUT: VSCode may touch/sync all files at once, creating misleading mtimes.
|
|
984
|
-
// Strategy:
|
|
985
|
-
// - If session has many tool invocations (agentic), estimate duration from tool count
|
|
986
|
-
// - Otherwise fall back to request timestamps
|
|
987
|
-
const _fileMtime = stats.mtime;
|
|
988
|
-
let effectiveEndTime;
|
|
989
|
-
if (toolCount > 10 && lastReqTime) {
|
|
990
|
-
// Agentic session: estimate ~3.5s per tool invocation as a rough heuristic
|
|
991
|
-
const estimatedDurationMs = Math.max(toolCount * 3500, 60000); // at least 1 min
|
|
992
|
-
const estimatedEnd = new Date(createdAt.getTime() + estimatedDurationMs);
|
|
993
|
-
effectiveEndTime = estimatedEnd;
|
|
994
|
-
} else if (lastReqTime) {
|
|
995
|
-
effectiveEndTime = lastReqTime;
|
|
996
|
-
} else {
|
|
997
|
-
effectiveEndTime = updatedAt;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
const model = firstReq.modelId || null;
|
|
1001
|
-
const agentId = firstReq.agent?.id || 'vscode-copilot';
|
|
1002
|
-
const copilotChatVersion = firstReq.agent?.extensionVersion || null;
|
|
1003
|
-
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
1004
|
-
|
|
1005
|
-
const session = new Session(
|
|
1006
|
-
sessionId,
|
|
1007
|
-
'file',
|
|
1008
|
-
{
|
|
1009
|
-
source: 'vscode',
|
|
1010
|
-
filePath: fullPath,
|
|
1011
|
-
workspaceHash,
|
|
1012
|
-
createdAt,
|
|
1013
|
-
updatedAt: effectiveEndTime,
|
|
1014
|
-
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
1015
|
-
hasEvents: true,
|
|
1016
|
-
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
1017
|
-
duration: effectiveEndTime.getTime() - createdAt.getTime(),
|
|
1018
|
-
sessionStatus: ((Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000 || (Date.now() - stats.mtime.getTime()) < 15 * 60 * 1000) ? 'wip' : 'completed',
|
|
1019
|
-
selectedModel: model,
|
|
1020
|
-
agentId,
|
|
1021
|
-
toolCount,
|
|
1022
|
-
copilotVersion: copilotChatVersion,
|
|
1023
|
-
workspace: { cwd: realWorkspacePath || workspaceHashDir },
|
|
1024
|
-
}
|
|
942
|
+
const statsWithPath = { ...stats, filePath: fullPath };
|
|
943
|
+
const session = this._buildVsCodeSession(
|
|
944
|
+
sessionId, requests, sessionJson, statsWithPath, workspaceHash,
|
|
945
|
+
realWorkspacePath || workspaceHashDir
|
|
1025
946
|
);
|
|
1026
|
-
|
|
1027
947
|
sessions.push(session);
|
|
1028
948
|
} catch (err) {
|
|
1029
949
|
// Skip malformed files silently
|
|
@@ -1033,6 +953,76 @@ class SessionRepository {
|
|
|
1033
953
|
}
|
|
1034
954
|
|
|
1035
955
|
/** Extract plain text from a VSCode message object */
|
|
956
|
+
/**
|
|
957
|
+
* Build a VSCode Session object from parsed JSONL data.
|
|
958
|
+
* Single source of truth for VSCode session construction — used by both
|
|
959
|
+
* the main scan loop and _findVsCodeSession to avoid duplicate logic.
|
|
960
|
+
* @private
|
|
961
|
+
*/
|
|
962
|
+
_buildVsCodeSession(sessionId, requests, sessionJson, stats, workspaceHash, workspaceCwd) {
|
|
963
|
+
const firstReq = requests[0];
|
|
964
|
+
const lastReq = requests[requests.length - 1];
|
|
965
|
+
|
|
966
|
+
const createdAt = sessionJson.creationDate
|
|
967
|
+
? new Date(sessionJson.creationDate)
|
|
968
|
+
: (firstReq.timestamp ? new Date(firstReq.timestamp) : stats.birthtime);
|
|
969
|
+
|
|
970
|
+
const lastReqTime = lastReq.timestamp ? new Date(lastReq.timestamp) : null;
|
|
971
|
+
const fallbackUpdatedAt = sessionJson.lastMessageDate
|
|
972
|
+
? new Date(sessionJson.lastMessageDate)
|
|
973
|
+
: (lastReqTime || stats.mtime);
|
|
974
|
+
|
|
975
|
+
// Use last terminal command timestamp (truest end time for agentic sessions).
|
|
976
|
+
// terminalCommandState.timestamp = when the agent actually executed a command.
|
|
977
|
+
// request.timestamp = when the user sent the message (start of turn, not end).
|
|
978
|
+
// mtime is unreliable — VSCode syncs/touches all files when the workspace opens.
|
|
979
|
+
const lastTerminalTime = this._extractLastTerminalTimestamp(requests);
|
|
980
|
+
const effectiveEndTime = lastTerminalTime || lastReqTime || fallbackUpdatedAt;
|
|
981
|
+
|
|
982
|
+
const isWip = (Date.now() - effectiveEndTime.getTime()) < 15 * 60 * 1000;
|
|
983
|
+
const userText = this._extractVsCodeUserText(firstReq.message);
|
|
984
|
+
const toolCount = requests.reduce(
|
|
985
|
+
(sum, req) => sum + (req.response || []).filter(r => r.kind === 'toolInvocationSerialized').length,
|
|
986
|
+
0
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
return new Session(sessionId, 'file', {
|
|
990
|
+
source: 'vscode',
|
|
991
|
+
filePath: stats.filePath,
|
|
992
|
+
workspaceHash,
|
|
993
|
+
createdAt,
|
|
994
|
+
updatedAt: effectiveEndTime,
|
|
995
|
+
summary: userText ? userText.slice(0, 120) : `VSCode chat (${requests.length} requests)`,
|
|
996
|
+
hasEvents: true,
|
|
997
|
+
eventCount: requests.reduce((s, r) => s + (r.response || []).length, 0) + requests.length * 2 + 1,
|
|
998
|
+
duration: effectiveEndTime.getTime() - createdAt.getTime(),
|
|
999
|
+
sessionStatus: isWip ? 'wip' : 'completed',
|
|
1000
|
+
selectedModel: firstReq.modelId || null,
|
|
1001
|
+
agentId: firstReq.agent?.id || 'vscode-copilot',
|
|
1002
|
+
toolCount,
|
|
1003
|
+
copilotVersion: firstReq.agent?.extensionVersion || null,
|
|
1004
|
+
workspace: { cwd: workspaceCwd },
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
_extractLastTerminalTimestamp(requests) {
|
|
1009
|
+
let maxTs = 0;
|
|
1010
|
+
|
|
1011
|
+
function walk(obj) {
|
|
1012
|
+
if (!obj || typeof obj !== 'object') return;
|
|
1013
|
+
if (Array.isArray(obj)) { obj.forEach(walk); return; }
|
|
1014
|
+
if (obj.terminalCommandState && typeof obj.terminalCommandState.timestamp === 'number') {
|
|
1015
|
+
const ts = obj.terminalCommandState.timestamp;
|
|
1016
|
+
if (ts > 1_000_000_000_000 && ts < 9_999_999_999_999 && ts > maxTs) maxTs = ts;
|
|
1017
|
+
}
|
|
1018
|
+
for (const val of Object.values(obj)) walk(val);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
for (const req of requests) walk(req.response);
|
|
1022
|
+
|
|
1023
|
+
return maxTs > 0 ? new Date(maxTs) : null;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1036
1026
|
_extractVsCodeUserText(message) {
|
|
1037
1027
|
if (!message) return '';
|
|
1038
1028
|
if (typeof message.text === 'string') return message.text;
|
package/src/utils/fileUtils.js
CHANGED
|
@@ -146,7 +146,8 @@ async function getSessionMetadataOptimized(filePath, maxMessageLength = 200) {
|
|
|
146
146
|
copilotVersion: copilotVersion || null,
|
|
147
147
|
selectedModel: selectedModel || null,
|
|
148
148
|
hasSessionEnd,
|
|
149
|
-
lastEventTime: lastTimestamp
|
|
149
|
+
lastEventTime: lastTimestamp,
|
|
150
|
+
firstEventTime: firstTimestamp
|
|
150
151
|
};
|
|
151
152
|
} catch (err) {
|
|
152
153
|
console.error(`Error reading session metadata from ${filePath}:`, err.message);
|