@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.
- package/package.json +1 -1
- package/src/acp/session-snapshot.ts +260 -0
- package/src/config/bundled-skills/acp/SKILL.md +1 -1
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/skill-management/TOOLS.json +1 -1
- package/src/live-voice/__tests__/live-voice-look-follow-up.test.ts +374 -0
- package/src/live-voice/__tests__/live-voice-vad.test.ts +34 -0
- package/src/live-voice/__tests__/protocol.test.ts +48 -0
- package/src/live-voice/__tests__/session-controls.test.ts +25 -0
- package/src/live-voice/live-voice-session.ts +210 -12
- package/src/live-voice/protocol.ts +20 -0
- package/src/live-voice/session-controls.ts +55 -1
- package/src/notifications/__tests__/decision-engine.test.ts +175 -0
- package/src/notifications/decision-engine.ts +43 -8
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-prompt.test.ts +10 -0
- package/src/plugins/defaults/memory/memory-retrospective-prompt.ts +1 -1
- package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
- package/src/runtime/routes/acp-routes.ts +17 -161
- package/src/skills/managed-store.ts +56 -0
- package/src/tools/acp/status.test.ts +276 -21
- package/src/tools/acp/status.ts +98 -32
- package/src/tools/skills/find-similar-skills.test.ts +211 -2
- package/src/tools/skills/find-similar-skills.ts +62 -9
|
@@ -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
|
|
874
|
-
signal.attentionHints.urgency
|
|
875
|
-
|
|
876
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
112
|
+
type MergedSession = AcpSessionSnapshot;
|
|
107
113
|
|
|
108
|
-
/** Drop
|
|
114
|
+
/** Drop internal snapshot metadata before the session goes out on the wire. */
|
|
109
115
|
function stripMarkerCredential({
|
|
110
|
-
authErrorCredential:
|
|
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 } =
|
|
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
|
|
574
|
-
|
|
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 ?
|
|
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;
|