crewhaus 0.1.8 → 0.2.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 (136) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +172 -0
  25. package/dist/dataset-mine.js +403 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11601 -964
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
package/dist/faq.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Item #55 — distill repeated user questions into an auto-discovered FAQ skill.
3
+ *
4
+ * `crewhaus faq distill` clusters recurring `user_message` questions across
5
+ * sessions (deterministic token clustering — the same greedy-jaccard approach
6
+ * `graders suggest` uses, no model call), pairs each cluster with its best-rated
7
+ * answer (the up-rated turn's output), and emits a SKILL.md FAQ skill into
8
+ * `.crewhaus/skills/faq/` so `@crewhaus/skills-registry` auto-discovers it —
9
+ * its name + description go into the prompt and the body loads on demand when
10
+ * the model invokes the `Skill` tool (the registry is lazy, not eager).
11
+ *
12
+ * Everything here is pure + deterministic so it is unit-testable; the FS access
13
+ * and PII/secret redaction wiring live in `apps/cli/src/index.ts`. Both the
14
+ * harvested ANSWER and the representative QUESTION are user/model text that may
15
+ * carry pasted credentials, so the caller redacts BOTH (via
16
+ * `SYNTHESIZE_PII_DETECTORS`) before they land in the skill body (#55 F4).
17
+ *
18
+ * The emitted SKILL.md is guaranteed valid per skills-registry's `parseSkillFile`
19
+ * (leading `---` frontmatter with `name` + `description`, then the FAQ body).
20
+ */
21
+ import type { FeedbackRecord, LoggedEvent, SessionTurn } from "./feedback";
22
+ /** One recurring-question cluster paired with its best answer. */
23
+ export type FaqEntry = {
24
+ /** The representative (most-frequent-token, then shortest) question. */
25
+ readonly question: string;
26
+ /** The best-rated answer for the cluster (already redacted by the caller). */
27
+ readonly answer: string;
28
+ /** How many distinct sessions asked a question in this cluster. */
29
+ readonly sessionCount: number;
30
+ /** How many questions total fell into this cluster. */
31
+ readonly occurrences: number;
32
+ };
33
+ export type ClusterOptions = {
34
+ /** Jaccard overlap at/above which a question joins a cluster's seed. Default 0.5. */
35
+ readonly threshold?: number;
36
+ /** Minimum number of DISTINCT sessions that must ask a clustered question
37
+ * for it to become an FAQ entry. Default 2 (recurring across sessions — a
38
+ * one-off, or a single session that repeats itself, is not an FAQ). */
39
+ readonly minOccurrences?: number;
40
+ /** Normalized rating at/above which an answer is "good". Default 0.7. */
41
+ readonly minScore?: number;
42
+ /** Redactor for answers (async). Identity by default. */
43
+ readonly redact?: (text: string) => Promise<string>;
44
+ };
45
+ /**
46
+ * Build the FAQ entries from per-session turns + feedback. Deterministic:
47
+ * questions are clustered greedily by normalized-token Jaccard overlap in a
48
+ * stable visit order; each cluster keeps its best-rated answer (highest score,
49
+ * ties broken by session/turn order). Only clusters asked in ≥ `minOccurrences`
50
+ * DISTINCT sessions (#55 F9) AND with a qualifying answer (score ≥ `minScore`)
51
+ * become entries. Entries come back most-recurring-first.
52
+ */
53
+ export declare function distillFaq(turnsBySession: ReadonlyArray<SessionTurn>, feedback: ReadonlyArray<FeedbackRecord>, opts?: ClusterOptions): Promise<FaqEntry[]>;
54
+ /** Derive `SessionTurn`s from many sessions' event logs. Convenience wrapper so
55
+ * the CLI and tests build the clustering input the same way. */
56
+ export declare function turnsFromSessions(perSession: ReadonlyArray<{
57
+ sessionId: string;
58
+ events: ReadonlyArray<LoggedEvent>;
59
+ }>): SessionTurn[];
60
+ /**
61
+ * Render a valid SKILL.md for the FAQ. Frontmatter carries `name` +
62
+ * `description` (the required pair skills-registry enforces) plus `triggers`;
63
+ * the body is the Q→A list. Guaranteed to parse via `parseSkillFile`.
64
+ */
65
+ export declare function buildFaqSkill(entries: ReadonlyArray<FaqEntry>, opts?: {
66
+ name?: string;
67
+ harnessName?: string;
68
+ }): string;
package/dist/faq.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Item #55 — distill repeated user questions into an auto-discovered FAQ skill.
3
+ *
4
+ * `crewhaus faq distill` clusters recurring `user_message` questions across
5
+ * sessions (deterministic token clustering — the same greedy-jaccard approach
6
+ * `graders suggest` uses, no model call), pairs each cluster with its best-rated
7
+ * answer (the up-rated turn's output), and emits a SKILL.md FAQ skill into
8
+ * `.crewhaus/skills/faq/` so `@crewhaus/skills-registry` auto-discovers it —
9
+ * its name + description go into the prompt and the body loads on demand when
10
+ * the model invokes the `Skill` tool (the registry is lazy, not eager).
11
+ *
12
+ * Everything here is pure + deterministic so it is unit-testable; the FS access
13
+ * and PII/secret redaction wiring live in `apps/cli/src/index.ts`. Both the
14
+ * harvested ANSWER and the representative QUESTION are user/model text that may
15
+ * carry pasted credentials, so the caller redacts BOTH (via
16
+ * `SYNTHESIZE_PII_DETECTORS`) before they land in the skill body (#55 F4).
17
+ *
18
+ * The emitted SKILL.md is guaranteed valid per skills-registry's `parseSkillFile`
19
+ * (leading `---` frontmatter with `name` + `description`, then the FAQ body).
20
+ */
21
+ import { deriveTurns, mergeFeedback, normalizeRating } from "./feedback";
22
+ import { normalizeEvidenceTokens } from "./graders-suggest";
23
+ function jaccard(a, b) {
24
+ let intersection = 0;
25
+ for (const t of a)
26
+ if (b.has(t))
27
+ intersection += 1;
28
+ const union = a.size + b.size - intersection;
29
+ return union === 0 ? 0 : intersection / union;
30
+ }
31
+ /**
32
+ * Build the FAQ entries from per-session turns + feedback. Deterministic:
33
+ * questions are clustered greedily by normalized-token Jaccard overlap in a
34
+ * stable visit order; each cluster keeps its best-rated answer (highest score,
35
+ * ties broken by session/turn order). Only clusters asked in ≥ `minOccurrences`
36
+ * DISTINCT sessions (#55 F9) AND with a qualifying answer (score ≥ `minScore`)
37
+ * become entries. Entries come back most-recurring-first.
38
+ */
39
+ export async function distillFaq(turnsBySession, feedback, opts = {}) {
40
+ const threshold = opts.threshold ?? 0.5;
41
+ const minOccurrences = opts.minOccurrences ?? 2;
42
+ const minScore = opts.minScore ?? 0.7;
43
+ const redact = opts.redact ?? (async (t) => t);
44
+ // Rating lookup keyed by session#turn.
45
+ const scoreByKey = new Map();
46
+ for (const fb of mergeFeedback(feedback)) {
47
+ const s = normalizeRating(fb);
48
+ if (s !== undefined)
49
+ scoreByKey.set(`${fb.sessionId}#${fb.turnNumber}`, s);
50
+ }
51
+ const records = turnsBySession
52
+ .filter((t) => t.input.trim() !== "")
53
+ .map((t) => ({
54
+ sessionId: t.sessionId,
55
+ turnNumber: t.turnNumber,
56
+ question: t.input.trim(),
57
+ answer: t.output,
58
+ score: scoreByKey.get(`${t.sessionId}#${t.turnNumber}`),
59
+ }))
60
+ .sort((a, b) => a.sessionId.localeCompare(b.sessionId) ||
61
+ a.turnNumber - b.turnNumber ||
62
+ a.question.localeCompare(b.question));
63
+ const clusters = [];
64
+ for (const rec of records) {
65
+ const tokens = new Set(normalizeEvidenceTokens(rec.question));
66
+ if (tokens.size === 0)
67
+ continue;
68
+ let bestIdx = -1;
69
+ let bestOverlap = 0;
70
+ for (let i = 0; i < clusters.length; i += 1) {
71
+ const overlap = jaccard(tokens, clusters[i].seed);
72
+ if (overlap > bestOverlap) {
73
+ bestOverlap = overlap;
74
+ bestIdx = i;
75
+ }
76
+ }
77
+ if (bestIdx >= 0 && bestOverlap >= threshold) {
78
+ const c = clusters[bestIdx];
79
+ c.items.push(rec);
80
+ c.tokenSets.push(tokens);
81
+ }
82
+ else {
83
+ clusters.push({ seed: tokens, items: [rec], tokenSets: [tokens] });
84
+ }
85
+ }
86
+ const entries = [];
87
+ for (const c of clusters) {
88
+ // #55 F9 — "recurring" means asked across DISTINCT sessions, not the same
89
+ // session twice. Gate on the distinct-session count so two questions from
90
+ // one session don't manufacture an FAQ entry at minOccurrences:2.
91
+ const sessionCount = new Set(c.items.map((i) => i.sessionId)).size;
92
+ if (sessionCount < minOccurrences)
93
+ continue;
94
+ // Best answer: highest-scored qualifying turn (stable tie-break by order).
95
+ const qualifying = c.items
96
+ .filter((i) => i.score !== undefined && i.score >= minScore && i.answer.trim() !== "")
97
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0) ||
98
+ a.sessionId.localeCompare(b.sessionId) ||
99
+ a.turnNumber - b.turnNumber);
100
+ const best = qualifying[0];
101
+ if (best === undefined)
102
+ continue;
103
+ // Representative question: the shortest question in the cluster (most
104
+ // canonical phrasing), stable tie-break. #55 F4 — the representative
105
+ // question is a raw user turn rendered as a Markdown heading in SKILL.md,
106
+ // so it is redacted with the SAME redactor as the answer (a question can
107
+ // paste a secret/email just as an answer can).
108
+ const rawQuestion = [...c.items]
109
+ .map((i) => i.question)
110
+ .sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
111
+ const question = (await redact(rawQuestion)).trim();
112
+ const answer = (await redact(best.answer)).trim();
113
+ if (question === "" || answer === "")
114
+ continue;
115
+ entries.push({
116
+ question,
117
+ answer,
118
+ sessionCount,
119
+ occurrences: c.items.length,
120
+ });
121
+ }
122
+ return entries.sort((a, b) => b.occurrences - a.occurrences || a.question.localeCompare(b.question));
123
+ }
124
+ /** Derive `SessionTurn`s from many sessions' event logs. Convenience wrapper so
125
+ * the CLI and tests build the clustering input the same way. */
126
+ export function turnsFromSessions(perSession) {
127
+ const turns = [];
128
+ for (const { sessionId, events } of perSession) {
129
+ for (const t of deriveTurns(events))
130
+ turns.push({ ...t, sessionId });
131
+ }
132
+ return turns;
133
+ }
134
+ /** YAML-escape a scalar for the SKILL.md frontmatter (JSON is a YAML subset). */
135
+ function yamlScalar(s) {
136
+ return JSON.stringify(s);
137
+ }
138
+ /**
139
+ * Render a valid SKILL.md for the FAQ. Frontmatter carries `name` +
140
+ * `description` (the required pair skills-registry enforces) plus `triggers`;
141
+ * the body is the Q→A list. Guaranteed to parse via `parseSkillFile`.
142
+ */
143
+ export function buildFaqSkill(entries, opts = {}) {
144
+ const name = opts.name ?? "faq";
145
+ const label = opts.harnessName ?? "this harness";
146
+ const description = `Frequently asked questions for ${label}, distilled from recurring user questions and their best-rated answers. Consult before answering common questions.`;
147
+ const triggers = ["faq", "frequently asked", "common question", "how do i"];
148
+ const bodyLines = [
149
+ `# FAQ — ${label}`,
150
+ "",
151
+ "These are recurring user questions paired with the answers users rated best. Prefer these answers for matching questions.",
152
+ "",
153
+ ];
154
+ entries.forEach((e, i) => {
155
+ bodyLines.push(`## Q${i + 1}: ${e.question}`);
156
+ bodyLines.push("");
157
+ bodyLines.push(e.answer);
158
+ bodyLines.push("");
159
+ });
160
+ const frontmatter = [
161
+ "---",
162
+ `name: ${yamlScalar(name)}`,
163
+ `description: ${yamlScalar(description)}`,
164
+ `triggers: ${JSON.stringify(triggers)}`,
165
+ "---",
166
+ ].join("\n");
167
+ return `${frontmatter}\n${bodyLines.join("\n")}`;
168
+ }
@@ -165,6 +165,11 @@ export type GraderSpecObject = {
165
165
  name: string;
166
166
  type: "regex";
167
167
  pattern: string;
168
+ } | {
169
+ name: string;
170
+ type: "json_path";
171
+ path: string;
172
+ expected?: unknown;
168
173
  } | {
169
174
  name: string;
170
175
  type: "llm_judge";
@@ -234,6 +239,8 @@ export declare function buildJudgeRubricGrader(positiveComments: ReadonlyArray<s
234
239
  /** One SampleSchema-valid JSON object per line (round-trips through loadJsonl). */
235
240
  export declare function samplesToJsonl(samples: ReadonlyArray<Sample>): string;
236
241
  /** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
237
- * arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset). */
238
- export declare function gradersConfigToYaml(config: GradersConfigObject): string;
242
+ * arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
243
+ * `headerLines` replaces the distill provenance header (each line must be a
244
+ * full `# …` YAML comment). */
245
+ export declare function gradersConfigToYaml(config: GradersConfigObject, headerLines?: ReadonlyArray<string>): string;
239
246
  export {};
package/dist/feedback.js CHANGED
@@ -557,14 +557,19 @@ function commonToken(answers) {
557
557
  export function samplesToJsonl(samples) {
558
558
  return `${samples.map((s) => JSON.stringify(s)).join("\n")}\n`;
559
559
  }
560
+ /** The default graders.yaml header — distill's provenance note. Callers with
561
+ * a different provenance (scaffold-evals item 13, graders suggest item 4)
562
+ * pass their own header lines instead. */
563
+ const DISTILL_GRADERS_HEADER = [
564
+ "# Synthesized by `crewhaus distill` from user ratings.",
565
+ "# Exactly one grader — stacking graders hard-ANDs their scores (see eval-grader `all`).",
566
+ ];
560
567
  /** Emit a GradersConfigSchema-valid graders.yaml. Scalars are JSON-encoded so
561
- * arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset). */
562
- export function gradersConfigToYaml(config) {
563
- const lines = [
564
- "# Synthesized by `crewhaus distill` from user ratings.",
565
- "# Exactly one grader — stacking graders hard-ANDs their scores (see eval-grader `all`).",
566
- "graders:",
567
- ];
568
+ * arbitrary substrings/tool names stay YAML-safe (JSON is a YAML subset).
569
+ * `headerLines` replaces the distill provenance header (each line must be a
570
+ * full `# …` YAML comment). */
571
+ export function gradersConfigToYaml(config, headerLines = DISTILL_GRADERS_HEADER) {
572
+ const lines = [...headerLines, "graders:"];
568
573
  for (const g of config.graders) {
569
574
  lines.push(` - name: ${JSON.stringify(g.name)}`);
570
575
  lines.push(` type: ${g.type}`);
@@ -577,6 +582,11 @@ export function gradersConfigToYaml(config) {
577
582
  if (g.case_insensitive !== undefined)
578
583
  lines.push(` case_insensitive: ${g.case_insensitive}`);
579
584
  }
585
+ else if (g.type === "json_path") {
586
+ lines.push(` path: ${JSON.stringify(g.path)}`);
587
+ if (g.expected !== undefined)
588
+ lines.push(` expected: ${JSON.stringify(g.expected)}`);
589
+ }
580
590
  else if (g.type === "llm_judge") {
581
591
  lines.push(" rubric:");
582
592
  lines.push(" criteria:");
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Item #54 — harvest up-rated turns into a golden few-shot pool.
3
+ *
4
+ * `crewhaus fewshot harvest` mines POSITIVELY-rated conversation turns (the
5
+ * same `user_feedback` ratings `distill` consumes) into a curated pool of
6
+ * input→good-output few-shot examples. The pool is a versioned, provenance-
7
+ * tagged JSONL under `.crewhaus/fewshot/<spec>.jsonl`; `crewhaus optimize
8
+ * --few-shot <file>` injects the top-K examples into the agent prompt as
9
+ * in-context demonstrations, and `agent.instructions` can carry them at
10
+ * compile time via `formatFewShotForPrompt`.
11
+ *
12
+ * Everything here is pure + deterministic (stable ordering, id-based dedupe)
13
+ * so it is unit-testable; all filesystem access + the model/redactor wiring
14
+ * lives in `apps/cli/src/index.ts` (mirrors `feedback.ts` / `dataset-mine.ts`).
15
+ * The harvested OUTPUT text is the rated assistant answer (or the user's
16
+ * correction), so it is PII/secret-redacted before it lands in the pool: the
17
+ * caller threads a redactor built from `SYNTHESIZE_PII_DETECTORS` (the shared
18
+ * secret/API-key + DEFAULT_PII detector set) so a pasted credential never
19
+ * survives into the pool or the optimizer meta-prompt.
20
+ */
21
+ import type { FeedbackRecord, SessionTurn } from "./feedback";
22
+ /** Schema version of a persisted few-shot pool line. */
23
+ export declare const FEWSHOT_SCHEMA_VERSION: 1;
24
+ /** One curated few-shot example: an input paired with a known-good output. */
25
+ export type FewShotExample = {
26
+ readonly schemaVersion: 1;
27
+ /** Stable id: `${sessionId}_t${turnNumber}` — the join key, dedupes reruns. */
28
+ readonly id: string;
29
+ readonly input: string;
30
+ readonly output: string;
31
+ /** [0,1] normalized rating that qualified this turn (or 1 for a correction). */
32
+ readonly score: number;
33
+ /** Provenance for audit + optimizer trust. */
34
+ readonly provenance: {
35
+ readonly sessionId: string;
36
+ readonly turnNumber: number;
37
+ /** "rating" (up-rated answer) | "correction" (user-supplied better answer). */
38
+ readonly source: "rating" | "correction";
39
+ readonly rater?: string;
40
+ };
41
+ };
42
+ export type HarvestOptions = {
43
+ /** Normalized score at/above which a rated turn qualifies. Default 0.7. */
44
+ readonly minScore?: number;
45
+ /** Async redactor applied to the harvested OUTPUT (and correction). The CLI
46
+ * passes a `SYNTHESIZE_PII_DETECTORS`-backed redactor; tests pass a stub. */
47
+ readonly redact?: (text: string) => Promise<string>;
48
+ };
49
+ export type HarvestStats = {
50
+ readonly totalFeedback: number;
51
+ readonly qualified: number;
52
+ readonly skippedUnmatched: number;
53
+ readonly skippedEmpty: number;
54
+ };
55
+ export type HarvestResult = {
56
+ readonly examples: FewShotExample[];
57
+ readonly stats: HarvestStats;
58
+ };
59
+ /**
60
+ * Harvest positively-rated turns into few-shot examples. A turn qualifies when
61
+ * its merged rating normalizes to ≥ `minScore`, OR it carries a `correction`
62
+ * (the user's own better answer — always gold). The example OUTPUT is the
63
+ * correction when present, else the assistant's answer; empty answers are
64
+ * skipped. Deterministic: examples are sorted by (score desc, id asc) and
65
+ * deduped by id, so re-harvesting the same feedback is idempotent.
66
+ */
67
+ export declare function harvestFewShot(turns: ReadonlyArray<SessionTurn>, feedback: ReadonlyArray<FeedbackRecord>, opts?: HarvestOptions): Promise<HarvestResult>;
68
+ /** Narrow an arbitrary parsed JSON value to a persisted FewShotExample. */
69
+ export declare function isFewShotExample(value: unknown): value is FewShotExample;
70
+ /**
71
+ * Merge a freshly-harvested pool into an existing one, deduping by id (the
72
+ * newer example wins so re-runs refresh outputs without duplicating). Returns
73
+ * the deterministically-ordered union.
74
+ */
75
+ export declare function mergePools(existing: ReadonlyArray<FewShotExample>, fresh: ReadonlyArray<FewShotExample>): FewShotExample[];
76
+ /** Serialize a pool to JSONL (one example per line). */
77
+ export declare function poolToJsonl(examples: ReadonlyArray<FewShotExample>): string;
78
+ /**
79
+ * Render the top-K examples as an in-context few-shot block for the system
80
+ * prompt / `agent.instructions`. Empty input → "" so callers append
81
+ * unconditionally. `k` caps how many examples are injected (default 5).
82
+ */
83
+ export declare function formatFewShotForPrompt(examples: ReadonlyArray<FewShotExample>, k?: number): string;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Item #54 — harvest up-rated turns into a golden few-shot pool.
3
+ *
4
+ * `crewhaus fewshot harvest` mines POSITIVELY-rated conversation turns (the
5
+ * same `user_feedback` ratings `distill` consumes) into a curated pool of
6
+ * input→good-output few-shot examples. The pool is a versioned, provenance-
7
+ * tagged JSONL under `.crewhaus/fewshot/<spec>.jsonl`; `crewhaus optimize
8
+ * --few-shot <file>` injects the top-K examples into the agent prompt as
9
+ * in-context demonstrations, and `agent.instructions` can carry them at
10
+ * compile time via `formatFewShotForPrompt`.
11
+ *
12
+ * Everything here is pure + deterministic (stable ordering, id-based dedupe)
13
+ * so it is unit-testable; all filesystem access + the model/redactor wiring
14
+ * lives in `apps/cli/src/index.ts` (mirrors `feedback.ts` / `dataset-mine.ts`).
15
+ * The harvested OUTPUT text is the rated assistant answer (or the user's
16
+ * correction), so it is PII/secret-redacted before it lands in the pool: the
17
+ * caller threads a redactor built from `SYNTHESIZE_PII_DETECTORS` (the shared
18
+ * secret/API-key + DEFAULT_PII detector set) so a pasted credential never
19
+ * survives into the pool or the optimizer meta-prompt.
20
+ */
21
+ import { mergeFeedback, normalizeRating } from "./feedback";
22
+ /** Schema version of a persisted few-shot pool line. */
23
+ export const FEWSHOT_SCHEMA_VERSION = 1;
24
+ function turnKey(sessionId, turnNumber) {
25
+ return `${sessionId}#${turnNumber}`;
26
+ }
27
+ /**
28
+ * Harvest positively-rated turns into few-shot examples. A turn qualifies when
29
+ * its merged rating normalizes to ≥ `minScore`, OR it carries a `correction`
30
+ * (the user's own better answer — always gold). The example OUTPUT is the
31
+ * correction when present, else the assistant's answer; empty answers are
32
+ * skipped. Deterministic: examples are sorted by (score desc, id asc) and
33
+ * deduped by id, so re-harvesting the same feedback is idempotent.
34
+ */
35
+ export async function harvestFewShot(turns, feedback, opts = {}) {
36
+ const minScore = opts.minScore ?? 0.7;
37
+ const redact = opts.redact ?? (async (t) => t);
38
+ const turnByKey = new Map();
39
+ for (const t of turns)
40
+ turnByKey.set(turnKey(t.sessionId, t.turnNumber), t);
41
+ const merged = mergeFeedback(feedback);
42
+ const byId = new Map();
43
+ let qualified = 0;
44
+ let skippedUnmatched = 0;
45
+ let skippedEmpty = 0;
46
+ for (const fb of merged) {
47
+ const turn = turnByKey.get(turnKey(fb.sessionId, fb.turnNumber));
48
+ if (turn === undefined) {
49
+ skippedUnmatched += 1;
50
+ continue;
51
+ }
52
+ const score = normalizeRating(fb);
53
+ const isCorrection = fb.correction !== undefined && fb.correction.trim() !== "";
54
+ const isPositive = (score !== undefined && score >= minScore) || isCorrection;
55
+ if (!isPositive)
56
+ continue;
57
+ const rawOutput = isCorrection ? fb.correction : turn.output;
58
+ if (rawOutput.trim() === "") {
59
+ skippedEmpty += 1;
60
+ continue;
61
+ }
62
+ const input = await redact(turn.input);
63
+ const output = await redact(rawOutput);
64
+ if (output.trim() === "") {
65
+ skippedEmpty += 1;
66
+ continue;
67
+ }
68
+ qualified += 1;
69
+ const id = `${fb.sessionId}_t${fb.turnNumber}`;
70
+ byId.set(id, {
71
+ schemaVersion: FEWSHOT_SCHEMA_VERSION,
72
+ id,
73
+ input,
74
+ output,
75
+ score: isCorrection ? 1 : (score ?? 1),
76
+ provenance: {
77
+ sessionId: fb.sessionId,
78
+ turnNumber: fb.turnNumber,
79
+ source: isCorrection ? "correction" : "rating",
80
+ ...(fb.rater !== undefined ? { rater: fb.rater } : {}),
81
+ },
82
+ });
83
+ }
84
+ const examples = [...byId.values()].sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
85
+ return {
86
+ examples,
87
+ stats: {
88
+ totalFeedback: merged.length,
89
+ qualified,
90
+ skippedUnmatched,
91
+ skippedEmpty,
92
+ },
93
+ };
94
+ }
95
+ /** Narrow an arbitrary parsed JSON value to a persisted FewShotExample. */
96
+ export function isFewShotExample(value) {
97
+ if (typeof value !== "object" || value === null)
98
+ return false;
99
+ const v = value;
100
+ if (v["schemaVersion"] !== 1)
101
+ return false;
102
+ if (typeof v["id"] !== "string")
103
+ return false;
104
+ if (typeof v["input"] !== "string" || typeof v["output"] !== "string")
105
+ return false;
106
+ if (typeof v["score"] !== "number" || !Number.isFinite(v["score"]))
107
+ return false;
108
+ const p = v["provenance"];
109
+ if (typeof p !== "object" || p === null)
110
+ return false;
111
+ const pr = p;
112
+ return typeof pr["sessionId"] === "string" && typeof pr["turnNumber"] === "number";
113
+ }
114
+ /**
115
+ * Merge a freshly-harvested pool into an existing one, deduping by id (the
116
+ * newer example wins so re-runs refresh outputs without duplicating). Returns
117
+ * the deterministically-ordered union.
118
+ */
119
+ export function mergePools(existing, fresh) {
120
+ const byId = new Map();
121
+ for (const e of existing)
122
+ byId.set(e.id, e);
123
+ for (const e of fresh)
124
+ byId.set(e.id, e);
125
+ return [...byId.values()].sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
126
+ }
127
+ /** Serialize a pool to JSONL (one example per line). */
128
+ export function poolToJsonl(examples) {
129
+ return `${examples.map((e) => JSON.stringify(e)).join("\n")}\n`;
130
+ }
131
+ const FEWSHOT_CLOSE_TAG_RE = /<\s*\/\s*few_shot_examples\s*>/gi;
132
+ /**
133
+ * #54 F5 — delimiter safety. An example's input/output is user/model text and
134
+ * can be adversarially shaped, so before interpolating it into the
135
+ * `<few_shot_examples>` block we neutralize any embedded closing tag: a
136
+ * `</few_shot_examples>` inside an example would otherwise let a poisoned
137
+ * example terminate the block early and inject trailing instructions. The
138
+ * closing tag is rewritten to an inert `<\/few_shot_examples>` (content
139
+ * preserved for the model, but no longer parses as the real delimiter).
140
+ * Redaction of secrets/PII already runs upstream — this is purely structural.
141
+ */
142
+ function escapeFewShotContent(text) {
143
+ return text.replace(FEWSHOT_CLOSE_TAG_RE, "<\\/few_shot_examples>");
144
+ }
145
+ /**
146
+ * Render the top-K examples as an in-context few-shot block for the system
147
+ * prompt / `agent.instructions`. Empty input → "" so callers append
148
+ * unconditionally. `k` caps how many examples are injected (default 5).
149
+ */
150
+ export function formatFewShotForPrompt(examples, k = 5) {
151
+ const top = examples.slice(0, Math.max(0, k));
152
+ if (top.length === 0)
153
+ return "";
154
+ const blocks = top
155
+ .map((e, i) => `Example ${i + 1}:\nUser: ${escapeFewShotContent(e.input)}\nAssistant: ${escapeFewShotContent(e.output)}`)
156
+ .join("\n\n");
157
+ return `<few_shot_examples>\nThese are examples of responses users rated highly. Follow their style and quality:\n\n${blocks}\n</few_shot_examples>`;
158
+ }