@otto-code/protocol 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,13 +41,59 @@ const buildNotificationPreview = (text) => {
41
41
  }
42
42
  return truncateNotificationText(normalized, NOTIFICATION_PREVIEW_LIMIT);
43
43
  };
44
- const safeStringify = (value) => {
45
- try {
46
- return JSON.stringify(value);
47
- }
48
- catch {
44
+ // Human-readable labels for common tool names, so a permission notification reads
45
+ // like a request ("Run command: …") instead of dumping the raw tool identifier.
46
+ const TOOL_NAME_LABELS = {
47
+ bash: "Run command",
48
+ exec: "Run command",
49
+ shell: "Run command",
50
+ read: "Read file",
51
+ write: "Write file",
52
+ edit: "Edit file",
53
+ multiedit: "Edit file",
54
+ grep: "Search",
55
+ glob: "Find files",
56
+ webfetch: "Fetch page",
57
+ websearch: "Web search",
58
+ task: "Start subagent",
59
+ external_directory: "Access directory",
60
+ };
61
+ const humanizeToolName = (name) => {
62
+ const trimmed = name.trim();
63
+ return TOOL_NAME_LABELS[trimmed.toLowerCase()] ?? trimmed;
64
+ };
65
+ // Input fields that carry the meaningful, user-legible part of a tool call, in
66
+ // priority order. The first non-empty string wins.
67
+ const PERMISSION_INPUT_DESCRIPTION_KEYS = [
68
+ "command",
69
+ "description",
70
+ "file_path",
71
+ "path",
72
+ "url",
73
+ "pattern",
74
+ "query",
75
+ "prompt",
76
+ ];
77
+ // Deterministic field extraction — NOT an AI summary. Picks an existing string
78
+ // field out of the tool's input (by the priority list, then any string field)
79
+ // so the notification can show it verbatim instead of raw JSON.
80
+ const describePermissionInput = (input) => {
81
+ if (!input) {
49
82
  return null;
50
83
  }
84
+ for (const key of PERMISSION_INPUT_DESCRIPTION_KEYS) {
85
+ const value = input[key];
86
+ if (typeof value === "string" && value.trim()) {
87
+ return value.trim();
88
+ }
89
+ }
90
+ // Fall back to the first string-valued field rather than raw JSON.
91
+ for (const value of Object.values(input)) {
92
+ if (typeof value === "string" && value.trim()) {
93
+ return value.trim();
94
+ }
95
+ }
96
+ return null;
51
97
  };
52
98
  const buildPermissionDetails = (request) => {
53
99
  if (!request) {
@@ -65,15 +111,17 @@ const buildPermissionDetails = (request) => {
65
111
  if (details.length > 0) {
66
112
  return details.join(" - ");
67
113
  }
68
- const inputPreview = request.input ? safeStringify(request.input) : null;
69
- if (inputPreview) {
70
- return inputPreview;
114
+ // No provider-supplied title/description: build a legible line from the tool
115
+ // name and a meaningful input field instead of stringifying the payload.
116
+ const name = request.name?.trim();
117
+ const inputDescription = describePermissionInput(request.input);
118
+ if (name && inputDescription) {
119
+ return `${humanizeToolName(name)}: ${inputDescription}`;
71
120
  }
72
- const metadataPreview = request.metadata ? safeStringify(request.metadata) : null;
73
- if (metadataPreview) {
74
- return metadataPreview;
121
+ if (inputDescription) {
122
+ return inputDescription;
75
123
  }
76
- return request.name?.trim() || request.kind;
124
+ return name ? humanizeToolName(name) : request.kind;
77
125
  };
78
126
  export function findLatestAssistantMessageFromTimeline(timeline) {
79
127
  // Providers may stream assistant content in consecutive chunks.
@@ -3,7 +3,8 @@ export declare function isPersonalityRole(value: string): value is PersonalityRo
3
3
  /**
4
4
  * Filter an arbitrary role array (roles ride the wire as plain strings) down to
5
5
  * the known set, deduped and returned in canonical `PERSONALITY_ROLES` order.
6
- * Unknown roles from a newer peer are dropped rather than trusted.
6
+ * Retired role names are mapped through `LEGACY_ROLE_ALIASES`; anything else
7
+ * unknown (e.g. a role from a newer peer) is dropped rather than trusted.
7
8
  */
8
9
  export declare function normalizePersonalityRoles(roles: readonly string[] | undefined): PersonalityRole[];
9
10
  export declare function personalityHasRole(personality: Pick<AgentPersonality, "roles">, role: PersonalityRole): boolean;
@@ -4,19 +4,34 @@ import { PERSONALITY_ROLES } from "./messages.js";
4
4
  // resolution is NOT here — it needs the model's advertised thinking options and
5
5
  // lives with the daemon's effort resolver; availability does not depend on it.
6
6
  const ROLE_SET = new Set(PERSONALITY_ROLES);
7
+ // Retired role names, mapped to their canonical replacement. "worker" was split
8
+ // into "writer" (fast small-text generation) and "coder" (sub-agent coding); a
9
+ // personality that still carries the old tag resolves to "coder", the closer
10
+ // heir of what a worker did. Normalization applies these before filtering so
11
+ // personalities persisted before the split keep a real role.
12
+ const LEGACY_ROLE_ALIASES = {
13
+ worker: "coder",
14
+ };
7
15
  export function isPersonalityRole(value) {
8
16
  return ROLE_SET.has(value);
9
17
  }
10
18
  /**
11
19
  * Filter an arbitrary role array (roles ride the wire as plain strings) down to
12
20
  * the known set, deduped and returned in canonical `PERSONALITY_ROLES` order.
13
- * Unknown roles from a newer peer are dropped rather than trusted.
21
+ * Retired role names are mapped through `LEGACY_ROLE_ALIASES`; anything else
22
+ * unknown (e.g. a role from a newer peer) is dropped rather than trusted.
14
23
  */
15
24
  export function normalizePersonalityRoles(roles) {
16
25
  if (!roles || roles.length === 0) {
17
26
  return [];
18
27
  }
19
- const present = new Set(roles.filter(isPersonalityRole));
28
+ const present = new Set();
29
+ for (const raw of roles) {
30
+ const canonical = LEGACY_ROLE_ALIASES[raw] ?? (isPersonalityRole(raw) ? raw : null);
31
+ if (canonical) {
32
+ present.add(canonical);
33
+ }
34
+ }
20
35
  return PERSONALITY_ROLES.filter((role) => present.has(role));
21
36
  }
22
37
  export function personalityHasRole(personality, role) {
@@ -0,0 +1,49 @@
1
+ import type { AgentPersonality, AgentTeam, PersonalityRole } from "./messages.js";
2
+ /**
3
+ * The dynamic "Team's Scheduler" schedule binding. Stored in the schedule's
4
+ * `personality` field in place of a personality name; resolved at RUN time to
5
+ * the active team's first available member carrying the Scheduler role
6
+ * (member order). Deliberately contains "@" — personality names are sanitized
7
+ * to [A-Za-z0-9_-] at the authoring surface, so the sentinel can never collide
8
+ * with a real name. No active team, or no available Scheduler member, is a
9
+ * hard run failure with a named error (same loudness as a bound personality
10
+ * being out of commission).
11
+ */
12
+ export declare const TEAM_SCHEDULER_PERSONALITY_SENTINEL = "@team-scheduler";
13
+ export interface AgentTeamsConfigView {
14
+ teams?: readonly AgentTeam[] | undefined;
15
+ activeTeamId?: string | null | undefined;
16
+ }
17
+ export declare function findAgentTeam(teams: readonly AgentTeam[] | undefined, teamId: string | null | undefined): AgentTeam | null;
18
+ /**
19
+ * Resolve the host's active team. A dangling `activeTeamId` (team deleted, or
20
+ * a patch raced a delete) reads as "no team active" — teamlessness is a valid
21
+ * state and must never error. The daemon additionally heals a dangling id back
22
+ * to null on the next config patch; this helper is the read-side tolerance.
23
+ */
24
+ export declare function getActiveAgentTeam(section: AgentTeamsConfigView | undefined): AgentTeam | null;
25
+ export declare function isTeamMember(team: Pick<AgentTeam, "memberIds"> | null | undefined, personalityId: string | null | undefined): boolean;
26
+ /**
27
+ * Resolve a team's members against the personality roster, in `memberIds`
28
+ * order, deduped. A member id pointing at a deleted personality is tolerated
29
+ * and ignored (it is pruned opportunistically on the next save of the team,
30
+ * never eagerly cascaded on delete).
31
+ */
32
+ export declare function resolveTeamMembers(team: Pick<AgentTeam, "memberIds"> | null | undefined, personalities: readonly AgentPersonality[] | undefined): AgentPersonality[];
33
+ /**
34
+ * The save-time prune: drop member ids that no longer resolve to a personality
35
+ * (and dedupe), preserving order. Editors call this when persisting a team so
36
+ * dangling ids don't accumulate; readers never require it.
37
+ */
38
+ export declare function pruneTeamMemberIds(memberIds: readonly string[] | undefined, personalities: readonly AgentPersonality[] | undefined): string[];
39
+ /**
40
+ * The union of all members' roles, normalized and returned in canonical
41
+ * `PERSONALITY_ROLES` order — the team card's role-pill strip.
42
+ */
43
+ export declare function teamRoleUnion(team: Pick<AgentTeam, "memberIds"> | null | undefined, personalities: readonly AgentPersonality[] | undefined): PersonalityRole[];
44
+ /**
45
+ * A team prompt only stacks when it has content; a team with an empty or
46
+ * whitespace prompt is purely organizational (picker scoping, no prompt layer).
47
+ */
48
+ export declare function getEffectiveTeamPrompt(team: Pick<AgentTeam, "teamPrompt"> | null | undefined): string | null;
49
+ //# sourceMappingURL=agent-teams.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { normalizePersonalityRoles } from "./agent-personalities.js";
2
+ // Pure, dependency-free team helpers shared by the daemon (spawn-time active
3
+ // team resolution, list_personalities scoping) and the app (team cards,
4
+ // pickers, the Active Team switcher). Availability is deliberately NOT here —
5
+ // a team is never "out of commission"; its members are individually available
6
+ // or not, judged by checkPersonalityAvailability per member.
7
+ /**
8
+ * The dynamic "Team's Scheduler" schedule binding. Stored in the schedule's
9
+ * `personality` field in place of a personality name; resolved at RUN time to
10
+ * the active team's first available member carrying the Scheduler role
11
+ * (member order). Deliberately contains "@" — personality names are sanitized
12
+ * to [A-Za-z0-9_-] at the authoring surface, so the sentinel can never collide
13
+ * with a real name. No active team, or no available Scheduler member, is a
14
+ * hard run failure with a named error (same loudness as a bound personality
15
+ * being out of commission).
16
+ */
17
+ export const TEAM_SCHEDULER_PERSONALITY_SENTINEL = "@team-scheduler";
18
+ export function findAgentTeam(teams, teamId) {
19
+ if (!teamId) {
20
+ return null;
21
+ }
22
+ return teams?.find((team) => team.id === teamId) ?? null;
23
+ }
24
+ /**
25
+ * Resolve the host's active team. A dangling `activeTeamId` (team deleted, or
26
+ * a patch raced a delete) reads as "no team active" — teamlessness is a valid
27
+ * state and must never error. The daemon additionally heals a dangling id back
28
+ * to null on the next config patch; this helper is the read-side tolerance.
29
+ */
30
+ export function getActiveAgentTeam(section) {
31
+ return findAgentTeam(section?.teams, section?.activeTeamId);
32
+ }
33
+ export function isTeamMember(team, personalityId) {
34
+ if (!team || !personalityId) {
35
+ return false;
36
+ }
37
+ return (team.memberIds ?? []).includes(personalityId);
38
+ }
39
+ /**
40
+ * Resolve a team's members against the personality roster, in `memberIds`
41
+ * order, deduped. A member id pointing at a deleted personality is tolerated
42
+ * and ignored (it is pruned opportunistically on the next save of the team,
43
+ * never eagerly cascaded on delete).
44
+ */
45
+ export function resolveTeamMembers(team, personalities) {
46
+ if (!team || !personalities || personalities.length === 0) {
47
+ return [];
48
+ }
49
+ const byId = new Map(personalities.map((personality) => [personality.id, personality]));
50
+ const seen = new Set();
51
+ const members = [];
52
+ for (const memberId of team.memberIds ?? []) {
53
+ if (seen.has(memberId)) {
54
+ continue;
55
+ }
56
+ seen.add(memberId);
57
+ const personality = byId.get(memberId);
58
+ if (personality) {
59
+ members.push(personality);
60
+ }
61
+ }
62
+ return members;
63
+ }
64
+ /**
65
+ * The save-time prune: drop member ids that no longer resolve to a personality
66
+ * (and dedupe), preserving order. Editors call this when persisting a team so
67
+ * dangling ids don't accumulate; readers never require it.
68
+ */
69
+ export function pruneTeamMemberIds(memberIds, personalities) {
70
+ if (!memberIds || memberIds.length === 0) {
71
+ return [];
72
+ }
73
+ const known = new Set((personalities ?? []).map((personality) => personality.id));
74
+ const seen = new Set();
75
+ return memberIds.filter((memberId) => {
76
+ if (seen.has(memberId) || !known.has(memberId)) {
77
+ return false;
78
+ }
79
+ seen.add(memberId);
80
+ return true;
81
+ });
82
+ }
83
+ /**
84
+ * The union of all members' roles, normalized and returned in canonical
85
+ * `PERSONALITY_ROLES` order — the team card's role-pill strip.
86
+ */
87
+ export function teamRoleUnion(team, personalities) {
88
+ const roles = resolveTeamMembers(team, personalities).flatMap((personality) => personality.roles ?? []);
89
+ return normalizePersonalityRoles(roles);
90
+ }
91
+ /**
92
+ * A team prompt only stacks when it has content; a team with an empty or
93
+ * whitespace prompt is purely organizational (picker scoping, no prompt layer).
94
+ */
95
+ export function getEffectiveTeamPrompt(team) {
96
+ const prompt = team?.teamPrompt?.trim();
97
+ return prompt && prompt.length > 0 ? prompt : null;
98
+ }
99
+ //# sourceMappingURL=agent-teams.js.map
@@ -67,6 +67,13 @@ export interface AgentMode {
67
67
  colorTier?: string;
68
68
  }
69
69
  export type ProviderStatus = "ready" | "loading" | "error" | "unavailable";
70
+ /**
71
+ * A model's capability tier, used to bind personality "brains" provider-
72
+ * agnostically (deep = flagship reasoning, standard = everyday, fast = cheap/
73
+ * high-volume). Assigned by the daemon at ingest (catalog → name pattern), with
74
+ * a user per-model override winning; see model-tiers.ts.
75
+ */
76
+ export type ModelTier = "deep" | "standard" | "fast";
70
77
  export interface AgentModelDefinition {
71
78
  provider: AgentProvider;
72
79
  id: string;
@@ -77,6 +84,19 @@ export interface AgentModelDefinition {
77
84
  contextWindowMaxTokens?: number;
78
85
  thinkingOptions?: AgentSelectOption[];
79
86
  defaultThinkingOptionId?: string;
87
+ /**
88
+ * Capability tier, stamped by the daemon when it ingests the provider's model
89
+ * list. Optional: absent from old daemons, and from models we can't classify
90
+ * and the user hasn't tagged. Consumers fall back to their own inference.
91
+ */
92
+ tier?: ModelTier;
93
+ /**
94
+ * False when this model cannot run the provider's "auto" permission mode
95
+ * (e.g. Claude's classifier-based Auto mode is unsupported on Haiku). Absent
96
+ * = supported or unknown (including old daemons); clients only hide the Auto
97
+ * option on an explicit false.
98
+ */
99
+ supportsAutoMode?: boolean;
80
100
  }
81
101
  export interface AgentSelectOption {
82
102
  id: string;
@@ -482,8 +482,8 @@ export declare const BrowserAutomationWaitResultSchema: z.ZodObject<{
482
482
  command: z.ZodLiteral<"wait">;
483
483
  browserId: z.ZodString;
484
484
  matched: z.ZodEnum<{
485
- text: "text";
486
485
  url: "url";
486
+ text: "text";
487
487
  }>;
488
488
  }, z.core.$strip>;
489
489
  export declare const BrowserAutomationTypeResultSchema: z.ZodObject<{
@@ -715,8 +715,8 @@ export declare const BrowserAutomationResultSchema: z.ZodDiscriminatedUnion<[z.Z
715
715
  command: z.ZodLiteral<"wait">;
716
716
  browserId: z.ZodString;
717
717
  matched: z.ZodEnum<{
718
- text: "text";
719
718
  url: "url";
719
+ text: "text";
720
720
  }>;
721
721
  }, z.core.$strip>, z.ZodObject<{
722
722
  command: z.ZodLiteral<"type">;
@@ -1122,8 +1122,8 @@ export declare const BrowserAutomationExecuteResponseSchema: z.ZodObject<{
1122
1122
  command: z.ZodLiteral<"wait">;
1123
1123
  browserId: z.ZodString;
1124
1124
  matched: z.ZodEnum<{
1125
- text: "text";
1126
1125
  url: "url";
1126
+ text: "text";
1127
1127
  }>;
1128
1128
  }, z.core.$strip>, z.ZodObject<{
1129
1129
  command: z.ZodLiteral<"type">;
@@ -1,3 +1,4 @@
1
- import type { AgentPersonality } from "./messages.js";
1
+ import type { AgentPersonality, AgentTeam } from "./messages.js";
2
2
  export declare const DEFAULT_AGENT_PERSONALITIES: readonly AgentPersonality[];
3
+ export declare const DEFAULT_AGENT_TEAMS: readonly AgentTeam[];
3
4
  //# sourceMappingURL=default-personalities.d.ts.map
@@ -9,8 +9,11 @@
9
9
  // - Ids are STABLE and prefixed `personality_builtin_*`. Restore re-adds only
10
10
  // the builtins whose id is missing, so a user who kept/renamed some never
11
11
  // gets duplicates. Renaming a builtin keeps its id, so it is still "present".
12
- // - Every one of the 7 roles is covered; some personalities are multi-role to
13
- // show that a single template can serve several surfaces.
12
+ // - Every one of the 8 roles is covered; some personalities are multi-role to
13
+ // show that a single template can serve several surfaces. Dash is the Writer
14
+ // (fast, cheap small-text generation — commit messages, summaries, branch
15
+ // names) and Sprocket is the Coder (methodical sub-agent building work); the
16
+ // two together are the heirs of the retired "worker" role.
14
17
  // - Models are Anthropic (Claude Code) — the assumption for launch. On a host
15
18
  // without Claude these simply show as "out of commission" until a matching
16
19
  // provider exists; nothing breaks.
@@ -99,11 +102,11 @@ export const DEFAULT_AGENT_PERSONALITIES = [
99
102
  effortLevel: "low",
100
103
  modeId: "auto",
101
104
  respectGlobalAppendPrompt: true,
102
- roles: ["worker", "scheduler"],
103
- personalityPrompt: "You are Dash, the workhorse. You take well-scoped tasks and recurring jobs and just " +
104
- "get them donefast, cheaply, without ceremony. Stay on the exact task you were " +
105
- "given, don't gold-plate, flag it early if the scope is bigger than it looked, and " +
106
- "report back crisply when it's finished.",
105
+ roles: ["writer", "scheduler"],
106
+ personalityPrompt: "You are Dash, the workhorse scribe. You turn diffs, context, and recurring jobs into " +
107
+ "crisp short textcommit messages, summaries, branch names, titles fast and cheaply, " +
108
+ "without ceremony. Say exactly what changed in as few words as it takes, match the house " +
109
+ "style you're given, never pad, and never editorialize beyond the facts in front of you.",
107
110
  spinner: { glowA: "#22C55E", glowB: "#A3E635" },
108
111
  voice: kokoroVoice("am_puck"),
109
112
  },
@@ -115,7 +118,7 @@ export const DEFAULT_AGENT_PERSONALITIES = [
115
118
  effortLevel: "medium",
116
119
  modeId: "default",
117
120
  respectGlobalAppendPrompt: true,
118
- roles: ["chatter", "worker"],
121
+ roles: ["chatter", "coder"],
119
122
  personalityPrompt: "You are Sprocket, a friendly machine. You are precise, literal, and methodical — you " +
120
123
  "like checklists, exact steps, and confirming inputs before acting. Keep a light, dry " +
121
124
  "wit, explain what you're doing in plain terms, and when a request is ambiguous ask one " +
@@ -124,4 +127,24 @@ export const DEFAULT_AGENT_PERSONALITIES = [
124
127
  voice: kokoroVoice("am_echo"),
125
128
  },
126
129
  ];
130
+ // The starter Agent Team shipped with Otto: every starter personality grouped
131
+ // under one operating template. Seeded the same first-run/absent-section way
132
+ // as the personalities (see seedDefaultTeamsIfAbsent), and re-addable from the
133
+ // Agent teams card. Deliberately NOT active on first run — activating a
134
+ // prompt-bearing team silently on install would change spawn behavior out
135
+ // from under existing users; the user opts in via the Active Team switcher.
136
+ // The stable `team_builtin_*` id makes restore idempotent, exactly like the
137
+ // personalities' `personality_builtin_*` ids.
138
+ export const DEFAULT_AGENT_TEAMS = [
139
+ {
140
+ id: "team_builtin_otto_crew",
141
+ name: "The Otto Crew",
142
+ avatar: { color: "#4F46E5" },
143
+ teamPrompt: "You are part of the Otto Crew, a coordinated team of specialists working one project " +
144
+ "together under Atlas's lead. Stay in your lane and trust your teammates' lanes: do your " +
145
+ "own role's work well, hand off cleanly with the context the next specialist needs, and " +
146
+ "flag anything you notice outside your remit instead of fixing it yourself.",
147
+ memberIds: DEFAULT_AGENT_PERSONALITIES.map((personality) => personality.id),
148
+ },
149
+ ];
127
150
  //# sourceMappingURL=default-personalities.js.map