@sigrun/ext-curator 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +60 -2
- package/dist/backoff.d.ts +22 -0
- package/dist/backoff.js +39 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +143 -0
- package/dist/learn.d.ts +60 -0
- package/dist/learn.js +97 -0
- package/dist/loop1.d.ts +77 -0
- package/dist/loop1.js +250 -0
- package/dist/loop2.d.ts +108 -0
- package/dist/loop2.js +323 -0
- package/dist/notices.d.ts +54 -0
- package/dist/notices.js +127 -0
- package/dist/prompts.d.ts +45 -0
- package/dist/prompts.js +57 -0
- package/dist/queue.d.ts +111 -0
- package/dist/queue.js +237 -0
- package/dist/settings.d.ts +38 -0
- package/dist/settings.js +56 -0
- package/dist/steps/archive.d.ts +40 -0
- package/dist/steps/archive.js +88 -0
- package/dist/steps/diet.d.ts +29 -0
- package/dist/steps/diet.js +131 -0
- package/dist/steps/digests.d.ts +55 -0
- package/dist/steps/digests.js +243 -0
- package/dist/steps/merge.d.ts +17 -0
- package/dist/steps/merge.js +81 -0
- package/dist/steps/overlays.d.ts +88 -0
- package/dist/steps/overlays.js +358 -0
- package/dist/steps/run.d.ts +77 -0
- package/dist/steps/run.js +76 -0
- package/dist/steps/targets.d.ts +16 -0
- package/dist/steps/targets.js +42 -0
- package/dist/view.d.ts +92 -0
- package/dist/view.js +405 -0
- package/package.json +55 -5
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post-review notice (`src/notices.ts`, Task B5): what the user
|
|
3
|
+
* sees when a review changed something — ONE ambient record on the
|
|
4
|
+
* originating conversation (`curator.notice`, place `status`), one
|
|
5
|
+
* line per change (`Learned: <summary>`), with a `navigate` action to
|
|
6
|
+
* the curator view anchored at the review.
|
|
7
|
+
*
|
|
8
|
+
* The ambient seam is the ONLY carrier (the brief): a notice is never
|
|
9
|
+
* a transcript entry (a fork would replay it) and never a notifier or
|
|
10
|
+
* channel delivery (gateway channels get no notice by default, spec
|
|
11
|
+
* P8). Ambient records are display-only — no action route reaches
|
|
12
|
+
* them — so Undo lives on the curator VIEW; the notice's button just
|
|
13
|
+
* navigates there.
|
|
14
|
+
*
|
|
15
|
+
* The body must pass the server's own ambient validation (a `status`
|
|
16
|
+
* slot allows ONE line-level block, literal content only — ambient
|
|
17
|
+
* bodies carry no data document, so bindings are rejected): a single
|
|
18
|
+
* `callout` whose title joins the change lines and whose actions
|
|
19
|
+
* carry the navigate button. One line-level block per seam rule.
|
|
20
|
+
*
|
|
21
|
+
* DURABILITY: each notice is remembered in `notices.json` under the
|
|
22
|
+
* user's state dir (`readNotices` is the view's lookup) — an Undo
|
|
23
|
+
* after a restart still knows which conversation's ambient record to
|
|
24
|
+
* clear. The file is bounded: recording a notice replaces the entry
|
|
25
|
+
* of the SAME review id (a re-run of one review id is one notice) and
|
|
26
|
+
* the file keeps the newest 50 (matching the feed window the view
|
|
27
|
+
* shows — older notices have nothing left to undo once the feed rolls
|
|
28
|
+
* past them).
|
|
29
|
+
*/
|
|
30
|
+
import type { ConversationId } from "@earendil-works/pi-durable";
|
|
31
|
+
import type { CurationRunResult, SigrunHost } from "@sigrun/sigrun/extension";
|
|
32
|
+
import { CURATOR_NOTICE_KIND } from "./index.js";
|
|
33
|
+
export { CURATOR_NOTICE_KIND };
|
|
34
|
+
/** What one recorded notice remembers (the view's undo lookup). */
|
|
35
|
+
export interface NoticeRecord {
|
|
36
|
+
/** The review the notice announced (the undo key). */
|
|
37
|
+
readonly reviewId: string;
|
|
38
|
+
/** The conversation whose ambient slot carries the notice. */
|
|
39
|
+
readonly conversationId: ConversationId;
|
|
40
|
+
/** When the notice was set (ISO). */
|
|
41
|
+
readonly at: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Announce one completed review: when the result carries changes, set
|
|
45
|
+
* the `curator.notice` ambient record on the conversation and record
|
|
46
|
+
* the notice durably. No changes → nothing (an unchanged conversation
|
|
47
|
+
* stays untouched). Answers the notice record (undefined when no
|
|
48
|
+
* notice was set).
|
|
49
|
+
*/
|
|
50
|
+
export declare function notify(host: SigrunHost, conversationId: ConversationId, user: string, result: CurationRunResult & {
|
|
51
|
+
readonly reviewId?: string;
|
|
52
|
+
}): Promise<NoticeRecord | undefined>;
|
|
53
|
+
/** Read the user's recorded notices (newest first); absent/malformed → []. */
|
|
54
|
+
export declare function readNotices(host: SigrunHost, user: string): Promise<readonly NoticeRecord[]>;
|
package/dist/notices.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post-review notice (`src/notices.ts`, Task B5): what the user
|
|
3
|
+
* sees when a review changed something — ONE ambient record on the
|
|
4
|
+
* originating conversation (`curator.notice`, place `status`), one
|
|
5
|
+
* line per change (`Learned: <summary>`), with a `navigate` action to
|
|
6
|
+
* the curator view anchored at the review.
|
|
7
|
+
*
|
|
8
|
+
* The ambient seam is the ONLY carrier (the brief): a notice is never
|
|
9
|
+
* a transcript entry (a fork would replay it) and never a notifier or
|
|
10
|
+
* channel delivery (gateway channels get no notice by default, spec
|
|
11
|
+
* P8). Ambient records are display-only — no action route reaches
|
|
12
|
+
* them — so Undo lives on the curator VIEW; the notice's button just
|
|
13
|
+
* navigates there.
|
|
14
|
+
*
|
|
15
|
+
* The body must pass the server's own ambient validation (a `status`
|
|
16
|
+
* slot allows ONE line-level block, literal content only — ambient
|
|
17
|
+
* bodies carry no data document, so bindings are rejected): a single
|
|
18
|
+
* `callout` whose title joins the change lines and whose actions
|
|
19
|
+
* carry the navigate button. One line-level block per seam rule.
|
|
20
|
+
*
|
|
21
|
+
* DURABILITY: each notice is remembered in `notices.json` under the
|
|
22
|
+
* user's state dir (`readNotices` is the view's lookup) — an Undo
|
|
23
|
+
* after a restart still knows which conversation's ambient record to
|
|
24
|
+
* clear. The file is bounded: recording a notice replaces the entry
|
|
25
|
+
* of the SAME review id (a re-run of one review id is one notice) and
|
|
26
|
+
* the file keeps the newest 50 (matching the feed window the view
|
|
27
|
+
* shows — older notices have nothing left to undo once the feed rolls
|
|
28
|
+
* past them).
|
|
29
|
+
*/
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { CURATOR_NOTICE_KIND } from "./index.js";
|
|
32
|
+
export { CURATOR_NOTICE_KIND };
|
|
33
|
+
/** The notice ledger's file name under the user's state dir. */
|
|
34
|
+
const NOTICES_FILE = "notices.json";
|
|
35
|
+
/** How many notices the ledger keeps (the view's feed window). */
|
|
36
|
+
const NOTICE_LEDGER_MAX = 50;
|
|
37
|
+
/** The zero-signal context the ambient write runs under (the review
|
|
38
|
+
* body has no caller signal of its own — the run's deadline bound it). */
|
|
39
|
+
const CONTEXT = { abortSignal: undefined };
|
|
40
|
+
/**
|
|
41
|
+
* Announce one completed review: when the result carries changes, set
|
|
42
|
+
* the `curator.notice` ambient record on the conversation and record
|
|
43
|
+
* the notice durably. No changes → nothing (an unchanged conversation
|
|
44
|
+
* stays untouched). Answers the notice record (undefined when no
|
|
45
|
+
* notice was set).
|
|
46
|
+
*/
|
|
47
|
+
export async function notify(host, conversationId, user, result) {
|
|
48
|
+
if (result.status !== "completed")
|
|
49
|
+
return undefined;
|
|
50
|
+
if (result.changes.length === 0)
|
|
51
|
+
return undefined;
|
|
52
|
+
const reviewId = result.reviewId;
|
|
53
|
+
if (reviewId === undefined || reviewId === "")
|
|
54
|
+
return undefined;
|
|
55
|
+
const at = new Date().toISOString();
|
|
56
|
+
await host.setAmbient(conversationId, {
|
|
57
|
+
key: CURATOR_NOTICE_KIND,
|
|
58
|
+
place: "status",
|
|
59
|
+
body: noticeBody(result.changes.map((change) => change.summary), reviewId),
|
|
60
|
+
}, CONTEXT);
|
|
61
|
+
const record = { reviewId, conversationId, at };
|
|
62
|
+
await recordNotice(host, user, record);
|
|
63
|
+
return record;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The notice body: ONE line-level `callout` (the `status` slot's
|
|
67
|
+
* one-line rule) whose title joins the change summaries and whose
|
|
68
|
+
* actions navigate to the curator view anchored at the review.
|
|
69
|
+
*/
|
|
70
|
+
function noticeBody(summaries, reviewId) {
|
|
71
|
+
const lines = summaries.map((summary) => `Learned: ${summary}`);
|
|
72
|
+
return {
|
|
73
|
+
v: 1,
|
|
74
|
+
root: {
|
|
75
|
+
type: "callout",
|
|
76
|
+
id: "curator-notice",
|
|
77
|
+
tone: "neutral",
|
|
78
|
+
// The one-line rule makes the title the line the channels show;
|
|
79
|
+
// multiple changes join on the same line (a statusline slot).
|
|
80
|
+
title: lines.join(" · "),
|
|
81
|
+
body: "Open the curator view to review or undo.",
|
|
82
|
+
actions: [
|
|
83
|
+
{
|
|
84
|
+
id: "open",
|
|
85
|
+
label: "Curator",
|
|
86
|
+
icon: "book",
|
|
87
|
+
action: {
|
|
88
|
+
type: "navigate",
|
|
89
|
+
target: {
|
|
90
|
+
view: {
|
|
91
|
+
extension: "curator",
|
|
92
|
+
id: "curator",
|
|
93
|
+
params: { review: reviewId },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** The ledger file path for one user. */
|
|
103
|
+
function noticesFile(host, user) {
|
|
104
|
+
return join(host.stores.user(user).paths.state, NOTICES_FILE);
|
|
105
|
+
}
|
|
106
|
+
/** Read the user's recorded notices (newest first); absent/malformed → []. */
|
|
107
|
+
export async function readNotices(host, user) {
|
|
108
|
+
const { readStateJson } = await import("./queue.js");
|
|
109
|
+
const parsed = await readStateJson(noticesFile(host, user), []);
|
|
110
|
+
if (!Array.isArray(parsed))
|
|
111
|
+
return [];
|
|
112
|
+
return parsed.filter((entry) => typeof entry === "object" &&
|
|
113
|
+
entry !== null &&
|
|
114
|
+
typeof entry.reviewId === "string" &&
|
|
115
|
+
typeof entry.conversationId === "number" &&
|
|
116
|
+
typeof entry.at === "string");
|
|
117
|
+
}
|
|
118
|
+
/** Record one notice: replace the same review's entry, keep the newest 50. */
|
|
119
|
+
async function recordNotice(host, user, record) {
|
|
120
|
+
const { writeStateJson } = await import("./queue.js");
|
|
121
|
+
const current = await readNotices(host, user);
|
|
122
|
+
const next = [
|
|
123
|
+
record,
|
|
124
|
+
...current.filter((entry) => entry.reviewId !== record.reviewId),
|
|
125
|
+
].slice(0, NOTICE_LEDGER_MAX);
|
|
126
|
+
await writeStateJson(host.stores.user(user).paths.state, NOTICES_FILE, next);
|
|
127
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review prompt (`src/prompts.ts`, Task B2): what a fork review or
|
|
3
|
+
* digest review submits as its only input. The fork carries the
|
|
4
|
+
* parent's transcript, so the instruction is the reviewer's brief, not
|
|
5
|
+
* the material.
|
|
6
|
+
*
|
|
7
|
+
* The CONTENT is spec §6.2's review-prompt requirements (the wording is
|
|
8
|
+
* ours; the snapshot test pins it once written):
|
|
9
|
+
*
|
|
10
|
+
* - what a skill is — a procedure for a repeatable class of task, for
|
|
11
|
+
* this user; pitfalls as a rule plus one clause of why; no incident
|
|
12
|
+
* narration, dates, PR numbers or quoted chatter;
|
|
13
|
+
* - the do-not-capture list — environment failures, negative claims
|
|
14
|
+
* about tools, transient errors, unresolved failures presented as
|
|
15
|
+
* workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
|
|
16
|
+
* secrets, and personal facts (a memory target when one is offered);
|
|
17
|
+
* - the routing rule — each lesson goes in exactly one place: one
|
|
18
|
+
* skill, one overlay, or one target;
|
|
19
|
+
* - update the skill in play before creating one; class-level names;
|
|
20
|
+
* - "Nothing to save" is a valid outcome;
|
|
21
|
+
* - never store another participant's words;
|
|
22
|
+
* - the hint arguments and exactly the allowed tools.
|
|
23
|
+
*
|
|
24
|
+
* Loop 2's digest reviews reuse this instruction verbatim (Task B4).
|
|
25
|
+
*/
|
|
26
|
+
import type { CurationHint } from "@sigrun/sigrun/extension";
|
|
27
|
+
/** What `reviewInstruction` needs (the brief's exact shape). */
|
|
28
|
+
export interface ReviewInstructionOpts {
|
|
29
|
+
/** Owner write attempts the review folds in (empty: none pending). */
|
|
30
|
+
readonly hints: readonly CurationHint[];
|
|
31
|
+
/** Curation targets offered to this run, with their guidance. */
|
|
32
|
+
readonly targets: readonly {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly guidance: readonly string[];
|
|
35
|
+
}[];
|
|
36
|
+
/** The run's allow list — the instruction names these and no others. */
|
|
37
|
+
readonly allow: readonly string[];
|
|
38
|
+
/** `/learn`'s optional focus, riding along when given. */
|
|
39
|
+
readonly focus?: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the review instruction (the run's only input). Deterministic:
|
|
43
|
+
* fixed inputs produce the fixed text the snapshot pins.
|
|
44
|
+
*/
|
|
45
|
+
export declare function reviewInstruction(opts: ReviewInstructionOpts): string;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review prompt (`src/prompts.ts`, Task B2): what a fork review or
|
|
3
|
+
* digest review submits as its only input. The fork carries the
|
|
4
|
+
* parent's transcript, so the instruction is the reviewer's brief, not
|
|
5
|
+
* the material.
|
|
6
|
+
*
|
|
7
|
+
* The CONTENT is spec §6.2's review-prompt requirements (the wording is
|
|
8
|
+
* ours; the snapshot test pins it once written):
|
|
9
|
+
*
|
|
10
|
+
* - what a skill is — a procedure for a repeatable class of task, for
|
|
11
|
+
* this user; pitfalls as a rule plus one clause of why; no incident
|
|
12
|
+
* narration, dates, PR numbers or quoted chatter;
|
|
13
|
+
* - the do-not-capture list — environment failures, negative claims
|
|
14
|
+
* about tools, transient errors, unresolved failures presented as
|
|
15
|
+
* workflows, one-off tasks, codebase facts (the repo's AGENTS.md),
|
|
16
|
+
* secrets, and personal facts (a memory target when one is offered);
|
|
17
|
+
* - the routing rule — each lesson goes in exactly one place: one
|
|
18
|
+
* skill, one overlay, or one target;
|
|
19
|
+
* - update the skill in play before creating one; class-level names;
|
|
20
|
+
* - "Nothing to save" is a valid outcome;
|
|
21
|
+
* - never store another participant's words;
|
|
22
|
+
* - the hint arguments and exactly the allowed tools.
|
|
23
|
+
*
|
|
24
|
+
* Loop 2's digest reviews reuse this instruction verbatim (Task B4).
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Build the review instruction (the run's only input). Deterministic:
|
|
28
|
+
* fixed inputs produce the fixed text the snapshot pins.
|
|
29
|
+
*/
|
|
30
|
+
export function reviewInstruction(opts) {
|
|
31
|
+
const lines = [
|
|
32
|
+
"You are the curator. Review the conversation above and distill durable lessons for this user.",
|
|
33
|
+
];
|
|
34
|
+
if (opts.focus !== undefined) {
|
|
35
|
+
lines.push(`Focus: ${opts.focus}.`);
|
|
36
|
+
}
|
|
37
|
+
lines.push("", "A skill is a procedure for a repeatable class of task, for this user. State pitfalls as a rule plus one clause of why. No incident narration, no dates, no PR numbers, no quoted chatter.", "", "Do NOT capture: environment failures; negative claims about tools; transient errors; unresolved failures presented as workflows; one-off tasks; codebase facts (those belong in the repo's AGENTS.md); secrets; personal facts (those go to the memory target when one is offered). Never store another participant's words.", "", "Each lesson goes in exactly one place: one skill, one overlay, or one target. Update the skill in play before creating one; pick class-level names.", "", '"Nothing to save" is a valid outcome — when nothing qualifies, finish with that sentence and change nothing.');
|
|
38
|
+
if (opts.targets.length > 0) {
|
|
39
|
+
lines.push("", "Targets:");
|
|
40
|
+
for (const target of opts.targets) {
|
|
41
|
+
for (const line of target.guidance) {
|
|
42
|
+
lines.push(`- ${target.id}: ${line}`);
|
|
43
|
+
}
|
|
44
|
+
if (target.guidance.length === 0) {
|
|
45
|
+
lines.push(`- ${target.id}.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (opts.hints.length > 0) {
|
|
50
|
+
lines.push("", "The user made these write attempts between reviews — fold their intent in:");
|
|
51
|
+
for (const [index, hint] of opts.hints.entries()) {
|
|
52
|
+
lines.push(`${index + 1}. ${hint.tool} ${JSON.stringify(hint.args)} at ${hint.at}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
lines.push("", `Your tools: ${opts.allow.join(", ")}. Nothing else.`);
|
|
56
|
+
return `${lines.join("\n")}\n`;
|
|
57
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review queues (`src/queue.ts`, Task B2):
|
|
3
|
+
*
|
|
4
|
+
* - `ReviewQueue` — one in-flight review PER USER, FIFO per user (a
|
|
5
|
+
* burst of settles on one user's conversations reviews in order,
|
|
6
|
+
* never concurrently; two users never serialize against each other).
|
|
7
|
+
* `onRunSettled` enqueues and returns; the queue owns the run. The
|
|
8
|
+
* auto-namer's pattern: a per-key promise chain, `idle()` for tests.
|
|
9
|
+
* - `queueDigest` — the mixed-conversation spill: loop 1 never forks a
|
|
10
|
+
* shared conversation, it appends the id to the per-user
|
|
11
|
+
* `digest-queue.json` (state), and loop 2's digest step (Task B4)
|
|
12
|
+
* consumes the list. Dedup by conversation id — one pending entry per
|
|
13
|
+
* conversation, however many runs triggered it — with the taken
|
|
14
|
+
* hints merged into the entry (the decision took them read-and-clear;
|
|
15
|
+
* the digest run folds them into its instruction).
|
|
16
|
+
* - the refunded-hints file (`pending-hints.json`) — the fix for the
|
|
17
|
+
* dropped-hints bug: `takeHints` is read-and-clear, so a review that
|
|
18
|
+
* ends failed / cancelled / deadline would otherwise lose its hints
|
|
19
|
+
* for good. Non-completed reviews write them back (refund, merged by
|
|
20
|
+
* hint id); the decision merges a conversation's refunded batch into
|
|
21
|
+
* every fresh take; a COMPLETED review (or a digest entry that takes
|
|
22
|
+
* ownership) clears the ids it consumed. The transfer is deliberately
|
|
23
|
+
* at-least-once: a crash between refund and clear duplicates a hint
|
|
24
|
+
* (dedup absorbs it), never drops one.
|
|
25
|
+
*
|
|
26
|
+
* Errors never break a chain: the queue's contract is that a job's
|
|
27
|
+
* failure is the job's business (the loop records it); the chain keeps
|
|
28
|
+
* draining.
|
|
29
|
+
*
|
|
30
|
+
* Every hints helper here dedups by hint id: a hint may briefly live in
|
|
31
|
+
* two places (host + refund file, refund file + digest entry) around a
|
|
32
|
+
* crash, and dedup makes the overlap harmless.
|
|
33
|
+
*/
|
|
34
|
+
import type { SigrunHost, CurationHint } from "@sigrun/sigrun/extension";
|
|
35
|
+
import type { ConversationId } from "@earendil-works/pi-durable";
|
|
36
|
+
/** One queued review: the user it belongs to, and the work. */
|
|
37
|
+
export interface ReviewJob {
|
|
38
|
+
/** The reviewed user — the one-in-flight key. */
|
|
39
|
+
readonly user: string;
|
|
40
|
+
/** The review body; must not throw (record failures inside). */
|
|
41
|
+
readonly work: () => Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The per-user FIFO review queue. `enqueue` never runs the work on the
|
|
45
|
+
* caller's stack — it appends to the user's chain; `idle()` resolves
|
|
46
|
+
* when NO user has work in flight or queued.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ReviewQueue {
|
|
49
|
+
/** One promise chain per user (the FIFO + the one-in-flight rule). */
|
|
50
|
+
private readonly chains;
|
|
51
|
+
/** Jobs not yet finished, across every user (the `idle()` counter). */
|
|
52
|
+
private pending;
|
|
53
|
+
/** Every live `idle()` waiter, released together at drain. */
|
|
54
|
+
private waiters;
|
|
55
|
+
/** Append one job to its user's chain. */
|
|
56
|
+
enqueue(job: ReviewJob): void;
|
|
57
|
+
/**
|
|
58
|
+
* Resolve when every enqueued job has finished (immediately when
|
|
59
|
+
* the queue is empty). Tests await this; production never does.
|
|
60
|
+
*/
|
|
61
|
+
idle(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read one JSON state file with a fallback: absent or malformed → the
|
|
65
|
+
* fallback value (state files are the loop's own bookkeeping — a bad
|
|
66
|
+
* file degrades to defaults, never a broken loop).
|
|
67
|
+
*/
|
|
68
|
+
export declare function readStateJson<T>(file: string, fallback: T): Promise<T>;
|
|
69
|
+
/** Write one JSON state file under the user's state dir (tab indents). */
|
|
70
|
+
export declare function writeStateJson<T>(stateDir: string, name: string, value: T): Promise<void>;
|
|
71
|
+
/** The digest-queue file: pending mixed conversations, deduped by id. */
|
|
72
|
+
export interface DigestQueueFile {
|
|
73
|
+
readonly conversations: readonly DigestQueueEntry[];
|
|
74
|
+
}
|
|
75
|
+
/** One pending digest: the conversation, with the hints it triggered on. */
|
|
76
|
+
export interface DigestQueueEntry {
|
|
77
|
+
readonly id: ConversationId;
|
|
78
|
+
readonly hints: readonly CurationHint[];
|
|
79
|
+
}
|
|
80
|
+
/** Read the digest queue's entries for one user; absent/malformed → []. */
|
|
81
|
+
export declare function readDigestQueue(host: SigrunHost, user: string): Promise<readonly DigestQueueEntry[]>;
|
|
82
|
+
/** Overwrite the digest queue's entries (the digest step's write-back). */
|
|
83
|
+
export declare function writeDigestQueue(host: SigrunHost, user: string, conversations: readonly DigestQueueEntry[]): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Remove one conversation's digest entry (the digest step, after the
|
|
86
|
+
* review completed and took ownership of its hints). Idempotent.
|
|
87
|
+
*/
|
|
88
|
+
export declare function removeDigestEntry(host: SigrunHost, user: string, conversationId: ConversationId): Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Queue one conversation for loop 2's digest (Task B4 consumes): append
|
|
91
|
+
* to `digest-queue.json` under the user's state dir, dedup by id, the
|
|
92
|
+
* taken hints merged into the existing entry by hint id (never dropped).
|
|
93
|
+
*/
|
|
94
|
+
export declare function queueDigest(host: SigrunHost, user: string, conversationId: ConversationId, hints?: readonly CurationHint[]): Promise<void>;
|
|
95
|
+
/** Merge two hint batches into one, deduped by hint id. */
|
|
96
|
+
export declare function mergeHints(first: readonly CurationHint[], second: readonly CurationHint[]): readonly CurationHint[];
|
|
97
|
+
/** The hints refunded for one conversation (a read; nothing is cleared). */
|
|
98
|
+
export declare function pendingHintsFor(host: SigrunHost, user: string, conversationId: ConversationId): Promise<readonly CurationHint[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Refund: write a non-completed review's hints back, merged into the
|
|
101
|
+
* conversation's refunded batch by hint id (never dropped, never
|
|
102
|
+
* duplicated — an unchanged entry is not rewritten).
|
|
103
|
+
*/
|
|
104
|
+
export declare function refundHints(host: SigrunHost, user: string, conversationId: ConversationId, hints: readonly CurationHint[]): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* Clear: drop the hint ids a consumer took ownership of (a completed
|
|
107
|
+
* review, or a digest entry that persists them); the conversation's
|
|
108
|
+
* entry leaves when it empties. An unknown id (e.g. a fresh hint that
|
|
109
|
+
* was never refunded) is a no-op, and so is an untouched file.
|
|
110
|
+
*/
|
|
111
|
+
export declare function clearPendingHints(host: SigrunHost, user: string, conversationId: ConversationId, hints: readonly CurationHint[]): Promise<void>;
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review queues (`src/queue.ts`, Task B2):
|
|
3
|
+
*
|
|
4
|
+
* - `ReviewQueue` — one in-flight review PER USER, FIFO per user (a
|
|
5
|
+
* burst of settles on one user's conversations reviews in order,
|
|
6
|
+
* never concurrently; two users never serialize against each other).
|
|
7
|
+
* `onRunSettled` enqueues and returns; the queue owns the run. The
|
|
8
|
+
* auto-namer's pattern: a per-key promise chain, `idle()` for tests.
|
|
9
|
+
* - `queueDigest` — the mixed-conversation spill: loop 1 never forks a
|
|
10
|
+
* shared conversation, it appends the id to the per-user
|
|
11
|
+
* `digest-queue.json` (state), and loop 2's digest step (Task B4)
|
|
12
|
+
* consumes the list. Dedup by conversation id — one pending entry per
|
|
13
|
+
* conversation, however many runs triggered it — with the taken
|
|
14
|
+
* hints merged into the entry (the decision took them read-and-clear;
|
|
15
|
+
* the digest run folds them into its instruction).
|
|
16
|
+
* - the refunded-hints file (`pending-hints.json`) — the fix for the
|
|
17
|
+
* dropped-hints bug: `takeHints` is read-and-clear, so a review that
|
|
18
|
+
* ends failed / cancelled / deadline would otherwise lose its hints
|
|
19
|
+
* for good. Non-completed reviews write them back (refund, merged by
|
|
20
|
+
* hint id); the decision merges a conversation's refunded batch into
|
|
21
|
+
* every fresh take; a COMPLETED review (or a digest entry that takes
|
|
22
|
+
* ownership) clears the ids it consumed. The transfer is deliberately
|
|
23
|
+
* at-least-once: a crash between refund and clear duplicates a hint
|
|
24
|
+
* (dedup absorbs it), never drops one.
|
|
25
|
+
*
|
|
26
|
+
* Errors never break a chain: the queue's contract is that a job's
|
|
27
|
+
* failure is the job's business (the loop records it); the chain keeps
|
|
28
|
+
* draining.
|
|
29
|
+
*
|
|
30
|
+
* Every hints helper here dedups by hint id: a hint may briefly live in
|
|
31
|
+
* two places (host + refund file, refund file + digest entry) around a
|
|
32
|
+
* crash, and dedup makes the overlap harmless.
|
|
33
|
+
*/
|
|
34
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
/**
|
|
37
|
+
* The per-user FIFO review queue. `enqueue` never runs the work on the
|
|
38
|
+
* caller's stack — it appends to the user's chain; `idle()` resolves
|
|
39
|
+
* when NO user has work in flight or queued.
|
|
40
|
+
*/
|
|
41
|
+
export class ReviewQueue {
|
|
42
|
+
/** One promise chain per user (the FIFO + the one-in-flight rule). */
|
|
43
|
+
chains = new Map();
|
|
44
|
+
/** Jobs not yet finished, across every user (the `idle()` counter). */
|
|
45
|
+
pending = 0;
|
|
46
|
+
/** Every live `idle()` waiter, released together at drain. */
|
|
47
|
+
waiters = [];
|
|
48
|
+
/** Append one job to its user's chain. */
|
|
49
|
+
enqueue(job) {
|
|
50
|
+
const previous = this.chains.get(job.user) ?? Promise.resolve();
|
|
51
|
+
this.pending += 1;
|
|
52
|
+
const next = previous
|
|
53
|
+
.then(job.work)
|
|
54
|
+
.catch(() => undefined)
|
|
55
|
+
.finally(() => {
|
|
56
|
+
this.pending -= 1;
|
|
57
|
+
if (this.chains.get(job.user) === next)
|
|
58
|
+
this.chains.delete(job.user);
|
|
59
|
+
if (this.pending === 0 && this.waiters.length > 0) {
|
|
60
|
+
const waiters = this.waiters;
|
|
61
|
+
this.waiters = [];
|
|
62
|
+
for (const resolve of waiters)
|
|
63
|
+
resolve();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
this.chains.set(job.user, next);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Resolve when every enqueued job has finished (immediately when
|
|
70
|
+
* the queue is empty). Tests await this; production never does.
|
|
71
|
+
*/
|
|
72
|
+
idle() {
|
|
73
|
+
if (this.pending === 0)
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
this.waiters.push(resolve);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** The digest-queue file name under the user's state dir. */
|
|
81
|
+
const DIGEST_QUEUE_FILE = "digest-queue.json";
|
|
82
|
+
/**
|
|
83
|
+
* Read one JSON state file with a fallback: absent or malformed → the
|
|
84
|
+
* fallback value (state files are the loop's own bookkeeping — a bad
|
|
85
|
+
* file degrades to defaults, never a broken loop).
|
|
86
|
+
*/
|
|
87
|
+
export async function readStateJson(file, fallback) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = await readFile(file, "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return fallback; // absent: never written
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(raw);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Write one JSON state file under the user's state dir (tab indents). */
|
|
103
|
+
export async function writeStateJson(stateDir, name, value) {
|
|
104
|
+
await mkdir(stateDir, { recursive: true });
|
|
105
|
+
await writeFile(join(stateDir, name), `${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
106
|
+
}
|
|
107
|
+
/** Read the digest queue file path for one user's state dir. */
|
|
108
|
+
function digestQueueFile(stateDir) {
|
|
109
|
+
return join(stateDir, DIGEST_QUEUE_FILE);
|
|
110
|
+
}
|
|
111
|
+
/** Read the digest queue from a file; absent/malformed → []. */
|
|
112
|
+
async function readDigestQueueFile(file) {
|
|
113
|
+
const parsed = await readStateJson(file, {});
|
|
114
|
+
if (!Array.isArray(parsed.conversations))
|
|
115
|
+
return [];
|
|
116
|
+
return parsed.conversations.filter((entry) => typeof entry === "object" &&
|
|
117
|
+
entry !== null &&
|
|
118
|
+
typeof entry.id === "number" &&
|
|
119
|
+
Array.isArray(entry.hints));
|
|
120
|
+
}
|
|
121
|
+
/** Read the digest queue's entries for one user; absent/malformed → []. */
|
|
122
|
+
export async function readDigestQueue(host, user) {
|
|
123
|
+
return await readDigestQueueFile(digestQueueFile(host.stores.user(user).paths.state));
|
|
124
|
+
}
|
|
125
|
+
/** Overwrite the digest queue's entries (the digest step's write-back). */
|
|
126
|
+
export async function writeDigestQueue(host, user, conversations) {
|
|
127
|
+
await writeStateJson(host.stores.user(user).paths.state, DIGEST_QUEUE_FILE, { conversations });
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Remove one conversation's digest entry (the digest step, after the
|
|
131
|
+
* review completed and took ownership of its hints). Idempotent.
|
|
132
|
+
*/
|
|
133
|
+
export async function removeDigestEntry(host, user, conversationId) {
|
|
134
|
+
const queue = [...(await readDigestQueue(host, user))];
|
|
135
|
+
const next = queue.filter((entry) => entry.id !== conversationId);
|
|
136
|
+
if (next.length === queue.length)
|
|
137
|
+
return; // absent: nothing to remove
|
|
138
|
+
await writeDigestQueue(host, user, next);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Queue one conversation for loop 2's digest (Task B4 consumes): append
|
|
142
|
+
* to `digest-queue.json` under the user's state dir, dedup by id, the
|
|
143
|
+
* taken hints merged into the existing entry by hint id (never dropped).
|
|
144
|
+
*/
|
|
145
|
+
export async function queueDigest(host, user, conversationId, hints = []) {
|
|
146
|
+
const file = digestQueueFile(host.stores.user(user).paths.state);
|
|
147
|
+
const queue = [...(await readDigestQueueFile(file))];
|
|
148
|
+
const existing = queue.find((entry) => entry.id === conversationId);
|
|
149
|
+
if (existing === undefined) {
|
|
150
|
+
queue.push({ id: conversationId, hints });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
const seen = new Set(existing.hints.map((hint) => hint.id));
|
|
154
|
+
const merged = [
|
|
155
|
+
...existing.hints,
|
|
156
|
+
...hints.filter((hint) => !seen.has(hint.id)),
|
|
157
|
+
];
|
|
158
|
+
if (merged.length === existing.hints.length)
|
|
159
|
+
return; // nothing new
|
|
160
|
+
queue[queue.indexOf(existing)] = { id: conversationId, hints: merged };
|
|
161
|
+
}
|
|
162
|
+
await writeDigestQueue(host, user, queue);
|
|
163
|
+
}
|
|
164
|
+
/** The refunded-hints file name under the user's state dir. */
|
|
165
|
+
const PENDING_HINTS_FILE = "pending-hints.json";
|
|
166
|
+
/** Read the refunded-hints file; absent/malformed → []. */
|
|
167
|
+
async function readPendingHints(file) {
|
|
168
|
+
const parsed = await readStateJson(file, []);
|
|
169
|
+
if (!Array.isArray(parsed))
|
|
170
|
+
return [];
|
|
171
|
+
return parsed.filter((entry) => typeof entry === "object" &&
|
|
172
|
+
entry !== null &&
|
|
173
|
+
typeof entry.conversationId === "number" &&
|
|
174
|
+
Array.isArray(entry.hints));
|
|
175
|
+
}
|
|
176
|
+
/** Merge two hint batches into one, deduped by hint id. */
|
|
177
|
+
export function mergeHints(first, second) {
|
|
178
|
+
const seen = new Set(first.map((hint) => hint.id));
|
|
179
|
+
return [...first, ...second.filter((hint) => !seen.has(hint.id))];
|
|
180
|
+
}
|
|
181
|
+
/** The hints refunded for one conversation (a read; nothing is cleared). */
|
|
182
|
+
export async function pendingHintsFor(host, user, conversationId) {
|
|
183
|
+
const entries = await readPendingHints(join(host.stores.user(user).paths.state, PENDING_HINTS_FILE));
|
|
184
|
+
return entries.find((entry) => entry.conversationId === conversationId)
|
|
185
|
+
?.hints ?? [];
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refund: write a non-completed review's hints back, merged into the
|
|
189
|
+
* conversation's refunded batch by hint id (never dropped, never
|
|
190
|
+
* duplicated — an unchanged entry is not rewritten).
|
|
191
|
+
*/
|
|
192
|
+
export async function refundHints(host, user, conversationId, hints) {
|
|
193
|
+
if (hints.length === 0)
|
|
194
|
+
return;
|
|
195
|
+
const stateDir = host.stores.user(user).paths.state;
|
|
196
|
+
const entries = [
|
|
197
|
+
...(await readPendingHints(join(stateDir, PENDING_HINTS_FILE))),
|
|
198
|
+
];
|
|
199
|
+
const existing = entries.find((entry) => entry.conversationId === conversationId);
|
|
200
|
+
if (existing === undefined) {
|
|
201
|
+
entries.push({ conversationId, hints });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
const merged = mergeHints(existing.hints, hints);
|
|
205
|
+
if (merged.length === existing.hints.length)
|
|
206
|
+
return; // nothing new
|
|
207
|
+
entries[entries.indexOf(existing)] = {
|
|
208
|
+
conversationId,
|
|
209
|
+
hints: merged,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
await writeStateJson(stateDir, PENDING_HINTS_FILE, entries);
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Clear: drop the hint ids a consumer took ownership of (a completed
|
|
216
|
+
* review, or a digest entry that persists them); the conversation's
|
|
217
|
+
* entry leaves when it empties. An unknown id (e.g. a fresh hint that
|
|
218
|
+
* was never refunded) is a no-op, and so is an untouched file.
|
|
219
|
+
*/
|
|
220
|
+
export async function clearPendingHints(host, user, conversationId, hints) {
|
|
221
|
+
const stateDir = host.stores.user(user).paths.state;
|
|
222
|
+
const entries = [
|
|
223
|
+
...(await readPendingHints(join(stateDir, PENDING_HINTS_FILE))),
|
|
224
|
+
];
|
|
225
|
+
const index = entries.findIndex((entry) => entry.conversationId === conversationId);
|
|
226
|
+
if (index === -1)
|
|
227
|
+
return; // nothing refunded: no entry to clear
|
|
228
|
+
const consumed = new Set(hints.map((hint) => hint.id));
|
|
229
|
+
const remaining = entries[index].hints.filter((hint) => !consumed.has(hint.id));
|
|
230
|
+
if (remaining.length === entries[index].hints.length)
|
|
231
|
+
return; // untouched
|
|
232
|
+
if (remaining.length === 0)
|
|
233
|
+
entries.splice(index, 1);
|
|
234
|
+
else
|
|
235
|
+
entries[index] = { conversationId, hints: remaining };
|
|
236
|
+
await writeStateJson(stateDir, PENDING_HINTS_FILE, entries);
|
|
237
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator's per-user settings (`src/settings.ts`, Task B1): read
|
|
3
|
+
* LIVE through the per-user scoped store, with defaults for every
|
|
4
|
+
* absent or malformed field — the read never throws (a bad edit disables
|
|
5
|
+
* nothing; the loop reads per event, like the gateway's live config).
|
|
6
|
+
*
|
|
7
|
+
* The stored object is the user's scoped settings for THIS extension
|
|
8
|
+
* (`settings/curator.json` in the real host); the fields sit at its top
|
|
9
|
+
* level. Unknown keys ride along untouched — they may belong to a later
|
|
10
|
+
* task of this extension and are none of the reader's business.
|
|
11
|
+
*/
|
|
12
|
+
import type { SigrunHost } from "@sigrun/sigrun/extension";
|
|
13
|
+
/** The per-user curator settings (spec §6.2). */
|
|
14
|
+
export interface CuratorSettings {
|
|
15
|
+
/** Whether the curator runs for this user at all (restart-free: read per event). */
|
|
16
|
+
readonly enabled: boolean;
|
|
17
|
+
/** Pauses the loops without disabling the view or `/learn` (a vacation switch). */
|
|
18
|
+
readonly paused: boolean;
|
|
19
|
+
/** The model idle-pass digest runs use (absent: the server default). */
|
|
20
|
+
readonly idleModel?: string;
|
|
21
|
+
/** Model calls between reviews (spec §5 pins 10). */
|
|
22
|
+
readonly threshold: number;
|
|
23
|
+
/** Idle-pass digest runs per day (spec §5 pins 12). */
|
|
24
|
+
readonly dailyRuns: number;
|
|
25
|
+
}
|
|
26
|
+
/** The defaults every absent or malformed field falls back to. */
|
|
27
|
+
export declare const DEFAULT_CURATOR_SETTINGS: Readonly<CuratorSettings>;
|
|
28
|
+
/**
|
|
29
|
+
* Parse a raw settings object into {@link CuratorSettings}: every field
|
|
30
|
+
* validated by TYPE and range, falling back to its default — the read
|
|
31
|
+
* is total, never a throw.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseCuratorSettings(raw: Record<string, unknown>): CuratorSettings;
|
|
34
|
+
/**
|
|
35
|
+
* Read one user's settings LIVE through the per-user scoped store,
|
|
36
|
+
* merged over the defaults.
|
|
37
|
+
*/
|
|
38
|
+
export declare function readSettings(host: SigrunHost, user: string): Promise<CuratorSettings>;
|