Ctrl K
ComunidadGuías…

src/lib/background.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * A role's background, read as sections rather than as one blob.
 *
 * `Role.background` is and stays ONE markdown string. That is deliberate:
 * `update_role` replaces it wholesale, the workspace export round-trips it as a
 * single field, and the schema has promised "unlimited markdown" since the
 * first commit. Columns would cost a migration and every round-trip path.
 *
 * What was missing is that a background holds four different KINDS of thing,
 * and only one of them belongs on a resume:
 *
 *   evidence  what they did. Raw material. This is what gets mined.
 *   rules     how to write about this role. Binding on whoever writes.
 *   caveats   positioning and interview prep. NEVER resume content.
 *   open      something they have not settled yet — a date they are unsure of,
 *             a number they need to check. Not usable until it is resolved.
 *
 * Before this, all of it was the same undifferentiated text, so a tailoring
 * conversation read "tenure is short, have the answer ready" with exactly the
 * same status as "brought the work in-house". People were already signalling
 * the difference by shouting in capitals; this makes the app understand it.
 *
 * DEFAULT IS EVIDENCE. A section is only reclassified when its heading matches
 * the small list below, or a paragraph opens with one of the shouted markers
 * after it, so ordinary prose means precisely what it meant yesterday. Nothing is silently excluded from a
 * resume; a section has to ask to be.
 *
 * Pure, and not in src/lib/data/: it touches no database and takes no userId,
 * the same way resume-schema.ts and letter-kinds.ts do not.
 */

export type SectionKind = "evidence" | "rules" | "caveats" | "open";

export type BackgroundSection = {
  kind: SectionKind;
  /**
   * The heading text without its hashes. Empty for the opening paragraph, and
   * for evidence that carries on under a heading after a marked paragraph.
   * For a marked paragraph it is the marker as written, e.g. "NAMING RULE".
   */
  heading: string;
  /** Everything under the heading, trimmed. May be empty. */
  body: string;
  /** Where this section starts in the original string, for an editor to seek. */
  offset: number;
  /**
   * True when a marker at the start of a paragraph opened this section rather
   * than a `##` heading. It runs to the next blank line — or, for a marked
   * bullet, to the next bullet — and then whatever the heading above held
   * resumes.
   */
  inline?: boolean;
};

/**
 * Headings that mean something, lowercased.
 *
 * Kept SHORT on purpose. Every alias is a chance to classify something as a
 * rule that a person meant as evidence — which silently drops real material
 * out of their resumes, and is the failure this list must not cause. Two
 * spellings and one synonym each is the whole budget; the editor inserts the
 * canonical heading so nobody has to remember them, and the reader shows every
 * section's kind so a miss is visible rather than silent.
 */
const RESERVED: Record<string, SectionKind> = {
  rule: "rules",
  rules: "rules",
  "naming rule": "rules",
  "naming rules": "rules",
  "writing rules": "rules",
  constraints: "rules",
  caveat: "caveats",
  caveats: "caveats",
  positioning: "caveats",
  "interview prep": "caveats",
  open: "open",
  "open questions": "open",
  unresolved: "open",
  "to confirm": "open",
};

/** The spellings the editor writes and the tool descriptions teach. */
export const CANONICAL: Record<Exclude<SectionKind, "evidence">, string> = {
  rules: "Rules",
  caveats: "Caveats",
  open: "Open questions",
};

/**
 * Markers written in CAPITALS, inside the text rather than as a heading.
 *
 * This is how people — and the assistants writing for them — actually mark
 * things: "NAMING RULE: describe it by function", "⚠️ OPEN: confirm the start
 * month", "## Post-departure signal — INTERVIEW ONLY". Before this was read,
 * every one of those was evidence, so an interview-only line went straight into
 * resumeEvidence with a warning sign on it.
 *
 * The capitals are the whole safety argument. A lowercase "open source:" or
 * "Positioning:" at the start of a paragraph is prose and stays evidence; only
 * a label that is SHOUTED, and that matches one of these exactly, is read as a
 * marker. "OPEN SOURCE:" does not match OPEN, because these are whole-label
 * matches, not substrings.
 */
