@aragon-agent/cli 0.6.35 → 0.6.37
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/CHANGELOG.md +21 -0
- package/README.md +3173 -3071
- package/dist/agent/agent-mode.d.ts +20 -4
- package/dist/agent/agent-mode.js +20 -4
- package/dist/agent/controller.d.ts +38 -3
- package/dist/agent/controller.js +64 -4
- package/dist/agent/headless.js +31 -0
- package/dist/agent/reducer.d.ts +13 -2
- package/dist/agent/reducer.js +1 -0
- package/dist/cli.js +12 -0
- package/dist/commands/agents-command.d.ts +15 -0
- package/dist/commands/agents-command.js +27 -0
- package/dist/commands/builtins.js +131 -4
- package/dist/commands/perf.d.ts +11 -0
- package/dist/commands/perf.js +2 -1
- package/dist/commands/registry.d.ts +14 -0
- package/dist/config/app-paths.d.ts +15 -0
- package/dist/config/app-paths.js +33 -2
- package/dist/config/env.d.ts +4 -1
- package/dist/config/env.js +28 -2
- package/dist/config/load.d.ts +10 -0
- package/dist/config/load.js +8 -1
- package/dist/config/schema.d.ts +68 -0
- package/dist/config/schema.js +39 -0
- package/dist/config/store.js +12 -1
- package/dist/config/ui-state.d.ts +18 -0
- package/dist/config/ui-state.js +9 -0
- package/dist/exec/events.d.ts +25 -0
- package/dist/exec/events.js +4 -0
- package/dist/exec/index.js +16 -1
- package/dist/exec/runner.js +10 -0
- package/dist/proc/limits.d.ts +16 -0
- package/dist/proc/limits.js +16 -0
- package/dist/proc/supervisor.d.ts +20 -1
- package/dist/proc/supervisor.js +42 -14
- package/dist/prompts/command.d.ts +34 -0
- package/dist/prompts/command.js +237 -0
- package/dist/prompts/copy.d.ts +89 -0
- package/dist/prompts/copy.js +131 -0
- package/dist/prompts/fill.d.ts +51 -0
- package/dist/prompts/fill.js +46 -0
- package/dist/prompts/limits.d.ts +53 -0
- package/dist/prompts/limits.js +53 -0
- package/dist/prompts/normalize.d.ts +53 -0
- package/dist/prompts/normalize.js +190 -0
- package/dist/prompts/resolve.d.ts +31 -0
- package/dist/prompts/resolve.js +60 -0
- package/dist/prompts/search.d.ts +58 -0
- package/dist/prompts/search.js +146 -0
- package/dist/prompts/store.d.ts +86 -0
- package/dist/prompts/store.js +453 -0
- package/dist/prompts/types.d.ts +204 -0
- package/dist/prompts/types.js +13 -0
- package/dist/session/checkpoint.d.ts +8 -1
- package/dist/session/checkpoint.js +6 -0
- package/dist/session/persist.d.ts +19 -1
- package/dist/session/persist.js +13 -1
- package/dist/session/startup-session.d.ts +7 -2
- package/dist/session/startup-session.js +5 -2
- package/dist/session/store.js +3 -0
- package/dist/session/validate-session.js +64 -1
- package/dist/team/limits.d.ts +25 -0
- package/dist/team/limits.js +26 -0
- package/dist/team/runtime.js +27 -2
- package/dist/team/settled.d.ts +57 -0
- package/dist/team/settled.js +87 -0
- package/dist/team/subagent.js +48 -2
- package/dist/team/timeline.d.ts +74 -0
- package/dist/team/timeline.js +101 -0
- package/dist/team/types.d.ts +24 -1
- package/dist/todo/acceptance.d.ts +120 -0
- package/dist/todo/acceptance.js +399 -0
- package/dist/todo/follow-through.d.ts +48 -23
- package/dist/todo/follow-through.js +110 -44
- package/dist/todo/limits.d.ts +62 -1
- package/dist/todo/limits.js +62 -1
- package/dist/todo/panel-rows.d.ts +29 -39
- package/dist/todo/panel-rows.js +37 -83
- package/dist/todo/plan-sections.d.ts +60 -0
- package/dist/todo/plan-sections.js +108 -0
- package/dist/todo/prompt.d.ts +17 -12
- package/dist/todo/prompt.js +59 -40
- package/dist/todo/row-window.d.ts +51 -0
- package/dist/todo/row-window.js +107 -0
- package/dist/todo/store.d.ts +139 -31
- package/dist/todo/store.js +271 -73
- package/dist/todo/todo-tool.d.ts +33 -8
- package/dist/todo/todo-tool.js +186 -30
- package/dist/todo/types.d.ts +58 -0
- package/dist/ui/App.js +310 -46
- package/dist/ui/Composer.d.ts +6 -2
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/Header.d.ts +9 -1
- package/dist/ui/Header.js +2 -2
- package/dist/ui/PromptInput.d.ts +21 -1
- package/dist/ui/PromptInput.js +21 -1
- package/dist/ui/ScrollbackShell.d.ts +19 -19
- package/dist/ui/ScrollbackShell.js +8 -1
- package/dist/ui/StatusBar.d.ts +5 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/TeamPanel.d.ts +26 -44
- package/dist/ui/TeamPanel.js +46 -135
- package/dist/ui/TodoPanel.d.ts +17 -14
- package/dist/ui/TodoPanel.js +146 -38
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/composer-input.d.ts +10 -0
- package/dist/ui/composer-input.js +57 -24
- package/dist/ui/entries/TodoCard.d.ts +26 -11
- package/dist/ui/entries/TodoCard.js +56 -15
- package/dist/ui/glyphs.d.ts +19 -0
- package/dist/ui/glyphs.js +4 -0
- package/dist/ui/layout/agents-board.d.ts +102 -0
- package/dist/ui/layout/agents-board.js +389 -0
- package/dist/ui/layout/cells.d.ts +61 -0
- package/dist/ui/layout/cells.js +156 -0
- package/dist/ui/layout/header-hints.d.ts +13 -3
- package/dist/ui/layout/header-hints.js +48 -14
- package/dist/ui/layout/status-detail-layout.js +9 -0
- package/dist/ui/layout/status-layout.d.ts +13 -0
- package/dist/ui/layout/team-row.d.ts +139 -0
- package/dist/ui/layout/team-row.js +246 -0
- package/dist/ui/layout/todo-layout.d.ts +38 -0
- package/dist/ui/layout/todo-layout.js +75 -0
- package/dist/ui/layout/virtual-window.js +23 -3
- package/dist/ui/overlays/AgentsBoardOverlay.d.ts +57 -0
- package/dist/ui/overlays/AgentsBoardOverlay.js +183 -0
- package/dist/ui/overlays/HelpOverlay.js +17 -2
- package/dist/ui/overlays/PromptEditorPage.d.ts +48 -0
- package/dist/ui/overlays/PromptEditorPage.js +227 -0
- package/dist/ui/overlays/PromptLibraryOverlay.d.ts +37 -0
- package/dist/ui/overlays/PromptLibraryOverlay.js +191 -0
- package/dist/ui/overlays/PromptPicker.d.ts +36 -0
- package/dist/ui/overlays/PromptPicker.js +129 -0
- package/dist/ui/overlays/PromptTextArea.d.ts +86 -0
- package/dist/ui/overlays/PromptTextArea.js +141 -0
- package/dist/ui/overlays/prompt-library-state.d.ts +162 -0
- package/dist/ui/overlays/prompt-library-state.js +335 -0
- package/dist/ui/overlays/prompt-picker-state.d.ts +71 -0
- package/dist/ui/overlays/prompt-picker-state.js +98 -0
- package/dist/ui/scrollback/band.js +14 -1
- package/dist/ui/team-status.d.ts +25 -0
- package/dist/ui/team-status.js +31 -3
- package/dist/ui/use-startup-notices.d.ts +35 -1
- package/dist/ui/use-startup-notices.js +29 -2
- package/dist/ui/use-wheel-routing.js +9 -1
- package/package.json +2 -2
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filtering and view ordering for the picker (quick-prompt-library D-9 / D-10).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/prompts/**` is inside `glyphs.test.ts::inScope`.
|
|
5
|
+
*
|
|
6
|
+
* SUBSEQUENCE MATCHING, NOT SUBSTRING MATCHING. `clr` should find `clean code
|
|
7
|
+
* review`, and Chinese titles make the same demand in a different shape: the
|
|
8
|
+
* characters a user remembers are rarely contiguous. The query is matched
|
|
9
|
+
* case-insensitively against the title, then the tags, then the description, and
|
|
10
|
+
* the FIELD a match landed in is a stronger signal than how good the match was.
|
|
11
|
+
*
|
|
12
|
+
* NO CHARACTER HIGHLIGHTING (D-10). Colouring the matched characters would mean
|
|
13
|
+
* splitting a row into styled runs, and every row in this package is one
|
|
14
|
+
* `wrap="truncate"` `<Text>` precisely so its height is exactly one row. The
|
|
15
|
+
* detail block answers "why did this match" instead.
|
|
16
|
+
*/
|
|
17
|
+
/** How much worse a match in tags, or in the description, is than in the title. */
|
|
18
|
+
const TAG_PENALTY = 1_000;
|
|
19
|
+
const DESCRIPTION_PENALTY = 2_000;
|
|
20
|
+
/**
|
|
21
|
+
* Greedy subsequence match; `null` when `query` is not a subsequence of `text`.
|
|
22
|
+
*
|
|
23
|
+
* The score is LOWER-is-better and is built from what a reader would actually
|
|
24
|
+
* call a good match: how early the first hit is, how tightly the hits cluster,
|
|
25
|
+
* and how many characters had to be skipped between them.
|
|
26
|
+
*/
|
|
27
|
+
export function subsequenceScore(text, query) {
|
|
28
|
+
if (query.length === 0)
|
|
29
|
+
return 0;
|
|
30
|
+
const haystack = text.toLowerCase();
|
|
31
|
+
const needle = query.toLowerCase();
|
|
32
|
+
let at = 0;
|
|
33
|
+
let first = -1;
|
|
34
|
+
let last = -1;
|
|
35
|
+
let gaps = 0;
|
|
36
|
+
for (const char of needle) {
|
|
37
|
+
const hit = haystack.indexOf(char, at);
|
|
38
|
+
if (hit === -1)
|
|
39
|
+
return null;
|
|
40
|
+
if (first === -1)
|
|
41
|
+
first = hit;
|
|
42
|
+
else if (hit > last + 1)
|
|
43
|
+
gaps += hit - last - 1;
|
|
44
|
+
last = hit;
|
|
45
|
+
at = hit + 1;
|
|
46
|
+
}
|
|
47
|
+
const span = last - first + 1;
|
|
48
|
+
return first * 4 + gaps * 2 + (span - needle.length);
|
|
49
|
+
}
|
|
50
|
+
function fieldScore(prompt, query) {
|
|
51
|
+
const title = subsequenceScore(prompt.title, query);
|
|
52
|
+
if (title !== null)
|
|
53
|
+
return title;
|
|
54
|
+
let best = null;
|
|
55
|
+
for (const tag of prompt.tags ?? []) {
|
|
56
|
+
const score = subsequenceScore(tag, query);
|
|
57
|
+
if (score !== null && (best === null || score < best))
|
|
58
|
+
best = score;
|
|
59
|
+
}
|
|
60
|
+
if (best !== null)
|
|
61
|
+
return TAG_PENALTY + best;
|
|
62
|
+
const description = prompt.description;
|
|
63
|
+
if (description === undefined)
|
|
64
|
+
return null;
|
|
65
|
+
const score = subsequenceScore(description, query);
|
|
66
|
+
return score === null ? null : DESCRIPTION_PENALTY + score;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Filter, but NEVER REORDER BEYOND WHAT THE QUERY EARNS.
|
|
70
|
+
*
|
|
71
|
+
* Ties keep the library's own order, because a filter that reshuffles entries with
|
|
72
|
+
* equal scores makes the list move under the user's arrow keys while they type.
|
|
73
|
+
* Favourites win ties, which is the only preference the picker states.
|
|
74
|
+
*/
|
|
75
|
+
export function searchPrompts(prompts, query) {
|
|
76
|
+
const trimmed = query.trim();
|
|
77
|
+
if (trimmed.length === 0)
|
|
78
|
+
return prompts.map((prompt) => ({ prompt, score: 0 }));
|
|
79
|
+
const hits = [];
|
|
80
|
+
for (let at = 0; at < prompts.length; at += 1) {
|
|
81
|
+
const prompt = prompts[at];
|
|
82
|
+
const score = fieldScore(prompt, trimmed);
|
|
83
|
+
if (score !== null)
|
|
84
|
+
hits.push({ prompt, score, at });
|
|
85
|
+
}
|
|
86
|
+
hits.sort((a, b) => {
|
|
87
|
+
const favourite = (b.prompt.favorite === true ? 1 : 0) - (a.prompt.favorite === true ? 1 : 0);
|
|
88
|
+
if (favourite !== 0)
|
|
89
|
+
return favourite;
|
|
90
|
+
if (a.score !== b.score)
|
|
91
|
+
return a.score - b.score;
|
|
92
|
+
return a.at - b.at;
|
|
93
|
+
});
|
|
94
|
+
return hits.map(({ prompt, score }) => ({ prompt, score }));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The three presentations (D-9).
|
|
98
|
+
*
|
|
99
|
+
* `manual` is the only persisted order; the other two are VANISHING views. The one
|
|
100
|
+
* that rearranges is `favorite` - `recent` sorts by `lastUsedAt` only, so an entry
|
|
101
|
+
* that has never been used keeps its place at the tail instead of jumping around
|
|
102
|
+
* while the user watches.
|
|
103
|
+
*/
|
|
104
|
+
export function viewOrder(prompts, view) {
|
|
105
|
+
if (view === 'manual')
|
|
106
|
+
return [...prompts];
|
|
107
|
+
const indexed = prompts.map((prompt, at) => ({ prompt, at }));
|
|
108
|
+
if (view === 'favorite') {
|
|
109
|
+
indexed.sort((a, b) => {
|
|
110
|
+
const favourite = (b.prompt.favorite === true ? 1 : 0) - (a.prompt.favorite === true ? 1 : 0);
|
|
111
|
+
return favourite !== 0 ? favourite : a.at - b.at;
|
|
112
|
+
});
|
|
113
|
+
return indexed.map(({ prompt }) => prompt);
|
|
114
|
+
}
|
|
115
|
+
indexed.sort((a, b) => {
|
|
116
|
+
const recent = (b.prompt.lastUsedAt ?? 0) - (a.prompt.lastUsedAt ?? 0);
|
|
117
|
+
return recent !== 0 ? recent : a.at - b.at;
|
|
118
|
+
});
|
|
119
|
+
return indexed.map(({ prompt }) => prompt);
|
|
120
|
+
}
|
|
121
|
+
/** The next view in the `Ctrl+S` cycle (manual -> recent -> favorite -> manual). */
|
|
122
|
+
export function nextView(view) {
|
|
123
|
+
if (view === 'manual')
|
|
124
|
+
return 'recent';
|
|
125
|
+
if (view === 'recent')
|
|
126
|
+
return 'favorite';
|
|
127
|
+
return 'manual';
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Title order, for the `/prompts sort name` write.
|
|
131
|
+
*
|
|
132
|
+
* A TOTAL order (title, then id): a sort the user asked for has to be reproducible, and
|
|
133
|
+
* two entries sharing a title would otherwise swap places between runs - a "sorted"
|
|
134
|
+
* library that reorders itself on every read is worse than an unsorted one. Code-unit
|
|
135
|
+
* comparison rather than `localeCompare`, because the file is written on one machine and
|
|
136
|
+
* read on the next, and a locale-dependent order would silently rewrite the user's
|
|
137
|
+
* sequence on a different machine for no reason they could see.
|
|
138
|
+
*/
|
|
139
|
+
export function sortedPromptTitleOrder(prompts) {
|
|
140
|
+
const compare = (a, b) => {
|
|
141
|
+
if (a.title !== b.title)
|
|
142
|
+
return a.title < b.title ? -1 : 1;
|
|
143
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
144
|
+
};
|
|
145
|
+
return [...prompts].sort(compare);
|
|
146
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quick-prompt library's ONLY read/write authority (quick-prompt-library D-3,
|
|
3
|
+
* D-4, D-14, I-6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/prompts/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* EVERY MUTATION IS: read the current file, apply ONE pure change, write the whole
|
|
8
|
+
* library atomically. No lock (D-14) - this is the strategy `ui-state.ts` and
|
|
9
|
+
* `prompt-history.ts` already use, and a stale lock is a worse failure than two
|
|
10
|
+
* concurrent TUIs overwriting each other's order once.
|
|
11
|
+
*
|
|
12
|
+
* NOTHING HERE THROWS (D-4). A corrupt file becomes a `.bak` copy plus an empty
|
|
13
|
+
* library; an unwritable file becomes `write_failed` while the new value STAYS IN
|
|
14
|
+
* MEMORY, because rolling back would make the prompt the user just typed vanish
|
|
15
|
+
* from the screen it was typed on.
|
|
16
|
+
*
|
|
17
|
+
* THE SNAPSHOT IS THE RESULT. Every mutation returns the new `PromptLibrary`, and
|
|
18
|
+
* that is what `App` renders. A component that computed its own optimistic library
|
|
19
|
+
* would be a second truth (I-6).
|
|
20
|
+
*/
|
|
21
|
+
import type { PromptDraftInput, PromptDraftVerdict, PromptLibrary, PromptMutationResult, PromptSortKey } from './types.js';
|
|
22
|
+
/**
|
|
23
|
+
* What the last read had to repair.
|
|
24
|
+
*
|
|
25
|
+
* `'entries'` is not a repair of the FILE: it counts entries that were dropped while the
|
|
26
|
+
* file itself was readable. The caller says something different for it ("N records were
|
|
27
|
+
* skipped" rather than "the file was backed up"), and folding the two together would
|
|
28
|
+
* promise a `.bak` that does not exist.
|
|
29
|
+
*/
|
|
30
|
+
type PromptRepair = {
|
|
31
|
+
dropped: number;
|
|
32
|
+
repaired: 'schema' | 'parse' | 'entries';
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Validate one draft, at the boundary where a draft becomes data (I-5).
|
|
36
|
+
*
|
|
37
|
+
* The editor calls this too, so the code that decides "that title is too long" and
|
|
38
|
+
* the code that decides which ROW owns the error cannot disagree - which is why it
|
|
39
|
+
* lives here rather than in the view model.
|
|
40
|
+
*/
|
|
41
|
+
export declare function validatePromptDraft(input: PromptDraftInput): PromptDraftVerdict;
|
|
42
|
+
/** Parsed tags from the editor's raw comma/space separated field. Never throws. */
|
|
43
|
+
export declare function parsePromptTags(raw: string): string[];
|
|
44
|
+
/** Read the cached snapshot, loading it once per process. Never throws. */
|
|
45
|
+
export declare function loadPromptLibrary(): PromptLibrary;
|
|
46
|
+
/**
|
|
47
|
+
* One-shot report of a repair or of dropped entries (§8).
|
|
48
|
+
*
|
|
49
|
+
* READ, AND CLEARED: the caller shows it once and the next caller gets `null`. A
|
|
50
|
+
* standing flag would make every later render believe the file is still broken.
|
|
51
|
+
*/
|
|
52
|
+
export declare function takePromptRepairNotice(): PromptRepair | null;
|
|
53
|
+
/** Monotonic count of successful writes (I-3). Tests assert "one change, one write". */
|
|
54
|
+
export declare function getPromptLibraryRevision(): number;
|
|
55
|
+
export declare function createPrompt(input: PromptDraftInput, now?: number): PromptMutationResult;
|
|
56
|
+
export declare function updatePrompt(id: string, patch: Partial<PromptDraftInput>, now?: number): PromptMutationResult;
|
|
57
|
+
export declare function deletePrompt(id: string): PromptMutationResult;
|
|
58
|
+
/** Swap one entry with its neighbour. A no-op at either end is a refusal, not a write. */
|
|
59
|
+
export declare function movePrompt(id: string, delta: -1 | 1): PromptMutationResult;
|
|
60
|
+
/** Move one entry to a 0-based absolute position (`/prompts move <ref> <to>`). */
|
|
61
|
+
export declare function reorderPrompt(fromIndex: number, toIndex: number): PromptMutationResult;
|
|
62
|
+
/**
|
|
63
|
+
* Replace the whole manual order in ONE write (`/prompts sort`).
|
|
64
|
+
*
|
|
65
|
+
* A sibling of `reorderPrompt` rather than a loop over it, because a sort touches
|
|
66
|
+
* every position: calling the single-move function N times would rewrite the whole
|
|
67
|
+
* library N times, which is the write amplification R-2 exists to bound.
|
|
68
|
+
*/
|
|
69
|
+
export declare function reorderPrompts(ids: readonly string[]): PromptMutationResult;
|
|
70
|
+
/**
|
|
71
|
+
* The ids `/prompts sort <key>` should write, in the order it should write them.
|
|
72
|
+
*
|
|
73
|
+
* `name` is title order, `recent` and `favorite` are the two views promoted to a
|
|
74
|
+
* persistent order. It returns IDS rather than a library so the caller can hand them
|
|
75
|
+
* straight to `reorderPrompts`, which is the one function that writes a whole order in a
|
|
76
|
+
* single pass (R-2).
|
|
77
|
+
*/
|
|
78
|
+
export declare function sortPromptIds(library: PromptLibrary, key: PromptSortKey): string[];
|
|
79
|
+
export declare function setPromptFavorite(id: string, favorite: boolean, now?: number): PromptMutationResult;
|
|
80
|
+
/** In-memory usage bump; the write is debounced (D-7). */
|
|
81
|
+
export declare function recordPromptUse(id: string, now?: number): void;
|
|
82
|
+
/** Write pending counters now. Idempotent: a clean library writes nothing. */
|
|
83
|
+
export declare function flushPromptStats(now?: number): void;
|
|
84
|
+
/** Test-only: forget the cache, the counters and the revision. */
|
|
85
|
+
export declare function resetPromptLibraryForTests(): void;
|
|
86
|
+
export {};
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quick-prompt library's ONLY read/write authority (quick-prompt-library D-3,
|
|
3
|
+
* D-4, D-14, I-6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/prompts/**` is inside `glyphs.test.ts::inScope`.
|
|
6
|
+
*
|
|
7
|
+
* EVERY MUTATION IS: read the current file, apply ONE pure change, write the whole
|
|
8
|
+
* library atomically. No lock (D-14) - this is the strategy `ui-state.ts` and
|
|
9
|
+
* `prompt-history.ts` already use, and a stale lock is a worse failure than two
|
|
10
|
+
* concurrent TUIs overwriting each other's order once.
|
|
11
|
+
*
|
|
12
|
+
* NOTHING HERE THROWS (D-4). A corrupt file becomes a `.bak` copy plus an empty
|
|
13
|
+
* library; an unwritable file becomes `write_failed` while the new value STAYS IN
|
|
14
|
+
* MEMORY, because rolling back would make the prompt the user just typed vanish
|
|
15
|
+
* from the screen it was typed on.
|
|
16
|
+
*
|
|
17
|
+
* THE SNAPSHOT IS THE RESULT. Every mutation returns the new `PromptLibrary`, and
|
|
18
|
+
* that is what `App` renders. A component that computed its own optimistic library
|
|
19
|
+
* would be a second truth (I-6).
|
|
20
|
+
*/
|
|
21
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
import process from 'node:process';
|
|
24
|
+
import { getHomeRoot, getQuickPromptsPath } from '../config/app-paths.js';
|
|
25
|
+
import { PROMPT_LIMITS } from './limits.js';
|
|
26
|
+
import { emptyPromptLibrary, normalizePromptLibrary } from './normalize.js';
|
|
27
|
+
import { promptIndexOf } from './resolve.js';
|
|
28
|
+
import { sortedPromptTitleOrder, viewOrder } from './search.js';
|
|
29
|
+
/** How long usage counters may sit in memory before they are written (D-7). */
|
|
30
|
+
const STATS_FLUSH_MS = 1000;
|
|
31
|
+
/** Same suffix as `ui-state.ts`: one writer per pid, never a stale temp file. */
|
|
32
|
+
const TMP_SUFFIX = 'tmp';
|
|
33
|
+
let memory = null;
|
|
34
|
+
let revision = 0;
|
|
35
|
+
let statsDirty = false;
|
|
36
|
+
let flushTimer = null;
|
|
37
|
+
let pendingRepair = null;
|
|
38
|
+
function codePoints(text) {
|
|
39
|
+
return [...text].length;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate one draft, at the boundary where a draft becomes data (I-5).
|
|
43
|
+
*
|
|
44
|
+
* The editor calls this too, so the code that decides "that title is too long" and
|
|
45
|
+
* the code that decides which ROW owns the error cannot disagree - which is why it
|
|
46
|
+
* lives here rather than in the view model.
|
|
47
|
+
*/
|
|
48
|
+
export function validatePromptDraft(input) {
|
|
49
|
+
const title = input.title.trim();
|
|
50
|
+
const content = input.content;
|
|
51
|
+
if (title.length === 0)
|
|
52
|
+
return { ok: false, code: 'title_required', field: 'title' };
|
|
53
|
+
if (codePoints(title) > PROMPT_LIMITS.titleMaxChars) {
|
|
54
|
+
return { ok: false, code: 'title_too_long', field: 'title' };
|
|
55
|
+
}
|
|
56
|
+
if (content.trim().length === 0)
|
|
57
|
+
return { ok: false, code: 'content_required', field: 'content' };
|
|
58
|
+
if (codePoints(content) > PROMPT_LIMITS.contentMaxChars) {
|
|
59
|
+
return { ok: false, code: 'content_too_long', field: 'content' };
|
|
60
|
+
}
|
|
61
|
+
const description = (input.description ?? '').trim();
|
|
62
|
+
if (codePoints(description) > PROMPT_LIMITS.descriptionMaxChars) {
|
|
63
|
+
return { ok: false, code: 'description_too_long', field: 'description' };
|
|
64
|
+
}
|
|
65
|
+
const tags = input.tags ?? [];
|
|
66
|
+
if (tags.length > PROMPT_LIMITS.maxTags) {
|
|
67
|
+
return { ok: false, code: 'too_many_tags', field: 'tags' };
|
|
68
|
+
}
|
|
69
|
+
if (tags.some((tag) => codePoints(tag) > PROMPT_LIMITS.tagMaxChars)) {
|
|
70
|
+
return { ok: false, code: 'tag_too_long', field: 'tags' };
|
|
71
|
+
}
|
|
72
|
+
const verdict = { title, content };
|
|
73
|
+
if (description.length > 0)
|
|
74
|
+
verdict.description = description;
|
|
75
|
+
if (tags.length > 0)
|
|
76
|
+
verdict.tags = [...tags];
|
|
77
|
+
if (input.favorite === true)
|
|
78
|
+
verdict.favorite = true;
|
|
79
|
+
return { ok: true, input: verdict };
|
|
80
|
+
}
|
|
81
|
+
/** Parsed tags from the editor's raw comma/space separated field. Never throws. */
|
|
82
|
+
export function parsePromptTags(raw) {
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
const tags = [];
|
|
85
|
+
for (const part of raw.split(/[,;\s]+/)) {
|
|
86
|
+
const tag = part.trim();
|
|
87
|
+
if (tag.length === 0 || seen.has(tag))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(tag);
|
|
90
|
+
tags.push(tag);
|
|
91
|
+
if (tags.length >= PROMPT_LIMITS.maxTags)
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
return tags;
|
|
95
|
+
}
|
|
96
|
+
/** Copy the unreadable file aside once. Best effort: a failure here is not fatal. */
|
|
97
|
+
function backupBrokenFile() {
|
|
98
|
+
const path = getQuickPromptsPath();
|
|
99
|
+
try {
|
|
100
|
+
copyFileSync(path, `${path}.bak`);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// A read-only directory is the same class of problem as the write failure
|
|
104
|
+
// below: report the empty library, keep the original file, do not throw.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Atomic replace, copied from `ui-state.ts:179-194`.
|
|
109
|
+
*
|
|
110
|
+
* `mkdirSync` first because `<home>` is not guaranteed to exist, and `chmodSync`
|
|
111
|
+
* after the rename because rename does not preserve the mode on every platform.
|
|
112
|
+
*/
|
|
113
|
+
function writeToDisk(library) {
|
|
114
|
+
const path = getQuickPromptsPath();
|
|
115
|
+
const tmp = `${path}.${TMP_SUFFIX}-${process.pid}`;
|
|
116
|
+
try {
|
|
117
|
+
mkdirSync(getHomeRoot(), { recursive: true });
|
|
118
|
+
writeFileSync(tmp, `${JSON.stringify(library, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
|
|
119
|
+
renameSync(tmp, path);
|
|
120
|
+
if (process.platform !== 'win32')
|
|
121
|
+
chmodSync(path, 0o600);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
try {
|
|
126
|
+
rmSync(tmp, { force: true });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Nothing further to try; the value stays in memory (D-4).
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function readFromDisk() {
|
|
135
|
+
const path = getQuickPromptsPath();
|
|
136
|
+
// THE FILE IS NOT CREATED ON READ (D-3): a user who never adds a prompt has no
|
|
137
|
+
// library file, and an empty list is the honest representation of that.
|
|
138
|
+
if (!existsSync(path))
|
|
139
|
+
return emptyPromptLibrary();
|
|
140
|
+
let parsed;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(readFileSync(path, 'utf-8'));
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
backupBrokenFile();
|
|
146
|
+
pendingRepair = { dropped: 0, repaired: 'parse' };
|
|
147
|
+
return emptyPromptLibrary();
|
|
148
|
+
}
|
|
149
|
+
const result = normalizePromptLibrary(parsed);
|
|
150
|
+
if (result.repaired !== 'none') {
|
|
151
|
+
backupBrokenFile();
|
|
152
|
+
pendingRepair = { dropped: result.dropped, repaired: result.repaired };
|
|
153
|
+
}
|
|
154
|
+
else if (result.dropped > 0) {
|
|
155
|
+
// Readable file, unusable records: no `.bak` is written, because there is nothing
|
|
156
|
+
// wrong with the file to preserve - but the user still has to be told.
|
|
157
|
+
pendingRepair = { dropped: result.dropped, repaired: 'entries' };
|
|
158
|
+
}
|
|
159
|
+
return result.library;
|
|
160
|
+
}
|
|
161
|
+
/** Read the cached snapshot, loading it once per process. Never throws. */
|
|
162
|
+
export function loadPromptLibrary() {
|
|
163
|
+
if (memory === null)
|
|
164
|
+
memory = readFromDisk();
|
|
165
|
+
return memory;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* One-shot report of a repair or of dropped entries (§8).
|
|
169
|
+
*
|
|
170
|
+
* READ, AND CLEARED: the caller shows it once and the next caller gets `null`. A
|
|
171
|
+
* standing flag would make every later render believe the file is still broken.
|
|
172
|
+
*/
|
|
173
|
+
export function takePromptRepairNotice() {
|
|
174
|
+
const notice = pendingRepair;
|
|
175
|
+
pendingRepair = null;
|
|
176
|
+
return notice;
|
|
177
|
+
}
|
|
178
|
+
/** Monotonic count of successful writes (I-3). Tests assert "one change, one write". */
|
|
179
|
+
export function getPromptLibraryRevision() {
|
|
180
|
+
return revision;
|
|
181
|
+
}
|
|
182
|
+
function stamp(library, now) {
|
|
183
|
+
return { ...library, updatedAt: Math.max(now, library.updatedAt + 1) };
|
|
184
|
+
}
|
|
185
|
+
function refuse(code, library) {
|
|
186
|
+
return { ok: false, code, library };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The single write path. `fn` must be pure: it may return a new library or a
|
|
190
|
+
* refusal, and it never touches the filesystem itself.
|
|
191
|
+
*/
|
|
192
|
+
function mutate(fn, now = Date.now()) {
|
|
193
|
+
// PENDING STATISTICS GO FIRST, or the fresh read below would drop the usage
|
|
194
|
+
// counts this process has already shown the user.
|
|
195
|
+
if (statsDirty)
|
|
196
|
+
flushPromptStats(now);
|
|
197
|
+
const current = readFromDisk();
|
|
198
|
+
const outcome = fn(current);
|
|
199
|
+
if (!outcome.ok) {
|
|
200
|
+
memory = current;
|
|
201
|
+
return refuse(outcome.code, current);
|
|
202
|
+
}
|
|
203
|
+
const next = stamp(outcome.library, now);
|
|
204
|
+
const written = writeToDisk(next);
|
|
205
|
+
memory = next;
|
|
206
|
+
// A FAILED WRITE IS STILL AN APPLIED CHANGE (D-4): `memory` already holds it, so
|
|
207
|
+
// rolling back would make the prompt the user just typed vanish from the screen it was
|
|
208
|
+
// typed on. Only `revision` - "how many times the DISK changed" - moves on success,
|
|
209
|
+
// which is exactly what the "one change, one write" assertions count.
|
|
210
|
+
if (!written)
|
|
211
|
+
return refuse('write_failed', next);
|
|
212
|
+
revision += 1;
|
|
213
|
+
return outcome.id === undefined
|
|
214
|
+
? { ok: true, library: next }
|
|
215
|
+
: { ok: true, library: next, id: outcome.id };
|
|
216
|
+
}
|
|
217
|
+
function withPrompts(library, prompts) {
|
|
218
|
+
return { ...library, prompts };
|
|
219
|
+
}
|
|
220
|
+
function findEntry(library, id) {
|
|
221
|
+
return library.prompts.find((prompt) => prompt.id === id);
|
|
222
|
+
}
|
|
223
|
+
/** Apply a validated draft onto an existing entry, dropping the optional fields. */
|
|
224
|
+
function applyDraft(entry, input, now) {
|
|
225
|
+
const next = {
|
|
226
|
+
...entry,
|
|
227
|
+
title: input.title,
|
|
228
|
+
content: input.content,
|
|
229
|
+
updatedAt: Math.max(now, entry.updatedAt + 1),
|
|
230
|
+
};
|
|
231
|
+
if (input.description !== undefined)
|
|
232
|
+
next.description = input.description;
|
|
233
|
+
else
|
|
234
|
+
delete next.description;
|
|
235
|
+
if (input.tags !== undefined)
|
|
236
|
+
next.tags = [...input.tags];
|
|
237
|
+
else
|
|
238
|
+
delete next.tags;
|
|
239
|
+
if (input.favorite === true)
|
|
240
|
+
next.favorite = true;
|
|
241
|
+
else
|
|
242
|
+
delete next.favorite;
|
|
243
|
+
return next;
|
|
244
|
+
}
|
|
245
|
+
export function createPrompt(input, now = Date.now()) {
|
|
246
|
+
const verdict = validatePromptDraft(input);
|
|
247
|
+
if (!verdict.ok)
|
|
248
|
+
return refuse(verdict.code, loadPromptLibrary());
|
|
249
|
+
return mutate((current) => {
|
|
250
|
+
if (current.prompts.length >= PROMPT_LIMITS.maxPrompts) {
|
|
251
|
+
return { ok: false, code: 'too_many_prompts' };
|
|
252
|
+
}
|
|
253
|
+
const entry = {
|
|
254
|
+
id: randomUUID(),
|
|
255
|
+
title: verdict.input.title,
|
|
256
|
+
content: verdict.input.content,
|
|
257
|
+
useCount: 0,
|
|
258
|
+
createdAt: now,
|
|
259
|
+
updatedAt: now,
|
|
260
|
+
};
|
|
261
|
+
if (verdict.input.description !== undefined)
|
|
262
|
+
entry.description = verdict.input.description;
|
|
263
|
+
if (verdict.input.tags !== undefined)
|
|
264
|
+
entry.tags = [...verdict.input.tags];
|
|
265
|
+
if (verdict.input.favorite === true)
|
|
266
|
+
entry.favorite = true;
|
|
267
|
+
return {
|
|
268
|
+
ok: true,
|
|
269
|
+
library: withPrompts(current, [...current.prompts, entry]),
|
|
270
|
+
id: entry.id,
|
|
271
|
+
};
|
|
272
|
+
}, now);
|
|
273
|
+
}
|
|
274
|
+
export function updatePrompt(id, patch, now = Date.now()) {
|
|
275
|
+
return mutate((current) => {
|
|
276
|
+
const entry = findEntry(current, id);
|
|
277
|
+
if (!entry)
|
|
278
|
+
return { ok: false, code: 'not_found' };
|
|
279
|
+
// A PARTIAL PATCH, merged over the stored entry BEFORE validation: `/prompts edit`
|
|
280
|
+
// sends whole drafts, but the store's own contract (and every test) may send one
|
|
281
|
+
// field, and validating `{ title }` alone would answer `content_required`.
|
|
282
|
+
const merged = {
|
|
283
|
+
title: patch.title ?? entry.title,
|
|
284
|
+
content: patch.content ?? entry.content,
|
|
285
|
+
};
|
|
286
|
+
const description = patch.description ?? entry.description;
|
|
287
|
+
if (description !== undefined)
|
|
288
|
+
merged.description = description;
|
|
289
|
+
const tags = patch.tags ?? entry.tags;
|
|
290
|
+
if (tags !== undefined)
|
|
291
|
+
merged.tags = [...tags];
|
|
292
|
+
const favorite = patch.favorite ?? entry.favorite;
|
|
293
|
+
if (favorite !== undefined)
|
|
294
|
+
merged.favorite = favorite;
|
|
295
|
+
const verdict = validatePromptDraft(merged);
|
|
296
|
+
if (!verdict.ok)
|
|
297
|
+
return { ok: false, code: verdict.code };
|
|
298
|
+
return {
|
|
299
|
+
ok: true,
|
|
300
|
+
library: withPrompts(current, current.prompts.map((prompt) => prompt.id === id ? applyDraft(entry, verdict.input, now) : prompt)),
|
|
301
|
+
id,
|
|
302
|
+
};
|
|
303
|
+
}, now);
|
|
304
|
+
}
|
|
305
|
+
export function deletePrompt(id) {
|
|
306
|
+
return mutate((current) => {
|
|
307
|
+
if (!findEntry(current, id))
|
|
308
|
+
return { ok: false, code: 'not_found' };
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
library: withPrompts(current, current.prompts.filter((prompt) => prompt.id !== id)),
|
|
312
|
+
id,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
/** Swap one entry with its neighbour. A no-op at either end is a refusal, not a write. */
|
|
317
|
+
export function movePrompt(id, delta) {
|
|
318
|
+
return mutate((current) => {
|
|
319
|
+
const at = promptIndexOf(current, id);
|
|
320
|
+
if (at < 0)
|
|
321
|
+
return { ok: false, code: 'not_found' };
|
|
322
|
+
const to = at + delta;
|
|
323
|
+
if (to < 0 || to >= current.prompts.length) {
|
|
324
|
+
return { ok: false, code: 'not_found' };
|
|
325
|
+
}
|
|
326
|
+
const prompts = [...current.prompts];
|
|
327
|
+
const [entry] = prompts.splice(at, 1);
|
|
328
|
+
prompts.splice(to, 0, entry);
|
|
329
|
+
return { ok: true, library: withPrompts(current, prompts), id };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
/** Move one entry to a 0-based absolute position (`/prompts move <ref> <to>`). */
|
|
333
|
+
export function reorderPrompt(fromIndex, toIndex) {
|
|
334
|
+
return mutate((current) => {
|
|
335
|
+
if (fromIndex < 0 || fromIndex >= current.prompts.length) {
|
|
336
|
+
return { ok: false, code: 'not_found' };
|
|
337
|
+
}
|
|
338
|
+
if (toIndex < 0 || toIndex >= current.prompts.length) {
|
|
339
|
+
return { ok: false, code: 'not_found' };
|
|
340
|
+
}
|
|
341
|
+
const moving = current.prompts[fromIndex];
|
|
342
|
+
if (fromIndex === toIndex) {
|
|
343
|
+
return { ok: true, library: current, id: moving.id };
|
|
344
|
+
}
|
|
345
|
+
const prompts = [...current.prompts];
|
|
346
|
+
const [entry] = prompts.splice(fromIndex, 1);
|
|
347
|
+
prompts.splice(toIndex, 0, entry);
|
|
348
|
+
return { ok: true, library: withPrompts(current, prompts), id: moving.id };
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Replace the whole manual order in ONE write (`/prompts sort`).
|
|
353
|
+
*
|
|
354
|
+
* A sibling of `reorderPrompt` rather than a loop over it, because a sort touches
|
|
355
|
+
* every position: calling the single-move function N times would rewrite the whole
|
|
356
|
+
* library N times, which is the write amplification R-2 exists to bound.
|
|
357
|
+
*/
|
|
358
|
+
export function reorderPrompts(ids) {
|
|
359
|
+
return mutate((current) => {
|
|
360
|
+
const byId = new Map(current.prompts.map((prompt) => [prompt.id, prompt]));
|
|
361
|
+
const prompts = [];
|
|
362
|
+
for (const id of ids) {
|
|
363
|
+
const entry = byId.get(id);
|
|
364
|
+
if (!entry)
|
|
365
|
+
continue;
|
|
366
|
+
prompts.push(entry);
|
|
367
|
+
byId.delete(id);
|
|
368
|
+
}
|
|
369
|
+
// Anything the caller did not name keeps its relative position at the tail
|
|
370
|
+
// rather than being dropped: a lost entry is data loss, a tail position is a
|
|
371
|
+
// cosmetic surprise.
|
|
372
|
+
for (const entry of current.prompts)
|
|
373
|
+
if (byId.has(entry.id))
|
|
374
|
+
prompts.push(entry);
|
|
375
|
+
return { ok: true, library: withPrompts(current, prompts) };
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* The ids `/prompts sort <key>` should write, in the order it should write them.
|
|
380
|
+
*
|
|
381
|
+
* `name` is title order, `recent` and `favorite` are the two views promoted to a
|
|
382
|
+
* persistent order. It returns IDS rather than a library so the caller can hand them
|
|
383
|
+
* straight to `reorderPrompts`, which is the one function that writes a whole order in a
|
|
384
|
+
* single pass (R-2).
|
|
385
|
+
*/
|
|
386
|
+
export function sortPromptIds(library, key) {
|
|
387
|
+
const ordered = key === 'name'
|
|
388
|
+
? sortedPromptTitleOrder(library.prompts)
|
|
389
|
+
: viewOrder(library.prompts, key);
|
|
390
|
+
return ordered.map((prompt) => prompt.id);
|
|
391
|
+
}
|
|
392
|
+
export function setPromptFavorite(id, favorite, now = Date.now()) {
|
|
393
|
+
return mutate((current) => {
|
|
394
|
+
const entry = findEntry(current, id);
|
|
395
|
+
if (!entry)
|
|
396
|
+
return { ok: false, code: 'not_found' };
|
|
397
|
+
const replacement = { ...entry, updatedAt: Math.max(now, entry.updatedAt + 1) };
|
|
398
|
+
if (favorite)
|
|
399
|
+
replacement.favorite = true;
|
|
400
|
+
else
|
|
401
|
+
delete replacement.favorite;
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
library: withPrompts(current, current.prompts.map((prompt) => (prompt.id === id ? replacement : prompt))),
|
|
405
|
+
id,
|
|
406
|
+
};
|
|
407
|
+
}, now);
|
|
408
|
+
}
|
|
409
|
+
/** In-memory usage bump; the write is debounced (D-7). */
|
|
410
|
+
export function recordPromptUse(id, now = Date.now()) {
|
|
411
|
+
const current = loadPromptLibrary();
|
|
412
|
+
const at = promptIndexOf(current, id);
|
|
413
|
+
if (at < 0)
|
|
414
|
+
return;
|
|
415
|
+
const entry = current.prompts[at];
|
|
416
|
+
const prompts = [...current.prompts];
|
|
417
|
+
prompts[at] = { ...entry, useCount: entry.useCount + 1, lastUsedAt: now };
|
|
418
|
+
memory = withPrompts(current, prompts);
|
|
419
|
+
statsDirty = true;
|
|
420
|
+
if (flushTimer)
|
|
421
|
+
return;
|
|
422
|
+
flushTimer = setTimeout(() => {
|
|
423
|
+
flushTimer = null;
|
|
424
|
+
flushPromptStats();
|
|
425
|
+
}, STATS_FLUSH_MS);
|
|
426
|
+
flushTimer.unref?.();
|
|
427
|
+
}
|
|
428
|
+
/** Write pending counters now. Idempotent: a clean library writes nothing. */
|
|
429
|
+
export function flushPromptStats(now = Date.now()) {
|
|
430
|
+
if (flushTimer) {
|
|
431
|
+
clearTimeout(flushTimer);
|
|
432
|
+
flushTimer = null;
|
|
433
|
+
}
|
|
434
|
+
if (!statsDirty || memory === null)
|
|
435
|
+
return;
|
|
436
|
+
const next = stamp(memory, now);
|
|
437
|
+
const written = writeToDisk(next);
|
|
438
|
+
memory = next;
|
|
439
|
+
statsDirty = false;
|
|
440
|
+
if (written)
|
|
441
|
+
revision += 1;
|
|
442
|
+
}
|
|
443
|
+
/** Test-only: forget the cache, the counters and the revision. */
|
|
444
|
+
export function resetPromptLibraryForTests() {
|
|
445
|
+
if (flushTimer) {
|
|
446
|
+
clearTimeout(flushTimer);
|
|
447
|
+
flushTimer = null;
|
|
448
|
+
}
|
|
449
|
+
memory = null;
|
|
450
|
+
revision = 0;
|
|
451
|
+
statsDirty = false;
|
|
452
|
+
pendingRepair = null;
|
|
453
|
+
}
|