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.
- package/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +9 -2
- package/dist/feedback.js +17 -7
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11601 -964
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -65
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 63 — cross-harness knowledge sync. `crewhaus knowledge sync
|
|
3
|
+
* [--pull|--push]` moves three kinds of hard-won knowledge between a harness
|
|
4
|
+
* and a fleet-level SHARED store, so a lesson the Slack bot learned reaches
|
|
5
|
+
* the CLI concierge:
|
|
6
|
+
* - memories: `@crewhaus/memory-store` JSONL entries (facts the agent should
|
|
7
|
+
* remember across sessions);
|
|
8
|
+
* - graders: reusable `graders.yaml` fragments (a grader suite one harness
|
|
9
|
+
* hardened is worth sharing);
|
|
10
|
+
* - prompt fragments: few-shot examples / lessons (`.md` snippets).
|
|
11
|
+
*
|
|
12
|
+
* Factored out of the entry file `index.ts` (which runs a top-level argv
|
|
13
|
+
* switch and so cannot be imported by a test without executing the CLI).
|
|
14
|
+
* Side-effect-free on import, mirroring `state-backup.ts` / `retention.ts`:
|
|
15
|
+
* - the merge (dedupe-by-content-hash) + provenance tagging are pure;
|
|
16
|
+
* - the redactor is injected (a `Redactor` seam over `@crewhaus/pii-redactor`
|
|
17
|
+
* with an added token detector), so redaction is tested without the
|
|
18
|
+
* package; the real CLI wires the concrete redactor.
|
|
19
|
+
*
|
|
20
|
+
* SHARED STORE FORMAT — a fleet-level directory (default `.crewhaus-shared/`
|
|
21
|
+
* under the fleet root, or `CREWHAUS_SHARED_DIR`):
|
|
22
|
+
*
|
|
23
|
+
* .crewhaus-shared/
|
|
24
|
+
* memories.jsonl one line per shared memory:
|
|
25
|
+
* { contentHash, text, tags, provenance }
|
|
26
|
+
* graders/<contentHash>.yaml one shared grader fragment per file
|
|
27
|
+
* prompts/<contentHash>.md one shared prompt fragment per file
|
|
28
|
+
* manifest.jsonl append-only provenance log of every push
|
|
29
|
+
*
|
|
30
|
+
* DEDUPE — every artifact is keyed by the sha256 of its NORMALIZED content
|
|
31
|
+
* (memory: text+sorted-tags; grader/prompt: the file bytes). A push adds an
|
|
32
|
+
* artifact only when its hash is new to the shared store; a pull adds it to
|
|
33
|
+
* the harness only when the harness lacks it. Re-running a sync is therefore a
|
|
34
|
+
* no-op — the same determinism contract as `state-backup`'s feedback merge.
|
|
35
|
+
*
|
|
36
|
+
* PROVENANCE — each shared artifact records which harness pushed it and when,
|
|
37
|
+
* so `sync` never loses attribution and a pulled memory can be traced home.
|
|
38
|
+
*
|
|
39
|
+
* REDACTION ON PUSH — text pushed OUT of a harness is run through the injected
|
|
40
|
+
* redactor first (PII categories from pii-redactor PLUS a token detector for
|
|
41
|
+
* credential-shaped strings). A memory whose text still holds a
|
|
42
|
+
* credential-shaped token AFTER redaction is DROPPED with a warning, not
|
|
43
|
+
* shared — leaking one into the shared store would poison every harness that
|
|
44
|
+
* pulls it.
|
|
45
|
+
*
|
|
46
|
+
* OPT-IN — a harness participates only when it carries a
|
|
47
|
+
* `.crewhaus/knowledge.json` marker (`{ "share": true }`). A harness that
|
|
48
|
+
* hasn't opted in is skipped by a fleet-wide sync.
|
|
49
|
+
*/
|
|
50
|
+
import { createHash } from "node:crypto";
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { join } from "node:path";
|
|
53
|
+
/** Thrown for operational failures. The CLI routes the message through
|
|
54
|
+
* `die()`; tests assert on `.message`. */
|
|
55
|
+
export class KnowledgeSyncError extends Error {
|
|
56
|
+
name = "KnowledgeSyncError";
|
|
57
|
+
}
|
|
58
|
+
export const SHARED_DIR_DEFAULT = ".crewhaus-shared";
|
|
59
|
+
export const KNOWLEDGE_MARKER_RELPATH = ".crewhaus/knowledge.json";
|
|
60
|
+
const MEMORIES_RELPATH = ".crewhaus/memories";
|
|
61
|
+
const GRADERS_FILE_CANDIDATES = ["graders.yaml", ".crewhaus/graders.yaml"];
|
|
62
|
+
const PROMPTS_RELDIR = ".crewhaus/prompts";
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// content hashing + normalization
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
/** sha256 hex of a normalized memory (text + sorted, deduped tags) — the
|
|
67
|
+
* dedupe key, independent of tag order or the memory's own id/timestamp. */
|
|
68
|
+
export function memoryContentHash(text, tags) {
|
|
69
|
+
const normTags = [...new Set(tags)].sort();
|
|
70
|
+
return createHash("sha256")
|
|
71
|
+
.update(`${text.trim()}\n${normTags.join(",")}`)
|
|
72
|
+
.digest("hex");
|
|
73
|
+
}
|
|
74
|
+
/** sha256 hex of a file fragment's bytes (grader/prompt), trimmed. */
|
|
75
|
+
export function fragmentContentHash(bytes) {
|
|
76
|
+
return createHash("sha256").update(bytes.trim()).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
/** A pass-through redactor (tests / --no-redact). Never flags tokens. */
|
|
79
|
+
export const IDENTITY_REDACTOR = async (text) => ({ text, secretRemains: false });
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// credential-shaped token detection (F1 — leak-proof knowledge push)
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
/**
|
|
84
|
+
* Credential-shaped token detectors, used both to REDACT a token out of pushed
|
|
85
|
+
* text and to CHECK (post-redaction) that nothing credential-shaped survived —
|
|
86
|
+
* a memory whose text still holds a secret after redaction is DROPPED, never
|
|
87
|
+
* shared (a leaked credential poisons every harness that pulls it).
|
|
88
|
+
*
|
|
89
|
+
* The prior custom set MISSED whole credential families — AWS secret access
|
|
90
|
+
* keys (their `/`+`+` alphabet), Stripe `sk_live_…`/`sk_test_…` (the `_` after
|
|
91
|
+
* the prefix), PEM private-key BODIES (only the header was caught), Slack
|
|
92
|
+
* `xoxb-` tails — so those leaked verbatim into the shared store. This set is
|
|
93
|
+
* the union of the battle-tested shapes shipped elsewhere in the repo
|
|
94
|
+
* (`packages/ir|spec-patch/src/redact.ts` `TOKEN_SHAPE_RES`,
|
|
95
|
+
* `apps/cli/src/dataset-mine.ts` `SECRET_KEY_DETECTOR`) PLUS the families they
|
|
96
|
+
* predate.
|
|
97
|
+
*
|
|
98
|
+
* Every pattern is assembled from character-class parts / joined literals so
|
|
99
|
+
* NO credential-shaped literal appears in this source (repo push-protection).
|
|
100
|
+
*/
|
|
101
|
+
const b62 = "[A-Za-z0-9]";
|
|
102
|
+
const b64 = "[A-Za-z0-9+/]"; // base64 alphabet incl. `+` and `/` (AWS secret keys)
|
|
103
|
+
/** The credential token shapes, each a global RegExp so `replace` masks every
|
|
104
|
+
* occurrence. Order matters only for the PEM block (masked whole, first). */
|
|
105
|
+
export const KNOWLEDGE_SECRET_DETECTORS = [
|
|
106
|
+
// PEM blocks — mask the WHOLE block (header + body + footer), not just the
|
|
107
|
+
// header. `[\s\S]` so it spans newlines; non-greedy so adjacent blocks split.
|
|
108
|
+
{
|
|
109
|
+
kind: "pem_block",
|
|
110
|
+
regex: /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g,
|
|
111
|
+
},
|
|
112
|
+
// JWT: three base64url segments separated by dots.
|
|
113
|
+
{
|
|
114
|
+
kind: "jwt",
|
|
115
|
+
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
116
|
+
},
|
|
117
|
+
// Stripe-style keys: (sk|pk|rk)_(live|test)_<tail> — the `_` after the prefix
|
|
118
|
+
// is what broke the old `sk-`-only match.
|
|
119
|
+
{
|
|
120
|
+
kind: "stripe_key",
|
|
121
|
+
regex: new RegExp(`\\b(?:sk|pk|rk)_(?:live|test)_${b62}{16,}\\b`, "g"),
|
|
122
|
+
},
|
|
123
|
+
// Generic provider secret keys: `sk-…` (OpenAI/Anthropic), allowing `_`/`-`.
|
|
124
|
+
{ kind: "sk_key", regex: /\bsk-[A-Za-z0-9_-]{16,}/g },
|
|
125
|
+
// GitHub tokens: ghp_/gho_/ghu_/ghs_/ghr_ + long tail.
|
|
126
|
+
{ kind: "github_token", regex: /\bgh[oprsu]_[A-Za-z0-9]{16,}/g },
|
|
127
|
+
// Slack tokens: xox[baprs]-… (the whole dashed tail, not just the prefix).
|
|
128
|
+
{ kind: "slack_token", regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
129
|
+
// AWS access key id: AKIA + 12+ uppercase/digit chars.
|
|
130
|
+
{ kind: "aws_access_key_id", regex: /\bAKIA[A-Z0-9]{12,}/g },
|
|
131
|
+
// AWS secret access key: a 40-char base64 blob (incl. `/` and `+`), matched
|
|
132
|
+
// standalone (context-free) — the `/`+`+` alphabet is exactly what the old
|
|
133
|
+
// base62 detector missed. Longer/shorter high-entropy blobs are caught by the
|
|
134
|
+
// looksLikeSecret fallback (which DROPS rather than masks).
|
|
135
|
+
{ kind: "aws_secret_key", regex: new RegExp(`\\b${b64}{40}\\b`, "g") },
|
|
136
|
+
// `Bearer <token>` — scheme word kept, token masked (mirrors repo BEARER_RE).
|
|
137
|
+
{ kind: "bearer", regex: /\b(bearer)\s+[A-Za-z0-9._~+/-]{16,}=*/gi },
|
|
138
|
+
];
|
|
139
|
+
/**
|
|
140
|
+
* A conservative "this looks like a secret" heuristic used as the STRICT
|
|
141
|
+
* fallback: after redaction, if ANY of these fire on the surviving text the
|
|
142
|
+
* artifact is DROPPED rather than shared. It combines the named detectors above
|
|
143
|
+
* with a high-entropy long-token probe (32+ chars mixing letter+digit, or a
|
|
144
|
+
* 40+-char base64 blob) so a novel credential shape the named set doesn't
|
|
145
|
+
* recognize is still refused rather than leaked.
|
|
146
|
+
*/
|
|
147
|
+
const HIGH_ENTROPY_MIXED_RE = /\b(?=[A-Za-z0-9+/_-]*[A-Za-z])(?=[A-Za-z0-9+/_-]*[0-9])[A-Za-z0-9+/_-]{32,}\b/;
|
|
148
|
+
const LONG_BASE64_RE = new RegExp(`\\b${b64}{40,}\\b`);
|
|
149
|
+
/** True when `text` still contains something credential-shaped — used as the
|
|
150
|
+
* `secretRemains` verdict AFTER redaction so anything suspicious is dropped. */
|
|
151
|
+
export function looksLikeSecret(text) {
|
|
152
|
+
for (const d of KNOWLEDGE_SECRET_DETECTORS) {
|
|
153
|
+
// Fresh lastIndex isn't a concern: `.test` on a `g` regex advances
|
|
154
|
+
// lastIndex, so reset before each probe to keep this deterministic.
|
|
155
|
+
d.regex.lastIndex = 0;
|
|
156
|
+
if (d.regex.test(text))
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
return HIGH_ENTROPY_MIXED_RE.test(text) || LONG_BASE64_RE.test(text);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Mask every credential-shaped token in `text` with `***`. PEM blocks are
|
|
163
|
+
* masked whole. Pure + synchronous — the PII pass is layered on top by
|
|
164
|
+
* {@link buildKnowledgeRedactor}.
|
|
165
|
+
*/
|
|
166
|
+
export function maskKnowledgeSecrets(text) {
|
|
167
|
+
let out = text;
|
|
168
|
+
for (const d of KNOWLEDGE_SECRET_DETECTORS) {
|
|
169
|
+
d.regex.lastIndex = 0;
|
|
170
|
+
if (d.kind === "bearer") {
|
|
171
|
+
// Group 1 is the scheme word "bearer" — keep it, mask only the token.
|
|
172
|
+
out = out.replace(d.regex, (_m, scheme) => `${scheme} ***`);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
out = out.replace(d.regex, "***");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Build the production knowledge redactor: run a PII pass, then mask every
|
|
182
|
+
* credential-shaped token, then re-scan the result with the STRICT
|
|
183
|
+
* {@link looksLikeSecret} heuristic — anything that still looks like a secret
|
|
184
|
+
* sets `secretRemains` so the caller DROPS the artifact rather than share a
|
|
185
|
+
* leaked credential.
|
|
186
|
+
*
|
|
187
|
+
* The `piiPass` is injected so this is unit-testable without the redactor
|
|
188
|
+
* package (the CLI wires the real `@crewhaus/pii-redactor` pass).
|
|
189
|
+
*/
|
|
190
|
+
export function buildKnowledgeRedactor(piiPass) {
|
|
191
|
+
return async (text) => {
|
|
192
|
+
const afterPii = await piiPass(text);
|
|
193
|
+
const masked = maskKnowledgeSecrets(afterPii);
|
|
194
|
+
return { text: masked, secretRemains: looksLikeSecret(masked) };
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
// harness reads
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
/** Whether a harness has opted into knowledge sharing (marker file with
|
|
201
|
+
* `{ share: true }`). */
|
|
202
|
+
export function harnessOptedIn(harnessDir) {
|
|
203
|
+
const path = join(harnessDir, KNOWLEDGE_MARKER_RELPATH);
|
|
204
|
+
if (!existsSync(path))
|
|
205
|
+
return false;
|
|
206
|
+
try {
|
|
207
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
208
|
+
return parsed.share === true;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Read every memory-store entry across a harness's `.crewhaus/memories/*.jsonl`. */
|
|
215
|
+
export function readHarnessMemories(harnessDir) {
|
|
216
|
+
const dir = join(harnessDir, MEMORIES_RELPATH);
|
|
217
|
+
if (!existsSync(dir))
|
|
218
|
+
return [];
|
|
219
|
+
const out = [];
|
|
220
|
+
for (const f of readdirSync(dir)) {
|
|
221
|
+
if (!f.endsWith(".jsonl"))
|
|
222
|
+
continue;
|
|
223
|
+
for (const line of readFileSync(join(dir, f), "utf-8").split("\n")) {
|
|
224
|
+
if (line.trim() === "")
|
|
225
|
+
continue;
|
|
226
|
+
try {
|
|
227
|
+
const rec = JSON.parse(line);
|
|
228
|
+
if (typeof rec.text !== "string")
|
|
229
|
+
continue;
|
|
230
|
+
const tags = Array.isArray(rec.tags)
|
|
231
|
+
? rec.tags.filter((t) => typeof t === "string")
|
|
232
|
+
: [];
|
|
233
|
+
out.push({ text: rec.text, tags });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// tolerated
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
/** Read a harness's grader fragment (first of the candidate paths present). */
|
|
243
|
+
export function readHarnessGraders(harnessDir) {
|
|
244
|
+
for (const rel of GRADERS_FILE_CANDIDATES) {
|
|
245
|
+
const path = join(harnessDir, rel);
|
|
246
|
+
if (existsSync(path)) {
|
|
247
|
+
return { filename: rel.split("/").pop(), contents: readFileSync(path, "utf-8") };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
/** Read a harness's prompt fragments (`.crewhaus/prompts/*.md`). */
|
|
253
|
+
export function readHarnessPrompts(harnessDir) {
|
|
254
|
+
const dir = join(harnessDir, PROMPTS_RELDIR);
|
|
255
|
+
if (!existsSync(dir))
|
|
256
|
+
return [];
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const f of readdirSync(dir)) {
|
|
259
|
+
if (!f.endsWith(".md") && !f.endsWith(".txt"))
|
|
260
|
+
continue;
|
|
261
|
+
out.push({ filename: f, contents: readFileSync(join(dir, f), "utf-8") });
|
|
262
|
+
}
|
|
263
|
+
return out.sort((a, b) => a.filename.localeCompare(b.filename));
|
|
264
|
+
}
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// shared-store reads
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
/** Upper bound on a shared memory's text length. A shared store is untrusted
|
|
269
|
+
* input on pull; an absurdly long "memory" is a poisoning / DoS vector, never
|
|
270
|
+
* a real lesson. 64 KiB is far above any genuine memory. */
|
|
271
|
+
export const MAX_SHARED_MEMORY_TEXT = 64 * 1024;
|
|
272
|
+
/**
|
|
273
|
+
* Validate one parsed shared-memory record (F6). A pulled record is UNTRUSTED:
|
|
274
|
+
* the file could have been hand-edited to poison the harness's dedupe (a lying
|
|
275
|
+
* `contentHash` so a malicious `text` masquerades as an already-trusted entry)
|
|
276
|
+
* or to bloat it (oversized text / malformed tags). Returns the record when it
|
|
277
|
+
* is a well-formed {@link SharedMemory} whose stored `contentHash` MATCHES the
|
|
278
|
+
* recomputed hash of its own text+tags; otherwise a `reason` for the skip.
|
|
279
|
+
*/
|
|
280
|
+
export function validateSharedMemory(rec) {
|
|
281
|
+
if (rec === null || typeof rec !== "object")
|
|
282
|
+
return { ok: false, reason: "not an object" };
|
|
283
|
+
const r = rec;
|
|
284
|
+
if (typeof r["contentHash"] !== "string")
|
|
285
|
+
return { ok: false, reason: "contentHash not a string" };
|
|
286
|
+
if (typeof r["text"] !== "string")
|
|
287
|
+
return { ok: false, reason: "text not a string" };
|
|
288
|
+
if (r["text"].length > MAX_SHARED_MEMORY_TEXT) {
|
|
289
|
+
return { ok: false, reason: `text exceeds ${MAX_SHARED_MEMORY_TEXT} bytes` };
|
|
290
|
+
}
|
|
291
|
+
if (!Array.isArray(r["tags"]) || !r["tags"].every((t) => typeof t === "string")) {
|
|
292
|
+
return { ok: false, reason: "tags is not a string[]" };
|
|
293
|
+
}
|
|
294
|
+
const tags = r["tags"];
|
|
295
|
+
// Re-hash text+tags and reject a record whose stored hash lies — this is what
|
|
296
|
+
// defeats dedupe-poisoning (a forged hash colliding with a trusted entry).
|
|
297
|
+
const recomputed = memoryContentHash(r["text"], tags);
|
|
298
|
+
if (recomputed !== r["contentHash"]) {
|
|
299
|
+
return { ok: false, reason: "contentHash does not match recomputed hash of text+tags" };
|
|
300
|
+
}
|
|
301
|
+
const prov = r["provenance"];
|
|
302
|
+
const provenance = prov !== null &&
|
|
303
|
+
typeof prov === "object" &&
|
|
304
|
+
typeof prov.harness === "string" &&
|
|
305
|
+
typeof prov.pushedAt === "string"
|
|
306
|
+
? prov
|
|
307
|
+
: { harness: "unknown", pushedAt: "unknown" };
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
memory: { contentHash: r["contentHash"], text: r["text"], tags, provenance },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** Read the shared memories.jsonl. Missing → []. Each record is validated as a
|
|
314
|
+
* proper {@link SharedMemory} with a matching content hash (F6); invalid
|
|
315
|
+
* records are SKIPPED with a warning to stderr, never loaded. */
|
|
316
|
+
export function readSharedMemories(sharedDir) {
|
|
317
|
+
const path = join(sharedDir, "memories.jsonl");
|
|
318
|
+
if (!existsSync(path))
|
|
319
|
+
return [];
|
|
320
|
+
const out = [];
|
|
321
|
+
for (const line of readFileSync(path, "utf-8").split("\n")) {
|
|
322
|
+
if (line.trim() === "")
|
|
323
|
+
continue;
|
|
324
|
+
let parsed;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(line);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue; // tolerated — a malformed JSON line
|
|
330
|
+
}
|
|
331
|
+
const v = validateSharedMemory(parsed);
|
|
332
|
+
if (v.ok) {
|
|
333
|
+
out.push(v.memory);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
warnSkippedShared(`memory (${v.reason})`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** Emit a skip warning for a rejected shared artifact. Isolated so tests can
|
|
342
|
+
* silence/observe it and the two readers share one message shape. */
|
|
343
|
+
function warnSkippedShared(what) {
|
|
344
|
+
process.stderr.write(`crewhaus: warning: skipped invalid shared ${what}\n`);
|
|
345
|
+
}
|
|
346
|
+
/** Read the shared fragments of a kind (`graders/` or `prompts/`). */
|
|
347
|
+
export function readSharedFragments(sharedDir, kind) {
|
|
348
|
+
const dir = join(sharedDir, kind === "grader" ? "graders" : "prompts");
|
|
349
|
+
if (!existsSync(dir))
|
|
350
|
+
return [];
|
|
351
|
+
const out = [];
|
|
352
|
+
const ext = kind === "grader" ? ".yaml" : ".md";
|
|
353
|
+
for (const f of readdirSync(dir)) {
|
|
354
|
+
if (!f.endsWith(ext))
|
|
355
|
+
continue;
|
|
356
|
+
const contents = readFileSync(join(dir, f), "utf-8");
|
|
357
|
+
// Strip the provenance header line if present; the body is the fragment.
|
|
358
|
+
const { provenance, body } = splitProvenanceHeader(contents);
|
|
359
|
+
// F6 — the filename IS the claimed content hash. RECOMPUTE it from the body
|
|
360
|
+
// and reject a mismatch: a fragment file renamed to a trusted hash would
|
|
361
|
+
// otherwise poison the harness's dedupe (pulled under a hash it thinks it
|
|
362
|
+
// already vetted). Also bound the body size like memories.
|
|
363
|
+
const claimedHash = f.slice(0, -ext.length);
|
|
364
|
+
if (body.length > MAX_SHARED_MEMORY_TEXT) {
|
|
365
|
+
warnSkippedShared(`${kind} fragment ${f} (exceeds ${MAX_SHARED_MEMORY_TEXT} bytes)`);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (fragmentContentHash(body) !== claimedHash) {
|
|
369
|
+
warnSkippedShared(`${kind} fragment ${f} (filename hash does not match recomputed body hash)`);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
out.push({
|
|
373
|
+
contentHash: claimedHash,
|
|
374
|
+
kind,
|
|
375
|
+
filename: f,
|
|
376
|
+
contents: body,
|
|
377
|
+
provenance: provenance ?? { harness: "unknown", pushedAt: "unknown" },
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
const PROVENANCE_HEADER_RE = /^#!\s*crewhaus-knowledge\s+(\{.*\})\s*$/;
|
|
383
|
+
/** A fragment file carries a first-line provenance comment
|
|
384
|
+
* `#! crewhaus-knowledge {json}`; split it from the body. */
|
|
385
|
+
export function splitProvenanceHeader(contents) {
|
|
386
|
+
const nl = contents.indexOf("\n");
|
|
387
|
+
const firstLine = nl === -1 ? contents : contents.slice(0, nl);
|
|
388
|
+
const m = PROVENANCE_HEADER_RE.exec(firstLine.trim());
|
|
389
|
+
if (m === null)
|
|
390
|
+
return { body: contents };
|
|
391
|
+
try {
|
|
392
|
+
const provenance = JSON.parse(m[1]);
|
|
393
|
+
return { provenance, body: nl === -1 ? "" : contents.slice(nl + 1) };
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return { body: contents };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/** Prepend a provenance header line to a fragment body. */
|
|
400
|
+
export function withProvenanceHeader(body, provenance) {
|
|
401
|
+
return `#! crewhaus-knowledge ${JSON.stringify(provenance)}\n${body}`;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Plan a push: redact each harness memory, drop any whose text still carries a
|
|
405
|
+
* credential-shaped token, hash the rest, and keep only artifacts whose
|
|
406
|
+
* content hash is NEW to the shared store. Pure over the injected redactor +
|
|
407
|
+
* the current shared hashes. Grader/prompt fragments are redacted too (a
|
|
408
|
+
* lesson can quote a key).
|
|
409
|
+
*/
|
|
410
|
+
export async function planPush(opts) {
|
|
411
|
+
const provenance = { harness: opts.harness, pushedAt: opts.now().toISOString() };
|
|
412
|
+
const memories = [];
|
|
413
|
+
const droppedSecrets = [];
|
|
414
|
+
let skippedDuplicates = 0;
|
|
415
|
+
const seenThisPush = new Set();
|
|
416
|
+
for (const m of opts.memories) {
|
|
417
|
+
const outcome = await opts.redact(m.text);
|
|
418
|
+
if (outcome.secretRemains) {
|
|
419
|
+
droppedSecrets.push(m.text.slice(0, 60));
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const hash = memoryContentHash(outcome.text, m.tags);
|
|
423
|
+
if (opts.existingMemoryHashes.has(hash) || seenThisPush.has(hash)) {
|
|
424
|
+
skippedDuplicates += 1;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
seenThisPush.add(hash);
|
|
428
|
+
memories.push({ contentHash: hash, text: outcome.text, tags: m.tags, provenance });
|
|
429
|
+
}
|
|
430
|
+
const fragments = [];
|
|
431
|
+
const pushFragment = async (kind, filename, contents) => {
|
|
432
|
+
const outcome = await opts.redact(contents);
|
|
433
|
+
if (outcome.secretRemains) {
|
|
434
|
+
droppedSecrets.push(`${filename} (fragment)`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const hash = fragmentContentHash(outcome.text);
|
|
438
|
+
if (opts.existingFragmentHashes.has(hash) || seenThisPush.has(hash)) {
|
|
439
|
+
skippedDuplicates += 1;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
seenThisPush.add(hash);
|
|
443
|
+
fragments.push({ contentHash: hash, kind, filename, contents: outcome.text, provenance });
|
|
444
|
+
};
|
|
445
|
+
if (opts.graders !== undefined) {
|
|
446
|
+
await pushFragment("grader", opts.graders.filename, opts.graders.contents);
|
|
447
|
+
}
|
|
448
|
+
for (const p of opts.prompts)
|
|
449
|
+
await pushFragment("prompt", p.filename, p.contents);
|
|
450
|
+
return { memories, fragments, droppedSecrets, skippedDuplicates };
|
|
451
|
+
}
|
|
452
|
+
/** Write a push plan into the shared store (append memories, write fragment
|
|
453
|
+
* files, append the manifest log). Not called in --dry-run. */
|
|
454
|
+
export function applyPush(sharedDir, plan, now) {
|
|
455
|
+
if (plan.memories.length === 0 && plan.fragments.length === 0)
|
|
456
|
+
return;
|
|
457
|
+
mkdirSync(sharedDir, { recursive: true });
|
|
458
|
+
if (plan.memories.length > 0) {
|
|
459
|
+
const path = join(sharedDir, "memories.jsonl");
|
|
460
|
+
const lines = plan.memories.map((m) => JSON.stringify(m)).join("\n");
|
|
461
|
+
appendLine(path, lines);
|
|
462
|
+
}
|
|
463
|
+
for (const frag of plan.fragments) {
|
|
464
|
+
const dir = join(sharedDir, frag.kind === "grader" ? "graders" : "prompts");
|
|
465
|
+
mkdirSync(dir, { recursive: true });
|
|
466
|
+
const ext = frag.kind === "grader" ? ".yaml" : ".md";
|
|
467
|
+
writeFileSync(join(dir, `${frag.contentHash}${ext}`), withProvenanceHeader(frag.contents, frag.provenance), { mode: 0o600 });
|
|
468
|
+
}
|
|
469
|
+
appendLine(join(sharedDir, "manifest.jsonl"), JSON.stringify({
|
|
470
|
+
action: "push",
|
|
471
|
+
at: now().toISOString(),
|
|
472
|
+
memories: plan.memories.map((m) => m.contentHash),
|
|
473
|
+
fragments: plan.fragments.map((f) => `${f.kind}:${f.contentHash}`),
|
|
474
|
+
}));
|
|
475
|
+
}
|
|
476
|
+
function appendLine(path, line) {
|
|
477
|
+
const existing = existsSync(path) ? readFileSync(path, "utf-8") : "";
|
|
478
|
+
const sep = existing === "" || existing.endsWith("\n") ? "" : "\n";
|
|
479
|
+
writeFileSync(path, `${existing}${sep}${line}\n`, { mode: 0o600 });
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Plan a pull: shared artifacts whose content hash the harness does not
|
|
483
|
+
* already have. Pure over the harness's existing hashes.
|
|
484
|
+
*/
|
|
485
|
+
export function planPull(opts) {
|
|
486
|
+
const memories = opts.sharedMemories.filter((m) => !opts.harnessMemoryHashes.has(m.contentHash));
|
|
487
|
+
const fragments = opts.sharedFragments.filter((f) => !opts.harnessFragmentHashes.has(f.contentHash));
|
|
488
|
+
const skippedDuplicates = opts.sharedMemories.length - memories.length + (opts.sharedFragments.length - fragments.length);
|
|
489
|
+
return { memories, fragments, skippedDuplicates };
|
|
490
|
+
}
|
|
491
|
+
/** Write a pull plan into a harness: append memories to a shared-memories
|
|
492
|
+
* JSONL the memory-store already reads, and drop fragment files into
|
|
493
|
+
* `.crewhaus/prompts` / a `graders.shared.yaml`. Not called in --dry-run. */
|
|
494
|
+
export function applyPull(harnessDir, plan, now) {
|
|
495
|
+
if (plan.memories.length > 0) {
|
|
496
|
+
const dir = join(harnessDir, MEMORIES_RELPATH);
|
|
497
|
+
mkdirSync(dir, { recursive: true });
|
|
498
|
+
// Land in a dedicated shared file the memory-store loads alongside the
|
|
499
|
+
// harness's own memories. Each entry is a valid MemoryEntry (id + text +
|
|
500
|
+
// tags + createdAt), so recall() ranks them like local memories.
|
|
501
|
+
const path = join(dir, "shared.jsonl");
|
|
502
|
+
const stamp = now().toISOString();
|
|
503
|
+
const lines = plan.memories.map((m) => JSON.stringify({
|
|
504
|
+
id: `mem_shared_${m.contentHash.slice(0, 16)}`,
|
|
505
|
+
text: m.text,
|
|
506
|
+
tags: [...m.tags, `shared:${m.provenance.harness}`],
|
|
507
|
+
createdAt: stamp,
|
|
508
|
+
}));
|
|
509
|
+
appendLine(path, lines.join("\n"));
|
|
510
|
+
}
|
|
511
|
+
for (const frag of plan.fragments) {
|
|
512
|
+
if (frag.kind === "prompt") {
|
|
513
|
+
const dir = join(harnessDir, PROMPTS_RELDIR);
|
|
514
|
+
mkdirSync(dir, { recursive: true });
|
|
515
|
+
writeFileSync(join(dir, `shared-${frag.contentHash.slice(0, 12)}.md`), frag.contents, {
|
|
516
|
+
mode: 0o600,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
// grader fragments land beside the spec as graders.shared-*.yaml
|
|
521
|
+
// (informational — a human folds them into graders.yaml; auto-merging
|
|
522
|
+
// grader YAML is unsafe).
|
|
523
|
+
writeFileSync(join(harnessDir, `graders.shared-${frag.contentHash.slice(0, 12)}.yaml`), frag.contents, { mode: 0o600 });
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
// reporting
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
export function formatPushReport(harness, plan, dryRun) {
|
|
531
|
+
const verb = dryRun ? "would push" : "pushed";
|
|
532
|
+
const lines = [
|
|
533
|
+
`${harness}: ${verb} ${plan.memories.length} memory(ies), ${plan.fragments.length} fragment(s)`,
|
|
534
|
+
];
|
|
535
|
+
for (const s of plan.droppedSecrets) {
|
|
536
|
+
lines.push(` ⚠ dropped (token survived redaction): "${s}…"`);
|
|
537
|
+
}
|
|
538
|
+
if (plan.skippedDuplicates > 0) {
|
|
539
|
+
lines.push(` • ${plan.skippedDuplicates} already in the shared store (deduped)`);
|
|
540
|
+
}
|
|
541
|
+
return lines;
|
|
542
|
+
}
|
|
543
|
+
export function formatPullReport(harness, plan, dryRun) {
|
|
544
|
+
const verb = dryRun ? "would pull" : "pulled";
|
|
545
|
+
const lines = [
|
|
546
|
+
`${harness}: ${verb} ${plan.memories.length} memory(ies), ${plan.fragments.length} fragment(s)`,
|
|
547
|
+
];
|
|
548
|
+
if (plan.skippedDuplicates > 0)
|
|
549
|
+
lines.push(` • ${plan.skippedDuplicates} already present (deduped)`);
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item #56 — auto-maintained LESSONS.md + per-user preference files.
|
|
3
|
+
*
|
|
4
|
+
* `crewhaus lessons update` mines two durable signals:
|
|
5
|
+
* - CORRECTION lessons: a `user_feedback` record carrying a `correction`
|
|
6
|
+
* (the user's better answer) OR a low-rated turn with a comment — the
|
|
7
|
+
* "here's what went wrong / do this instead" signal.
|
|
8
|
+
* - FAILURE→FIX lessons: recurring negative signals from `dataset-mine`'s
|
|
9
|
+
* `mineSession` (tool-error spikes, runtime errors, loop nudges, retries)
|
|
10
|
+
* paired to the triggering turn — "when X happens, watch out."
|
|
11
|
+
* These fold into a deduped, append-with-merge LESSONS.md (like the project-
|
|
12
|
+
* memory files the runtime already auto-loads — LESSONS.md is added to the
|
|
13
|
+
* canonical set, so it is injected into the system prompt at run start).
|
|
14
|
+
*
|
|
15
|
+
* Per-user preferences: when feedback carries a `rater` identity, ratings +
|
|
16
|
+
* comments for that rater fold into a per-user prefs file
|
|
17
|
+
* (`.crewhaus/preferences/<rater>.md`) injected when that user is known.
|
|
18
|
+
*
|
|
19
|
+
* Pure + deterministic (stable order, normalized-text dedupe → idempotent
|
|
20
|
+
* re-runs); FS + redaction wiring lives in `apps/cli/src/index.ts`. Lesson +
|
|
21
|
+
* preference text is model/user output → PII/secret-redacted by the caller.
|
|
22
|
+
*/
|
|
23
|
+
import type { FeedbackRecord, SessionTurn } from "./feedback";
|
|
24
|
+
/** One durable lesson. `key` is the normalized-text dedupe handle. */
|
|
25
|
+
export type Lesson = {
|
|
26
|
+
/** Normalized dedupe key (lowercased, whitespace-collapsed). */
|
|
27
|
+
readonly key: string;
|
|
28
|
+
/** The human-readable lesson line rendered into LESSONS.md. */
|
|
29
|
+
readonly text: string;
|
|
30
|
+
readonly kind: "correction" | "failure-fix";
|
|
31
|
+
};
|
|
32
|
+
export type LessonsOptions = {
|
|
33
|
+
/** Low-rated threshold: a turn at/below this contributes a correction lesson
|
|
34
|
+
* when it carries a comment. Default 0.5. */
|
|
35
|
+
readonly lowScore?: number;
|
|
36
|
+
/** Async redactor for lesson text. Identity by default. */
|
|
37
|
+
readonly redact?: (text: string) => Promise<string>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Mine lessons from turns + feedback + pre-mined failure signals. Returns a
|
|
41
|
+
* deterministically-ordered, deduped list. `failureSignals` is the output of
|
|
42
|
+
* `mineSession` across the scanned sessions (each `{ input, reason }`), so the
|
|
43
|
+
* failure→fix lessons reuse the same negative-signal detection the dataset
|
|
44
|
+
* miner uses rather than reinventing it.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mineLessons(turns: ReadonlyArray<SessionTurn>, feedback: ReadonlyArray<FeedbackRecord>, failureSignals: ReadonlyArray<{
|
|
47
|
+
input: string;
|
|
48
|
+
reason: string;
|
|
49
|
+
}>, opts?: LessonsOptions): Promise<Lesson[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Merge fresh lessons into existing ones, deduping by normalized key so a
|
|
52
|
+
* re-run never duplicates a lesson (append-with-merge idempotency). Existing
|
|
53
|
+
* lessons are preserved; new keys appended; ordering re-stabilized.
|
|
54
|
+
*/
|
|
55
|
+
export declare function mergeLessons(existing: ReadonlyArray<Lesson>, fresh: ReadonlyArray<Lesson>): Lesson[];
|
|
56
|
+
/**
|
|
57
|
+
* Render LESSONS.md. Everything BELOW the `crewhaus:lessons` marker is machine-
|
|
58
|
+
* managed (the sorted lesson list); anything a human wrote ABOVE it is
|
|
59
|
+
* preserved verbatim by `parseLessonsMd` on the next update.
|
|
60
|
+
*/
|
|
61
|
+
export declare function renderLessonsMd(lessons: ReadonlyArray<Lesson>, preamble?: string): string;
|
|
62
|
+
/**
|
|
63
|
+
* Parse an existing LESSONS.md back into `{ preamble, lessons }`. The preamble
|
|
64
|
+
* is everything before the `crewhaus:lessons` marker (preserved on re-render);
|
|
65
|
+
* lessons are the `- ` bullets under the two managed sections, tagged by which
|
|
66
|
+
* section they came from. Files without the marker are treated as all-preamble
|
|
67
|
+
* (a human-authored file), so we never clobber hand-written lessons.
|
|
68
|
+
*/
|
|
69
|
+
export declare function parseLessonsMd(content: string): {
|
|
70
|
+
preamble: string;
|
|
71
|
+
lessons: Lesson[];
|
|
72
|
+
};
|
|
73
|
+
export type UserPreferences = {
|
|
74
|
+
readonly rater: string;
|
|
75
|
+
/** Preference bullets (comments on up-rated turns, corrections). */
|
|
76
|
+
readonly notes: ReadonlyArray<string>;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Fold feedback carrying a `rater` into per-user preference notes. A rater's
|
|
80
|
+
* comments (on any rating) and corrections become their preference bullets,
|
|
81
|
+
* deduped + stably ordered. Returns one entry per rater, sorted by rater id.
|
|
82
|
+
*/
|
|
83
|
+
export declare function minePreferences(feedback: ReadonlyArray<FeedbackRecord>, opts?: {
|
|
84
|
+
redact?: (text: string) => Promise<string>;
|
|
85
|
+
}): Promise<UserPreferences[]>;
|
|
86
|
+
/** Render a per-user preferences markdown file (a project-memory-style block). */
|
|
87
|
+
export declare function renderPreferencesMd(prefs: UserPreferences): string;
|