@agimon-ai/doompi-prompt 0.0.1-alpha.0

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 (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +118 -0
  3. package/dist/_virtual/_rolldown/runtime.cjs +1 -0
  4. package/dist/adapters/hubApi.cjs +2 -0
  5. package/dist/adapters/hubApi.cjs.map +1 -0
  6. package/dist/adapters/hubApi.d.cts +13 -0
  7. package/dist/adapters/hubApi.d.cts.map +1 -0
  8. package/dist/adapters/hubApi.d.mts +13 -0
  9. package/dist/adapters/hubApi.d.mts.map +1 -0
  10. package/dist/adapters/hubApi.mjs +2 -0
  11. package/dist/adapters/hubApi.mjs.map +1 -0
  12. package/dist/adapters/node/promptStore.cjs +2 -0
  13. package/dist/adapters/node/promptStore.cjs.map +1 -0
  14. package/dist/adapters/node/promptStore.d.cts +12 -0
  15. package/dist/adapters/node/promptStore.d.cts.map +1 -0
  16. package/dist/adapters/node/promptStore.d.mts +12 -0
  17. package/dist/adapters/node/promptStore.d.mts.map +1 -0
  18. package/dist/adapters/node/promptStore.mjs +2 -0
  19. package/dist/adapters/node/promptStore.mjs.map +1 -0
  20. package/dist/adapters/pi/extension.cjs +2 -0
  21. package/dist/adapters/pi/extension.cjs.map +1 -0
  22. package/dist/adapters/pi/extension.d.cts +19 -0
  23. package/dist/adapters/pi/extension.d.cts.map +1 -0
  24. package/dist/adapters/pi/extension.d.mts +19 -0
  25. package/dist/adapters/pi/extension.d.mts.map +1 -0
  26. package/dist/adapters/pi/extension.mjs +2 -0
  27. package/dist/adapters/pi/extension.mjs.map +1 -0
  28. package/dist/adapters/pi/inputCapture.cjs +2 -0
  29. package/dist/adapters/pi/inputCapture.cjs.map +1 -0
  30. package/dist/adapters/pi/inputCapture.mjs +2 -0
  31. package/dist/adapters/pi/inputCapture.mjs.map +1 -0
  32. package/dist/adapters/pi/leader.cjs +2 -0
  33. package/dist/adapters/pi/leader.cjs.map +1 -0
  34. package/dist/adapters/pi/leader.mjs +2 -0
  35. package/dist/adapters/pi/leader.mjs.map +1 -0
  36. package/dist/adapters/pi/promptConstants.cjs +2 -0
  37. package/dist/adapters/pi/promptConstants.cjs.map +1 -0
  38. package/dist/adapters/pi/promptConstants.mjs +2 -0
  39. package/dist/adapters/pi/promptConstants.mjs.map +1 -0
  40. package/dist/commands/promptSaveCommand.cjs +2 -0
  41. package/dist/commands/promptSaveCommand.cjs.map +1 -0
  42. package/dist/commands/promptSaveCommand.mjs +2 -0
  43. package/dist/commands/promptSaveCommand.mjs.map +1 -0
  44. package/dist/commands/promptsCommand.cjs +2 -0
  45. package/dist/commands/promptsCommand.cjs.map +1 -0
  46. package/dist/commands/promptsCommand.mjs +2 -0
  47. package/dist/commands/promptsCommand.mjs.map +1 -0
  48. package/dist/container/index.cjs +2 -0
  49. package/dist/container/index.cjs.map +1 -0
  50. package/dist/container/index.d.cts +7 -0
  51. package/dist/container/index.d.cts.map +1 -0
  52. package/dist/container/index.d.mts +7 -0
  53. package/dist/container/index.d.mts.map +1 -0
  54. package/dist/container/index.mjs +2 -0
  55. package/dist/container/index.mjs.map +1 -0
  56. package/dist/extensions/pi.cjs +1 -0
  57. package/dist/extensions/pi.d.cts +2 -0
  58. package/dist/extensions/pi.d.mts +2 -0
  59. package/dist/extensions/pi.mjs +1 -0
  60. package/dist/hubApi.cjs +1 -0
  61. package/dist/hubApi.d.cts +2 -0
  62. package/dist/hubApi.d.mts +2 -0
  63. package/dist/hubApi.mjs +1 -0
  64. package/dist/index.cjs +1 -0
  65. package/dist/index.d.cts +7 -0
  66. package/dist/index.d.mts +7 -0
  67. package/dist/index.mjs +1 -0
  68. package/dist/services/promptItems.cjs +2 -0
  69. package/dist/services/promptItems.cjs.map +1 -0
  70. package/dist/services/promptItems.mjs +2 -0
  71. package/dist/services/promptItems.mjs.map +1 -0
  72. package/dist/services/recentPrompts.cjs +2 -0
  73. package/dist/services/recentPrompts.cjs.map +1 -0
  74. package/dist/services/recentPrompts.d.cts +20 -0
  75. package/dist/services/recentPrompts.d.cts.map +1 -0
  76. package/dist/services/recentPrompts.d.mts +20 -0
  77. package/dist/services/recentPrompts.d.mts.map +1 -0
  78. package/dist/services/recentPrompts.mjs +2 -0
  79. package/dist/services/recentPrompts.mjs.map +1 -0
  80. package/dist/services/savedPromptDocument.cjs +11 -0
  81. package/dist/services/savedPromptDocument.cjs.map +1 -0
  82. package/dist/services/savedPromptDocument.d.cts +21 -0
  83. package/dist/services/savedPromptDocument.d.cts.map +1 -0
  84. package/dist/services/savedPromptDocument.d.mts +21 -0
  85. package/dist/services/savedPromptDocument.d.mts.map +1 -0
  86. package/dist/services/savedPromptDocument.mjs +11 -0
  87. package/dist/services/savedPromptDocument.mjs.map +1 -0
  88. package/dist/types/prompt.d.cts +54 -0
  89. package/dist/types/prompt.d.cts.map +1 -0
  90. package/dist/types/prompt.d.mts +54 -0
  91. package/dist/types/prompt.d.mts.map +1 -0
  92. package/dist/types/webPrompts.cjs +2 -0
  93. package/dist/types/webPrompts.cjs.map +1 -0
  94. package/dist/types/webPrompts.mjs +2 -0
  95. package/dist/types/webPrompts.mjs.map +1 -0
  96. package/llms.txt +11 -0
  97. package/package.json +124 -0
  98. package/src/exports/webClient.ts +2 -0
  99. package/src/prompts/doompi-use-prompt/SKILL.md +17 -0
  100. package/src/types/webPrompts.ts +61 -0
  101. package/src/web/PromptEditor.tsx +57 -0
  102. package/src/web/PromptPickerList.tsx +108 -0
  103. package/src/web/PromptsActivitySection.tsx +69 -0
  104. package/src/web/PromptsDialog.tsx +111 -0
  105. package/src/web/index.ts +32 -0
  106. package/src/web/promptsActions.ts +72 -0
  107. package/src/web/promptsApi.ts +88 -0
  108. package/src/web/tsconfig.json +22 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The vocabulary this package's HTTP API and its cockpit plugin share.
3
+ *
4
+ * DESIGN PATTERNS:
5
+ * - Wire JSON only, no node types, so the browser bundle can carry this module.
6
+ * - Route paths and URL building live here, so a page never assembles a URL of
7
+ * its own and the two halves cannot drift.
8
+ * - Saved prompts are machine-wide, so no route names a session.
9
+ *
10
+ * AVOID:
11
+ * - Importing anything from src/services or src/adapters here.
12
+ */
13
+
14
+ /** Where a host mounts this package's API; the segment after /api/plugin/. */
15
+ export const API_BASE_PATH = 'prompts';
16
+
17
+ /** The collection, relative to the API's own mount. */
18
+ export const PROMPTS_PATH = '/prompts';
19
+
20
+ /** One saved prompt, relative to the API's own mount. */
21
+ export function promptPath(name: string): string {
22
+ return `${PROMPTS_PATH}/${encodeURIComponent(name)}`;
23
+ }
24
+
25
+ /** The absolute URL a page fetches for the collection. */
26
+ export function promptsUrl(): string {
27
+ return `/api/plugin/${API_BASE_PATH}${PROMPTS_PATH}`;
28
+ }
29
+
30
+ /** The absolute URL a page fetches for one saved prompt. */
31
+ export function promptUrl(name: string): string {
32
+ return `/api/plugin/${API_BASE_PATH}${promptPath(name)}`;
33
+ }
34
+
35
+ /** One saved prompt, as the cockpit shows it. */
36
+ export interface SavedPromptView {
37
+ name: string;
38
+ description: string;
39
+ text: string;
40
+ }
41
+
42
+ /** What the collection route answers with. */
43
+ export interface SavedPromptListResponse {
44
+ prompts: readonly SavedPromptView[];
45
+ }
46
+
47
+ /** What a write route answers with. */
48
+ export interface SavedPromptWriteResponse {
49
+ prompt: SavedPromptView;
50
+ /** True when the write replaced a template that already existed. */
51
+ replaced: boolean;
52
+ }
53
+
54
+ /** What every route answers with when it refuses. */
55
+ export interface PromptErrorResponse {
56
+ error: string;
57
+ }
58
+
59
+ export function isPromptErrorResponse(value: unknown): value is PromptErrorResponse {
60
+ return typeof value === 'object' && value !== null && typeof (value as PromptErrorResponse).error === 'string';
61
+ }
@@ -0,0 +1,57 @@
1
+ import { Button, Input, Textarea } from '@agimon-ai/doompi-web-components';
2
+ import { canSaveDraft, type DraftState } from './promptsActions.ts';
3
+
4
+ /**
5
+ * The create and edit form for one saved prompt.
6
+ *
7
+ * DESIGN PATTERNS:
8
+ * - Presentational: the draft and every action come from props, so the panel
9
+ * owns the state and this file stays a form.
10
+ * - Save stays disabled until the draft could actually be written, which keeps
11
+ * the obvious mistakes off the network without duplicating the server rule.
12
+ *
13
+ * AVOID:
14
+ * - Validating the name shape here. The API owns that rule and reports it.
15
+ */
16
+
17
+ export interface PromptEditorProps {
18
+ draft: DraftState;
19
+ busy: boolean;
20
+ onChange: (draft: DraftState) => void;
21
+ onSave: () => void;
22
+ onCancel: () => void;
23
+ }
24
+
25
+ export function PromptEditor({ draft, busy, onChange, onSave, onCancel }: PromptEditorProps) {
26
+ return (
27
+ <div data-testid="prompts-editor" className="flex flex-col gap-2 rounded-[5px] border border-doom-border p-2">
28
+ <Input
29
+ data-testid="prompts-name"
30
+ value={draft.name}
31
+ placeholder="name, lowercase letters, digits and dashes"
32
+ onChange={(event) => onChange({ ...draft, name: event.target.value })}
33
+ />
34
+ <Textarea
35
+ data-testid="prompts-text"
36
+ rows={8}
37
+ value={draft.text}
38
+ placeholder="the prompt text"
39
+ onChange={(event) => onChange({ ...draft, text: event.target.value })}
40
+ />
41
+ <div className="flex items-center gap-2">
42
+ <Button
43
+ size="xs"
44
+ className="text-[9px]"
45
+ data-testid="prompts-save"
46
+ disabled={busy || !canSaveDraft(draft)}
47
+ onClick={onSave}
48
+ >
49
+ save
50
+ </Button>
51
+ <Button variant="ghost" size="xs" className="text-[9px]" data-testid="prompts-cancel" onClick={onCancel}>
52
+ cancel
53
+ </Button>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
@@ -0,0 +1,108 @@
1
+ import { Button, Input } from '@agimon-ai/doompi-web-components';
2
+ import type { SavedPromptView } from '../types/webPrompts.ts';
3
+ import { PromptEditor } from './PromptEditor.tsx';
4
+ import type { DraftState } from './promptsActions.ts';
5
+ import { filterPrompts } from './promptsActions.ts';
6
+
7
+ /**
8
+ * The body of the prompt dialog: filter, rows, and the editor when one is open.
9
+ *
10
+ * DESIGN PATTERNS:
11
+ * - Presentational and outside the dialog chrome, so it renders without a DOM
12
+ * portal and the tests can see it.
13
+ * - Sending is the row's primary action; managing sits to its right.
14
+ *
15
+ * AVOID:
16
+ * - Fetching here. The dialog owns the calls, this owns the markup.
17
+ */
18
+
19
+ export interface PromptPickerListProps {
20
+ prompts: readonly SavedPromptView[];
21
+ filter: string;
22
+ draft: DraftState | undefined;
23
+ busy: boolean;
24
+ error: string;
25
+ onFilterChange: (filter: string) => void;
26
+ onSend: (prompt: SavedPromptView) => void;
27
+ onEdit: (prompt: SavedPromptView) => void;
28
+ onDelete: (name: string) => void;
29
+ onDraftChange: (draft: DraftState) => void;
30
+ onSave: () => void;
31
+ onCancelDraft: () => void;
32
+ }
33
+
34
+ export function PromptPickerList(props: PromptPickerListProps) {
35
+ const visible = filterPrompts(props.prompts, props.filter);
36
+
37
+ return (
38
+ <div data-testid="prompts-picker" className="flex flex-col gap-2">
39
+ <Input
40
+ data-testid="prompts-filter"
41
+ value={props.filter}
42
+ placeholder="filter"
43
+ onChange={(event) => props.onFilterChange(event.target.value)}
44
+ />
45
+
46
+ {props.error === '' ? null : (
47
+ <span className="text-[10px] text-doom-red" data-testid="prompts-error">
48
+ {props.error}
49
+ </span>
50
+ )}
51
+
52
+ {visible.length === 0 ? (
53
+ <p data-testid="prompts-empty" className="text-[11px] text-doom-faint">
54
+ {props.prompts.length === 0 ? 'no saved prompts yet' : 'nothing matches that filter'}
55
+ </p>
56
+ ) : null}
57
+
58
+ {visible.map((prompt) => (
59
+ <div
60
+ key={prompt.name}
61
+ data-testid={`prompts-item-${prompt.name}`}
62
+ className="flex items-center gap-2 rounded-[5px] px-2 py-1 text-[11px] text-doom-text hover:bg-doom-panel"
63
+ >
64
+ <Button
65
+ variant="ghost"
66
+ size="xs"
67
+ className="min-w-0 flex-1 justify-start text-left text-[11px]"
68
+ data-testid={`prompts-send-${prompt.name}`}
69
+ title="send this prompt to the focused session"
70
+ onClick={() => props.onSend(prompt)}
71
+ >
72
+ <span className="min-w-0 flex-1 truncate font-bold">/{prompt.name}</span>
73
+ <span className="min-w-0 flex-1 truncate text-[9px] text-doom-faint">{prompt.description}</span>
74
+ </Button>
75
+ <Button
76
+ variant="ghost"
77
+ size="xs"
78
+ className="text-[9px]"
79
+ data-testid={`prompts-edit-${prompt.name}`}
80
+ onClick={() => props.onEdit(prompt)}
81
+ >
82
+ edit
83
+ </Button>
84
+ <Button
85
+ variant="ghost"
86
+ size="xs"
87
+ className="text-[9px]"
88
+ data-testid={`prompts-delete-${prompt.name}`}
89
+ disabled={props.busy}
90
+ onClick={() => props.onDelete(prompt.name)}
91
+ >
92
+ delete
93
+ </Button>
94
+ </div>
95
+ ))}
96
+
97
+ {props.draft === undefined ? null : (
98
+ <PromptEditor
99
+ draft={props.draft}
100
+ busy={props.busy}
101
+ onChange={props.onDraftChange}
102
+ onSave={props.onSave}
103
+ onCancel={props.onCancelDraft}
104
+ />
105
+ )}
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,69 @@
1
+ import { Button } from '@agimon-ai/doompi-web-components';
2
+ import type { WebPluginSlotProps } from '@agimon-ai/doompi-web-contracts';
3
+ import { useEffect, useState } from 'react';
4
+ import type { SavedPromptView } from '../types/webPrompts.ts';
5
+ import { PromptsDialog } from './PromptsDialog.tsx';
6
+ import { fetchSavedPrompts } from './promptsApi.ts';
7
+
8
+ /**
9
+ * The prompts group's body in the activity dock.
10
+ *
11
+ * DESIGN PATTERNS:
12
+ * - One line and one action, like the agents and workflows groups: the dock is
13
+ * a status surface, so the library itself opens in a dialog over the
14
+ * conversation rather than pushing the reader into another page.
15
+ * - The count is read once on mount and again whenever the dialog closes,
16
+ * because a write inside it is the only thing that changes the number.
17
+ *
18
+ * AVOID:
19
+ * - Rendering the library here. A dock group is a summary.
20
+ */
21
+
22
+ export function PromptsActivitySection({ sessionId, sendSessionFrame }: WebPluginSlotProps) {
23
+ const [prompts, setPrompts] = useState<readonly SavedPromptView[]>([]);
24
+ const [open, setOpen] = useState(false);
25
+ const [error, setError] = useState('');
26
+
27
+ useEffect(() => {
28
+ if (open) return undefined;
29
+ const controller = new AbortController();
30
+ void fetchSavedPrompts(controller.signal).then((result) => {
31
+ if (controller.signal.aborted) return;
32
+ if ('error' in result) setError(result.error);
33
+ else {
34
+ setError('');
35
+ setPrompts(result.prompts);
36
+ }
37
+ });
38
+ return () => controller.abort();
39
+ }, [open]);
40
+
41
+ const summary = error !== '' ? error : prompts.length === 0 ? 'idle' : `${String(prompts.length)} saved`;
42
+
43
+ return (
44
+ <div data-testid="activity-section-prompts" className="flex flex-col gap-0.5">
45
+ <div className="flex items-center gap-2 px-1">
46
+ <p data-testid="activity-summary-prompts" className="text-[10px] text-doom-faint">
47
+ {summary}
48
+ </p>
49
+ <Button
50
+ variant="link"
51
+ size="xs"
52
+ data-testid="activity-prompts-open"
53
+ className="px-0"
54
+ onClick={() => setOpen(true)}
55
+ >
56
+ send a prompt
57
+ </Button>
58
+ </div>
59
+
60
+ <PromptsDialog
61
+ open={open}
62
+ prompts={prompts}
63
+ sessionId={sessionId}
64
+ onOpenChange={setOpen}
65
+ onSend={sendSessionFrame}
66
+ />
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ Button,
3
+ Dialog,
4
+ DialogBody,
5
+ DialogContent,
6
+ DialogDescription,
7
+ DialogFooter,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from '@agimon-ai/doompi-web-components';
11
+ import type { SessionFrameSender } from '@agimon-ai/doompi-web-contracts';
12
+ import { useState } from 'react';
13
+ import type { SavedPromptView } from '../types/webPrompts.ts';
14
+ import { PromptPickerList } from './PromptPickerList.tsx';
15
+ import { commitDraft, type DraftState, draftOf, EMPTY_DRAFT, promptFrame } from './promptsActions.ts';
16
+ import { deleteSavedPrompt, saveSavedPrompt } from './promptsApi.ts';
17
+
18
+ /**
19
+ * The prompt picker, opened from the activity dock.
20
+ *
21
+ * DESIGN PATTERNS:
22
+ * - Picking sends. A saved prompt is a message the reader already wrote, so the
23
+ * primary action submits it to the focused session and closes the dialog.
24
+ * - Managing is secondary and lives in the same dialog, because a library with
25
+ * no way to fix an entry is a library nobody trusts.
26
+ * - This file is chrome and state; the rows are PromptPickerList.
27
+ *
28
+ * AVOID:
29
+ * - Sending with no session focused. There would be nowhere for it to land.
30
+ */
31
+
32
+ const MUTATION_API = { save: saveSavedPrompt, remove: deleteSavedPrompt };
33
+ const NO_SESSION = 'Focus a session first: a prompt has to be sent somewhere.';
34
+
35
+ export interface PromptsDialogProps {
36
+ open: boolean;
37
+ prompts: readonly SavedPromptView[];
38
+ sessionId: string | null;
39
+ onOpenChange: (open: boolean) => void;
40
+ onSend: SessionFrameSender;
41
+ }
42
+
43
+ export function PromptsDialog({ open, prompts, sessionId, onOpenChange, onSend }: PromptsDialogProps) {
44
+ const [filter, setFilter] = useState('');
45
+ const [draft, setDraft] = useState<DraftState | undefined>(undefined);
46
+ const [error, setError] = useState('');
47
+ const [busy, setBusy] = useState(false);
48
+
49
+ const send = (prompt: SavedPromptView): void => {
50
+ if (sessionId === null) {
51
+ setError(NO_SESSION);
52
+ return;
53
+ }
54
+ onSend(sessionId, promptFrame(prompt.text));
55
+ onOpenChange(false);
56
+ };
57
+
58
+ const runMutation = async (mutation: Promise<{ error: string } | undefined>): Promise<void> => {
59
+ setBusy(true);
60
+ const failure = await mutation;
61
+ setBusy(false);
62
+ if (failure) {
63
+ setError(failure.error);
64
+ return;
65
+ }
66
+ setDraft(undefined);
67
+ // The activity section reloads the library whenever this dialog closes.
68
+ onOpenChange(false);
69
+ };
70
+
71
+ return (
72
+ <Dialog open={open} onOpenChange={onOpenChange}>
73
+ <DialogContent data-testid="prompts-dialog">
74
+ <DialogHeader>
75
+ <DialogTitle>prompts</DialogTitle>
76
+ <DialogDescription>pick one to send it to this session</DialogDescription>
77
+ </DialogHeader>
78
+
79
+ <DialogBody>
80
+ <PromptPickerList
81
+ prompts={prompts}
82
+ filter={filter}
83
+ draft={draft}
84
+ busy={busy}
85
+ error={error}
86
+ onFilterChange={setFilter}
87
+ onSend={send}
88
+ onEdit={(prompt) => setDraft(draftOf(prompt))}
89
+ onDelete={(name) => void runMutation(deleteSavedPrompt(name))}
90
+ onDraftChange={setDraft}
91
+ onSave={() => void runMutation(draft ? commitDraft(draft, MUTATION_API) : Promise.resolve(undefined))}
92
+ onCancelDraft={() => setDraft(undefined)}
93
+ />
94
+ </DialogBody>
95
+
96
+ <DialogFooter>
97
+ <Button
98
+ variant="ghost"
99
+ size="xs"
100
+ className="text-[9px]"
101
+ data-testid="prompts-new"
102
+ disabled={busy}
103
+ onClick={() => setDraft(EMPTY_DRAFT)}
104
+ >
105
+ new
106
+ </Button>
107
+ </DialogFooter>
108
+ </DialogContent>
109
+ </Dialog>
110
+ );
111
+ }
@@ -0,0 +1,32 @@
1
+ import { defineWebPlugin } from '@agimon-ai/doompi-web-contracts';
2
+ import { PromptsActivitySection } from './PromptsActivitySection.tsx';
3
+
4
+ /**
5
+ * This package's cockpit presence: one activity group.
6
+ *
7
+ * No tab. The library is not a place to sit in, it is something reached for
8
+ * mid-conversation, so it lives in the activity dock next to agents, runners
9
+ * and workflows, and the picking happens in a dialog over the conversation.
10
+ *
11
+ * The group is always visible and never claims background work: saving a
12
+ * prompt is not a running job, and an empty library still needs the entry
13
+ * point that fills it.
14
+ */
15
+ const promptsActivitySource = {
16
+ subscribe: () => () => undefined,
17
+ isActive: () => false,
18
+ };
19
+
20
+ export const webPlugin = defineWebPlugin({
21
+ id: 'prompts',
22
+ activityGroups: [
23
+ {
24
+ name: 'prompts',
25
+ // The TUI's SPC e p, so the two surfaces are reached the same way.
26
+ keys: 'e p',
27
+ activeSource: promptsActivitySource,
28
+ order: 40,
29
+ },
30
+ ],
31
+ activitySections: [{ id: 'prompts', component: PromptsActivitySection }],
32
+ });
@@ -0,0 +1,72 @@
1
+ import type { SavedPromptView } from '../types/webPrompts.ts';
2
+
3
+ /**
4
+ * What the panel does to the library, independent of how it is drawn.
5
+ *
6
+ * DESIGN PATTERNS:
7
+ * - The API is a parameter, so the ordering rules here are testable without a
8
+ * transport, and the panel keeps only state and markup.
9
+ * - A rename is a write followed by a delete of the old template, never the
10
+ * other way round: a failed write must not lose the original.
11
+ *
12
+ * AVOID:
13
+ * - Reporting success when the cleanup delete failed. The library would then
14
+ * hold two copies and the page would show a state nobody chose.
15
+ */
16
+
17
+ export interface DraftState {
18
+ name: string;
19
+ text: string;
20
+ /** The name being replaced, or '' while creating. */
21
+ original: string;
22
+ }
23
+
24
+ export interface PromptsMutationApi {
25
+ save(name: string, text: string): Promise<{ error: string } | undefined>;
26
+ remove(name: string): Promise<{ error: string } | undefined>;
27
+ }
28
+
29
+ /** The frame the cockpit sends to submit a message to a session. */
30
+ export function promptFrame(text: string): Record<string, unknown> {
31
+ return { type: 'prompt', message: text };
32
+ }
33
+
34
+ /** Substring matching over the two columns a row shows. */
35
+ export function filterPrompts(prompts: readonly SavedPromptView[], filter: string): readonly SavedPromptView[] {
36
+ const needle = filter.trim().toLowerCase();
37
+ if (needle === '') return prompts;
38
+ return prompts.filter(
39
+ (prompt) => prompt.name.toLowerCase().includes(needle) || prompt.description.toLowerCase().includes(needle),
40
+ );
41
+ }
42
+
43
+ export const EMPTY_DRAFT: DraftState = { name: '', text: '', original: '' };
44
+
45
+ export function draftOf(prompt: SavedPromptView): DraftState {
46
+ return { name: prompt.name, text: prompt.text, original: prompt.name };
47
+ }
48
+
49
+ /** Whether the draft is complete enough to send. */
50
+ export function canSaveDraft(draft: DraftState): boolean {
51
+ return draft.name.trim().length > 0 && draft.text.trim().length > 0;
52
+ }
53
+
54
+ /** The template a rename leaves behind, or undefined when nothing was renamed. */
55
+ export function renamedFrom(draft: DraftState): string | undefined {
56
+ const name = draft.name.trim();
57
+ return draft.original !== '' && draft.original !== name ? draft.original : undefined;
58
+ }
59
+
60
+ /** Writes the draft, then clears the template a rename orphaned. */
61
+ export async function commitDraft(draft: DraftState, api: PromptsMutationApi): Promise<{ error: string } | undefined> {
62
+ const name = draft.name.trim();
63
+ const failure = await api.save(name, draft.text);
64
+ if (failure) return failure;
65
+
66
+ const orphan = renamedFrom(draft);
67
+ if (orphan === undefined) return undefined;
68
+
69
+ const cleanup = await api.remove(orphan);
70
+ if (cleanup) return { error: `Saved /${name}, but /${orphan} could not be removed: ${cleanup.error}` };
71
+ return undefined;
72
+ }
@@ -0,0 +1,88 @@
1
+ import { sealedTransport } from '@agimon-ai/doompi-web-security/browser';
2
+ import {
3
+ isPromptErrorResponse,
4
+ promptsUrl,
5
+ promptUrl,
6
+ type SavedPromptListResponse,
7
+ type SavedPromptView,
8
+ } from '../types/webPrompts.ts';
9
+
10
+ /**
11
+ * The browser half of the prompt library API.
12
+ *
13
+ * DESIGN PATTERNS:
14
+ * - Every call goes through the sealed transport, like every other cockpit
15
+ * plugin, so a remote listener gets the same protection the host expects.
16
+ * - Failures come back as values, never thrown: the panel shows one message
17
+ * next to the thing that failed instead of losing the page to an error.
18
+ * - URLs are built by src/types, never assembled here.
19
+ *
20
+ * AVOID:
21
+ * - Caching answers in this module. The panel owns state.
22
+ */
23
+
24
+ const UNREACHABLE = 'The hub did not answer.';
25
+ const ABORT_ERROR = 'AbortError';
26
+
27
+ interface Failure {
28
+ error: string;
29
+ }
30
+
31
+ function isAbort(error: unknown): boolean {
32
+ return error instanceof DOMException && error.name === ABORT_ERROR;
33
+ }
34
+
35
+ async function readError(response: Response): Promise<string> {
36
+ try {
37
+ const body: unknown = await response.json();
38
+ if (isPromptErrorResponse(body)) return body.error;
39
+ } catch {
40
+ // A body that is not JSON tells the reader nothing useful; the status does.
41
+ }
42
+ return `The hub answered ${String(response.status)}.`;
43
+ }
44
+
45
+ export async function fetchSavedPrompts(
46
+ signal?: AbortSignal,
47
+ ): Promise<{ prompts: readonly SavedPromptView[] } | Failure> {
48
+ let response: Response;
49
+ try {
50
+ response = await sealedTransport.fetch(promptsUrl(), signal ? { signal } : {});
51
+ } catch (error) {
52
+ // An aborted request is the caller replacing it, not a failure to report.
53
+ if (isAbort(error)) return { error: '' };
54
+ return { error: UNREACHABLE };
55
+ }
56
+
57
+ if (!response.ok) return { error: await readError(response) };
58
+ try {
59
+ const body = (await response.json()) as SavedPromptListResponse;
60
+ return { prompts: body.prompts ?? [] };
61
+ } catch {
62
+ return { error: 'The hub sent a malformed prompt list.' };
63
+ }
64
+ }
65
+
66
+ export async function saveSavedPrompt(name: string, text: string): Promise<Failure | undefined> {
67
+ let response: Response;
68
+ try {
69
+ response = await sealedTransport.fetch(promptUrl(name), {
70
+ method: 'PUT',
71
+ headers: { 'content-type': 'application/json' },
72
+ body: JSON.stringify({ text }),
73
+ });
74
+ } catch {
75
+ return { error: UNREACHABLE };
76
+ }
77
+ return response.ok ? undefined : { error: await readError(response) };
78
+ }
79
+
80
+ export async function deleteSavedPrompt(name: string): Promise<Failure | undefined> {
81
+ let response: Response;
82
+ try {
83
+ response = await sealedTransport.fetch(promptUrl(name), { method: 'DELETE' });
84
+ } catch {
85
+ return { error: UNREACHABLE };
86
+ }
87
+ return response.ok ? undefined : { error: await readError(response) };
88
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "noUnusedLocals": true,
11
+ "noUnusedParameters": true,
12
+ "noImplicitReturns": true,
13
+ "noFallthroughCasesInSwitch": true,
14
+ "noEmit": true,
15
+ "resolveJsonModule": true,
16
+ "esModuleInterop": true,
17
+ "skipLibCheck": true,
18
+ "allowImportingTsExtensions": true,
19
+ "forceConsistentCasingInFileNames": true
20
+ },
21
+ "include": [".", "../types", "../exports/webClient.ts", "../../tests/web"]
22
+ }