const MARKERS: Array<[RegExp, Exclude<SectionKind, "evidence">]> = [
  [/^(?:(?:[A-Z]+ ){0,3}(?:RULES?|GUARDRAILS?)|NAMING|FRAMING|WORDING)$/, "rules"],
  [/^(?:CAVEATS?|INTERVIEW ONLY|INTERVIEW PREP|POSITIONING|NOT FOR (?:THE )?RESUMES?|NEVER ON A RESUME)$/, "caveats"],
  [/^(?:OPEN|OPEN QUESTIONS?|UNRESOLVED|TO CONFIRM|UNCONFIRMED|NEEDS CONFIRMING|TBD|TODO)$/, "open"],
];

/** What a shouted label means, or null when it is not one of the markers. */
function markerKind(label: string): Exclude<SectionKind, "evidence"> | null {
  const bare = label.replace(/\([^)]*\)/g, " ").replace(/\s+/g, " ").trim();
  // Shouting is the signal. A label with any lowercase letter is prose.
  if (!/[A-Z]/.test(bare) || bare !== bare.toUpperCase()) return null;
  for (const [pattern, kind] of MARKERS) if (pattern.test(bare)) return kind;
  return null;
}

/**
 * A marker at the start of a line: an optional bullet, optional bold, an
 * optional warning sign or other symbol, the CAPITAL label with an optional
 * parenthetical, then a colon. Group 1 is the bullet, 2 the label, 3 the rest.
 */
