ms-ai-architect/scripts/kb-update/lib/user-data.mjs
Kjell Tore Guttormsen 5a46818dd6 feat(ms-ai-architect): skjerm Fri kontekst mot overflow-kuttet [skip-docs]
Operatoerbeslutning: naar linje-cappen (25) sprekker, skal fri kontekst
overleve i stedet for aa vaere det foerste feltet som forsvinner.

Fri kontekst legges SIST fordi den skal LESES sist. Under et rent head-slice
gjorde nettopp det den til den foerste som falt ut - og det er den ene sloten
brukeren skriver fritt i, som onboarding-agent.md:125 selv kaller den
enkeltkategorien som gjoer raadene mest treffsikre. Posisjon i sammendraget er
en lese-rekkefoelge, ikke en prioritetsrekkefoelge; kuttet blandet de to.

Ved overflow beholdes naa (cap-2) strukturerte felt + fri kontekst + markoer,
altsaa fortsatt noeyaktig cap linjer. Uten fri kontekst er kuttet uendret.

Dette ble den bindende grensen foerst etter a7098e0: med readdir aapnet kan
brukeren legge til filer fritt, saa terskelen paa 26 felt naas naa av
brukerhandling og ikke bare av design.

Verifisert paa den ekte hooken: 40 felt + fri kontekst gir 25 linjer, med
"Fri kontekst" som siste felt og markoeren "(+22 flere felt)" til slutt.

TDD: 2 nye tester, skjermingstesten roed foerst mot forrige commit. Den andre
pinner at overflow UTEN fri kontekst er uendret (back-compat). Suite 1064/1064.

[skip-docs]: ren intern mekanikk - ingen endring i utoverrettet flate.
2026-08-26 10:41:28 +02:00

270 lines
11 KiB
JavaScript

