@pellux/goodvibes-agent 1.4.4 → 1.5.1
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 +22 -0
- package/README.md +52 -7
- package/dist/package/main.js +7491 -12253
- package/docs/README.md +2 -2
- package/docs/channels-remote-and-api.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/release-and-publishing.md +1 -1
- package/docs/tools-and-commands.md +5 -5
- package/package.json +4 -2
- package/release/live-verification/live-verification.json +13 -13
- package/release/live-verification/live-verification.md +15 -15
- package/release/performance-snapshot.json +2 -2
- package/release/release-notes.md +5 -7
- package/release/release-readiness.json +6 -6
- package/src/agent/behavior-discovery-summary.ts +4 -18
- package/src/agent/calendar-registry.ts +322 -0
- package/src/agent/competitive-feature-inventory.ts +268 -130
- package/src/agent/document-registry.ts +5 -1
- package/src/agent/email/email-service.ts +350 -0
- package/src/agent/email/imap-client.ts +596 -0
- package/src/agent/email/smtp-client.ts +453 -0
- package/src/agent/ics-calendar.ts +662 -0
- package/src/agent/ics-timezone.ts +172 -0
- package/src/agent/markdown-frontmatter.ts +31 -0
- package/src/agent/memory-safety.ts +1 -1
- package/src/agent/note-registry.ts +3 -9
- package/src/agent/persona-discovery.ts +3 -15
- package/src/agent/persona-registry.ts +1 -10
- package/src/agent/research-source-registry.ts +5 -1
- package/src/agent/routine-discovery.ts +3 -15
- package/src/agent/runtime-profile.ts +3 -0
- package/src/agent/skill-discovery.ts +3 -15
- package/src/agent/skill-draft-proposer.ts +203 -0
- package/src/agent/skill-draft-runner.ts +201 -0
- package/src/agent/skill-registry.ts +36 -1
- package/src/agent/skill-standard.ts +99 -0
- package/src/agent/vibe-file.ts +7 -22
- package/src/cli/completion.ts +1 -1
- package/src/cli/config-overrides.ts +10 -1
- package/src/cli/redaction.ts +17 -5
- package/src/config/provider-model.ts +2 -1
- package/src/config/secret-config.ts +13 -6
- package/src/core/activity-feed.ts +97 -0
- package/src/core/away-digest.ts +161 -0
- package/src/core/conversation-rendering.ts +7 -3
- package/src/core/conversation.ts +22 -15
- package/src/core/hardware-profile.ts +362 -0
- package/src/core/last-seen-store.ts +78 -0
- package/src/core/plain-language.ts +52 -0
- package/src/core/setup-incomplete-hint.ts +90 -0
- package/src/core/system-message-router.ts +38 -87
- package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
- package/src/input/agent-workspace-basic-command-editors.ts +39 -141
- package/src/input/agent-workspace-categories.ts +40 -125
- package/src/input/agent-workspace-command-editor.ts +4 -0
- package/src/input/agent-workspace-host-category.ts +0 -5
- package/src/input/agent-workspace-local-editor-submission.ts +3 -1
- package/src/input/agent-workspace-navigation.ts +10 -3
- package/src/input/agent-workspace-onboarding-actions.ts +132 -0
- package/src/input/agent-workspace-onboarding-categories.ts +29 -26
- package/src/input/agent-workspace-onboarding-finish.ts +11 -4
- package/src/input/agent-workspace-onboarding-state.ts +111 -0
- package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
- package/src/input/agent-workspace-profile-editors.ts +155 -0
- package/src/input/agent-workspace-subscription-editor.ts +7 -0
- package/src/input/agent-workspace-types.ts +5 -1
- package/src/input/agent-workspace.ts +65 -39
- package/src/input/command-registry.ts +18 -5
- package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
- package/src/input/commands/agent-skills-runtime.ts +60 -3
- package/src/input/commands/calendar-runtime.ts +209 -0
- package/src/input/commands/channels-runtime.ts +1 -0
- package/src/input/commands/command-error.ts +9 -0
- package/src/input/commands/compat-runtime.ts +1 -0
- package/src/input/commands/config.ts +1 -0
- package/src/input/commands/conversation-runtime.ts +1 -1
- package/src/input/commands/delegation-runtime.ts +5 -6
- package/src/input/commands/email-runtime.ts +387 -0
- package/src/input/commands/experience-runtime.ts +17 -4
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +6 -1
- package/src/input/commands/knowledge-format.ts +73 -0
- package/src/input/commands/knowledge.ts +9 -74
- package/src/input/commands/local-provider-runtime.ts +2 -1
- package/src/input/commands/local-runtime.ts +10 -4
- package/src/input/commands/mcp-runtime.ts +7 -0
- package/src/input/commands/notify-runtime.ts +17 -1
- package/src/input/commands/onboarding-runtime.ts +1 -0
- package/src/input/commands/operator-actions-runtime.ts +1 -0
- package/src/input/commands/operator-runtime.ts +3 -0
- package/src/input/commands/personas-runtime.ts +1 -0
- package/src/input/commands/platform-access-runtime.ts +40 -17
- package/src/input/commands/product-runtime.ts +6 -1
- package/src/input/commands/provider-accounts-runtime.ts +3 -2
- package/src/input/commands/qrcode-runtime.ts +1 -0
- package/src/input/commands/routines-runtime.ts +1 -0
- package/src/input/commands/runtime-services.ts +0 -13
- package/src/input/commands/security-runtime.ts +1 -0
- package/src/input/commands/session-content.ts +1 -0
- package/src/input/commands/shell-core.ts +5 -2
- package/src/input/commands/subscription-runtime.ts +33 -9
- package/src/input/commands/support-bundle-runtime.ts +8 -1
- package/src/input/commands/tasks-runtime.ts +1 -0
- package/src/input/commands/tts-runtime.ts +1 -0
- package/src/input/commands/vibe-runtime.ts +1 -0
- package/src/input/commands.ts +4 -0
- package/src/input/feed-context-factory.ts +3 -12
- package/src/input/handler-command-route.ts +7 -60
- package/src/input/handler-feed-routes.ts +0 -194
- package/src/input/handler-feed.ts +2 -54
- package/src/input/handler-interactions.ts +0 -2
- package/src/input/handler-modal-stack.ts +0 -9
- package/src/input/handler-picker-routes.ts +24 -1
- package/src/input/handler-shortcuts.ts +11 -40
- package/src/input/handler-ui-state.ts +13 -0
- package/src/input/handler.ts +8 -35
- package/src/input/keybindings.ts +40 -17
- package/src/input/model-picker-local-fit.ts +130 -0
- package/src/input/shell-passthrough.ts +58 -0
- package/src/input/submission-router.ts +0 -5
- package/src/main.ts +129 -90
- package/src/renderer/activity-sidebar.ts +186 -0
- package/src/renderer/agent-workspace-context-lines.ts +5 -48
- package/src/renderer/agent-workspace-style.ts +1 -1
- package/src/renderer/agent-workspace.ts +14 -2
- package/src/renderer/compositor.ts +37 -125
- package/src/renderer/conversation-overlays.ts +3 -3
- package/src/renderer/help-overlay.ts +7 -5
- package/src/renderer/model-workspace.ts +101 -86
- package/src/{panels → renderer}/polish.ts +3 -3
- package/src/renderer/shell-surface.ts +4 -5
- package/src/renderer/status-token.ts +31 -11
- package/src/renderer/tab-strip.ts +1 -1
- package/src/renderer/tool-call.ts +7 -8
- package/src/renderer/tool-labels.ts +92 -0
- package/src/renderer/ui-factory.ts +48 -96
- package/src/runtime/bootstrap-command-context.ts +0 -5
- package/src/runtime/bootstrap-command-parts.ts +6 -15
- package/src/runtime/bootstrap-core.ts +34 -13
- package/src/runtime/bootstrap-hook-bridge.ts +3 -1
- package/src/runtime/bootstrap-shell.ts +3 -50
- package/src/runtime/bootstrap.ts +3 -4
- package/src/runtime/context.ts +1 -1
- package/src/runtime/diagnostics/panels/index.ts +0 -1
- package/src/runtime/diagnostics/panels/policy.ts +1 -1
- package/src/runtime/execution-ledger.ts +16 -1
- package/src/runtime/index.ts +6 -1
- package/src/runtime/onboarding/index.ts +1 -0
- package/src/runtime/onboarding/onboarding-state.ts +200 -0
- package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
- package/src/runtime/services.ts +16 -4
- package/src/runtime/terminal-output-guard.ts +7 -0
- package/src/runtime/tool-permission-safety.ts +1 -1
- package/src/runtime/ui/model-picker/data-provider.ts +1 -1
- package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
- package/src/runtime/ui/model-picker/types.ts +2 -2
- package/src/runtime/ui/provider-health/data-provider.ts +3 -3
- package/src/runtime/ui/provider-health/types.ts +2 -2
- package/src/runtime/ui-services.ts +0 -2
- package/src/shell/agent-workspace-fullscreen.ts +0 -1
- package/src/shell/autonomy-surfacing.ts +272 -0
- package/src/shell/session-continuity-hints.ts +0 -6
- package/src/shell/startup-wiring.ts +221 -0
- package/src/shell/ui-openers.ts +18 -29
- package/src/tools/agent-context-policy.ts +1 -1
- package/src/tools/agent-harness-background-processes.ts +4 -4
- package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
- package/src/tools/agent-harness-command-runner.ts +6 -1
- package/src/tools/agent-harness-document-ops.ts +26 -53
- package/src/tools/agent-harness-execution-history.ts +1 -13
- package/src/tools/agent-harness-execution-posture.ts +0 -15
- package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
- package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
- package/src/tools/agent-harness-metadata.ts +16 -0
- package/src/tools/agent-harness-mode-catalog.ts +2 -9
- package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
- package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
- package/src/tools/agent-harness-tool-schema.ts +13 -14
- package/src/tools/agent-harness-tool.ts +27 -23
- package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
- package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
- package/src/tools/agent-workspace-tool.ts +2 -33
- package/src/utils/terminal-width.ts +9 -3
- package/src/version.ts +1 -1
- package/src/input/agent-workspace-panel-route.ts +0 -44
- package/src/input/panel-integration-actions.ts +0 -26
- package/src/panels/approval-panel.ts +0 -149
- package/src/panels/automation-control-panel.ts +0 -212
- package/src/panels/base-panel.ts +0 -254
- package/src/panels/builtin/agent.ts +0 -58
- package/src/panels/builtin/knowledge.ts +0 -26
- package/src/panels/builtin/operations.ts +0 -121
- package/src/panels/builtin/session.ts +0 -138
- package/src/panels/builtin/shared.ts +0 -275
- package/src/panels/builtin/usage.ts +0 -21
- package/src/panels/builtin-panels.ts +0 -23
- package/src/panels/confirm-state.ts +0 -61
- package/src/panels/context-visualizer-panel.ts +0 -204
- package/src/panels/cost-tracker-panel.ts +0 -444
- package/src/panels/docs-panel.ts +0 -285
- package/src/panels/index.ts +0 -25
- package/src/panels/knowledge-panel.ts +0 -417
- package/src/panels/memory-panel.ts +0 -226
- package/src/panels/panel-list-panel.ts +0 -464
- package/src/panels/panel-manager.ts +0 -570
- package/src/panels/provider-accounts-panel.ts +0 -233
- package/src/panels/provider-health-domains.ts +0 -208
- package/src/panels/provider-health-panel.ts +0 -720
- package/src/panels/provider-health-tracker.ts +0 -115
- package/src/panels/provider-stats-panel.ts +0 -366
- package/src/panels/qr-panel.ts +0 -207
- package/src/panels/schedule-panel.ts +0 -321
- package/src/panels/scrollable-list-panel.ts +0 -491
- package/src/panels/search-focus.ts +0 -32
- package/src/panels/security-panel.ts +0 -295
- package/src/panels/session-browser-panel.ts +0 -395
- package/src/panels/session-maintenance.ts +0 -125
- package/src/panels/subscription-panel.ts +0 -263
- package/src/panels/system-messages-panel.ts +0 -230
- package/src/panels/tasks-panel.ts +0 -344
- package/src/panels/thinking-panel.ts +0 -304
- package/src/panels/token-budget-panel.ts +0 -475
- package/src/panels/tool-inspector-panel.ts +0 -436
- package/src/panels/types.ts +0 -54
- package/src/renderer/panel-composite.ts +0 -158
- package/src/renderer/panel-tab-bar.ts +0 -69
- package/src/renderer/panel-workspace-bar.ts +0 -42
- package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
- package/src/tools/agent-harness-panel-metadata.ts +0 -211
- /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Away digest — a friendly "while you were away" summary shown once at launch.
|
|
3
|
+
*
|
|
4
|
+
* This module is intentionally pure: buildAwayDigest() takes a plain snapshot
|
|
5
|
+
* object and returns a structured digest (or null when nothing happened). No
|
|
6
|
+
* side-effects, no imports from the runtime.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface AwayDigestScheduleItem {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly lastRunAt?: number;
|
|
12
|
+
readonly runCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AwayDigestTaskItem {
|
|
16
|
+
readonly title: string;
|
|
17
|
+
readonly status: string;
|
|
18
|
+
readonly completedAt?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AwayDigestDeliveryItem {
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly at?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AwayDigestInput {
|
|
27
|
+
/** Epoch ms when the user last used the app. null = first run — digest is suppressed. */
|
|
28
|
+
readonly lastSeenAt: number | null;
|
|
29
|
+
/** Schedules that ran since lastSeenAt. */
|
|
30
|
+
readonly schedules: readonly AwayDigestScheduleItem[];
|
|
31
|
+
/** Tasks whose status changed since lastSeenAt. */
|
|
32
|
+
readonly tasks: readonly AwayDigestTaskItem[];
|
|
33
|
+
/** Number of approvals currently waiting. */
|
|
34
|
+
readonly pendingApprovals: number;
|
|
35
|
+
/** Optional channel deliveries since lastSeenAt. */
|
|
36
|
+
readonly deliveries?: readonly AwayDigestDeliveryItem[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AwayDigest {
|
|
40
|
+
readonly headline: string;
|
|
41
|
+
readonly lines: readonly string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maximum number of lines in the digest body (the headline is extra).
|
|
46
|
+
* Keeps the digest compact enough to scan without scrolling.
|
|
47
|
+
*/
|
|
48
|
+
const MAX_LINES = 5;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a relative time from `from` to `at`, e.g. "9:00" or "yesterday 9:00".
|
|
52
|
+
* Keeps language simple and avoids raw timestamps in user-facing copy.
|
|
53
|
+
*/
|
|
54
|
+
export function formatDigestTime(at: number, from: number = Date.now()): string {
|
|
55
|
+
const d = new Date(at);
|
|
56
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
57
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
58
|
+
const time = `${hh}:${mm}`;
|
|
59
|
+
|
|
60
|
+
const fromDate = new Date(from);
|
|
61
|
+
const sameDay =
|
|
62
|
+
d.getFullYear() === fromDate.getFullYear() &&
|
|
63
|
+
d.getMonth() === fromDate.getMonth() &&
|
|
64
|
+
d.getDate() === fromDate.getDate();
|
|
65
|
+
|
|
66
|
+
if (sameDay) return time;
|
|
67
|
+
|
|
68
|
+
// Check if it was yesterday
|
|
69
|
+
const yesterday = new Date(from);
|
|
70
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
71
|
+
const wasYesterday =
|
|
72
|
+
d.getFullYear() === yesterday.getFullYear() &&
|
|
73
|
+
d.getMonth() === yesterday.getMonth() &&
|
|
74
|
+
d.getDate() === yesterday.getDate();
|
|
75
|
+
|
|
76
|
+
if (wasYesterday) return `yesterday ${time}`;
|
|
77
|
+
return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Format a relative duration label for an upcoming item, e.g. "in 2h" or "in 30m".
|
|
82
|
+
*/
|
|
83
|
+
export function formatRelativeTime(targetMs: number, nowMs: number = Date.now()): string {
|
|
84
|
+
const diffMs = targetMs - nowMs;
|
|
85
|
+
if (diffMs <= 0) return 'soon';
|
|
86
|
+
const diffMin = Math.round(diffMs / 60_000);
|
|
87
|
+
if (diffMin < 60) return `in ${diffMin}m`;
|
|
88
|
+
const diffHours = Math.round(diffMs / 3_600_000);
|
|
89
|
+
if (diffHours < 24) return `in ${diffHours}h`;
|
|
90
|
+
const diffDays = Math.round(diffMs / 86_400_000);
|
|
91
|
+
return `in ${diffDays}d`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a friendly digest from a plain snapshot. Returns null when there is
|
|
96
|
+
* nothing to report or when lastSeenAt is null (first run).
|
|
97
|
+
*/
|
|
98
|
+
export function buildAwayDigest(input: AwayDigestInput): AwayDigest | null {
|
|
99
|
+
if (input.lastSeenAt === null) return null;
|
|
100
|
+
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
|
|
104
|
+
// ── Pending approvals (highest urgency first) ──────────────────────────────
|
|
105
|
+
if (input.pendingApprovals > 0) {
|
|
106
|
+
const word = input.pendingApprovals === 1 ? 'approval is waiting' : 'approvals are waiting';
|
|
107
|
+
lines.push(`${input.pendingApprovals} ${word} for you`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Tasks that completed/changed ──────────────────────────────────────────
|
|
111
|
+
const doneTasks = input.tasks.filter(
|
|
112
|
+
(t) => t.status === 'done' || t.status === 'completed' || t.status === 'finished',
|
|
113
|
+
);
|
|
114
|
+
const failedTasks = input.tasks.filter(
|
|
115
|
+
(t) => t.status === 'failed' || t.status === 'error',
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (doneTasks.length === 1) {
|
|
119
|
+
const t = doneTasks[0]!;
|
|
120
|
+
const when = t.completedAt ? ` (${formatDigestTime(t.completedAt, now)})` : '';
|
|
121
|
+
lines.push(`Research task finished: ${t.title}${when}`);
|
|
122
|
+
} else if (doneTasks.length > 1) {
|
|
123
|
+
lines.push(`${doneTasks.length} tasks finished since you were away`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (failedTasks.length === 1) {
|
|
127
|
+
lines.push(`A task needs attention: ${failedTasks[0]!.title}`);
|
|
128
|
+
} else if (failedTasks.length > 1) {
|
|
129
|
+
lines.push(`${failedTasks.length} tasks need attention since you were away`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Schedule runs ─────────────────────────────────────────────────────────
|
|
133
|
+
if (input.schedules.length === 1) {
|
|
134
|
+
const s = input.schedules[0]!;
|
|
135
|
+
const last = s.lastRunAt ? ` (last: ${s.name}, ${formatDigestTime(s.lastRunAt, now)})` : '';
|
|
136
|
+
lines.push(`1 reminder fired${last}`);
|
|
137
|
+
} else if (input.schedules.length > 1) {
|
|
138
|
+
// Find the most-recently fired for the "last:" note
|
|
139
|
+
const sorted = [...input.schedules].sort((a, b) => (b.lastRunAt ?? 0) - (a.lastRunAt ?? 0));
|
|
140
|
+
const mostRecent = sorted[0]!;
|
|
141
|
+
const last = mostRecent.lastRunAt
|
|
142
|
+
? `, last: ${mostRecent.name}, ${formatDigestTime(mostRecent.lastRunAt, now)}`
|
|
143
|
+
: '';
|
|
144
|
+
lines.push(`${input.schedules.length} reminders fired${last}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Channel deliveries ────────────────────────────────────────────────────
|
|
148
|
+
const deliveries = input.deliveries ?? [];
|
|
149
|
+
if (deliveries.length === 1) {
|
|
150
|
+
lines.push(`1 message delivered: ${deliveries[0]!.label}`);
|
|
151
|
+
} else if (deliveries.length > 1) {
|
|
152
|
+
lines.push(`${deliveries.length} messages delivered`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (lines.length === 0) return null;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
headline: 'While you were away',
|
|
159
|
+
lines: lines.slice(0, MAX_LINES),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
2
2
|
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
3
3
|
import { renderToolCallBlock } from '../renderer/tool-call.ts';
|
|
4
|
+
import { friendlyToolLabel } from '../renderer/tool-labels.ts';
|
|
4
5
|
import { renderThinkingBlock } from '../renderer/thinking.ts';
|
|
5
6
|
import { renderSystemMessage } from '../renderer/system-message.ts';
|
|
6
7
|
import { createEmptyLine, type Line, type Cell } from '../types/grid.ts';
|
|
@@ -184,7 +185,9 @@ export function renderConversationSystemMessage(
|
|
|
184
185
|
const sysStartLine = context.history.getLineCount();
|
|
185
186
|
const sysLines = renderSystemMessage(message.content, width);
|
|
186
187
|
context.history.addLines(sysLines);
|
|
187
|
-
|
|
188
|
+
// Match only messages that begin with a severity tag or bare "Error:" / "Error!" prefix.
|
|
189
|
+
// Bare /error/i over-matches false positives like "No errors found", "terror", etc.
|
|
190
|
+
if (/^\s*(\[(error|critical)\]|error[:!])/i.test(message.content)) {
|
|
188
191
|
context.errorLineRegistry.push(sysStartLine);
|
|
189
192
|
}
|
|
190
193
|
}
|
|
@@ -224,14 +227,15 @@ export function renderConversationToolMessage(
|
|
|
224
227
|
detailFg: '244',
|
|
225
228
|
}, [
|
|
226
229
|
...(message.toolName
|
|
227
|
-
? [{ text: ` ${message.toolName} `, fg: '#e2e8f0' as const }]
|
|
230
|
+
? [{ text: ` ${friendlyToolLabel(message.toolName)} `, fg: '#e2e8f0' as const }]
|
|
228
231
|
: [{ text: ` ${summarizeCallId(message.callId || 'standalone')} `, fg: '244' as const, dim: true }]),
|
|
229
232
|
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
230
233
|
]));
|
|
231
234
|
|
|
232
235
|
if (isCollapsed) {
|
|
233
236
|
const collapseSuffixReserve = 30;
|
|
234
|
-
const
|
|
237
|
+
const previewWidth = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - collapseSuffixReserve);
|
|
238
|
+
const preview = contentLines[0].slice(0, previewWidth);
|
|
235
239
|
const hiddenCount = lineCount - 1;
|
|
236
240
|
const collapsedText = hiddenCount > 0
|
|
237
241
|
? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
|
package/src/core/conversation.ts
CHANGED
|
@@ -60,8 +60,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
60
60
|
private lastRenderedWidth = 0;
|
|
61
61
|
/** When true the buffer needs to be rebuilt before the next display. */
|
|
62
62
|
private dirty = true;
|
|
63
|
-
|
|
64
|
-
private appendedUpTo = 0;
|
|
63
|
+
|
|
65
64
|
/** Optional config manager for display settings. */
|
|
66
65
|
private _configManager: ConfigManager | null = null;
|
|
67
66
|
/** Collapse state: stable key (msg_N) -> collapsed (true = collapsed). */
|
|
@@ -70,7 +69,7 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
70
69
|
protected blockRegistry: BlockMeta[] = [];
|
|
71
70
|
/** Message index -> first rendered line index in the history buffer. */
|
|
72
71
|
private messageLineRegistry: number[] = [];
|
|
73
|
-
/** Registry of rendered line indices for system messages matching /error
|
|
72
|
+
/** Registry of rendered line indices for system messages matching a leading [error]/[critical] tag or error:/error! prefix. */
|
|
74
73
|
private errorLineRegistry: number[] = [];
|
|
75
74
|
/** Streaming block start line in history buffer (for incremental streaming update). */
|
|
76
75
|
private streamingStartLine = -1;
|
|
@@ -184,11 +183,25 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
184
183
|
*/
|
|
185
184
|
public override updateStreamingBlock(content: string): void {
|
|
186
185
|
super.updateStreamingBlock(content);
|
|
187
|
-
// Incrementally update the history buffer instead of full rebuild
|
|
186
|
+
// Incrementally update the history buffer instead of full rebuild.
|
|
187
|
+
// Use the same width computation as the finalized path in renderConversationAssistantMessage
|
|
188
|
+
// so that streaming content does not reflow when it finalizes.
|
|
189
|
+
// NOTE: The 'all' line-number mode requires a two-pass measurement (total line count first,
|
|
190
|
+
// then render with gutter) which is impractical during streaming. For 'all' mode we apply
|
|
191
|
+
// a fixed pessimistic gutter (numWidth=3, gutterW=6) so the streaming width closely
|
|
192
|
+
// matches the finalized width; a single-line reflow may still occur if the final message
|
|
193
|
+
// exceeds 999 lines (extremely unlikely in practice). This is documented here as an
|
|
194
|
+
// intentional approximation, not a bug.
|
|
188
195
|
if (this.streamingStartLine >= 0) {
|
|
189
196
|
const width = this._getWidth();
|
|
197
|
+
const lineNumberMode = this._configManager?.get('display.lineNumbers') ?? 'off';
|
|
198
|
+
const showAllLineNumbers = lineNumberMode === 'all';
|
|
199
|
+
// Match the gutter computation from renderConversationAssistantMessage:
|
|
200
|
+
// use numWidth=3 (minimum) during streaming since total line count is unknown.
|
|
201
|
+
const gutterW = showAllLineNumbers ? 3 + 3 : 0; // numWidth=3, separator=' | ' = +3
|
|
202
|
+
const renderWidth = showAllLineNumbers ? width - gutterW : width;
|
|
190
203
|
this.history.truncateToLine(this.streamingStartLine);
|
|
191
|
-
const rendered = renderMarkdown(content,
|
|
204
|
+
const rendered = renderMarkdown(content, renderWidth);
|
|
192
205
|
this.history.addLines(rendered);
|
|
193
206
|
}
|
|
194
207
|
}
|
|
@@ -214,7 +227,6 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
214
227
|
public override resetAll(): void {
|
|
215
228
|
super.resetAll();
|
|
216
229
|
this.history.clear();
|
|
217
|
-
this.appendedUpTo = 0;
|
|
218
230
|
this.lastRenderedWidth = 0;
|
|
219
231
|
this.dirty = true;
|
|
220
232
|
this.collapseState.clear();
|
|
@@ -235,7 +247,6 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
235
247
|
public override replaceMessagesForLLM(newMessages: ProviderMessage[]): void {
|
|
236
248
|
super.replaceMessagesForLLM(newMessages);
|
|
237
249
|
this.history.clear();
|
|
238
|
-
this.appendedUpTo = 0;
|
|
239
250
|
this.lastRenderedWidth = 0;
|
|
240
251
|
this.dirty = true;
|
|
241
252
|
}
|
|
@@ -273,7 +284,6 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
273
284
|
}): void {
|
|
274
285
|
super.fromJSON(data);
|
|
275
286
|
this.history.clear();
|
|
276
|
-
this.appendedUpTo = 0;
|
|
277
287
|
this.lastRenderedWidth = 0;
|
|
278
288
|
this.dirty = true;
|
|
279
289
|
}
|
|
@@ -288,12 +298,10 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
288
298
|
}
|
|
289
299
|
|
|
290
300
|
/**
|
|
291
|
-
* rebuildHistory - Full rebuild. Called when
|
|
292
|
-
* For incremental appends use flushHistory().
|
|
301
|
+
* rebuildHistory - Full rebuild. Called when dirty flag is set or terminal width changes.
|
|
293
302
|
*/
|
|
294
303
|
public rebuildHistory(): void {
|
|
295
304
|
this.history.clear();
|
|
296
|
-
this.appendedUpTo = 0;
|
|
297
305
|
this.blockRegistry = [];
|
|
298
306
|
this.messageLineRegistry = [];
|
|
299
307
|
this.errorLineRegistry = [];
|
|
@@ -322,12 +330,12 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
this.appendMessages(visibleSnapshot, width);
|
|
325
|
-
this.appendedUpTo = snapshot.length;
|
|
326
333
|
}
|
|
327
334
|
|
|
328
335
|
/**
|
|
329
|
-
* flushHistory -
|
|
330
|
-
*
|
|
336
|
+
* flushHistory - Rebuilds the full buffer when dirty or when the terminal width has changed.
|
|
337
|
+
* Clears the history buffer and re-renders all messages from scratch on each call.
|
|
338
|
+
* No-ops when the buffer is clean and width is unchanged.
|
|
331
339
|
*/
|
|
332
340
|
public flushHistory(): void {
|
|
333
341
|
const currentWidth = this._getWidth();
|
|
@@ -541,7 +549,6 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
541
549
|
// Advance _displayFromMessageIndex to exclude all current messages from display.
|
|
542
550
|
// rebuildHistory() will only render messages added AFTER this point.
|
|
543
551
|
this._displayFromMessageIndex = this.getMessageSnapshot().length;
|
|
544
|
-
this.appendedUpTo = this._displayFromMessageIndex;
|
|
545
552
|
this.dirty = false;
|
|
546
553
|
// Do NOT re-render here — display stays blank until the next message is added.
|
|
547
554
|
// The lastRenderedWidth is kept so subsequent appends use the correct width.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { cpus, freemem, totalmem } from 'node:os';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Raw hardware snapshot for this process.
|
|
7
|
+
* All fields that cannot be determined safely are null.
|
|
8
|
+
* This module never throws — callers always receive a result.
|
|
9
|
+
*/
|
|
10
|
+
export interface HardwareProfile {
|
|
11
|
+
/** Total physical RAM in bytes, or null when unreadable. */
|
|
12
|
+
readonly totalRamBytes: number | null;
|
|
13
|
+
/** Available/free RAM in bytes at probe time, or null when unreadable. */
|
|
14
|
+
readonly availableRamBytes: number | null;
|
|
15
|
+
/** Discovered GPUs with VRAM sizes (empty when none detected). */
|
|
16
|
+
readonly gpus: readonly GpuInfo[];
|
|
17
|
+
/** Logical CPU core count (hyperthreaded), or null when unreadable. */
|
|
18
|
+
readonly cpuCores: number | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GpuInfo {
|
|
22
|
+
/** GPU product name (e.g. "NVIDIA GeForce RTX 3090"). */
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/** Total VRAM in bytes, or null when not reported. */
|
|
25
|
+
readonly vramBytes: number | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Plain-language verdict for whether a model fits on this machine.
|
|
30
|
+
*
|
|
31
|
+
* Heuristic (documented):
|
|
32
|
+
* Quantized model size in bytes is estimated as: params * bytesPerWeight
|
|
33
|
+
* Default quant assumed Q4 => bytesPerWeight = 0.6 bytes/param
|
|
34
|
+
* (1-bit quants like Q1 => 0.125, Q2 => 0.25, Q4 => ~0.55, Q8 => 1.0, fp16 => 2.0)
|
|
35
|
+
*
|
|
36
|
+
* fits-gpu : modelSizeBytes <= 0.80 * max(gpus[].vramBytes) (80% headroom for activations)
|
|
37
|
+
* fits-ram : modelSizeBytes <= 0.60 * availableRamBytes (60% of free RAM to leave OS headroom)
|
|
38
|
+
* tight : modelSizeBytes <= totalRamBytes (fits but will starve other processes)
|
|
39
|
+
* too-big : modelSizeBytes > totalRamBytes
|
|
40
|
+
* unknown : any required profile field is null / no data
|
|
41
|
+
*/
|
|
42
|
+
export type FitVerdict = 'fits-gpu' | 'fits-ram' | 'tight' | 'too-big' | 'unknown';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Bytes-per-weight constants for common quantization levels.
|
|
46
|
+
* Use these when converting a parameter count to an estimated model size.
|
|
47
|
+
*/
|
|
48
|
+
export const BYTES_PER_WEIGHT: Readonly<Record<string, number>> = {
|
|
49
|
+
q2: 0.25,
|
|
50
|
+
q4: 0.55, // Q4_K_M midpoint
|
|
51
|
+
q5: 0.69,
|
|
52
|
+
q8: 1.0,
|
|
53
|
+
f16: 2.0,
|
|
54
|
+
f32: 4.0,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Default quantization assumed when none is specified (Q4 GGUF). */
|
|
58
|
+
export const DEFAULT_BYTES_PER_WEIGHT = BYTES_PER_WEIGHT.q4;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Estimate model size in bytes from a parameter count.
|
|
62
|
+
* @param params Parameter count (e.g. 7_000_000_000 for a 7B model).
|
|
63
|
+
* @param bytesPerWeight Bytes per weight for the target quant. Defaults to Q4 (0.55).
|
|
64
|
+
*/
|
|
65
|
+
export function estimateModelBytes(params: number, bytesPerWeight = DEFAULT_BYTES_PER_WEIGHT): number {
|
|
66
|
+
return Math.round(params * bytesPerWeight);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Assess whether a model fits on the given hardware profile.
|
|
71
|
+
* @param modelSizeBytes Estimated model size in bytes (use estimateModelBytes if you have param count).
|
|
72
|
+
* @param profile Hardware profile from readHardwareProfile().
|
|
73
|
+
*/
|
|
74
|
+
export function fitAssessment(modelSizeBytes: number, profile: HardwareProfile): FitVerdict {
|
|
75
|
+
// Try GPU first — fastest/best path
|
|
76
|
+
const maxVram = maxVramBytes(profile.gpus);
|
|
77
|
+
if (maxVram !== null && modelSizeBytes <= Math.floor(maxVram * 0.80)) {
|
|
78
|
+
return 'fits-gpu';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// RAM fallback
|
|
82
|
+
if (profile.availableRamBytes !== null) {
|
|
83
|
+
if (modelSizeBytes <= Math.floor(profile.availableRamBytes * 0.60)) {
|
|
84
|
+
return 'fits-ram';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (profile.totalRamBytes !== null) {
|
|
89
|
+
return modelSizeBytes <= profile.totalRamBytes ? 'tight' : 'too-big';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return 'unknown';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Representative parameter count for a 7B starter model, used when estimating
|
|
97
|
+
* hardware fit across multiple call sites.
|
|
98
|
+
*/
|
|
99
|
+
export const REPRESENTATIVE_7B_PARAMS = 7_000_000_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Plain-language description of a fit verdict for display in the TUI renderer and cookbook.
|
|
103
|
+
* When `sizeDescriptor` is provided (e.g. '70B', '3B'), it is used in place of '7B' in the
|
|
104
|
+
* label so the hint reflects the actual model size. When omitted, falls back to '7B'
|
|
105
|
+
* (the representative constant used for the cookbook and unknown-size models).
|
|
106
|
+
* Returns an empty string when verdict is 'unknown' (no annotation shown).
|
|
107
|
+
*/
|
|
108
|
+
export function fitVerdictLabel(verdict: FitVerdict, sizeDescriptor?: string): string {
|
|
109
|
+
const size = sizeDescriptor ?? '7B';
|
|
110
|
+
switch (verdict) {
|
|
111
|
+
case 'fits-gpu': return `a ${size} model fits in GPU memory here`;
|
|
112
|
+
case 'fits-ram': return `a ${size} model runs on CPU RAM here`;
|
|
113
|
+
case 'tight': return `a ${size} model is tight here — close other apps`;
|
|
114
|
+
case 'too-big': return `a ${size} model needs more memory than this machine has`;
|
|
115
|
+
case 'unknown': return '';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a parameter count from a model identifier or display name.
|
|
121
|
+
* Matches patterns like '70b', '3b', '1.5b', '7B' (case-insensitive, word-boundary-like).
|
|
122
|
+
* Returns the count as a number (e.g. 70e9 for '70b') or null when no size token is found.
|
|
123
|
+
*
|
|
124
|
+
* Examples:
|
|
125
|
+
* 'llama-3.1-70b' → 70_000_000_000
|
|
126
|
+
* 'qwen2.5-3b' → 3_000_000_000
|
|
127
|
+
* '7b' → 7_000_000_000
|
|
128
|
+
* 'gpt-4o' → null
|
|
129
|
+
*/
|
|
130
|
+
export function paramCountFromModel(model: { id: string; displayName?: string }): number | null {
|
|
131
|
+
const candidates = [model.id, model.displayName ?? ''];
|
|
132
|
+
const re = /(\d+(?:\.\d+)?)\s*b\b/i;
|
|
133
|
+
for (const s of candidates) {
|
|
134
|
+
const m = re.exec(s);
|
|
135
|
+
if (m) {
|
|
136
|
+
const val = parseFloat(m[1]!);
|
|
137
|
+
if (Number.isFinite(val) && val > 0) {
|
|
138
|
+
return Math.round(val * 1e9);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Process-level cache — the probe runs at most once per process.
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
let cachedProfile: HardwareProfile | null = null;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Return a hardware profile immediately without spawning any child process.
|
|
152
|
+
* RAM and CPU are read from OS APIs (cheap, synchronous, non-blocking).
|
|
153
|
+
* GPU information is populated only if a prior `startHardwareProbe()` call
|
|
154
|
+
* has already completed and written to the module cache; otherwise `gpus` is
|
|
155
|
+
* an empty array.
|
|
156
|
+
*
|
|
157
|
+
* This is the correct reader for render-path callers (TUI renderer, cookbook).
|
|
158
|
+
* Never throws; degraded fields are null.
|
|
159
|
+
*/
|
|
160
|
+
export function readHardwareProfileSync(): HardwareProfile {
|
|
161
|
+
if (cachedProfile !== null) return cachedProfile;
|
|
162
|
+
// Build an immediate profile with RAM+CPU only (no GPU spawn).
|
|
163
|
+
// Populate the cache so repeated sync calls return the same object, and so
|
|
164
|
+
// that subsequent renders are instant. startHardwareProbe() will upgrade
|
|
165
|
+
// this entry with GPU data when the async probe completes.
|
|
166
|
+
const { totalRamBytes, availableRamBytes } = probeRam();
|
|
167
|
+
const cpuCores = probeCpuCores();
|
|
168
|
+
cachedProfile = { totalRamBytes, availableRamBytes, gpus: [], cpuCores };
|
|
169
|
+
return cachedProfile;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Probe hardware and return a snapshot.
|
|
174
|
+
* Safe to call many times — the OS probe runs exactly once per process.
|
|
175
|
+
* Never throws; degraded fields are null.
|
|
176
|
+
*
|
|
177
|
+
* @deprecated Delegates to `readHardwareProfileSync()`. Prefer calling
|
|
178
|
+
* `readHardwareProfileSync()` directly on the render path.
|
|
179
|
+
* GPU data is populated asynchronously — call `startHardwareProbe()` at
|
|
180
|
+
* application startup so that subsequent `readHardwareProfileSync()` calls
|
|
181
|
+
* include GPU information; this alias does not trigger the probe itself.
|
|
182
|
+
*/
|
|
183
|
+
export function readHardwareProfile(): HardwareProfile {
|
|
184
|
+
return readHardwareProfileSync();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Start a one-time async nvidia-smi probe in the background.
|
|
189
|
+
* When the probe completes it writes a full profile (RAM + CPU + GPUs) into
|
|
190
|
+
* the module cache, so subsequent `readHardwareProfileSync()` calls will
|
|
191
|
+
* include GPU data.
|
|
192
|
+
*
|
|
193
|
+
* Call once at application startup — off the render frame.
|
|
194
|
+
* Never throws; GPU errors are silently ignored.
|
|
195
|
+
*/
|
|
196
|
+
export function startHardwareProbe(): void {
|
|
197
|
+
try {
|
|
198
|
+
const child = spawn(
|
|
199
|
+
'nvidia-smi',
|
|
200
|
+
['--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
|
201
|
+
{ timeout: 1500 },
|
|
202
|
+
);
|
|
203
|
+
const chunks: Buffer[] = [];
|
|
204
|
+
child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
205
|
+
child.on('close', (code: number | null) => {
|
|
206
|
+
try {
|
|
207
|
+
const { totalRamBytes, availableRamBytes } = probeRam();
|
|
208
|
+
const cpuCores = probeCpuCores();
|
|
209
|
+
const gpus = code === 0 && chunks.length > 0
|
|
210
|
+
? parseNvidiaSmiOutput(Buffer.concat(chunks).toString('utf-8'))
|
|
211
|
+
: [];
|
|
212
|
+
// Populate if cache is cold, or upgrade an existing sync-only entry
|
|
213
|
+
// (gpus: []) with the GPU data returned by the async probe.
|
|
214
|
+
cachedProfile = _mergeProbeIntoCache(cachedProfile, { totalRamBytes, availableRamBytes, gpus, cpuCores });
|
|
215
|
+
} catch {
|
|
216
|
+
// GPU probe failure is non-fatal — leave cache unpopulated.
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
child.on('error', () => {
|
|
220
|
+
// nvidia-smi not found or not executable — leave cache unpopulated.
|
|
221
|
+
});
|
|
222
|
+
} catch {
|
|
223
|
+
// spawn itself failed — leave cache unpopulated.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Reset the process-level cache (test use only). */
|
|
228
|
+
export function _resetHardwareProfileCache(): void {
|
|
229
|
+
cachedProfile = null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Inject a hardware profile into the process-level cache (test use only).
|
|
234
|
+
* Useful when tests need a deterministic hardware profile to exercise
|
|
235
|
+
* verdict/label paths without depending on the real machine's hardware.
|
|
236
|
+
*/
|
|
237
|
+
export function _setHardwareProfileForTest(profile: HardwareProfile): void {
|
|
238
|
+
cachedProfile = profile;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Pure helper: decide how to merge a freshly-probed profile into the current
|
|
243
|
+
* module cache. Extracted so the merge logic can be unit-tested independently
|
|
244
|
+
* of the child-process lifecycle.
|
|
245
|
+
*
|
|
246
|
+
* Rules:
|
|
247
|
+
* - current === null → use fresh as-is (cold-cache populate)
|
|
248
|
+
* - current.gpus.length === 0 && fresh.gpus.length > 0
|
|
249
|
+
* → { ...current, gpus: fresh.gpus } (upgrade only the empty gpus field;
|
|
250
|
+
* all other fields — RAM, CPU — are kept from the existing entry)
|
|
251
|
+
* - otherwise → return current unchanged (never overwrite non-empty gpus)
|
|
252
|
+
*/
|
|
253
|
+
export function _mergeProbeIntoCache(
|
|
254
|
+
current: HardwareProfile | null,
|
|
255
|
+
fresh: HardwareProfile,
|
|
256
|
+
): HardwareProfile {
|
|
257
|
+
if (current === null) return fresh;
|
|
258
|
+
if (current.gpus.length === 0 && fresh.gpus.length > 0) {
|
|
259
|
+
return { ...current, gpus: fresh.gpus };
|
|
260
|
+
}
|
|
261
|
+
return current;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Internal helpers
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
function probeRam(): { totalRamBytes: number | null; availableRamBytes: number | null } {
|
|
269
|
+
// Linux: try /proc/meminfo first (accurate, no system call overhead)
|
|
270
|
+
try {
|
|
271
|
+
const raw = readFileSync('/proc/meminfo', 'utf-8');
|
|
272
|
+
const parsed = parseProcMeminfo(raw);
|
|
273
|
+
if (parsed.totalRamBytes !== null) return parsed;
|
|
274
|
+
} catch {
|
|
275
|
+
// not Linux or permission denied — fall through to node:os
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Fallback: node:os (works on macOS/Windows too)
|
|
279
|
+
try {
|
|
280
|
+
const total = totalmem();
|
|
281
|
+
const avail = freemem();
|
|
282
|
+
return {
|
|
283
|
+
totalRamBytes: Number.isFinite(total) && total > 0 ? total : null,
|
|
284
|
+
availableRamBytes: Number.isFinite(avail) && avail > 0 ? avail : null,
|
|
285
|
+
};
|
|
286
|
+
} catch {
|
|
287
|
+
return { totalRamBytes: null, availableRamBytes: null };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Parse /proc/meminfo text and extract MemTotal and MemAvailable.
|
|
293
|
+
* Lines look like: "MemTotal: 16384000 kB"
|
|
294
|
+
*/
|
|
295
|
+
export function parseProcMeminfo(raw: string): { totalRamBytes: number | null; availableRamBytes: number | null } {
|
|
296
|
+
let totalKb: number | null = null;
|
|
297
|
+
let availableKb: number | null = null;
|
|
298
|
+
|
|
299
|
+
for (const line of raw.split('\n')) {
|
|
300
|
+
if (totalKb === null && line.startsWith('MemTotal:')) {
|
|
301
|
+
totalKb = parseKbLine(line);
|
|
302
|
+
} else if (availableKb === null && line.startsWith('MemAvailable:')) {
|
|
303
|
+
availableKb = parseKbLine(line);
|
|
304
|
+
}
|
|
305
|
+
if (totalKb !== null && availableKb !== null) break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
totalRamBytes: totalKb !== null ? totalKb * 1024 : null,
|
|
310
|
+
availableRamBytes: availableKb !== null ? availableKb * 1024 : null,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function parseKbLine(line: string): number | null {
|
|
315
|
+
// Format: "MemTotal: 16384000 kB"
|
|
316
|
+
const parts = line.split(/\s+/);
|
|
317
|
+
const kb = parts[1] !== undefined ? Number(parts[1]) : NaN;
|
|
318
|
+
return Number.isFinite(kb) && kb > 0 ? kb : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parse nvidia-smi CSV output lines into GpuInfo entries.
|
|
323
|
+
* Each line: "GPU Name, vramMiB"
|
|
324
|
+
*/
|
|
325
|
+
export function parseNvidiaSmiOutput(raw: string): readonly GpuInfo[] {
|
|
326
|
+
const gpus: GpuInfo[] = [];
|
|
327
|
+
for (const line of raw.trim().split('\n')) {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (!trimmed) continue;
|
|
330
|
+
// Last comma-separated field is the memory in MiB
|
|
331
|
+
const lastComma = trimmed.lastIndexOf(',');
|
|
332
|
+
if (lastComma < 0) {
|
|
333
|
+
gpus.push({ name: trimmed, vramBytes: null });
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const name = trimmed.slice(0, lastComma).trim();
|
|
337
|
+
const mibStr = trimmed.slice(lastComma + 1).trim();
|
|
338
|
+
const mib = Number(mibStr);
|
|
339
|
+
const vramBytes = Number.isFinite(mib) && mib > 0 ? Math.round(mib * 1024 * 1024) : null;
|
|
340
|
+
gpus.push({ name, vramBytes });
|
|
341
|
+
}
|
|
342
|
+
return gpus;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function probeCpuCores(): number | null {
|
|
346
|
+
try {
|
|
347
|
+
const count = cpus().length;
|
|
348
|
+
return count > 0 ? count : null;
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function maxVramBytes(gpus: readonly GpuInfo[]): number | null {
|
|
355
|
+
let max: number | null = null;
|
|
356
|
+
for (const gpu of gpus) {
|
|
357
|
+
if (gpu.vramBytes !== null && (max === null || gpu.vramBytes > max)) {
|
|
358
|
+
max = gpu.vramBytes;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return max;
|
|
362
|
+
}
|