const INLINE_MARKER =
  /^(\s*(?:[-*+]|\d+[.)])\s+)?(?:\*\*)?\s*(?:[^\w\s(*#]{1,4}\s*)?([A-Z][A-Z '&/]*[A-Z](?:\s*\([^)]*\))?)\s*(?:\*\*)?\s*:\s*(?:\*\*)?\s*(.*)$/;

function inlineMarker(line: string) {
  const match = INLINE_MARKER.exec(line);
  if (!match) return null;
  const kind = markerKind(match[2]);
  if (!kind) return null;
  const rest = match[3];
  // Where the marked text stops. A marked bullet, a marked line indented
  // under somebody else's bullet ("- Pokitomik …\n  FRAMING RULE: …"), or a
  // marker that states its rule on the same line all end at the next bullet:
  // the list carries on, and the next bullet is evidence again. Only a bare
  // label — "RULES:" and nothing after it, or text ending in a colon — owns
  // the bullets that follow, because that is what a label over a list means.
  const indented = !match[1] && /^\s+\S/.test(line);
  const endsAtBullet = Boolean(match[1]) || indented || (rest.trim() !== "" && !/:\s*$/.test(rest));
  return { kind, bullet: Boolean(match[1]), endsAtBullet, label: match[2].trim(), rest };
}

/**
 * What a heading means.
 *
 * A trailing colon and a parenthetical are stripped before matching, because
 * people date their own decisions — "Naming rule (resolved 2026-08-17)" is a
 * naming rule, and failing to see that would be the app being pedantic about
 * punctuation at the cost of getting the answer wrong.
 */
export function sectionKind(heading: string): SectionKind {
  const bare = heading
    .replace(/\([^)]*\)/g, " ")
    .replace(/[:•\-–—]+\s*$/, "")
    .replace(/\s+/g, " ")
    .trim()
    .toLowerCase();
  const reserved = RESERVED[bare];
  if (reserved) return reserved;

  // A shouted suffix after a dash: "Post-departure signal — INTERVIEW ONLY".
  // The heading keeps its name; the suffix says what kind of thing it is.
  const suffix = /\s[—–-]+\s*([^—–-]+?)\s*$/.exec(heading.trim());
  if (suffix) {
    const kind = markerKind(suffix[1].replace(/^[^\w(]+/, ""));
    if (kind) return kind;
  }

  // Or a shouted prefix: "## ⚠️ OPEN: follower count".
  const prefix = inlineMarker(heading.trim());
  if (prefix && !prefix.bullet) return prefix.kind;

  return "evidence";
}

/**
 * Split a background into its sections.
 *
 * Only `##` opens a headed section. A `###` belongs to the section above it, so
 * a writer can subdivide without changing what anything MEANS — and so a stray
 * third hash cannot quietly turn a caveat back into evidence.
 *
 * Inside an EVIDENCE section, a paragraph that opens with a shouted marker
 * ("RULE:", "⚠️ OPEN:", "**INTERVIEW ONLY:**") becomes a section of its own
 * for as long as that paragraph runs — to the next blank line, or to the next
 * bullet unless the marker is a bare label over a list (see inlineMarker) —
 * and the evidence around it carries on under no
 * heading, so resumeEvidence reassembles it without repeating the one above.
 * Inside a rules, caveats or open section markers change nothing: the heading
 * already said what everything under it is, and a marker cannot promote a
 * caveat back to evidence.
 *
 * Text before the first heading becomes one untitled evidence section, which is
 * where the opening line of most backgrounds lives.
 */
export function parseBackground(text: string): BackgroundSection[] {
  if (!text.trim()) return [];

  const sections: BackgroundSection[] = [];
  const lines = text.split("\n");
  let heading = "";
  let kind: SectionKind = "evidence";
  let inline = false;
  let buffer: string[] = [];
  let offset = 0;
  let cursor = 0;
  // The kind of the `##` section we are inside, which a marked paragraph
  // interrupts and then hands back to.
  let headedKind: SectionKind = "evidence";
  let endsAtBullet = false;

  const flush = () => {
    const body = buffer.join("\n").trim();
    if (heading || body) {
      sections.push(inline ? { kind, heading, body, offset, inline } : { kind, heading, body, offset });
    }
    buffer = [];
  };

  /** Close a marked paragraph and return to plain evidence under no heading. */
  const resume = (at: number) => {
    flush();
    heading = "";
    kind = headedKind;
    inline = false;
    offset = at;
  };

  for (const line of lines) {
    const match = /^##(?!#)\s*(.*)$/.exec(line);
    if (match) {
      flush();
      heading = match[1].trim();
      kind = headedKind = sectionKind(heading);
      inline = false;
      offset = cursor;
    } else if (inline && (!line.trim() || (endsAtBullet && /^\s*(?:[-*+]|\d+[.)])\s+/.test(line)))) {
      // The marked paragraph is over: a blank line ends it, and so does the
      // next bullet unless the marker was a bare label over a list.
      resume(cursor);
      if (line.trim()) {
        const marker = inlineMarker(line);
        if (marker && headedKind === "evidence") {
          flush();
          heading = marker.label;
          kind = marker.kind;
          inline = true;
          endsAtBullet = marker.endsAtBullet;
          offset = cursor;
          buffer.push(marker.rest);
        } else {
          buffer.push(line);
        }
      }
    } else {
      const marker = !inline && headedKind === "evidence" ? inlineMarker(line) : null;
      if (marker) {
        flush();
        heading = marker.label;
        kind = marker.kind;
        inline = true;
        endsAtBullet = marker.endsAtBullet;
        offset = cursor;
        buffer.push(marker.rest);
      } else {
        buffer.push(line);
      }
    }
    cursor += line.length + 1;
  }
  flush();

  return sections;
}

/** Sections of one kind, in the order they appear. */
export function sectionsOfKind(text: string, kind: SectionKind): BackgroundSection[] {
  return parseBackground(text).filter((section) => section.kind === kind);
}

/**
 * The part of a background a resume may be written from.
 *
 * This is the whole point of the file. Anything that reaches a document — a
 * resume bullet, a cover letter line — comes from here and never from the
 * string itself.
 */
export function resumeEvidence(text: string): string {
  return sectionsOfKind(text, "evidence")
    .map((section) => (section.heading ? `## ${section.heading}\n${section.body}` : section.body))
    .join("\n\n")
    .trim();
}

/**
 * The rules, caveats and open questions, as lines, for a writer to obey
 * rather than quote.
 *
 * Bullets are unwrapped so a caller gets statements rather than markdown; a
 * section with no bullets comes back as its paragraphs. A marked paragraph
 * comes back as one line, because "NAMING RULE: by function only. No product
 * names." is one rule however many sentences it takes.
 */
export function writingGuidance(text: string): {
  rules: string[];
  caveats: string[];
  open: string[];
} {
  const linesOf = (kind: SectionKind) =>
    sectionsOfKind(text, kind)
      .flatMap((section) => {
        const lines = section.body.split("\n");
        const listed = lines.some((line) => /^\s*(?:[-*+]|\d+[.)])\s+/.test(line));
        return section.inline && !listed ? [lines.map((line) => line.trim()).join(" ")] : lines;
      })
      .map((line) => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, "").trim())
      .filter(Boolean);
  return { rules: linesOf("rules"), caveats: linesOf("caveats"), open: linesOf("open") };
}

/** True when this background carries anything a writer has to obey. */
export function hasGuidance(text: string): boolean {
  const { rules, caveats, open } = writingGuidance(text);
  return rules.length > 0 || caveats.length > 0 || open.length > 0;
}