// user-data.mjs — Spor C fase C2.1: user-owned storage + ambient org context.
//
// This module is a PURE RESOLVER. It NEVER writes and never spawns anything —
// it only resolves user-owned paths and builds a compact, deterministic
// summary from file contents it is HANDED (no fs read of its own). All org-/
// config writing happens elsewhere, gated, via lib/atomic-write + lib/backup.
//
// Why a user-owned dir? Org context + the C1 scheduler config previously lived
// inside the plugin directory (gitignored), so a plugin reinstall / marketplace
// move blew them away. Resolving them under ~/.claude/ms-ai-architect/ — a
// sibling of ~/.claude/plugins/, independent of pluginRoot — makes them survive
// reinstall (acceptance K2). Privacy is preserved: this path is in no git repo.
//
// Zero dependencies beyond node:os/path. Every function is pure.
import { homedir } from 'node:os';
import { join } from 'node:path';
// The plugin's folder under ~/.claude/ (sibling of ~/.claude/plugins/).
export const USER_DATA_DIRNAME = 'ms-ai-architect';
// The scheduler config filename. Single source of truth shared with
// detection-schedule.mjs (C1) so loadScheduleConfig stays backward-compatible.
export const CONFIG_FILENAME = 'ms-ai-architect.local.md';
// The five structured onboarding files, in the order they are surfaced.
export const ORG_FILES = Object.freeze([
'organization-profile.md',
'technology-stack.md',
'security-compliance.md',
'architecture-decisions.md',
'business-references.md',
]);
// The optional free-prose file (C2.2, #3): "everything else you want the plugin
// to know". Deliberately NOT in ORG_FILES — its presence is optional and must
// NOT affect the onboarding-completeness count (the SessionStart hook gates that
// on `count < ORG_FILES.length`). It is read for the ambient summary only.
export const FREE_CONTEXT_FILE = 'free-context.md';
const FREE_CONTEXT_LABEL = 'Fri kontekst';
const DEFAULT_SUMMARY_CAP = 25; // max summary lines (hook-injection budget)
// Total char budget for the field VALUES of one summary, shared across the
// fields that survive DEFAULT_SUMMARY_CAP.
//
// Why a TOTAL budget and not the per-field cap it replaces: the summary is
// injected ambiently at EVERY session start, so what costs is the total, not
// any single field — and a per-field cap cannot see how much of that total is
// unspent. Measured 2026-08-26 by running this function with the hook's
// defaults over the designed onboarding (19 fields, ~52 chars each): it spent
// ~1000 of the 4000 chars the mechanism already permitted, while still cutting
// any field past 160 chars — a 300-char answer lost 47 %, a 2000-char one 92 %.
// free-context.md, the one slot where the user writes freely, was cut the same
// way through its own code path (collapseProse before capValue).
//
// Why 4000 specifically: that is exactly what the old form already permitted at
// its worst case — DEFAULT_SUMMARY_CAP (25) lines x the old 160-char per-field
// cap. The budget is therefore cost-neutral against today's ceiling and
// strictly better below it. Because it is shared only over the fields that
// survive the line cap (at most 25), no field is ever allocated less than
// 4000/25 = 160: the old cap becomes the new floor, reached only at exactly 25
// fields. Raising this number raises the cost of every session in the repo.
const DEFAULT_MAX_TOTAL_LEN = 4000;
/** The user-owned data root: ~/.claude/ms-ai-architect/ (survives reinstall). */
export function resolveUserDataDir(home = homedir()) {
return join(home, '.claude', USER_DATA_DIRNAME);
}
/** The user-owned org/ directory (the five structured onboarding files). */
export function resolveOrgDir(home = homedir()) {
return join(resolveUserDataDir(home), 'org');
}
/** The user-owned scheduler config path (same filename as the C1 plugin-root one). */
export function resolveConfigPath(home = homedir()) {
return join(resolveUserDataDir(home), CONFIG_FILENAME);
}
/** Drop a leading `---`…`---` YAML frontmatter block, if present. */
function stripFrontmatter(text) {
if (!text.startsWith('---')) return text;
const m = text.match(/^---\n[\s\S]*?\n---\n?/);
return m ? text.slice(m[0].length) : text;
}
/**
* Extract `## Header` → collapsed-body pairs from one org file's markdown.
* Frontmatter and the leading H1 are ignored; H2 sections with an empty body
* are skipped. Body lines are collapsed to a single spaced line. Pure.
* @param {string} content
* @returns {Array<[string, string]>}
*/
function extractSections(content) {
const lines = stripFrontmatter(content).split('\n');
const out = [];
let header = null;
let body = [];
const flush = () => {
if (header !== null) {
const value = body.join(' ').replace(/\s+/g, ' ').trim();
if (value) out.push([header, value]);
}
};
for (const line of lines) {
const h2 = line.match(/^##\s+(.+?)\s*$/);
if (h2) {
flush();
header = h2[1];
body = [];
} else if (header !== null && !/^#\s/.test(line)) {
body.push(line);
}
}
flush();
return out;
}
/**
* Collapse a markdown document to one prose line: drop frontmatter and every
* header line, then squeeze whitespace. Used for the free-context file, which is
* ONE free field (not structured H2 sections) and may be plain prose — so it is
* surfaced whole rather than per-section, never silently dropped. Pure.
* @param {string} content
* @returns {string}
*/
function collapseProse(content) {
return stripFrontmatter(content)
.split('\n')
.filter((line) => !/^#{1,6}\s/.test(line))
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
/** Truncate a field value to maxValueLen, appending an ellipsis when cut. Pure. */
function capValue(value, maxValueLen) {
return value.length > maxValueLen
? `${value.slice(0, Math.max(0, maxValueLen - 1)).trimEnd()}`
: value;
}
/**
* Max-min fair allocation of a total char budget over field values: a field
* shorter than its even share is kept whole and leaves its slack to the longer
* ones, iterating until nothing more can be settled. This is what a per-field
* cap cannot express — one long, important answer surviving because five short
* ones did not need their share. Deterministic and pure; returns one allowance
* per input length, in input order.
* @param {number[]} lengths
* @param {number} budget
* @returns {number[]}
*/
function allocateBudget(lengths, budget) {
const allowance = new Array(lengths.length).fill(0);
let open = lengths.map((_, i) => i);
let remaining = budget;
while (open.length > 0) {
const share = Math.floor(remaining / open.length);
const settled = open.filter((i) => lengths[i] <= share);
if (settled.length === 0) {
// Every field still open wants more than the even share: split the rest,
// handing the indivisible remainder to the earliest fields.
let rest = remaining - share * open.length;
for (const i of open) {
allowance[i] = share + (rest > 0 ? 1 : 0);
if (rest > 0) rest -= 1;
}
break;
}
for (const i of settled) {
allowance[i] = lengths[i];
remaining -= lengths[i];
}
open = open.filter((i) => lengths[i] > share);
}
return allowance;
}
/**
* The org files to surface, in deterministic order: the canonical ORG_FILES
* first, then any OTHER `.md` file the user has added to org/, sorted by name.
* The free-prose file is excluded — callers append it last under its own label.
*
* Why it exists: the summary and the hook that feeds it used to iterate the
* frozen ORG_FILES list, so a seventh file in org/ never reached the model at
* all (measured 2026-08-26). Both sides now order their files through here.
*
* Pure: takes names (a readdir listing, or a content map's keys) and reads
* nothing.
* @param {string[]} names
* @returns {string[]}
*/
export function orderOrgFiles(names) {
if (!Array.isArray(names)) return [];
const present = new Set(names.filter((n) => typeof n === 'string' && n.endsWith('.md')));
const extra = [...present]
.filter((n) => !ORG_FILES.includes(n) && n !== FREE_CONTEXT_FILE)
.sort();
return [...ORG_FILES.filter((n) => present.has(n)), ...extra];
}
/**
* Build a compact, deterministic org-context summary from org file contents.
* PURE — takes a `{ filename: contentString }` map (the caller reads the files)
* and returns a budget-bounded `Header: value` block, one field per line, in
* orderOrgFiles order then header order. Empty/garbage input => "".
*
* Robust by design: it extracts whatever H2 sections the onboarding agent
* wrote rather than hard-coding field names, so header wording can drift
* without silently emptying the summary.
*
* @param {Record<string, string|null>|null|undefined} orgFiles
* @param {{cap?: number, maxTotalLen?: number}} [opts]
* @returns {string}
*/
export function buildOrgSummary(orgFiles, opts = {}) {
if (!orgFiles || typeof orgFiles !== 'object') return '';
const cap = opts.cap ?? DEFAULT_SUMMARY_CAP;
const maxTotalLen = opts.maxTotalLen ?? DEFAULT_MAX_TOTAL_LEN;
// Collect UNCUT [label, value] pairs first: the char budget can only be spent
// fairly once it is known which fields the line cap actually emits.
const fields = [];
for (const name of orderOrgFiles(Object.keys(orgFiles))) {
const content = orgFiles[name];
if (typeof content !== 'string' || !content.trim()) continue;
for (const section of extractSections(content)) fields.push(section);
}
// Free-prose context (C2.2, #3): the optional free-text file, surfaced LAST as
// one "Fri kontekst" field regardless of internal markdown structure, so a
// plain paragraph is never silently dropped (acceptance K3).
const freeContent = orgFiles[FREE_CONTEXT_FILE];
let hasFreeContext = false;
if (typeof freeContent === 'string' && freeContent.trim()) {
const body = collapseProse(freeContent);
if (body) {
fields.push([FREE_CONTEXT_LABEL, body]);
hasFreeContext = true;
}
}
if (fields.length === 0) return '';
// Budget 1 — lines: over cap, keep (cap-1) fields plus a marker for the rest.
//
// "Fri kontekst" is exempt from that cut. It is appended LAST so it reads last,
// which under a plain head-slice made it the FIRST field to disappear — and it
// is the one slot the user writes freely in, which onboarding-agent.md calls
// the single category that most sharpens the advice. Position in the summary
// is a reading order, not a priority order; the cut must not conflate them.
const overflow = fields.length > cap;
const room = Math.max(0, cap - 1); // fields emittable alongside the marker
let kept = fields;
if (overflow) {
kept =
hasFreeContext && room >= 1
? [...fields.slice(0, room - 1), fields[fields.length - 1]]
: fields.slice(0, room);
}
// Budget 2 — chars: shared max-min fairly over the fields actually emitted.
const allowance = allocateBudget(kept.map(([, value]) => value.length), maxTotalLen);
const lines = kept.map(([header, value], i) => `${header}: ${capValue(value, allowance[i])}`);
if (overflow) lines.push(`… (+${fields.length - kept.length} flere felt)`);
return lines.join('\n');
}