@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.
Files changed (230) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +52 -7
  3. package/dist/package/main.js +7491 -12253
  4. package/docs/README.md +2 -2
  5. package/docs/channels-remote-and-api.md +1 -1
  6. package/docs/getting-started.md +2 -2
  7. package/docs/release-and-publishing.md +1 -1
  8. package/docs/tools-and-commands.md +5 -5
  9. package/package.json +4 -2
  10. package/release/live-verification/live-verification.json +13 -13
  11. package/release/live-verification/live-verification.md +15 -15
  12. package/release/performance-snapshot.json +2 -2
  13. package/release/release-notes.md +5 -7
  14. package/release/release-readiness.json +6 -6
  15. package/src/agent/behavior-discovery-summary.ts +4 -18
  16. package/src/agent/calendar-registry.ts +322 -0
  17. package/src/agent/competitive-feature-inventory.ts +268 -130
  18. package/src/agent/document-registry.ts +5 -1
  19. package/src/agent/email/email-service.ts +350 -0
  20. package/src/agent/email/imap-client.ts +596 -0
  21. package/src/agent/email/smtp-client.ts +453 -0
  22. package/src/agent/ics-calendar.ts +662 -0
  23. package/src/agent/ics-timezone.ts +172 -0
  24. package/src/agent/markdown-frontmatter.ts +31 -0
  25. package/src/agent/memory-safety.ts +1 -1
  26. package/src/agent/note-registry.ts +3 -9
  27. package/src/agent/persona-discovery.ts +3 -15
  28. package/src/agent/persona-registry.ts +1 -10
  29. package/src/agent/research-source-registry.ts +5 -1
  30. package/src/agent/routine-discovery.ts +3 -15
  31. package/src/agent/runtime-profile.ts +3 -0
  32. package/src/agent/skill-discovery.ts +3 -15
  33. package/src/agent/skill-draft-proposer.ts +203 -0
  34. package/src/agent/skill-draft-runner.ts +201 -0
  35. package/src/agent/skill-registry.ts +36 -1
  36. package/src/agent/skill-standard.ts +99 -0
  37. package/src/agent/vibe-file.ts +7 -22
  38. package/src/cli/completion.ts +1 -1
  39. package/src/cli/config-overrides.ts +10 -1
  40. package/src/cli/redaction.ts +17 -5
  41. package/src/config/provider-model.ts +2 -1
  42. package/src/config/secret-config.ts +13 -6
  43. package/src/core/activity-feed.ts +97 -0
  44. package/src/core/away-digest.ts +161 -0
  45. package/src/core/conversation-rendering.ts +7 -3
  46. package/src/core/conversation.ts +22 -15
  47. package/src/core/hardware-profile.ts +362 -0
  48. package/src/core/last-seen-store.ts +78 -0
  49. package/src/core/plain-language.ts +52 -0
  50. package/src/core/setup-incomplete-hint.ts +90 -0
  51. package/src/core/system-message-router.ts +38 -87
  52. package/src/input/agent-workspace-basic-command-editor-submission.ts +60 -220
  53. package/src/input/agent-workspace-basic-command-editors.ts +39 -141
  54. package/src/input/agent-workspace-categories.ts +40 -125
  55. package/src/input/agent-workspace-command-editor.ts +4 -0
  56. package/src/input/agent-workspace-host-category.ts +0 -5
  57. package/src/input/agent-workspace-local-editor-submission.ts +3 -1
  58. package/src/input/agent-workspace-navigation.ts +10 -3
  59. package/src/input/agent-workspace-onboarding-actions.ts +132 -0
  60. package/src/input/agent-workspace-onboarding-categories.ts +29 -26
  61. package/src/input/agent-workspace-onboarding-finish.ts +11 -4
  62. package/src/input/agent-workspace-onboarding-state.ts +111 -0
  63. package/src/input/agent-workspace-profile-editor-submission.ts +260 -0
  64. package/src/input/agent-workspace-profile-editors.ts +155 -0
  65. package/src/input/agent-workspace-subscription-editor.ts +7 -0
  66. package/src/input/agent-workspace-types.ts +5 -1
  67. package/src/input/agent-workspace.ts +65 -39
  68. package/src/input/command-registry.ts +18 -5
  69. package/src/input/commands/agent-runtime-profile-runtime.ts +2 -1
  70. package/src/input/commands/agent-skills-runtime.ts +60 -3
  71. package/src/input/commands/calendar-runtime.ts +209 -0
  72. package/src/input/commands/channels-runtime.ts +1 -0
  73. package/src/input/commands/command-error.ts +9 -0
  74. package/src/input/commands/compat-runtime.ts +1 -0
  75. package/src/input/commands/config.ts +1 -0
  76. package/src/input/commands/conversation-runtime.ts +1 -1
  77. package/src/input/commands/delegation-runtime.ts +5 -6
  78. package/src/input/commands/email-runtime.ts +387 -0
  79. package/src/input/commands/experience-runtime.ts +17 -4
  80. package/src/input/commands/guidance-runtime.ts +1 -1
  81. package/src/input/commands/health-runtime.ts +6 -1
  82. package/src/input/commands/knowledge-format.ts +73 -0
  83. package/src/input/commands/knowledge.ts +9 -74
  84. package/src/input/commands/local-provider-runtime.ts +2 -1
  85. package/src/input/commands/local-runtime.ts +10 -4
  86. package/src/input/commands/mcp-runtime.ts +7 -0
  87. package/src/input/commands/notify-runtime.ts +17 -1
  88. package/src/input/commands/onboarding-runtime.ts +1 -0
  89. package/src/input/commands/operator-actions-runtime.ts +1 -0
  90. package/src/input/commands/operator-runtime.ts +3 -0
  91. package/src/input/commands/personas-runtime.ts +1 -0
  92. package/src/input/commands/platform-access-runtime.ts +40 -17
  93. package/src/input/commands/product-runtime.ts +6 -1
  94. package/src/input/commands/provider-accounts-runtime.ts +3 -2
  95. package/src/input/commands/qrcode-runtime.ts +1 -0
  96. package/src/input/commands/routines-runtime.ts +1 -0
  97. package/src/input/commands/runtime-services.ts +0 -13
  98. package/src/input/commands/security-runtime.ts +1 -0
  99. package/src/input/commands/session-content.ts +1 -0
  100. package/src/input/commands/shell-core.ts +5 -2
  101. package/src/input/commands/subscription-runtime.ts +33 -9
  102. package/src/input/commands/support-bundle-runtime.ts +8 -1
  103. package/src/input/commands/tasks-runtime.ts +1 -0
  104. package/src/input/commands/tts-runtime.ts +1 -0
  105. package/src/input/commands/vibe-runtime.ts +1 -0
  106. package/src/input/commands.ts +4 -0
  107. package/src/input/feed-context-factory.ts +3 -12
  108. package/src/input/handler-command-route.ts +7 -60
  109. package/src/input/handler-feed-routes.ts +0 -194
  110. package/src/input/handler-feed.ts +2 -54
  111. package/src/input/handler-interactions.ts +0 -2
  112. package/src/input/handler-modal-stack.ts +0 -9
  113. package/src/input/handler-picker-routes.ts +24 -1
  114. package/src/input/handler-shortcuts.ts +11 -40
  115. package/src/input/handler-ui-state.ts +13 -0
  116. package/src/input/handler.ts +8 -35
  117. package/src/input/keybindings.ts +40 -17
  118. package/src/input/model-picker-local-fit.ts +130 -0
  119. package/src/input/shell-passthrough.ts +58 -0
  120. package/src/input/submission-router.ts +0 -5
  121. package/src/main.ts +129 -90
  122. package/src/renderer/activity-sidebar.ts +186 -0
  123. package/src/renderer/agent-workspace-context-lines.ts +5 -48
  124. package/src/renderer/agent-workspace-style.ts +1 -1
  125. package/src/renderer/agent-workspace.ts +14 -2
  126. package/src/renderer/compositor.ts +37 -125
  127. package/src/renderer/conversation-overlays.ts +3 -3
  128. package/src/renderer/help-overlay.ts +7 -5
  129. package/src/renderer/model-workspace.ts +101 -86
  130. package/src/{panels → renderer}/polish.ts +3 -3
  131. package/src/renderer/shell-surface.ts +4 -5
  132. package/src/renderer/status-token.ts +31 -11
  133. package/src/renderer/tab-strip.ts +1 -1
  134. package/src/renderer/tool-call.ts +7 -8
  135. package/src/renderer/tool-labels.ts +92 -0
  136. package/src/renderer/ui-factory.ts +48 -96
  137. package/src/runtime/bootstrap-command-context.ts +0 -5
  138. package/src/runtime/bootstrap-command-parts.ts +6 -15
  139. package/src/runtime/bootstrap-core.ts +34 -13
  140. package/src/runtime/bootstrap-hook-bridge.ts +3 -1
  141. package/src/runtime/bootstrap-shell.ts +3 -50
  142. package/src/runtime/bootstrap.ts +3 -4
  143. package/src/runtime/context.ts +1 -1
  144. package/src/runtime/diagnostics/panels/index.ts +0 -1
  145. package/src/runtime/diagnostics/panels/policy.ts +1 -1
  146. package/src/runtime/execution-ledger.ts +16 -1
  147. package/src/runtime/index.ts +6 -1
  148. package/src/runtime/onboarding/index.ts +1 -0
  149. package/src/runtime/onboarding/onboarding-state.ts +200 -0
  150. package/src/{panels → runtime}/provider-account-snapshot.ts +5 -2
  151. package/src/runtime/services.ts +16 -4
  152. package/src/runtime/terminal-output-guard.ts +7 -0
  153. package/src/runtime/tool-permission-safety.ts +1 -1
  154. package/src/runtime/ui/model-picker/data-provider.ts +1 -1
  155. package/src/runtime/ui/model-picker/health-enrichment.ts +2 -2
  156. package/src/runtime/ui/model-picker/types.ts +2 -2
  157. package/src/runtime/ui/provider-health/data-provider.ts +3 -3
  158. package/src/runtime/ui/provider-health/types.ts +2 -2
  159. package/src/runtime/ui-services.ts +0 -2
  160. package/src/shell/agent-workspace-fullscreen.ts +0 -1
  161. package/src/shell/autonomy-surfacing.ts +272 -0
  162. package/src/shell/session-continuity-hints.ts +0 -6
  163. package/src/shell/startup-wiring.ts +221 -0
  164. package/src/shell/ui-openers.ts +18 -29
  165. package/src/tools/agent-context-policy.ts +1 -1
  166. package/src/tools/agent-harness-background-processes.ts +4 -4
  167. package/src/tools/agent-harness-browser-cockpit-route.ts +3 -3
  168. package/src/tools/agent-harness-command-runner.ts +6 -1
  169. package/src/tools/agent-harness-document-ops.ts +26 -53
  170. package/src/tools/agent-harness-execution-history.ts +1 -13
  171. package/src/tools/agent-harness-execution-posture.ts +0 -15
  172. package/src/tools/agent-harness-keybinding-metadata.ts +28 -29
  173. package/src/tools/agent-harness-local-model-cookbook.ts +24 -1
  174. package/src/tools/agent-harness-metadata.ts +16 -0
  175. package/src/tools/agent-harness-mode-catalog.ts +2 -9
  176. package/src/tools/agent-harness-provider-account-metadata.ts +2 -2
  177. package/src/tools/agent-harness-setup-posture-utils.ts +1 -1
  178. package/src/tools/agent-harness-tool-schema.ts +13 -14
  179. package/src/tools/agent-harness-tool.ts +27 -23
  180. package/src/tools/agent-harness-ui-surface-metadata.ts +10 -20
  181. package/src/tools/agent-harness-workspace-action-runner.ts +3 -4
  182. package/src/tools/agent-workspace-tool.ts +2 -33
  183. package/src/utils/terminal-width.ts +9 -3
  184. package/src/version.ts +1 -1
  185. package/src/input/agent-workspace-panel-route.ts +0 -44
  186. package/src/input/panel-integration-actions.ts +0 -26
  187. package/src/panels/approval-panel.ts +0 -149
  188. package/src/panels/automation-control-panel.ts +0 -212
  189. package/src/panels/base-panel.ts +0 -254
  190. package/src/panels/builtin/agent.ts +0 -58
  191. package/src/panels/builtin/knowledge.ts +0 -26
  192. package/src/panels/builtin/operations.ts +0 -121
  193. package/src/panels/builtin/session.ts +0 -138
  194. package/src/panels/builtin/shared.ts +0 -275
  195. package/src/panels/builtin/usage.ts +0 -21
  196. package/src/panels/builtin-panels.ts +0 -23
  197. package/src/panels/confirm-state.ts +0 -61
  198. package/src/panels/context-visualizer-panel.ts +0 -204
  199. package/src/panels/cost-tracker-panel.ts +0 -444
  200. package/src/panels/docs-panel.ts +0 -285
  201. package/src/panels/index.ts +0 -25
  202. package/src/panels/knowledge-panel.ts +0 -417
  203. package/src/panels/memory-panel.ts +0 -226
  204. package/src/panels/panel-list-panel.ts +0 -464
  205. package/src/panels/panel-manager.ts +0 -570
  206. package/src/panels/provider-accounts-panel.ts +0 -233
  207. package/src/panels/provider-health-domains.ts +0 -208
  208. package/src/panels/provider-health-panel.ts +0 -720
  209. package/src/panels/provider-health-tracker.ts +0 -115
  210. package/src/panels/provider-stats-panel.ts +0 -366
  211. package/src/panels/qr-panel.ts +0 -207
  212. package/src/panels/schedule-panel.ts +0 -321
  213. package/src/panels/scrollable-list-panel.ts +0 -491
  214. package/src/panels/search-focus.ts +0 -32
  215. package/src/panels/security-panel.ts +0 -295
  216. package/src/panels/session-browser-panel.ts +0 -395
  217. package/src/panels/session-maintenance.ts +0 -125
  218. package/src/panels/subscription-panel.ts +0 -263
  219. package/src/panels/system-messages-panel.ts +0 -230
  220. package/src/panels/tasks-panel.ts +0 -344
  221. package/src/panels/thinking-panel.ts +0 -304
  222. package/src/panels/token-budget-panel.ts +0 -475
  223. package/src/panels/tool-inspector-panel.ts +0 -436
  224. package/src/panels/types.ts +0 -54
  225. package/src/renderer/panel-composite.ts +0 -158
  226. package/src/renderer/panel-tab-bar.ts +0 -69
  227. package/src/renderer/panel-workspace-bar.ts +0 -42
  228. package/src/runtime/diagnostics/panels/panel-resources.ts +0 -118
  229. package/src/tools/agent-harness-panel-metadata.ts +0 -211
  230. /package/src/runtime/perf/{panel-health-monitor.ts → component-health.ts} +0 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * ics-timezone.ts — Dependency-free IANA timezone conversion using Intl.