/**
 * Add text to a background, merging into a section that already has that
 * heading rather than opening a second one with the same name.
 *
 * Appending twice under "Rules" used to leave two `## Rules` blocks, which then
 * read as two unrelated rules lists and rendered as a stutter. Merging is what
 * somebody means by "file this under Rules", and it keeps the reserved sections
 * to one each without a special case for them.
 *
 * The merge SPLICES rather than rebuilding the document from its sections.
 * Rebuilding was a line shorter and normalised everybody's blank lines on the
 * way past, so appending one rule rewrote every byte of the file — which the
 * version history would then show as "the whole background changed", and undo
 * would take them back past edits they never made. Everything outside the
 * target section comes out byte-identical.
 *
 * Pure: it returns the new string and writes nothing.
 */
export function appendToBackground(text: string, addition: string, heading?: string): string {
  const body = addition.trim();
  if (!body) return text;

  const name = heading?.trim();
  if (!name) return appendEvidence(text, body);

  // A reserved heading matches by KIND, not by spelling. Somebody whose rules
  // live under "Naming rule (resolved 2026-08-17)" gets the next one filed
  // there, rather than a bare "## Rules" opening beside it — which is the
  // stutter this function exists to prevent, and which an assistant would cause
  // every single time, because the tool description teaches it to pass "Rules".
  // Anything else matches by name, where an exact heading is a literal request.
  // Only `##` sections are candidates: a marked paragraph is somebody's single
  // flagged line, and filing the next rule inside it would glue two rules into
  // one paragraph.
  const kind = sectionKind(name);
  const sections = parseBackground(text);
  const headed = (section: BackgroundSection) => Boolean(section.heading) && !section.inline;
  const index =
    kind === "evidence"
      ? sections.findIndex(
          (section) => headed(section) && section.heading.toLowerCase() === name.toLowerCase(),
        )
      : sections.findIndex((section) => headed(section) && section.kind === kind);
  if (index < 0) return `${text.trim()}\n\n## ${name}\n${body}`.trim();

  // The section runs to the next `##`, or to the end of the text — past any
  // marked paragraphs and the evidence resuming after them, which are all
  // still under its heading.
  const next = sections.findIndex((section, i) => i > index && headed(section));
  const end = next >= 0 ? sections[next].offset : text.length;
  const head = text.slice(0, end).replace(/\s+$/, "");
  return `${head}\n${body}${text.slice(end) ? `\n\n${text.slice(end).replace(/^\s+/, "")}` : ""}`;
}

/**
 * A one-line preview with the markdown taken off.
 *
 * From the EVIDENCE only, so a list of roles never previews somebody's note to
 * themselves about how short their tenure looks.
 */
