@vellumai/assistant 0.12.2-staging.3 → 0.12.2-staging.5

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.
@@ -736,3 +736,178 @@ describe("schedule.result pass-through in notification decision engine", () => {
736
736
  expect(decision.shouldNotify).toBe(true);
737
737
  });
738
738
  });
739
+
740
+ const SCHEDULER_OWNED_REPORT = [
741
+ "# Daily briefing",
742
+ "",
743
+ "## Overnight",
744
+ "- Calendar is clear until 10:00.",
745
+ "- Two pull requests are waiting on review.",
746
+ "",
747
+ "## Account security",
748
+ "- Urgent: a sign-in from a new device needs confirmation before the weekly sync.",
749
+ "",
750
+ "## This week",
751
+ "- Project kickoff on Wednesday.",
752
+ "- Weekly planning on Friday.",
753
+ "- Follow up on the draft status update.",
754
+ ].join("\n");
755
+
756
+ function makeSchedulerShareSignal(
757
+ overrides?: Partial<NotificationSignal>,
758
+ ): NotificationSignal {
759
+ return {
760
+ signalId: "sig-scheduler-share-test-1",
761
+ createdAt: Date.now(),
762
+ sourceChannel: "scheduler",
763
+ sourceContextId: "conv-xyz",
764
+ sourceEventName: "assistant.share",
765
+ contextPayload: {
766
+ requestedMessage: SCHEDULER_OWNED_REPORT,
767
+ requestedBySource: "scheduler",
768
+ requestedTitle: "Your day",
769
+ },
770
+ attentionHints: {
771
+ requiresAction: true,
772
+ urgency: "high",
773
+ isAsyncBackground: true,
774
+ visibleInSourceNow: false,
775
+ },
776
+ ...overrides,
777
+ };
778
+ }
779
+
780
+ describe("scheduler requested-message pass-through in notification decision engine", () => {
781
+ beforeEach(() => {
782
+ persistedDecisions = [];
783
+ });
784
+
785
+ test("keeps a scheduler-owned report verbatim on every urgency-selected channel", async () => {
786
+ const available = [
787
+ "vellum",
788
+ "telegram",
789
+ "platform",
790
+ ] as NotificationChannel[];
791
+ const decision = await evaluateSignal(
792
+ makeSchedulerShareSignal(),
793
+ available,
794
+ );
795
+
796
+ expect(decision.shouldNotify).toBe(true);
797
+ expect(decision.selectedChannels).toEqual(available);
798
+ expect(decision.reasoningSummary).toBe(
799
+ "scheduler requested-message pass-through",
800
+ );
801
+ expect(decision.verbatimCopy).toBe(true);
802
+ expect(decision.fallbackUsed).toBe(false);
803
+ for (const ch of available) {
804
+ expect(decision.renderedCopy[ch]?.title).toBe("Your day");
805
+ expect(decision.renderedCopy[ch]?.body).toBe(SCHEDULER_OWNED_REPORT);
806
+ expect(decision.renderedCopy[ch]?.conversationSeedMessage).toBe(
807
+ SCHEDULER_OWNED_REPORT,
808
+ );
809
+ }
810
+ });
811
+
812
+ test("copies the complete report onto the urgency-narrowed channel set", async () => {
813
+ const available = [
814
+ "vellum",
815
+ "telegram",
816
+ "platform",
817
+ ] as NotificationChannel[];
818
+ const decision = await evaluateSignal(
819
+ makeSchedulerShareSignal({
820
+ attentionHints: {
821
+ requiresAction: false,
822
+ urgency: "medium",
823
+ isAsyncBackground: true,
824
+ visibleInSourceNow: false,
825
+ },
826
+ }),
827
+ available,
828
+ );
829
+
830
+ expect(decision.shouldNotify).toBe(true);
831
+ expect(decision.selectedChannels).toEqual(["vellum"]);
832
+ expect(decision.reasoningSummary).toBe(
833
+ "scheduler requested-message pass-through",
834
+ );
835
+ expect(decision.renderedCopy.vellum?.body).toBe(SCHEDULER_OWNED_REPORT);
836
+ expect(decision.renderedCopy.vellum?.conversationSeedMessage).toBe(
837
+ SCHEDULER_OWNED_REPORT,
838
+ );
839
+ expect(decision.renderedCopy.telegram?.body).toBe(SCHEDULER_OWNED_REPORT);
840
+ expect(decision.renderedCopy.platform?.body).toBe(SCHEDULER_OWNED_REPORT);
841
+ });
842
+
843
+ test("leaves an unowned scheduler requestedMessage on the model path", async () => {
844
+ const previousSendMessage = providerSendMessage;
845
+ let providerCalled = false;
846
+ providerSendMessage = async () => {
847
+ providerCalled = true;
848
+ return {};
849
+ };
850
+
851
+ try {
852
+ const decision = await evaluateSignal(
853
+ makeSchedulerShareSignal({
854
+ contextPayload: {
855
+ requestedMessage: SCHEDULER_OWNED_REPORT,
856
+ requestedTitle: "Your day",
857
+ },
858
+ }),
859
+ ["vellum", "telegram", "platform"] as NotificationChannel[],
860
+ );
861
+
862
+ expect(providerCalled).toBe(true);
863
+ expect(decision.verbatimCopy).toBeUndefined();
864
+ expect(decision.reasoningSummary).not.toBe(
865
+ "scheduler requested-message pass-through",
866
+ );
867
+ } finally {
868
+ providerSendMessage = previousSendMessage;
869
+ }
870
+ });
871
+
872
+ test("leaves scheduler notify-mode without the ownership marker on the model path", async () => {
873
+ const previousSendMessage = providerSendMessage;
874
+ let providerCalled = false;
875
+ providerSendMessage = async () => {
876
+ providerCalled = true;
877
+ return {};
878
+ };
879
+
880
+ try {
881
+ const decision = await evaluateSignal(
882
+ {
883
+ signalId: "sig-schedule-notify-test-1",
884
+ createdAt: Date.now(),
885
+ sourceChannel: "scheduler",
886
+ sourceContextId: "sched-notify-1",
887
+ sourceEventName: "schedule.notify",
888
+ contextPayload: {
889
+ scheduleId: "sched-notify-1",
890
+ label: "Take out the trash",
891
+ message: "Take out the trash",
892
+ },
893
+ attentionHints: {
894
+ requiresAction: true,
895
+ urgency: "high",
896
+ isAsyncBackground: false,
897
+ visibleInSourceNow: false,
898
+ },
899
+ },
900
+ ["vellum", "telegram", "platform"] as NotificationChannel[],
901
+ );
902
+
903
+ expect(providerCalled).toBe(true);
904
+ expect(decision.verbatimCopy).toBeUndefined();
905
+ expect(decision.reasoningSummary).not.toBe(
906
+ "scheduler requested-message pass-through",
907
+ );
908
+ expect(decision.reasoningSummary).not.toBe("schedule_result pass-through");
909
+ } finally {
910
+ providerSendMessage = previousSendMessage;
911
+ }
912
+ });
913
+ });
@@ -833,6 +833,19 @@ function buildPassThroughDecision(params: {
833
833
  return decision;
834
834
  }
835
835
 
836
+ function selectDefaultChannelsByUrgency(
837
+ urgency: NotificationSignal["attentionHints"]["urgency"],
838
+ availableChannels: NotificationChannel[],
839
+ ): NotificationChannel[] {
840
+ const isUrgent = urgency === "critical" || urgency === "high";
841
+ if (isUrgent) {
842
+ return [...availableChannels];
843
+ }
844
+ return availableChannels.includes("vellum")
845
+ ? ["vellum" as NotificationChannel]
846
+ : [];
847
+ }
848
+
836
849
  /**
837
850
  * The deterministic guards every decision passes through once the model,
838
851
  * the assistant-tool pass-through, or the fallback has rendered copy.
@@ -870,14 +883,10 @@ export async function evaluateSignal(
870
883
  );
871
884
  if (signal.sourceChannel === "assistant_tool" && requestedBody) {
872
885
  const payload = signal.contextPayload as Record<string, unknown>;
873
- const isUrgent =
874
- signal.attentionHints.urgency === "critical" ||
875
- signal.attentionHints.urgency === "high";
876
- const defaultChannels: NotificationChannel[] = isUrgent
877
- ? [...availableChannels]
878
- : availableChannels.includes("vellum")
879
- ? ["vellum" as NotificationChannel]
880
- : [];
886
+ const defaultChannels = selectDefaultChannelsByUrgency(
887
+ signal.attentionHints.urgency,
888
+ availableChannels,
889
+ );
881
890
  // Honor `--preferred-channels` as ADDITIVE push targets on top of
882
891
  // the default channel set. The notification center (vellum) is the
883
892
  // always-on canonical inbox; preferred channels add push surfaces
@@ -926,6 +935,32 @@ export async function evaluateSignal(
926
935
  });
927
936
  }
928
937
 
938
+ // Scheduler-owned requested copy: the scheduler already authored the
939
+ // complete message. Ownership requires both the signal source and the
940
+ // payload marker so schedule.result (requestedMessage, no
941
+ // requestedBySource) and notify-mode (message, no requestedBySource)
942
+ // stay on their existing paths. Urgency still chooses channels; every
943
+ // selected channel keeps the producer body.
944
+ const requestedBySource = nonEmpty(
945
+ readPayloadString(signal.contextPayload, "requestedBySource"),
946
+ );
947
+ if (
948
+ signal.sourceChannel === "scheduler" &&
949
+ requestedBySource === "scheduler" &&
950
+ requestedBody
951
+ ) {
952
+ return buildPassThroughDecision({
953
+ signal,
954
+ availableChannels,
955
+ selectedChannels: selectDefaultChannelsByUrgency(
956
+ signal.attentionHints.urgency,
957
+ availableChannels,
958
+ ),
959
+ body: requestedBody,
960
+ reasoningSummary: "scheduler requested-message pass-through",
961
+ });
962
+ }
963
+
929
964
  // Schedule-result pass-through: the body is the run's own reply, which is
930
965
  // the whole point of the notification — a briefing, a digest, a report. The
931
966
  // classifier rewrites bodies into short alerts, which would throw away the
@@ -115,6 +115,16 @@ For everything else in your review window, use the \`remember\` tool on facts, p
115
115
  expect(out).toContain(
116
116
  "\n---\n\nIf your review window contains a PROCEDURE you actually carried out",
117
117
  );
118
+ // A refinement overwrites the whole file and the pass has no other read
119
+ // path to the skill, so the instruction has to point at `current`.
120
+ expect(out).toContain(
121
+ "rewriting `body_markdown` from `current.body_markdown` plus what you actually observed in the trace",
122
+ );
123
+ // Hints are the retrieval signal; a refinement carries them forward
124
+ // rather than regenerating them from one trace.
125
+ expect(out).toContain(
126
+ "restate `current.activation_hints` (revised only if the procedure's triggers changed",
127
+ );
118
128
  // An UPDATE is announced by a notice whose only account of the change is
119
129
  // what the pass passes here, so the instruction has to ask for it.
120
130
  expect(out).toContain(
@@ -223,7 +223,7 @@ If your review window contains a PROCEDURE you actually carried out — a sequen
223
223
 
224
224
  When you do capture a procedure:
225
225
 
226
- 1. Deduplicate against existing skills first. Call \`find_similar_skills\` with the procedure's goal as its \`goal\` argument. Each hit carries a \`source\` (bundled, managed, plugin, workspace, or extra), and a managed hit also carries \`author\` (\`"assistant"\` if you authored it, \`"user"\` if a person did, omitted if untagged). You may only overwrite or refine a skill YOU authored: a hit with \`source: "managed"\` AND \`author: "assistant"\`. ANY other hit means the procedure is ALREADY COVERED: a non-managed source (bundled, plugin, workspace, or extra), OR a managed skill that is NOT \`author: "assistant"\` (a person wrote it, or it is untagged). For an ALREADY COVERED hit do not \`overwrite\` it, do not shadow it by creating a skill with its \`skill_id\`, and do not create a near-duplicate. Skip it. Only when a returned skill is one of your own (\`source: "managed"\`, \`author: "assistant"\`) and is the SAME procedure, UPDATE it: call \`scaffold_managed_skill\` with that \`skill_id\` and \`overwrite: true\`, rewriting \`body_markdown\` from what you actually observed in the trace, and pass \`change_summary\` (the update is rejected without it): one or two short sentences (under 200 characters) for the person who reads the "Skill updated" notice, naming what you changed and what in the trace prompted it (for example "Added the retry after an expired session and the export endpoint that held steady."). The notice shows nothing else about the change, so name the concrete step, value, or gotcha rather than saying the skill was refined. Only CREATE a new skill (fresh \`skill_id\`) when no existing skill of any source covers the procedure. Bias strongly toward reusing or refining your own skills over spawning near-duplicates.
226
+ 1. Deduplicate against existing skills first. Call \`find_similar_skills\` with the procedure's goal as its \`goal\` argument. Each hit carries a \`source\` (bundled, managed, plugin, workspace, or extra), and a managed hit also carries \`author\` (\`"assistant"\` if you authored it, \`"user"\` if a person did, omitted if untagged). You may only overwrite or refine a skill YOU authored: a hit with \`source: "managed"\` AND \`author: "assistant"\`. ANY other hit means the procedure is ALREADY COVERED: a non-managed source (bundled, plugin, workspace, or extra), OR a managed skill that is NOT \`author: "assistant"\` (a person wrote it, or it is untagged). For an ALREADY COVERED hit do not \`overwrite\` it, do not shadow it by creating a skill with its \`skill_id\`, and do not create a near-duplicate. Skip it. Only when a returned skill is one of your own (\`source: "managed"\`, \`author: "assistant"\`) and is the SAME procedure, UPDATE it. Such a hit carries \`current\`: the skill as it is now, in \`scaffold_managed_skill\`'s own argument names. Call \`scaffold_managed_skill\` with that \`skill_id\` and \`overwrite: true\`, rewriting \`body_markdown\` from \`current.body_markdown\` plus what you actually observed in the trace (keep the steps the trace did not contradict; correct or add the ones it did), restate \`current.activation_hints\` (revised only if the procedure's triggers changed; they are the skill's retrieval signal, not something to regenerate) and every other \`current\` field you are not changing (\`emoji\`, \`category\`, \`includes\`, \`avoid_when\`) since an overwrite replaces the whole file, and pass \`change_summary\` (the update is rejected without it): one or two short sentences (under 200 characters) for the person who reads the "Skill updated" notice, naming what you changed and what in the trace prompted it (for example "Added the retry after an expired session and the export endpoint that held steady."). The notice shows nothing else about the change, so name the concrete step, value, or gotcha rather than saying the skill was refined. Only CREATE a new skill (fresh \`skill_id\`) when no existing skill of any source covers the procedure. Bias strongly toward reusing or refining your own skills over spawning near-duplicates.
227
227
 
228
228
  2. Capture procedure-scoped knowledge alongside the body. Failure modes, gotchas, and cached values you observed in the trace (error signatures and how you recovered, preconditions, IDs/paths/endpoints that held steady) belong in companion files passed via \`scaffold_managed_skill\`'s \`files\` input (for example \`references/failure-modes.md\`), and the SKILL.md body should reference them so a future load surfaces them.
229
229
 
@@ -302,6 +302,25 @@ describe("GET /v1/acp/sessions — merged in-memory + history", () => {
302
302
  ]);
303
303
  });
304
304
 
305
+ test("does not expose snapshot-only history metadata", async () => {
306
+ insertHistoryRow({
307
+ id: "hist-private",
308
+ status: "completed",
309
+ cwd: "/tmp/private-project",
310
+ authErrorCredential: "credential-digest",
311
+ });
312
+
313
+ const handler = getSessionsHandler();
314
+ const body = (await handler({})) as ResponseShape;
315
+ const session = body.sessions.find((entry) => entry.id === "hist-private");
316
+
317
+ expect(session).toBeDefined();
318
+ expect(session).not.toHaveProperty("source");
319
+ expect(session).not.toHaveProperty("resumable");
320
+ expect(session).not.toHaveProperty("cwd");
321
+ expect(session).not.toHaveProperty("authErrorCredential");
322
+ });
323
+
305
324
  test("returns input/output tokens for live and history sessions", async () => {
306
325
  fakeInMemorySessions = [
307
326
  {
@@ -24,7 +24,13 @@ import {
24
24
  AcpResumeError,
25
25
  AcpSessionNotFoundError,
26
26
  } from "../../acp/session-manager.js";
27
- import { type AcpSessionState, isLiveAcpStatus } from "../../acp/types.js";
27
+ import {
28
+ type AcpSessionSnapshot,
29
+ listAcpSessionSnapshots,
30
+ snapshotHistoryRow,
31
+ withCurrentAuthMarkers,
32
+ } from "../../acp/session-snapshot.js";
33
+ import { isLiveAcpStatus } from "../../acp/types.js";
28
34
  import {
29
35
  AcpSessionModelUpdateEventSchema,
30
36
  type AssistantEvent,
@@ -103,11 +109,14 @@ type SessionEntry = z.infer<typeof sessionEntrySchema>;
103
109
  * client has no use for it and no way to resolve the other side of the
104
110
  * comparison, so serving it would only widen what leaves the daemon.
105
111
  */
106
- type MergedSession = SessionEntry & { authErrorCredential?: string };
112
+ type MergedSession = AcpSessionSnapshot;
107
113
 
108
- /** Drop the comparison's own input before the session goes out on the wire. */
114
+ /** Drop internal snapshot metadata before the session goes out on the wire. */
109
115
  function stripMarkerCredential({
110
- authErrorCredential: _dropped,
116
+ authErrorCredential: _credential,
117
+ source: _source,
118
+ resumable: _resumable,
119
+ cwd: _cwd,
111
120
  ...session
112
121
  }: MergedSession): SessionEntry {
113
122
  return session;
@@ -511,7 +520,7 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
511
520
  return resolvedByAgent.get(agentId);
512
521
  };
513
522
 
514
- const { sessions: merged, sawEveryHistoryRow } = listMergedSessions({
523
+ const { sessions: merged, sawEveryHistoryRow } = listAcpSessionSnapshots({
515
524
  limit,
516
525
  conversationId,
517
526
  });
@@ -552,43 +561,12 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
552
561
  return { sessions: [...page, stripMarkerCredential(marker)] };
553
562
  }
554
563
 
555
- /**
556
- * Blank the failure code on any session whose marker no longer describes the
557
- * credential its agent would resolve.
558
- *
559
- * This comparison is what retires a Connect card: not a sweep that has to run
560
- * at the right moment, but the marker no longer describing the credential in
561
- * use. Applied after merging rather than inside the query, so live sessions
562
- * and history rows are judged by exactly the same rule.
563
- *
564
- * Resolved per agent, because precedence is per agent: one alias can carry a
565
- * configured token while another falls through to the vault. Memoised across
566
- * the request, since a conversation's marked runs are nearly always one agent
567
- * and each resolution costs a vault read.
568
- */
569
564
  async function withCurrentMarkersOnly(
570
565
  sessions: MergedSession[],
571
566
  resolvedFor: (agentId: string) => Promise<string | undefined>,
572
567
  ): Promise<SessionEntry[]> {
573
- const strip = stripMarkerCredential;
574
- if (!sessions.some((s) => s.authErrorCode !== undefined)) {
575
- return sessions.map(strip);
576
- }
577
- const judged: SessionEntry[] = [];
578
- for (const session of sessions) {
579
- if (session.authErrorCode === undefined) {
580
- judged.push(strip(session));
581
- continue;
582
- }
583
- const current = acpAuthMarkerStillCurrent(
584
- session.authErrorCredential,
585
- await resolvedFor(session.agentId),
586
- );
587
- judged.push(
588
- strip(current ? session : { ...session, authErrorCode: undefined }),
589
- );
590
- }
591
- return judged;
568
+ const judged = await withCurrentAuthMarkers(sessions, resolvedFor);
569
+ return judged.map(stripMarkerCredential);
592
570
  }
593
571
 
594
572
  function bulkDeleteSessions({ queryParams }: RouteHandlerArgs) {
@@ -879,128 +857,6 @@ function parseLimit(raw: string | null | undefined): number {
879
857
  return Math.min(Math.floor(n), MAX_SESSION_LIMIT);
880
858
  }
881
859
 
882
- function listMergedSessions(opts: { limit: number; conversationId?: string }): {
883
- sessions: MergedSession[];
884
- /**
885
- * Whether the history query reached the end of this conversation's rows.
886
- *
887
- * A short read means there is nothing beyond what was returned, which is the
888
- * proof that no marker is hiding outside the page.
889
- */
890
- sawEveryHistoryRow: boolean;
891
- } {
892
- const manager = getAcpSessionManager();
893
- const inMemory = manager.getStatus() as AcpSessionState[];
894
-
895
- const merged = new Map<string, MergedSession>();
896
- for (const s of inMemory) {
897
- if (opts.conversationId && s.parentConversationId !== opts.conversationId) {
898
- continue;
899
- }
900
- merged.set(s.id, {
901
- id: s.id,
902
- agentId: s.agentId,
903
- acpSessionId: s.acpSessionId,
904
- parentConversationId: s.parentConversationId,
905
- status: s.status,
906
- startedAt: s.startedAt,
907
- completedAt: s.completedAt ?? null,
908
- error: s.error ?? null,
909
- stopReason: s.stopReason ?? null,
910
- task: s.task,
911
- parentToolUseId: s.parentToolUseId,
912
- authErrorCode: s.authErrorCode,
913
- authErrorCredential: s.authErrorCredential,
914
- model: s.model,
915
- availableModels: s.availableModels,
916
- modelRevisionEpoch: s.modelRevisionEpoch,
917
- modelRevision: s.modelRevision,
918
- usedTokens: s.latestUsage?.usedTokens,
919
- contextSize: s.latestUsage?.contextSize,
920
- costAmount: s.latestUsage?.costAmount,
921
- costCurrency: s.latestUsage?.costCurrency,
922
- inputTokens: s.latestUsage?.inputTokens,
923
- outputTokens: s.latestUsage?.outputTokens,
924
- eventLog: manager.getBufferedUpdates(s.id),
925
- });
926
- }
927
-
928
- const db = getDb();
929
- const baseQuery = db.select().from(acpSessionHistory);
930
- const filtered = opts.conversationId
931
- ? baseQuery.where(
932
- eq(acpSessionHistory.parentConversationId, opts.conversationId),
933
- )
934
- : baseQuery;
935
- // Fetch only enough rows to fill the requested page after merging with
936
- // in-memory sessions. In-memory entries take precedence on id collision,
937
- // so we pad by the count that survived the conversation filter to
938
- // guarantee we still surface `limit` distinct rows even when every
939
- // in-memory session shadows a DB row — without over-fetching when many
940
- // unrelated sessions are in memory.
941
- const historyLimit = opts.limit + merged.size;
942
- const historyRows = filtered
943
- .orderBy(desc(acpSessionHistory.startedAt))
944
- .limit(historyLimit)
945
- .all();
946
-
947
- for (const row of historyRows) {
948
- if (merged.has(row.id)) {
949
- continue;
950
- }
951
- merged.set(row.id, toMergedSession(row));
952
- }
953
-
954
- return {
955
- sessions: Array.from(merged.values()).sort(
956
- (a, b) => b.startedAt - a.startedAt,
957
- ),
958
- sawEveryHistoryRow: historyRows.length < historyLimit,
959
- };
960
- }
961
-
962
- /** Shape a history row for the response, parsing its stored event log. */
963
- function toMergedSession(
964
- row: typeof acpSessionHistory.$inferSelect,
965
- ): MergedSession {
966
- let eventLog: unknown[] = [];
967
- try {
968
- const parsed = JSON.parse(row.eventLogJson) as unknown;
969
- if (Array.isArray(parsed)) {
970
- eventLog = parsed;
971
- }
972
- } catch (err) {
973
- log.warn(
974
- { id: row.id, err },
975
- "Failed to parse event_log_json for ACP session history row",
976
- );
977
- }
978
- // Rows predating the usage migration carry NULLs for these columns and
979
- // degrade to undefined.
980
- return {
981
- id: row.id,
982
- agentId: row.agentId,
983
- acpSessionId: row.acpSessionId,
984
- parentConversationId: row.parentConversationId,
985
- status: row.status,
986
- startedAt: row.startedAt,
987
- completedAt: row.completedAt,
988
- error: row.error,
989
- stopReason: row.stopReason,
990
- task: row.task ?? undefined,
991
- parentToolUseId: row.parentToolUseId ?? undefined,
992
- authErrorCode: row.authErrorCode ?? undefined,
993
- authErrorCredential: row.authErrorCredential ?? undefined,
994
- usedTokens: row.usedTokens ?? undefined,
995
- contextSize: row.contextSize ?? undefined,
996
- costAmount: row.costAmount ?? undefined,
997
- costCurrency: row.costCurrency ?? undefined,
998
- inputTokens: row.inputTokens ?? undefined,
999
- outputTokens: row.outputTokens ?? undefined,
1000
- eventLog,
1001
- };
1002
- }
1003
-
1004
860
  /**
1005
861
  * Reach past the page for the one marked run a client would restore the card
1006
862
  * from, when the page itself holds none.
@@ -1061,7 +917,7 @@ async function findRecoveryMarker(
1061
917
  .from(acpSessionHistory)
1062
918
  .where(eq(acpSessionHistory.id, marker.id))
1063
919
  .get();
1064
- return row ? toMergedSession(row) : undefined;
920
+ return row ? snapshotHistoryRow(row) : undefined;
1065
921
  }
1066
922
  }
1067
923
  return undefined;
@@ -14,10 +14,12 @@ import { dirname, isAbsolute, join, normalize, relative, sep } from "node:path";
14
14
 
15
15
  import { stringify as stringifyYaml } from "yaml";
16
16
 
17
+ import { parseFrontmatter } from "../config/skills.js";
17
18
  import { deleteSkillCapabilityNode } from "../plugins/defaults/memory/graph/capability-seed.js";
18
19
  import { isDeniedBasename } from "../tools/shared/filesystem/path-policy.js";
19
20
  import { getLogger } from "../util/logger.js";
20
21
  import { getWorkspaceDir, getWorkspaceSkillsDir } from "../util/platform.js";
22
+ import { parseFrontmatterFields } from "./frontmatter.js";
21
23
  import { writeInstallMeta } from "./install-meta.js";
22
24
 
23
25
  const log = getLogger("managed-store");
@@ -450,6 +452,60 @@ export function createManagedSkill(
450
452
  return { created: true, path: skillFilePath };
451
453
  }
452
454
 
455
+ /**
456
+ * A managed skill as it is on disk. Frontmatter fields come through the
457
+ * catalog's parser so they are exactly what routing and the Skills UI see;
458
+ * `body` is the stored text after the frontmatter, verbatim except for the
459
+ * separator newline the store writes before it and the trailing newline it
460
+ * guarantees. Verbatim matters: the skill loader substitutes `{baseDir}` and
461
+ * `{workspaceDir}` and strips feature-gated sections, and a caller that wrote
462
+ * that output back would bake absolute paths into the skill; and a first line
463
+ * that opens an indented code block must keep its indentation or a copy turns
464
+ * it into prose.
465
+ */
466
+ export interface StoredManagedSkill {
467
+ name: string;
468
+ description: string;
469
+ emoji?: string;
470
+ includes?: string[];
471
+ activationHints?: string[];
472
+ avoidWhen?: string[];
473
+ category?: string;
474
+ body: string;
475
+ }
476
+
477
+ /**
478
+ * Read a managed skill from disk. Best-effort: a missing file or frontmatter
479
+ * that does not parse resolves to null, so a caller enriching or patching one
480
+ * skill never fails on a bad one.
481
+ */
482
+ export function readStoredManagedSkill(
483
+ skillId: string,
484
+ ): StoredManagedSkill | null {
485
+ const skillFilePath = join(getManagedSkillDir(skillId), "SKILL.md");
486
+ try {
487
+ const content = readFileSync(skillFilePath, "utf-8");
488
+ const parsed = parseFrontmatter(content, skillFilePath);
489
+ const raw = parseFrontmatterFields(content);
490
+ if (!parsed || !raw) {
491
+ return null;
492
+ }
493
+ return {
494
+ name: parsed.name,
495
+ description: parsed.description,
496
+ emoji: parsed.emoji,
497
+ includes: parsed.includes,
498
+ activationHints: parsed.activationHints,
499
+ avoidWhen: parsed.avoidWhen,
500
+ category: parsed.category,
501
+ body: raw.body.replace(/^(?:\r?\n)+/, "").replace(/(?:\r?\n)+$/, ""),
502
+ };
503
+ } catch (err) {
504
+ log.warn({ err, skillFilePath }, "Could not read managed skill");
505
+ return null;
506
+ }
507
+ }
508
+
453
509
  interface DeleteManagedSkillResult {
454
510
  deleted: boolean;
455
511
  error?: string;