Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Bulk reformats. `git config blame.ignoreRevsFile .git-blame-ignore-revs`
7d28c83b9e0527e874a5376eae0d95eb7c0eaedf # prettier, 27 files
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ name: Publish
# next thing it ignores will be a real failure.
on:
push:
tags: ['v*']
tags: ["v*"]

jobs:
publish:
Expand All @@ -23,8 +23,8 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
node-version: "22"
registry-url: "https://registry.npmjs.org"

- run: npm ci --ignore-scripts

Expand Down
32 changes: 32 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Build output and vendored trees — formatting these is noise.
node_modules
.next
dist
dist-*
build
out
coverage
.turbo
.vercel
*.min.js
*.min.css

# Generated during a build, so it is absent locally and present in CI — which
# makes a clean local --check no evidence at all. Contentlayer's output also
# uses import assertions, which prettier's parser rejects outright.
.contentlayer
.astro
.svelte-kit
storybook-static
test-results
playwright-report

# Lockfiles are generated; prettier would rewrite them wholesale.
package-lock.json
pnpm-lock.yaml
yarn.lock

# Markdown is deliberately out of scope for now. Prettier rewraps prose, which
# is where it is most opinionated and least useful, and it would bury the real
# diff. Remove this line when you want docs formatted too.
*.md
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "all",
"arrowParens": "always",
"endOfLine": "lf"
}
121 changes: 62 additions & 59 deletions dist-cjs/grounding/contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,29 +40,32 @@ exports.NO_BASIS = "Not in your data.";
* plainly beats hoping the model notices the context block is empty.
*/
function buildContract(facts, directives = []) {
const ids = [
...facts.map((f) => `[${f.id}]`),
...directives.map((_, i) => `[${directiveId(i)}]`),
].join(" ");
const gaps = (0, facts_js_1.unrecordedFields)(facts);
const rules = [
"## Grounding contract — this overrides every formatting instruction below",
"",
"You are answering from a fixed set of records. They are the ONLY things you know about the operator.",
"",
facts.length === 0 && directives.length === 0
? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${exports.NO_BASIS}" and say what you would need.`
: `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`,
`2. A field shown as \`${facts_js_1.NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`,
`3. If any part of the request has no supporting record, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`,
"4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.",
"5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.",
"6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.",
];
if (gaps.length > 0) {
rules.push("", `Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`);
}
return rules.join("\n");
const ids = [
...facts.map((f) => `[${f.id}]`),
...directives.map((_, i) => `[${directiveId(i)}]`),
].join(" ");
const gaps = (0, facts_js_1.unrecordedFields)(facts);
const rules = [
"## Grounding contract — this overrides every formatting instruction below",
"",
"You are answering from a fixed set of records. They are the ONLY things you know about the operator.",
"",
facts.length === 0 && directives.length === 0
? `1. NO records were retrieved for this turn. You therefore cannot answer any question about the operator's projects, people, goals, habits, commitments or events. Reply "${exports.NO_BASIS}" and say what you would need.`
: `1. Every claim about the operator MUST cite a record id. Legal citations this turn, and no others: ${ids}`,
`2. A field shown as \`${facts_js_1.NOT_RECORDED}\` means you DO NOT KNOW it. Never supply a value for it — not from the record's own wording, not from a name that looks like a place or an organisation, not from general knowledge about a similarly-named person. A surname is not an employer.`,
`3. If any part of the request has no supporting record, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format NEVER obliges you to invent an item. Returning three of five requested items, each cited, is a correct and complete answer.`,
"4. Do not describe a person's role, employer, seniority, or history unless a record field states it. Do not infer an organisation from a name.",
"5. You have not browsed the web this turn. If asked to research someone, say you cannot and report only what the records hold.",
"6. If you are correcting an earlier answer, the correction is subject to every rule above — cite the record, or say the record does not exist.",
];
if (gaps.length > 0) {
rules.push(
"",
`Unrecorded in THIS turn's records — you have no value for any of these and must not state one: ${gaps.join(", ")}`,
);
}
return rules.join("\n");
}
/**
* The subset of the contract that needs no fact ids — for an assistant whose
Expand All @@ -79,16 +82,16 @@ function buildContract(facts, directives = []) {
* layer; the destination is typed records here too.
*/
function buildAssistantRules(opts) {
return [
"## Grounding rules — these override formatting instructions",
"",
`1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`,
"2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.",
"3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.",
`4. If part of the request has no support in the context, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`,
"5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.",
"6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.",
].join("\n");
return [
"## Grounding rules — these override formatting instructions",
"",
`1. Everything you state about the user's own ${opts.subjectNoun} must come from the context above. Do not add an organisation, role, employer, history, or relationship that the context does not state.`,
"2. Do not infer an affiliation from a name. A word inside someone's name is not their employer or their city.",
"3. You have not browsed the web in this turn. If asked to research a person or company, say you cannot, and report only what the context holds.",
`4. If part of the request has no support in the context, answer that part with exactly "${exports.NO_BASIS}" and continue with the parts you can support. A requested format never obliges you to invent an item.`,
"5. General knowledge (how Bitcoin, Lightning, or a payment method works) is fine to use and is not covered by rules 1–2. The restriction is on facts about THIS user and the people and organisations in their data.",
"6. A correction is a claim too. If you are correcting yourself, it must be supported by the context or stated as unknown.",
].join("\n");
}
/**
* Citation handle for a computed answer, parallel to a Fact's [F1].
Expand All @@ -100,7 +103,7 @@ function buildAssistantRules(opts) {
* ids and the sentence cites [D1] like anything else.
*/
function directiveId(index) {
return `D${index + 1}`;
return `D${index + 1}`;
}
/**
* Render computed answers. These are stated as settled, because they are: the
Expand All @@ -109,22 +112,22 @@ function directiveId(index) {
* fact set.
*/
function renderDirectives(directives) {
if (directives.length === 0)
return "";
const blocks = directives.map((d, i) => {
const body = d.answer.length > 0
? d.answer.map((a) => ` - ${a}`).join("\n")
: " (none — the query ran and matched nothing)";
return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`;
});
return [
"## Computed answers — already resolved, do not re-derive",
"These were computed directly from the database for this turn. They are exact.",
"Report them as given and cite their id, exactly as you would a record.",
"Where the result is empty, say so plainly — do not substitute a plausible item from the records.",
"",
...blocks,
].join("\n");
if (directives.length === 0) return "";
const blocks = directives.map((d, i) => {
const body =
d.answer.length > 0
? d.answer.map((a) => ` - ${a}`).join("\n")
: " (none — the query ran and matched nothing)";
return ` [${directiveId(i)}] ${d.question} [${d.method}]\n${body}`;
});
return [
"## Computed answers — already resolved, do not re-derive",
"These were computed directly from the database for this turn. They are exact.",
"Report them as given and cite their id, exactly as you would a record.",
"Where the result is empty, say so plainly — do not substitute a plausible item from the records.",
"",
...blocks,
].join("\n");
}
/**
* Assemble the full grounded context: contract, computed answers, then records.
Expand All @@ -134,13 +137,13 @@ function renderDirectives(directives) {
* the user's question — the position small models weight most heavily.
*/
function buildGroundedContext(input) {
return [
buildContract(input.facts, input.directives ?? []),
renderDirectives(input.directives ?? []),
input.facts.length > 0
? ["## Records", "", input.renderedFacts].join("\n")
: "## Records\n\n(none retrieved)",
]
.filter(Boolean)
.join("\n\n---\n\n");
return [
buildContract(input.facts, input.directives ?? []),
renderDirectives(input.directives ?? []),
input.facts.length > 0
? ["## Records", "", input.renderedFacts].join("\n")
: "## Records\n\n(none retrieved)",
]
.filter(Boolean)
.join("\n\n---\n\n");
}
94 changes: 47 additions & 47 deletions dist-cjs/grounding/facts.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,24 @@ exports.NOT_RECORDED = "<not recorded>";
* point. Do not "clean up" this list by deleting the empty ones.
*/
exports.FACT_KINDS = {
person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"],
project: ["name", "status", "stack", "description", "latest_dev_log", "repo"],
goal: ["title", "project", "progress", "target_date", "last_updated"],
habit: ["title", "frequency", "current_streak", "last_checked"],
commitment: ["title", "due", "counterparty", "status"],
event: ["name", "type", "deadline", "url", "status"],
// Humans the operator delegates to, and the work handed to them. Separate
// from `person`/`commitment` because the questions are different: a crew
// member is asked what they are good FOR, an assignment is asked who has it
// and whether they said yes.
crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"],
assignment: ["title", "assignee", "status", "due", "fee", "why"],
document: ["title", "source", "excerpt"],
pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
person: ["name", "affiliation", "role", "how_we_met", "last_interaction", "notes", "channels"],
project: ["name", "status", "stack", "description", "latest_dev_log", "repo"],
goal: ["title", "project", "progress", "target_date", "last_updated"],
habit: ["title", "frequency", "current_streak", "last_checked"],
commitment: ["title", "due", "counterparty", "status"],
event: ["name", "type", "deadline", "url", "status"],
// Humans the operator delegates to, and the work handed to them. Separate
// from `person`/`commitment` because the questions are different: a crew
// member is asked what they are good FOR, an assignment is asked who has it
// and whether they said yes.
crew_member: ["name", "role", "skills", "engagement", "rate", "availability", "open_assignments"],
assignment: ["title", "assignee", "status", "due", "fee", "why"],
document: ["title", "source", "excerpt"],
pending_action: ["title", "type", "reasoning", "proposed_on", "id"],
};
/** Field list for a kind; unknown kinds fall back to whatever the fact carries. */
function declaredFields(kind, fallback = []) {
return exports.FACT_KINDS[kind] ?? fallback;
return exports.FACT_KINDS[kind] ?? fallback;
}
/**
* Build a Fact with every declared field present. Values not supplied become
Expand All @@ -75,29 +75,29 @@ function declaredFields(kind, fallback = []) {
* FACT_KINDS, otherwise the registry stops describing what the model sees.
*/
function makeFact(input) {
const keys = declaredFields(input.kind, Object.keys(input.values ?? {}));
const fields = {};
for (const key of keys) {
const raw = input.values?.[key];
const trimmed = typeof raw === "string" ? raw.trim() : raw;
fields[key] = trimmed ? String(trimmed) : null;
}
return {
id: "",
kind: input.kind,
subject: input.subject,
source: input.source,
fields,
...(input.similarity !== undefined ? { similarity: input.similarity } : {}),
};
const keys = declaredFields(input.kind, Object.keys(input.values ?? {}));
const fields = {};
for (const key of keys) {
const raw = input.values?.[key];
const trimmed = typeof raw === "string" ? raw.trim() : raw;
fields[key] = trimmed ? String(trimmed) : null;
}
return {
id: "",
kind: input.kind,
subject: input.subject,
source: input.source,
fields,
...(input.similarity !== undefined ? { similarity: input.similarity } : {}),
};
}
/** Stamp sequential citation ids. Call once, after assembling the final set. */
function assignFactIds(facts) {
return facts.map((f, i) => ({ ...f, id: `F${i + 1}` }));
return facts.map((f, i) => ({ ...f, id: `F${i + 1}` }));
}
/** Every citation handle in a fact set — the only legal citations in an answer. */
function factIds(facts) {
return new Set(facts.map((f) => f.id));
return new Set(facts.map((f) => f.id));
}
/**
* Render facts for the model. One block per record, every declared field on its
Expand All @@ -115,15 +115,16 @@ function factIds(facts) {
* hallucinates roles, over a field list it reports `<not recorded>`.
*/
function renderFacts(facts) {
if (facts.length === 0)
return "";
return facts
.map((f) => {
const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`;
const body = Object.entries(f.fields).map(([k, v]) => ` ${k}: ${v ?? exports.NOT_RECORDED}`);
return [head, ...body].join("\n");
if (facts.length === 0) return "";
return facts
.map((f) => {
const head = `[${f.id}] ${f.kind} — ${f.subject} (${f.source})`;
const body = Object.entries(f.fields).map(
([k, v]) => ` ${k}: ${v ?? exports.NOT_RECORDED}`,
);
return [head, ...body].join("\n");
})
.join("\n\n");
.join("\n\n");
}
/**
* Which declared fields are unrecorded across the set, as
Expand All @@ -132,12 +133,11 @@ function renderFacts(facts) {
* context rather than being a standing abstraction the model may ignore.
*/
function unrecordedFields(facts) {
const gaps = new Set();
for (const f of facts) {
for (const [k, v] of Object.entries(f.fields)) {
if (v === null)
gaps.add(`${f.kind}.${k}`);
}
const gaps = new Set();
for (const f of facts) {
for (const [k, v] of Object.entries(f.fields)) {
if (v === null) gaps.add(`${f.kind}.${k}`);
}
return [...gaps].sort();
}
return [...gaps].sort();
}
Loading