export function backgroundExcerpt(text: string, max = 240): string {
  // Headings go entirely rather than losing their hashes: "Scope — LEAD WITH
  // THIS" is a note to a writer, not a preview of the work.
  const flat = resumeEvidence(text)
    .replace(/^#{1,6}\s.*$/gm, "")
    .replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, "")
    .replace(/[*_`]/g, "")
    .replace(/\s+/g, " ")
    .trim();
  return flat.length > max ? `${flat.slice(0, max).trimEnd()}…` : flat;
}

/**
 * Add evidence with no heading, and make sure it stays evidence.
 *
 * Appending to the end is right almost always. It is wrong when the document
 * ENDS in a reserved section: text added after "## Caveats" is read as a
 * caveat, so "file this" would quietly have filed a real achievement where no
 * document could use it. In that case the text goes in just before the run of
 * reserved sections at the bottom — still under whatever evidence came last,
 * which is where it would have gone if the caveats had not been there.
 */
function appendEvidence(text: string, body: string): string {
  const trimmed = text.trim();
  if (!trimmed) return body;
  const candidate = `${trimmed}\n\n${body}`;
  // The section the new text falls inside: the last one starting at or before it.
  const at0 = trimmed.length + 2;
  const landed = parseBackground(candidate).filter((section) => section.offset <= at0).pop();
  if (!landed || landed.kind === "evidence") return candidate;

  // Walk back over the trailing `##` sections that are not evidence.
  const sections = parseBackground(trimmed);
  let at = -1;
  for (let i = sections.length - 1; i >= 0; i -= 1) {
    const section = sections[i];
    if (section.kind === "evidence") break;
    if (section.heading && !section.inline) at = section.offset;
  }
  if (at <= 0) return `${body}\n\n${trimmed}`;
  const head = trimmed.slice(0, at).replace(/\s+$/, "");
  return `${head}\n\n${body}\n\n${trimmed.slice(at)}`;
}

/** Where a section ends: the start of the next one, or the end of the text. */
function sectionEnd(sections: BackgroundSection[], index: number, text: string) {
  return index + 1 < sections.length ? sections[index + 1].offset : text.length;
}

const BULLET = /^(\s*(?:[-*+]|\d+[.)])\s+)/;

/** Whitespace-insensitive, for matching a question the way it was listed. */
function same(a: string, b: string) {
  const norm = (value: string) => value.replace(/\s+/g, " ").trim().toLowerCase();
  return norm(a) === norm(b);
}

/**
 * Settle one open question: take it out, and put the answer in as evidence.
 *
 * `question` is the text as writingGuidance / list_open_questions return it.
 * Where the answer goes depends on where the question was:
 *
 *   - a marked line inside evidence ("- ⚠️ OPEN: was it $40K or $45K?") is
 *     REPLACED in place by the answer, bullet kept, so the settled fact sits
 *     exactly where the doubt was — next to the work it is about;
 *   - a line in an "Open questions" section is removed, the heading goes too
 *     when nothing is left under it, and the answer is added as evidence —
 *     under `heading` when one is given, otherwise the way appendEvidence adds
 *     anything.
 *
 * No answer means the question is simply dropped, for "that no longer
 * matters". Everything outside the touched lines comes back byte-identical.
 * Resolving the same question twice fails the second time rather than doing
 * something else, because by then it is not there.
 * Pure: throws when the question is not there, writes nothing.
 */
export function resolveOpen(
  text: string,
  question: string,
  answer?: string,
  heading?: string,
): string {
  const sections = parseBackground(text);
  const reply = answer?.trim() ?? "";

  for (let i = 0; i < sections.length; i += 1) {
    const section = sections[i];
    if (section.kind !== "open") continue;
    const start = section.offset;
    const end = sectionEnd(sections, i, text);
    const raw = text.slice(start, end);

    if (section.inline) {
      const lines = section.body.split("\n");
      const listed = lines.some((line) => BULLET.test(line));
      const joined = lines.map((line) => line.trim()).join(" ");
      if (!(listed ? lines.some((line) => same(line.replace(BULLET, ""), question)) : same(joined, question))) {
        continue;
      }
      // The whole marked paragraph or bullet goes; the answer takes its place.
      const bullet = BULLET.exec(raw)?.[1] ?? "";
      const tail = /\s*$/.exec(raw)?.[0] ?? "";
      if (reply) return `${text.slice(0, start)}${bullet}${reply}${tail}${text.slice(end)}`;
      // A dropped bullet leaves its list intact; a dropped paragraph leaves one
      // blank line between its neighbours rather than three.
      return bullet ? `${text.slice(0, start)}${text.slice(end)}` : splice(text, start, end);
    }

    // A line inside an "Open questions" section. Find it by its own text, with
    // any continuation lines that belong to the same bullet.
    const lines = raw.split("\n");
    const index = lines.findIndex(
      (line, n) => n > 0 && same(line.replace(BULLET, ""), question),
    );
    if (index < 0) continue;
    let stop = index + 1;
    while (stop < lines.length && /^\s{2,}\S/.test(lines[stop]) && !BULLET.test(lines[stop])) stop += 1;
    const kept = [...lines.slice(0, index), ...lines.slice(stop)];
    // The heading goes with its last question: an empty "## Open questions"
    // is a to-do list that says there is something to do.
    const without = kept.slice(1).every((line) => !line.trim())
      ? splice(text, start, end)
      : `${text.slice(0, start)}${kept.join("\n")}${text.slice(end)}`;
    return reply ? appendToBackground(without, reply, heading) : without;
  }

  throw new Error(
    "That open question is not in this background. Call list_open_questions and pass the question exactly as it came back.",
  );
}

/**
 * Cut [start, end) out and close the gap with exactly one blank line, touching
 * nothing else — so the version history shows the one change that was made.
 */
function splice(text: string, start: number, end: number) {
  const before = text.slice(0, start).replace(/\s+$/, "");
  const after = text.slice(end).replace(/^\s+/, "");
  if (!before) return after;
  if (!after) return before;
  return `${before}\n\n${after}`;
}