3
+ *
4
+ * Converts between wall-clock date-time strings and UTC milliseconds using
5
+ * Intl.DateTimeFormat with the target timezone. The approach:
6
+ *
7
+ * 1. Treat the wall-clock string as a UTC candidate instant.
8
+ * 2. Format that candidate in the target timezone using Intl.DateTimeFormat.
9
+ * 3. Measure the offset (delta between what Intl says the local time is vs.
10
+ * what we fed in).
11
+ * 4. Correct the candidate by that delta.
12
+ * 5. Iterate once more to handle DST boundaries where step 3 may land on
13
+ * the wrong side of the transition.
14
+ *
15
+ * Handles all three RFC 5545 DTSTART forms:
16
+ * - UTC ('...Z'): caller should not call this; pass through as-is.
17
+ * - Floating (no TZID): kept as local/unzoned — caller treats as UTC.
18
+ * - TZID-qualified: converted here to a correct UTC instant.
19
+ *
20
+ * Unknown or unsupported TZID values fall back to treating the wall-clock as
21
+ * UTC (offset = 0). This is documented behaviour, not silent corruption.
22
+ */
23
+
24
+ // ──────────────────────────────────────────────────────────────────
25
+ // Intl formatter cache
26
+ // ──────────────────────────────────────────────────────────────────
27
+
28
+ // Maps TZID → Intl.DateTimeFormat (reuse across calls in a session)
29
+ const fmtCache = new Map<string, Intl.DateTimeFormat | null>();
30
+
31
+ /**
32
+ * Returns a cached Intl.DateTimeFormat for the given IANA timezone, or null
33
+ * if the timezone is unknown/unsupported by this runtime.
34
+ */
35
+ function getFormatter(tzid: string): Intl.DateTimeFormat | null {
36
+ if (fmtCache.has(tzid)) return fmtCache.get(tzid)!;
37
+ try {
38
+ const fmt = new Intl.DateTimeFormat('en-US', {
39
+ timeZone: tzid,
40
+ year: 'numeric',
41
+ month: '2-digit',
42
+ day: '2-digit',
43
+ hour: '2-digit',
44
+ minute: '2-digit',
45
+ second: '2-digit',
46
+ hour12: false,
47
+ });
48
+ fmtCache.set(tzid, fmt);
49
+ return fmt;
50
+ } catch {
51
+ // Unknown TZID — Intl throws RangeError for invalid timezone names
52
+ fmtCache.set(tzid, null);
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // ──────────────────────────────────────────────────────────────────
58
+ // Core conversion helpers
59
+ // ──────────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Parse a wall-clock string "YYYY-MM-DDTHH:MM:SS" into component parts.
63
+ * Returns null if the string does not match the expected form.
64
+ */
65
+ function parseWallClock(wallClock: string): {
66
+ year: number; month: number; day: number;
67
+ hour: number; minute: number; second: number;
68
+ } | null {
69
+ const m = wallClock.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/);
70
+ if (!m) return null;
71
+ return {
72
+ year: parseInt(m[1]!, 10),
73
+ month: parseInt(m[2]!, 10),
74
+ day: parseInt(m[3]!, 10),
75
+ hour: parseInt(m[4]!, 10),
76
+ minute: parseInt(m[5]!, 10),
77
+ second: parseInt(m[6]!, 10),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Format a UTC instant (ms) as its wall-clock representation in the given
83
+ * IANA timezone, returning "YYYY-MM-DDTHH:MM:SS".
84
+ *
85
+ * Uses the cached Intl.DateTimeFormat. Returns null if the formatter is
86
+ * unavailable (unknown TZID).
87
+ */
88
+ export function utcMsToWallClock(utcMs: number, tzid: string): string | null {
89
+ const fmt = getFormatter(tzid);
90
+ if (!fmt) return null;
91
+
92
+ // Intl.DateTimeFormat.formatToParts gives us named fields.
93
+ const parts = fmt.formatToParts(new Date(utcMs));
94
+ const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '00';
95
+
96
+ // hour12: false gives '00'-'23' for midnight through 11pm; some runtimes
97
+ // return '24' for midnight — normalise to '00'.
98
+ let hour = get('hour');
99
+ if (hour === '24') hour = '00';
100
+
101
+ return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`;
102
+ }
103
+
104
+ /**
105
+ * Convert a wall-clock date-time string ("YYYY-MM-DDTHH:MM:SS") in the given
106
+ * IANA timezone to a UTC timestamp (milliseconds since epoch).
107
+ *
108
+ * Algorithm (offset-derivation trick with one DST correction pass):
109
+ * 1. Treat wallClock as UTC candidate: candidateMs = Date.parse(wallClock + 'Z')
110
+ * 2. Ask Intl what the local time in tzid IS at that candidateMs → localAtCandidate
111
+ * 3. delta = candidateMs - Date.parse(localAtCandidate + 'Z')
112
+ * 4. correctedMs = candidateMs + delta
113
+ * 5. Verify: ask Intl what local time correctedMs is → should equal wallClock.
114
+ * If not (DST gap/fold), use correctedMs anyway (it's the closest valid instant).
115
+ *
116
+ * Falls back to treating wallClock as UTC (returns Date.parse(wallClock + 'Z'))
117
+ * when:
118
+ * - The TZID is unknown to Intl (not a valid IANA name)
119
+ * - The wallClock string cannot be parsed
120
+ * The fallback is documented: callers see this as offset=0 (UTC treatment).
121
+ */
122
+ export function wallClockToUtcMs(wallClock: string, tzid: string): number {
123
+ const parsed = parseWallClock(wallClock);
124
+ if (!parsed) {
125
+ // Malformed wall-clock — return as UTC
126
+ return Date.parse(`${wallClock}Z`);
127
+ }
128
+
129
+ const fmt = getFormatter(tzid);
130
+ if (!fmt) {
131
+ // Unknown TZID — fall back to UTC treatment (documented)
132
+ return Date.parse(`${wallClock}Z`);
133
+ }
134
+
135
+ // seed candidate from raw wall-clock treated as UTC
136
+ const candidateMs = Date.UTC(
137
+ parsed.year, parsed.month - 1, parsed.day,
138
+ parsed.hour, parsed.minute, parsed.second,
139
+ );
140
+
141
+ // first-pass offset correction (used as the gap fallback)
142
+ const firstLocal = utcMsToWallClock(candidateMs, tzid);
143
+ if (!firstLocal) return candidateMs;
144
+ const firstLocalParsed = parseWallClock(firstLocal);
145
+ if (!firstLocalParsed) return candidateMs;
146
+ const firstDelta = candidateMs - Date.UTC(
147
+ firstLocalParsed.year, firstLocalParsed.month - 1, firstLocalParsed.day,
148
+ firstLocalParsed.hour, firstLocalParsed.minute, firstLocalParsed.second,
149
+ );
150
+ const firstCorrected = candidateMs + firstDelta;
151
+
152
+ // iterate to a fixed point (converges <=3 passes for every valid wall-clock,
153
+ // incl. DST-transition-day post-transition hours)
154
+ let guess = firstCorrected;
155
+ for (let i = 0; i < 5; i++) {
156
+ const local = utcMsToWallClock(guess, tzid);
157
+ if (!local) return guess;
158
+ if (local === wallClock) return guess; // exact valid instant
159
+ const lp = parseWallClock(local);
160
+ if (!lp) return guess;
161
+ const delta = candidateMs - Date.UTC(
162
+ lp.year, lp.month - 1, lp.day,
163
+ lp.hour, lp.minute, lp.second,
164
+ );
165
+ if (delta === 0) return guess;
166
+ guess += delta;
167
+ }
168
+ // no fixed point => nonexistent spring-forward gap; return first-pass corrected
169
+ // (conventional forward-shift: 02:30 NY -> 03:30 EDT = 07:30Z), preserving the gap tests
170
+ return firstCorrected;
171
+ }
172
+
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared YAML-style frontmatter parser for agent markdown files.
3
+ *
4
+ * Parses the `---\nkey: value\n---` block at the top of a markdown file
5
+ * and optionally strips it from the body.
6
+ */
7
+
8
+ /**
9
+ * Parse the frontmatter block from a markdown string.
10
+ * Returns a flat record of string key/value pairs.
11
+ * Keys inside the block that have no value component are ignored.
12
+ */
13
+ export function parseMarkdownFrontmatter(content: string): Record<string, string> {
14
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
15
+ if (!match) return {};
16
+ const result: Record<string, string> = {};
17
+ for (const line of match[1].split('\n')) {
18
+ const [key, ...rest] = line.split(':');
19
+ if (key && rest.length > 0) result[key.trim()] = rest.join(':').trim();
20
+ }
21
+ return result;
22
+ }
23
+
24
+ /**
25
+ * Strip the leading frontmatter block from a markdown string and return the body.
26
+ * Trims the result unless `trim` is false.
27
+ */
28
+ export function stripMarkdownFrontmatter(content: string, trim = true): string {
29
+ const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
30
+ return trim ? body.trim() : body;
31
+ }
@@ -1,4 +1,4 @@
1
- const SECRET_PATTERNS: readonly RegExp[] = [
1
+ export const SECRET_PATTERNS: readonly RegExp[] = [
2
2
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
3
3
  /\bsk-[A-Za-z0-9_-]{16,}\b/,
4
4
  /\bgh[pousr]_[A-Za-z0-9_]{16,}\b/i,
@@ -92,13 +92,7 @@ function nowIso(): string {
92
92
  return new Date().toISOString();
93
93
  }
94
94
 
95
- function assertNoNoteSecretLikeText(fields: readonly string[]): void {
96
- try {
97
- assertNoSecretLikeText(fields);
98
- } catch {
99
- throw new Error('Notes cannot store secret-looking values. Store a secret reference or remove the sensitive text.');
100
- }
101
- }
95
+
102
96
 
103
97
  function parseNote(value: unknown): AgentNoteRecord | null {
104
98
  if (!isRecord(value)) return null;
@@ -193,7 +187,7 @@ export class AgentNoteRegistry {
193
187
  const body = input.body.trim();
194
188
  this.validateRequired(title, body);
195
189
  const sourceUrl = input.sourceUrl?.trim() || undefined;
196
- assertNoNoteSecretLikeText([title, body, sourceUrl ?? '', ...(input.tags ?? [])]);
190
+ assertNoSecretLikeText([title, body, sourceUrl ?? '', ...(input.tags ?? [])], 'Notes');
197
191
  const duplicate = store.notes.find((note) => note.title.toLowerCase() === title.toLowerCase());
198
192
  if (duplicate) throw new Error(`Note already exists ${duplicate.id}`);
199
193
  const timestamp = nowIso();
@@ -221,7 +215,7 @@ export class AgentNoteRegistry {
221
215
  const body = input.body === undefined ? existing.body : input.body.trim();
222
216
  this.validateRequired(title, body);
223
217
  const sourceUrl = input.sourceUrl === undefined ? existing.sourceUrl : input.sourceUrl.trim() || undefined;
224
- assertNoNoteSecretLikeText([title, body, sourceUrl ?? '', ...(input.tags ?? [])]);
218
+ assertNoSecretLikeText([title, body, sourceUrl ?? '', ...(input.tags ?? [])], 'Notes');
225
219
  const duplicate = store.notes.find((note) => note.id !== existing.id && note.title.toLowerCase() === title.toLowerCase());
226
220
  if (duplicate) throw new Error(`Note already exists ${duplicate.id}`);
227
221
  const updated: AgentNoteRecord = {
@@ -2,6 +2,7 @@ import { promises as fsPromises } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { ShellPathService } from '@/runtime/index.ts';
4
4
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
5
+ import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-frontmatter.ts';
5
6
 
6
7
  export type PersonaOrigin = 'project-local' | 'global' | 'custom';
7
8
 
@@ -16,19 +17,6 @@ export interface DiscoveredPersonaRecord {
16
17
 
17
18
  const DIRECTORY_MARKERS: readonly string[] = ['PERSONA.md', 'persona.md'];
18
19
 
19
- function parseFrontmatter(content: string): Record<string, string> {
20
- const match = content.match(/^---\n([\s\S]*?)\n---/);
21
- if (!match) return {};
22
- const result: Record<string, string> = {};
23
- for (const line of match[1].split('\n')) {
24
- const [key, ...rest] = line.split(':');
25
- if (key && rest.length > 0) {
26
- result[key.trim()] = rest.join(':').trim();
27
- }
28
- }
29
- return result;
30
- }
31
-
32
20
  function getPersonaDirectories(cwd: string, homeDir: string): Array<{ root: string; origin: PersonaOrigin }> {
33
21
  return [
34
22
  { root: join(cwd, '.goodvibes', 'personas'), origin: 'project-local' },
@@ -46,8 +34,8 @@ async function readPersonaFile(path: string, origin: PersonaOrigin): Promise<Dis
46
34
  return null;
47
35
  }
48
36
 
49
- const frontmatter = parseFrontmatter(content);
50
- const markdownBody = content.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
37
+ const frontmatter = parseMarkdownFrontmatter(content);
38
+ const markdownBody = stripMarkdownFrontmatter(content);
51
39
  const body = (frontmatter.system_prompt ?? markdownBody).trim();
52
40
  if (!body) return null;
53
41
  const name = frontmatter.name ?? path.split(/[\\/]/).pop()?.replace(/\.md$/i, '') ?? 'persona';
@@ -3,6 +3,7 @@ import { dirname } from 'node:path';
3
3
  import type { ShellPathService } from '@/runtime/index.ts';
4
4
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
5
5
  import { formatAgentRecordReviewState } from './record-labels.ts';
6
+ import { containsSecretLikeText } from './memory-safety.ts';
6
7
 
7
8
  export type AgentPersonaSource = 'user' | 'agent' | 'imported' | 'system';
8
9
  export type AgentPersonaReviewState = 'fresh' | 'reviewed' | 'stale';
@@ -56,12 +57,6 @@ interface PersonaStoreFile {
56
57
  }
57
58
 
58
59
  const STORE_VERSION = 1;
59
- const SECRET_PATTERNS: readonly RegExp[] = [
60
- /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
61
- /\bsk-[A-Za-z0-9_-]{16,}\b/,
62
- /\bgh[pousr]_[A-Za-z0-9_]{16,}\b/i,
63
- /\b(?:password|passwd|api[_-]?key|token|secret)\s*[:=]\s*\S{6,}/i,
64
- ];
65
60
 
66
61
  function isRecord(value: unknown): value is Record<string, unknown> {
67
62
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -103,10 +98,6 @@ function nowIso(): string {
103
98
  return new Date().toISOString();
104
99
  }
105
100
 
106
- function containsSecretLikeText(text: string): boolean {
107
- return SECRET_PATTERNS.some((pattern) => pattern.test(text));
108
- }
109
-
110
101
  export function assertNoSecretLikeText(fields: readonly string[], ownerLabel = 'Agent local records'): void {
111
102
  if (fields.some((field) => containsSecretLikeText(field))) {
112
103
  throw new Error(`${ownerLabel} cannot store secret-looking values. Store a secret reference or remove the sensitive text.`);
@@ -408,7 +408,11 @@ export class AgentResearchSourceRegistry {
408
408
 
409
409
  private readStore(): ResearchSourceStoreFile {
410
410
  if (!existsSync(this.storePath)) return { version: STORE_VERSION, sources: [] };
411
- return parseStore(readFileSync(this.storePath, 'utf-8'));
411
+ try {
412
+ return parseStore(readFileSync(this.storePath, 'utf-8'));
413
+ } catch (error) {
414
+ throw new Error(`Could not read Agent research source store: ${error instanceof Error ? error.message : String(error)}`);
415
+ }
412
416
  }
413
417
 
414
418
  private writeStore(store: ResearchSourceStoreFile): void {
@@ -2,6 +2,7 @@ import { promises as fsPromises } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { ShellPathService } from '@/runtime/index.ts';
4
4
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
5
+ import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-frontmatter.ts';
5
6
 
6
7
  export type RoutineOrigin = 'project-local' | 'global' | 'custom';
7
8
 
@@ -16,19 +17,6 @@ export interface DiscoveredRoutineRecord {
16
17
 
17
18
  const DIRECTORY_MARKERS: readonly string[] = ['ROUTINE.md', 'routine.md'];
18
19
 
19
- function parseFrontmatter(content: string): Record<string, string> {
20
- const match = content.match(/^---\n([\s\S]*?)\n---/);
21
- if (!match) return {};
22
- const result: Record<string, string> = {};
23
- for (const line of match[1].split('\n')) {
24
- const [key, ...rest] = line.split(':');
25
- if (key && rest.length > 0) {
26
- result[key.trim()] = rest.join(':').trim();
27
- }
28
- }
29
- return result;
30
- }
31
-
32
20
  function getRoutineDirectories(cwd: string, homeDir: string): Array<{ root: string; origin: RoutineOrigin }> {
33
21
  return [
34
22
  { root: join(cwd, '.goodvibes', 'routines'), origin: 'project-local' },
@@ -46,8 +34,8 @@ async function readRoutineFile(path: string, origin: RoutineOrigin): Promise<Dis
46
34
  return null;
47
35
  }
48
36
 
49
- const frontmatter = parseFrontmatter(content);
50
- const markdownBody = content.replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
37
+ const frontmatter = parseMarkdownFrontmatter(content);
38
+ const markdownBody = stripMarkdownFrontmatter(content);
51
39
  const steps = (frontmatter.steps ?? markdownBody).trim();
52
40
  if (!steps) return null;
53
41
  const name = frontmatter.name ?? path.split(/[\\/]/).pop()?.replace(/\.md$/i, '') ?? 'routine';
@@ -302,6 +302,7 @@ function selectDiscoveredRecords<T extends { readonly name: string; readonly pat
302
302
 
303
303
  function discoveredSkillToTemplate(skill: SkillRecord): AgentRuntimeProfileStarterTemplate['skills'][number] {
304
304
  if (!skill.body.trim()) throw new Error(`Discovered Agent skill ${skill.name} has no procedure body.`);
305
+ assertNoSecretLikeText([skill.name, skill.description, skill.body], `Discovered skill '${skill.name}'`);
305
306
  return {
306
307
  name: skill.name,
307
308
  description: skill.description || `Imported skill from ${skill.origin} skill file.`,
@@ -313,6 +314,7 @@ function discoveredSkillToTemplate(skill: SkillRecord): AgentRuntimeProfileStart
313
314
 
314
315
  function discoveredRoutineToTemplate(routine: DiscoveredRoutineRecord): AgentRuntimeProfileStarterTemplate['routines'][number] {
315
316
  if (!routine.steps.trim()) throw new Error(`Discovered Agent routine ${routine.name} has no steps.`);
317
+ assertNoSecretLikeText([routine.name, routine.description, routine.steps], `Discovered routine '${routine.name}'`);
316
318
  return {
317
319
  name: routine.name,
318
320
  description: routine.description || `Imported routine from ${routine.origin} markdown file.`,
@@ -323,6 +325,7 @@ function discoveredRoutineToTemplate(routine: DiscoveredRoutineRecord): AgentRun
323
325
  }
324
326
 
325
327
  function discoveredPersonaToTemplate(persona: DiscoveredPersonaRecord): AgentRuntimeProfileStarterTemplate['persona'] {
328
+ assertNoSecretLikeText([persona.name, persona.description, persona.body], `Discovered persona '${persona.name}'`);
326
329
  return {
327
330
  name: persona.name,
328
331
  description: persona.description || `Imported persona from ${persona.origin} markdown file.`,
@@ -2,6 +2,7 @@ import { promises as fsPromises } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { ShellPathService } from '@/runtime/index.ts';
4
4
  import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
5
+ import { parseMarkdownFrontmatter, stripMarkdownFrontmatter } from './markdown-frontmatter.ts';
5
6
 
6
7
  export type SkillOrigin = 'project-local' | 'global' | 'custom';
7
8
 
@@ -16,19 +17,6 @@ export interface SkillRecord {
16
17
  frontmatter: Record<string, string>;
17
18
  }
18
19
 
19
- function parseFrontmatter(content: string): Record<string, string> {
20
- const match = content.match(/^---\n([\s\S]*?)\n---/);
21
- if (!match) return {};
22
- const result: Record<string, string> = {};
23
- for (const line of match[1].split('\n')) {
24
- const [key, ...rest] = line.split(':');
25
- if (key && rest.length > 0) {
26
- result[key.trim()] = rest.join(':').trim();
27
- }
28
- }
29
- return result;
30
- }
31
-
32
20
  function getSkillDirectories(cwd: string, homeDir: string): Array<{ root: string; origin: SkillOrigin }> {
33
21
  return [
34
22
  { root: join(cwd, '.goodvibes', 'skills'), origin: 'project-local' },
@@ -46,8 +34,8 @@ async function readSkillFile(path: string, origin: SkillOrigin): Promise<SkillRe
46
34
  return null;
47
35
  }
48
36
 
49
- const frontmatter = parseFrontmatter(content);
50
- const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
37
+ const frontmatter = parseMarkdownFrontmatter(content);
38
+ const body = stripMarkdownFrontmatter(content, false);
51
39
  const name = frontmatter.name ?? path.split(/[\\/]/).pop()?.replace(/\.md$/, '') ?? 'skill';
52
40
  const description = frontmatter.description ?? frontmatter.summary ?? '';
53
41
  const dependencies = frontmatter.depends_on
@@ -0,0 +1,203 @@
1
+ import { assertNoSecretLikeText } from './persona-registry.ts';
2
+ import type { LearningCandidate } from '../tools/agent-harness-learning-curator-types.ts';
3
+ import type { AgentSkillCreateInput } from './skill-registry-types.ts';
4
+
5
+ /**
6
+ * The create-input payload produced for each proposed skill draft.
7
+ * Passed directly to AgentSkillRegistry.create().
8
+ */
9
+ export interface SkillDraftPayload extends AgentSkillCreateInput {
10
+ /** Candidate that originated this draft, for ledger bookkeeping. */
11
+ readonly candidateId: string;
12
+ /** Human-readable label carried from the learning candidate. */
13
+ readonly candidateLabel: string;
14
+ }
15
+
16
+ export interface ProposeSkillDraftsInput {
17
+ /** Full ranked candidate list from buildLearningCandidates. */
18
+ readonly candidates: readonly LearningCandidate[];
19
+ /** Lower-cased name set of skills already in the registry (dedup guard). */
20
+ readonly existingSkillNames: ReadonlySet<string>;
21
+ /** Lower-cased name set of skills proposed in earlier passes (dedup guard). */
22
+ readonly previouslyProposedNames: ReadonlySet<string>;
23
+ }
24
+
25
+ /** Maximum draft proposals emitted per pass. */
26
+ const MAX_PROPOSALS = 3;
27
+
28
+ const PROVENANCE = 'auto-proposed-skill-draft';
29
+
30
+ /** Convert an arbitrary string to a URL-safe kebab-case slug. */
31
+ function slugify(value: string): string {
32
+ const slug = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
33
+ return slug || 'skill';
34
+ }
35
+
36
+ /**
37
+ * Extract a one-line description from the proposalFields carried on the candidate.
38
+ * Prefers an explicit `description` field; falls back to the candidate label.
39
+ */
40
+ function extractDescription(candidate: LearningCandidate): string {
41
+ const fields = candidate.proposalFields ?? {};
42
+ const desc = (fields.description ?? '').trim();
43
+ if (desc) return desc.split('\n')[0]?.trim() || desc;
44
+ return candidate.label.split('->')[0]?.trim() || candidate.label;
45
+ }
46
+
47
+ /**
48
+ * Build a stepwise procedure body from available candidate evidence.
49
+ * Uses proposalFields.notes / detail / triggers when present.
50
+ */
51
+ function buildProcedureBody(candidate: LearningCandidate): string {
52
+ const fields = candidate.proposalFields ?? {};
53
+ const sections: string[] = [];
54
+
55
+ // Context block — reason and source
56
+ sections.push(`# ${extractDescription(candidate)}`);
57
+ sections.push('');
58
+ if (candidate.reason) {
59
+ sections.push(`## Origin`);
60
+ sections.push(candidate.reason.trim());
61
+ sections.push('');
62
+ }
63
+
64
+ // Triggers — derived from proposalFields.triggers or candidate.proposalTarget
65
+ const triggerHint = (fields.triggers ?? '').trim();
66
+ if (triggerHint) {
67
+ sections.push(`## When to use`);
68
+ sections.push(triggerHint);
69
+ sections.push('');
70
+ }
71
+
72
+ // Steps / body — prefer notes, then detail
73
+ const notes = (fields.notes ?? '').trim();
74
+ const detail = (fields.detail ?? '').trim();
75
+ const body = notes || detail;
76
+ if (body) {
77
+ sections.push(`## Steps`);
78
+ sections.push(body);
79
+ sections.push('');
80
+ }
81
+
82
+ // Tags / extra context
83
+ const tags = (fields.tags ?? '').trim();
84
+ if (tags) {
85
+ sections.push(`## Tags`);
86
+ sections.push(tags);
87
+ sections.push('');
88
+ }
89
+
90
+ sections.push(`## Notes`);
91
+ sections.push('This skill was auto-proposed from a learning candidate. Review and refine the steps before enabling.');
92
+
93
+ return sections.join('\n').trim();
94
+ }
95
+
96
+ /**
97
+ * Derive inferred trigger phrases from proposal fields and candidate metadata.
98
+ */
99
+ function buildTriggers(candidate: LearningCandidate): readonly string[] {
100
+ const fields = candidate.proposalFields ?? {};
101
+ const raw = (fields.triggers ?? '').trim();
102
+ if (!raw) return [];
103
+ return raw
104
+ .split(/[,;]+/)
105
+ .map((t) => t.trim())
106
+ .filter(Boolean);
107
+ }
108
+
109
+ /**
110
+ * Derive tags from proposal fields and provenance.
111
+ */
112
+ function buildTags(candidate: LearningCandidate): readonly string[] {
113
+ const fields = candidate.proposalFields ?? {};
114
+ const raw = (fields.tags ?? '').trim();
115
+ const base = raw
116
+ ? raw.split(/[,;]+/).map((t) => t.trim()).filter(Boolean)
117
+ : [];
118
+ // Always include the auto-proposal marker so it's filterable
119
+ if (!base.includes('auto-proposed')) base.push('auto-proposed');
120
+ return base;
121
+ }
122
+
123
+ /**
124
+ * Pure function: given the current learning candidates and name registries, select
125
+ * up to MAX_PROPOSALS skill-worthy candidates and return their draft payloads.
126
+ *
127
+ * Hard rules:
128
+ * - Only 'proposal-ready' or 'ready-to-promote' candidates with proposalTarget === 'skill'
129
+ * - Max 3 proposals per pass
130
+ * - Dedupe against existingSkillNames, previouslyProposedNames, and names proposed this pass
131
+ * (all comparisons use the kebab slug of the name, case-insensitive)
132
+ * - Skip any candidate whose text fields contain secret-like content
133
+ * - Skip any candidate whose derived name or description is empty
134
+ * - All drafts are created with enabled: false (caller's responsibility; registry enforces reviewState 'fresh')
135
+ */
136
+ export function proposeSkillDrafts(input: ProposeSkillDraftsInput): readonly SkillDraftPayload[] {
137
+ const { candidates, existingSkillNames, previouslyProposedNames } = input;
138
+
139
+ const proposals: SkillDraftPayload[] = [];
140
+ // Within-pass dedup: track slugified names already proposed in this call.
141
+ const proposedThisPass = new Set<string>();
142
+
143
+ // Filter to skill-proposal candidates, sorted by priority desc (candidates
144
+ // from buildLearningCandidates are already sorted but we enforce it here for
145
+ // purity — the function should not depend on caller sort order).
146
+ const eligible = [...candidates]
147
+ .filter(
148
+ (c) =>
149
+ (c.status === 'proposal-ready' || c.status === 'ready-to-promote') &&
150
+ c.proposalTarget === 'skill',
151
+ )
152
+ .sort((a, b) => b.priority - a.priority);
153
+
154
+ for (const candidate of eligible) {
155
+ if (proposals.length >= MAX_PROPOSALS) break;
156
+
157
+ const description = extractDescription(candidate);
158
+ // Guard: skip candidates whose description is empty after extraction.
159
+ if (!description.trim()) continue;
160
+
161
+ const rawName = description.slice(0, 120); // registry normalizes further
162
+ // Guard: skip candidates whose derived name is empty.
163
+ if (!rawName.trim()) continue;
164
+
165
+ const name = slugify(rawName);
166
+ const nameLower = name.toLowerCase(); // slugify already lowercases
167
+
168
+ // Dedup against existing, previously proposed, and within-pass proposed.
169
+ if (
170
+ existingSkillNames.has(nameLower) ||
171
+ previouslyProposedNames.has(nameLower) ||
172
+ proposedThisPass.has(nameLower)
173
+ ) continue;
174
+
175
+ // Secret scan — skip candidate rather than throw, as individual dirty
176
+ // candidates should not block the whole pass
177
+ const procedure = buildProcedureBody(candidate);
178
+ const triggers = buildTriggers(candidate);
179
+ const tags = buildTags(candidate);
180
+ const fieldsToScan = [name, description, procedure, ...triggers, ...tags];
181
+ try {
182
+ assertNoSecretLikeText(fieldsToScan, 'Skill draft proposer');
183
+ } catch {
184
+ continue;
185
+ }
186
+
187
+ proposedThisPass.add(nameLower);
188
+ proposals.push({
189
+ candidateId: candidate.id,
190
+ candidateLabel: candidate.label,
191
+ name,
192
+ description,
193
+ procedure,
194
+ triggers: triggers.length > 0 ? triggers : undefined,
195
+ tags: tags.length > 0 ? tags : undefined,
196
+ enabled: false,
197
+ source: 'agent',
198
+ provenance: PROVENANCE,
199
+ });
200
+ }
201
+
202
+ return proposals;
203
+ }