@plinth-music/cli 0.15.0 → 0.16.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/dist/cli.js CHANGED
@@ -7584,7 +7584,7 @@ function _getBuiltinFlags(long, short, userNames, userAliases) {
7584
7584
  // package.json
7585
7585
  var package_default = {
7586
7586
  name: "@plinth-music/cli",
7587
- version: "0.15.0",
7587
+ version: "0.16.0",
7588
7588
  description: "Plinth capstone sync daemon and CLI",
7589
7589
  type: "module",
7590
7590
  private: false,
@@ -22333,6 +22333,7 @@ var ArtistLiveSchema = exports_external.object({
22333
22333
  name: exports_external.string(),
22334
22334
  body: exports_external.string().nullable(),
22335
22335
  memory: exports_external.string().nullable().default(null),
22336
+ memory_generated: exports_external.boolean().default(false),
22336
22337
  status: exports_external.string(),
22337
22338
  bio: exports_external.string().nullable(),
22338
22339
  notes: exports_external.string().nullable(),
@@ -22373,7 +22374,8 @@ var SERVER_AUTHORITATIVE_KEYS = new Set([
22373
22374
  "color",
22374
22375
  "spotify_artist_id",
22375
22376
  "soundcharts_uuid",
22376
- "sort_order"
22377
+ "sort_order",
22378
+ "memory_generated"
22377
22379
  ]);
22378
22380
  var WIRE_EDITABLE_TOP_LEVEL_KEYS = new Set([
22379
22381
  "name",
@@ -23688,6 +23690,16 @@ function readArtistMemoryFile(workspaceSlug, entitySlug) {
23688
23690
  throw err;
23689
23691
  }
23690
23692
  }
23693
+ function readArtistAnchorFile(workspaceSlug, entitySlug) {
23694
+ const target = entityFilePath(workspaceSlug, "artists", entitySlug);
23695
+ try {
23696
+ return readFileSync(target, "utf8");
23697
+ } catch (err) {
23698
+ if (err?.code === "ENOENT")
23699
+ return null;
23700
+ throw err;
23701
+ }
23702
+ }
23691
23703
  function deleteArtistMemoryFile(workspaceSlug, entitySlug) {
23692
23704
  const target = artistMemoryFilePath(workspaceSlug, entitySlug);
23693
23705
  try {
@@ -23699,6 +23711,113 @@ function deleteArtistMemoryFile(workspaceSlug, entitySlug) {
23699
23711
  throw err;
23700
23712
  }
23701
23713
  }
23714
+ var MEMORY_FACTS_DIR = "memory";
23715
+ var MEMORY_FACTS_PRIVATE_SEGMENT = "private";
23716
+ var MEMORY_FACT_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23717
+ var MEMORY_FACT_SLUG_MAX = 64;
23718
+ function assertFactSlug(slug) {
23719
+ if (!MEMORY_FACT_SLUG_RE.test(slug) || slug.length > MEMORY_FACT_SLUG_MAX) {
23720
+ throw new MirrorPathError(`Invalid memory fact slug: ${JSON.stringify(slug)}`);
23721
+ }
23722
+ }
23723
+ function memoryFactRelPath(pile, slug) {
23724
+ assertFactSlug(slug);
23725
+ return pile === "private" ? `${MEMORY_FACTS_DIR}/${MEMORY_FACTS_PRIVATE_SEGMENT}/${slug}.md` : `${MEMORY_FACTS_DIR}/${slug}.md`;
23726
+ }
23727
+ function parseMemoryFactRelPath(rel) {
23728
+ const posix = rel.split(sep).join("/");
23729
+ if (!posix.startsWith(`${MEMORY_FACTS_DIR}/`) || !posix.endsWith(".md")) {
23730
+ return null;
23731
+ }
23732
+ const tail = posix.slice(MEMORY_FACTS_DIR.length + 1, -".md".length);
23733
+ const privatePrefix = `${MEMORY_FACTS_PRIVATE_SEGMENT}/`;
23734
+ if (tail.startsWith(privatePrefix)) {
23735
+ const slug = tail.slice(privatePrefix.length);
23736
+ return MEMORY_FACT_SLUG_RE.test(slug) && slug.length <= MEMORY_FACT_SLUG_MAX ? { pile: "private", slug } : null;
23737
+ }
23738
+ return MEMORY_FACT_SLUG_RE.test(tail) && tail.length <= MEMORY_FACT_SLUG_MAX ? { pile: "shared", slug: tail } : null;
23739
+ }
23740
+ function memoryFactFilePath(workspaceSlug, pile, slug) {
23741
+ const ws = workspaceMirrorPath(workspaceSlug);
23742
+ const posix = memoryFactRelPath(pile, slug);
23743
+ const native = sep === "/" ? posix : posix.split("/").join(sep);
23744
+ const target = resolve(join2(ws, native));
23745
+ const prefix = join2(ws, MEMORY_FACTS_DIR) + sep;
23746
+ if (!target.startsWith(prefix)) {
23747
+ throw new MirrorPathError(`Resolved memory fact path escapes the memory subtree`);
23748
+ }
23749
+ assertNoSymlinkedComponent(ws, target);
23750
+ return target;
23751
+ }
23752
+ function memoryFactsSubtreeRoot(workspaceSlug) {
23753
+ const ws = workspaceMirrorPath(workspaceSlug);
23754
+ const target = join2(ws, MEMORY_FACTS_DIR);
23755
+ assertNoSymlinkedComponent(ws, target);
23756
+ return target;
23757
+ }
23758
+ function writeMemoryFactFile(workspaceSlug, pile, slug, content) {
23759
+ const target = memoryFactFilePath(workspaceSlug, pile, slug);
23760
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
23761
+ mkdirSync(dirname(target), { recursive: true, mode: 448 });
23762
+ writeFileSync(tmp, content, { mode: 384 });
23763
+ try {
23764
+ renameSync(tmp, target);
23765
+ } catch (err) {
23766
+ try {
23767
+ unlinkSync(tmp);
23768
+ } catch {}
23769
+ throw err;
23770
+ }
23771
+ }
23772
+ function readMemoryFactFile(workspaceSlug, pile, slug) {
23773
+ try {
23774
+ return readFileSync(memoryFactFilePath(workspaceSlug, pile, slug), "utf8");
23775
+ } catch (err) {
23776
+ if (err?.code === "ENOENT")
23777
+ return null;
23778
+ throw err;
23779
+ }
23780
+ }
23781
+ function deleteMemoryFactFile(workspaceSlug, pile, slug) {
23782
+ const target = memoryFactFilePath(workspaceSlug, pile, slug);
23783
+ try {
23784
+ unlinkSync(target);
23785
+ return true;
23786
+ } catch (err) {
23787
+ if (err?.code === "ENOENT")
23788
+ return false;
23789
+ throw err;
23790
+ }
23791
+ }
23792
+ function listMemoryFactFiles(workspaceSlug) {
23793
+ let root;
23794
+ try {
23795
+ root = memoryFactsSubtreeRoot(workspaceSlug);
23796
+ } catch {
23797
+ return [];
23798
+ }
23799
+ const found = [];
23800
+ const read = (dir, pile) => {
23801
+ let entries;
23802
+ try {
23803
+ entries = readdirSync(dir, { withFileTypes: true });
23804
+ } catch {
23805
+ return;
23806
+ }
23807
+ for (const e of entries) {
23808
+ if (!e.isFile() || !e.name.endsWith(".md"))
23809
+ continue;
23810
+ const slug = e.name.slice(0, -3);
23811
+ if (!MEMORY_FACT_SLUG_RE.test(slug) || slug.length > MEMORY_FACT_SLUG_MAX) {
23812
+ continue;
23813
+ }
23814
+ found.push({ pile, slug });
23815
+ }
23816
+ };
23817
+ read(root, "shared");
23818
+ read(join2(root, MEMORY_FACTS_PRIVATE_SEGMENT), "private");
23819
+ return found.sort((a, b) => a.pile === b.pile ? a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0 : a.pile === "shared" ? -1 : 1);
23820
+ }
23702
23821
  var FILES_SUBTREE_DIR = "files";
23703
23822
  var FILES_CONTROL_CHAR_RE = new RegExp("[\\x00-\\x1f\\x7f]");
23704
23823
  function validateFilesRelPath(relPath) {
@@ -25910,6 +26029,9 @@ var COMMAND_BAR_BYTES = 24000;
25910
26029
  var INDEX_LINE_MAX_BYTES = 300;
25911
26030
  var MAX_NAMED_FACT_STORES = 5;
25912
26031
  var MAX_NAMED_LONG_INDEX_LINES = 3;
26032
+ var CONTEXT_BLOCK_BAR_CHARS = 2000;
26033
+ var CURRENT_STATE_AS_OF_MAX_DAYS = 30;
26034
+ var MAX_NAMED_CONTEXT_FINDINGS = 5;
25913
26035
  function countRuleShapedLines(body) {
25914
26036
  let count = 0;
25915
26037
  for (const line of body.split(`
@@ -25926,531 +26048,15 @@ function countParagraphs(body) {
25926
26048
  return body.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0).length;
25927
26049
  }
25928
26050
 
25929
- // src/lib/context/grounding.ts
25930
- var WORKSPACE_RULES_FILENAME = "workspace-rules.md";
25931
- var HOUSE_STYLE_LEGACY_FILENAME = "house-style.md";
25932
- var WORKSPACE_MEMORY_FILENAME = "workspace-memory.md";
25933
- var USER_MEMORY_FILENAME = "user-memory.md";
25934
- var VOICE_FILENAME = "voice.md";
25935
- var WORKSPACE_RULES_PRIVATE_HEADING = "## Workspace-specific (private, never promotes)";
25936
- var WORKSPACE_RULES_GENERAL_HEADING = "## Generalisable manager-craft (may promote)";
25937
- var WORKSPACE_RULES_SECTION_HEADINGS = [
25938
- WORKSPACE_RULES_PRIVATE_HEADING,
25939
- WORKSPACE_RULES_GENERAL_HEADING
25940
- ];
25941
- function extractWorkspaceRules(raw) {
25942
- if (raw == null)
25943
- return null;
25944
- const withoutComments = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "");
25945
- const stripped = withoutComments.split(`
25946
- `).filter((line) => !WORKSPACE_RULES_SECTION_HEADINGS.includes(line.trim())).join(`
25947
- `).trim();
25948
- return stripped.length > 0 ? stripped : null;
25949
- }
25950
- function extractWorkspaceMemory(raw) {
25951
- if (raw == null)
25952
- return null;
25953
- const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
25954
- return stripped.length > 0 ? stripped : null;
25955
- }
25956
- function extractUserMemory(raw) {
25957
- if (raw == null)
25958
- return null;
25959
- const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
25960
- return stripped.length > 0 ? stripped : null;
25961
- }
25962
- function extractVoiceProfile(raw) {
25963
- if (raw == null)
25964
- return null;
25965
- const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").trim();
25966
- return stripped.length > 0 ? stripped : null;
25967
- }
25968
- function daysBetween(from, to) {
25969
- const parse6 = (d) => {
25970
- const parts = d.split("-");
25971
- return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
25972
- };
25973
- return Math.round((parse6(to) - parse6(from)) / 86400000);
25974
- }
25975
- function renderFreshnessStamp(today, updatedAt) {
25976
- const UNKNOWN = `The age of this index is not known here, so treat its lines as undated ` + `rather than as fresh.`;
25977
- if (updatedAt == null)
25978
- return UNKNOWN;
25979
- const banked = new Date(updatedAt);
25980
- if (Number.isNaN(banked.getTime()))
25981
- return UNKNOWN;
25982
- const bankedDay = formatLocalDate(banked);
25983
- const days = Math.max(0, daysBetween(bankedDay, today));
25984
- const age = days === 0 ? "today" : days === 1 ? "1 day ago" : `${days} days ago`;
25985
- return `This index was last updated on ${bankedDay}, ${age}.`;
25986
- }
25987
- var GROUNDING_BUDGET_BYTES = 20000;
25988
- var GROUNDING_CATEGORY_ORDER = [
25989
- "grounding",
25990
- "identity",
25991
- "budgetNote",
25992
- "sizeNote",
25993
- "voice",
25994
- "rules",
25995
- "workspaceFacts",
25996
- "personalFacts"
25997
- ];
25998
- var GROUNDING_CATEGORY_LABELS = {
25999
- grounding: "Plinth's own grounding",
26000
- identity: "who you are",
26001
- budgetNote: "the trim notes",
26002
- sizeNote: "the size warning",
26003
- voice: "voice pointer",
26004
- rules: "this workspace's rules",
26005
- workspaceFacts: "workspace facts",
26006
- personalFacts: "your own facts"
26007
- };
26008
- var MEMBER_EDITABLE_CATEGORIES = [
26009
- "rules",
26010
- "workspaceFacts",
26011
- "personalFacts"
26012
- ];
26013
- function segmentBytes(lines, cats) {
26014
- const out = {
26015
- grounding: 0,
26016
- identity: 0,
26017
- rules: 0,
26018
- workspaceFacts: 0,
26019
- personalFacts: 0,
26020
- voice: 0,
26021
- budgetNote: 0,
26022
- sizeNote: 0
26023
- };
26024
- lines.forEach((text, i) => {
26025
- const cat = cats[i];
26026
- out[cat] += Buffer.byteLength(text) + (i < lines.length - 1 ? 1 : 0);
26027
- });
26028
- return out;
26029
- }
26030
- var IDENTITY_LINE_MAX_BYTES = 300;
26031
- function renderMemberIdentity(member, workspaceSlug) {
26032
- if (member === null)
26033
- return null;
26034
- const tail = `. Tasks you create are assigned to this member unless told otherwise.`;
26035
- const budget = IDENTITY_LINE_MAX_BYTES - Buffer.byteLength(tail);
26036
- const email3 = oneLine(member.email);
26037
- const build = (withName, withRole, withZone) => {
26038
- const who = withName && member.displayName ? `${oneLine(member.displayName)} (${email3})` : email3;
26039
- const clauses = [`You are working for ${who}`];
26040
- if (withRole && member.role)
26041
- clauses.push(`role ${oneLine(member.role)}`);
26042
- if (workspaceSlug !== null)
26043
- clauses.push(`in workspace ${workspaceSlug}`);
26044
- if (withZone && member.timezone)
26045
- clauses.push(`timezone ${oneLine(member.timezone)}`);
26046
- return clauses.join(", ");
26047
- };
26048
- const candidates = [
26049
- build(true, true, true),
26050
- build(true, true, false),
26051
- build(true, false, false),
26052
- build(false, false, false)
26053
- ];
26054
- if (member.displayName) {
26055
- const slugClause = workspaceSlug === null ? `` : `, in workspace ${workspaceSlug}`;
26056
- candidates.push(`You are working for ${oneLine(member.displayName)}${slugClause}`);
26057
- }
26058
- const head = candidates.find((c) => Buffer.byteLength(c) <= budget);
26059
- if (head === undefined)
26060
- return null;
26061
- return head + tail;
26062
- }
26063
- function oneLine(value) {
26064
- return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
26065
- }
26066
- function shouldOfferOnboarding(rosterIsEmpty, member) {
26067
- if (member?.onboardingCompletedAt)
26068
- return false;
26069
- return rosterIsEmpty;
26070
- }
26071
- function bankedDate(line) {
26072
- const matches = line.match(/\d{4}-\d{2}-\d{2}/g);
26073
- if (matches === null || matches.length === 0)
26074
- return null;
26075
- let latest = matches[0];
26076
- for (const m of matches)
26077
- if (m > latest)
26078
- latest = m;
26079
- return latest;
26080
- }
26081
- var POINTER_RE = /\[\[[^\]]+\]\]/g;
26082
- var TITLE_TRUNCATION_MARK = " …";
26083
- function factTitle(fact) {
26084
- const firstLine = fact.split(`
26085
- `)[0];
26086
- const match = /[.?!](\s|$)/.exec(firstLine);
26087
- const sentence = match === null ? firstLine : firstLine.slice(0, match.index + 1);
26088
- const missing = [...fact.matchAll(POINTER_RE)].map((m) => m[0]).filter((p) => !sentence.includes(p));
26089
- const unique = [...new Set(missing)];
26090
- const named = unique.length === 0 ? sentence : `${sentence} ${unique.join(" ")}`;
26091
- const truncated = named !== fact;
26092
- return { text: truncated ? `${named}${TITLE_TRUNCATION_MARK}` : named, truncated };
26093
- }
26094
- function rankFacts(facts) {
26095
- return facts.map((line, index) => ({ line, index, date: bankedDate(line) })).sort((a, b) => {
26096
- const ad = a.date ?? "";
26097
- const bd = b.date ?? "";
26098
- if (ad !== bd)
26099
- return ad < bd ? 1 : -1;
26100
- return b.index - a.index;
26101
- }).map(({ index, line }) => ({ index, line }));
26102
- }
26103
- function groupFacts(body) {
26104
- const facts = [];
26105
- for (const line of body.split(`
26106
- `)) {
26107
- if (line.trim().length === 0)
26108
- continue;
26109
- if (/^\s/.test(line) && facts.length > 0) {
26110
- facts[facts.length - 1] = `${facts[facts.length - 1]}
26111
- ${line}`;
26112
- } else {
26113
- facts.push(line);
26114
- }
26115
- }
26116
- return facts;
26117
- }
26118
- function selectIndexContent(body, capBytes) {
26119
- const lines = groupFacts(body);
26120
- if (Buffer.byteLength(body) <= capBytes) {
26121
- return { text: body, titles: "", kept: lines.length, named: 0, total: lines.length };
26122
- }
26123
- const ranked = rankFacts(lines);
26124
- const titleOf = new Map;
26125
- const info2 = new Map;
26126
- for (const { index, line } of ranked) {
26127
- const t = factTitle(line);
26128
- info2.set(index, t);
26129
- titleOf.set(index, t.text);
26130
- }
26131
- const carried = new Map;
26132
- const used = () => {
26133
- let bytes = 0;
26134
- let bodies = 0;
26135
- let titles = 0;
26136
- for (const [index, kind] of carried) {
26137
- const line = lines[index];
26138
- if (kind === "body") {
26139
- bytes += Buffer.byteLength(line);
26140
- bodies++;
26141
- } else {
26142
- bytes += Buffer.byteLength(titleOf.get(index));
26143
- titles++;
26144
- }
26145
- }
26146
- return bytes + Math.max(0, bodies - 1) + Math.max(0, titles - 1);
26147
- };
26148
- for (const { index } of ranked) {
26149
- if (info2.get(index)?.truncated !== true)
26150
- continue;
26151
- carried.set(index, "title");
26152
- if (used() > capBytes)
26153
- carried.delete(index);
26154
- }
26155
- for (const { index } of ranked) {
26156
- const was = carried.get(index);
26157
- carried.set(index, "body");
26158
- if (used() > capBytes) {
26159
- if (was === undefined)
26160
- carried.delete(index);
26161
- else
26162
- carried.set(index, was);
26163
- break;
26164
- }
26165
- }
26166
- const inFileOrder = (kind) => [...carried.entries()].filter(([, k]) => k === kind).map(([i]) => i).sort((a, b) => a - b);
26167
- const bodyIdx = inFileOrder("body");
26168
- const titleIdx = inFileOrder("title");
26169
- return {
26170
- text: bodyIdx.map((i) => lines[i]).join(`
26171
- `),
26172
- titles: titleIdx.map((i) => titleOf.get(i)).join(`
26173
- `),
26174
- kept: bodyIdx.length,
26175
- named: titleIdx.length,
26176
- total: lines.length
26177
- };
26178
- }
26179
- function trimmedIndexNote(kept, total, filename) {
26180
- if (kept === 0) {
26181
- return `None of this index is shown here: it is over the size this injection ` + `carries, so it lives in \`${filename}\` at the mirror root and nowhere ` + `else. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something.`;
26182
- }
26183
- return `Those are the ${kept} most recently banked of ${total} facts, not all of ` + `them: this injection carries a fixed budget and the rest of the index did ` + `not fit. The other ${total - kept} are in \`${filename}\` at the mirror ` + `root. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something. An index ` + `that is silent because it was trimmed reads exactly like one that has ` + `settled a subject.`;
26184
- }
26185
- function titlesLeadIn(kept, named, unnamed, total, filename) {
26186
- const parts = [`${kept} above in full`];
26187
- parts.push(`${named} below as ${named === 1 ? "its opening" : "their openings"} only`);
26188
- if (unnamed > 0)
26189
- parts.push(`${unnamed} not shown at all`);
26190
- return `Not all of this index fits. It holds ${total} facts: ${parts.join(", ")}. ` + `Every line below ends \`…\` because it is an opening and not the whole ` + `fact. They are all in \`${filename}\` at the mirror root: READ THAT FILE ` + `IN FULL before you answer that no fact covers something.`;
26191
- }
26192
- function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote, member, workspaceTitles = null, userTitles = null, filesPastBar = 0) {
26193
- const gateFlag = workspaceSlug === null ? `--workspace="<this workspace>"` : `--workspace ${workspaceSlug}`;
26194
- const identity = renderMemberIdentity(member, workspaceSlug);
26195
- const block = [];
26196
- const cats = [];
26197
- const add = (cat, ...lines) => {
26198
- for (const line of lines) {
26199
- block.push(line);
26200
- cats.push(cat);
26201
- }
26202
- };
26203
- add("grounding", `Plinth session grounding:`, ``, `Today's date: ${today}. You have no inherent sense of the current date and ` + `your training has a knowledge cutoff, so treat this injected date as ` + `authoritative for anything time-relative: deadlines, what counts as ` + `"recent" or "upcoming", and the age of any data or of your own knowledge.`);
26204
- if (identity !== null)
26205
- add("identity", ``, identity);
26206
- add("grounding", ``, `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`, ``, `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends.`, ``, `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`, ``, `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Reading ` + `dates is yours, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty. When a tool is the only way, ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs.`, ``, `Writing a date is not yours, by any route: not a calendar tool, and not a ` + `shell command line either, the Google Workspace one included. The calendar ` + `tools are refused for you where Plinth has configured this workspace. ` + `Nothing refuses a shell, anywhere, and where Plinth has not configured ` + `this workspace nothing is refused at all: you are the only thing holding ` + `those. The way ` + `through is a person, not a workaround, so when a date needs adding or ` + `correcting say plainly what the change is, say that it goes in through ` + `Plinth's calendar view, and say that a person makes it. Say why, too: a date ` + `left looking handled is worse than one openly outstanding, and a calendar ` + `write goes out to everyone invited the moment it lands and cannot be recalled.`, ``, `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying, forwarding and deleting are not yours, by any route: not a mail tool, and ` + `not a shell command line either. The mail tools are refused for you where ` + `Plinth has configured this workspace. Nothing refuses a shell, anywhere, ` + `and where Plinth has not configured this workspace nothing is refused at ` + `all: you are the only thing holding those. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and one deleted out of a mailbox is not yours to lose; the person whose name ` + `is on it is the one who decides.`);
26207
- if (workspaceRules === null && voiceProfile === null) {
26208
- add("voice", ``, `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and \`${VOICE_FILENAME}\` beside it holds your own voice profile, the ` + `sign-off and the habits that belong to one member rather than the team. Read ` + `them before you write and follow them as written, rather than drafting in ` + `your own register and correcting after. Nothing inspects a draft between you ` + `and the person reading it, so the rules in those files are the whole of it.`);
26209
- }
26210
- add("grounding", ``, `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`, ``, `The most recent artefact wins on facts, and it is not a judgement call. ` + `Dates, prices, availability, capacities, routings, who is on a thread, ` + `who said what. Do not weigh a newer message against an older banked ` + `line to work out which is more likely right: the newer one is the state ` + `and the older one is history. This holds for any dated artefact, not ` + `only email - call transcripts, meeting notes and executed documents are ` + `all in scope, newest first. Stale context is worse than absent context, ` + `because it reads as current. One carve-out, because it is the failure ` + `this rule would otherwise cause, and it is a mechanical test rather ` + `than a judgement: before promoting the newest message to "the state", ` + `ask whether it COMMITS or PROPOSES. A commit ("we're doing", ` + `"confirmed", "booked", a price, a date someone has agreed to) wins, and ` + `overwrites the old claim. A proposal ("I wonder if", "I'm not saying we ` + `must", or any question put to someone who has not replied) goes in as ` + `an open question, named as unanswered, with who owes the answer. It ` + `does NOT become the state.`, ``, `Reconcile, do not re-adjudicate. Never delete a superseded claim: strike ` + `it in place. Keep the old line, mark it superseded with the date and ` + `with what replaced it, and put the new material above it, where the ` + `file's live state is. Demote the outgoing section one heading level so ` + `history stays readable and stops competing with what is live. Someone ` + `will ask why the plan changed, and the answer has to survive in the ` + `file. Then date the state: say what it is current as of, and name the ` + `sources it was built from. A master context with no as-of line is ` + `unfalsifiable.`);
26211
- if (workspaceRules !== null) {
26212
- add("grounding", ``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:`);
26213
- add("rules", ``, workspaceRules);
26214
- }
26215
- if (voiceProfile !== null) {
26216
- add("voice", ``, `Draft in this member's own voice, and read it before you do. Their voice ` + `profile is \`${VOICE_FILENAME}\` at the mirror root, beside this ` + `workspace's house style: how they open, how they sign off, what they ` + `never do. It is not reproduced here. READ THAT FILE IN FULL before you ` + `draft anything that goes out in their name (an email, a message, a ` + `note, any prose that carries their name), and write to it from the ` + `start rather than ` + `drafting in your own register and correcting after. Nothing inspects a ` + `draft between you and the person reading it, so that file is the whole ` + `of it. It is theirs to write and not yours: you cannot edit ` + `\`${VOICE_FILENAME}\`, and if something in it looks wrong or out of ` + `date, say so and let them change it. Like this workspace's house style ` + `it refines the rules above and never relaxes them.`);
26217
- if (voiceMalformed) {
26218
- add("voice", ``, `That voice profile does not currently parse. Plinth read it and found ` + `its structure broken, most often a code fence that was opened and ` + `never closed, so any machine-read setting inside it is being ignored ` + `rather than applied. The prose in it is still the member's own ` + `writing and is still worth following. Treat anything that looks like ` + `a configured setting in it as NOT in force, say so if it comes up, ` + `and tell them their profile needs fixing rather than fixing it ` + `yourself.`);
26219
- }
26220
- }
26221
- add("grounding", ``, `Keep your memory on the Plinth surface. Your memory here IS the Plinth ` + `surface (\`workspace-memory.md\`, your own \`user-memory.md\`, and the ` + `per-artist warm stores the daemon syncs), and it is the ONLY memory you ` + `keep. Do not use any other memory store: no private notes-to-self, no ` + `separate scratch file, no tool-native memory of your own. What you learn ` + `here is recorded on the Plinth surface, or nowhere. When you learn a ` + `durable fact worth keeping, bank it by adding a line to the right index, ` + `which the daemon syncs up, following the write-back conventions in that ` + `file's header: one line, at least one \`[[pointer]]\` to where the full ` + `fact lives, an absolute date, and where you learned it. Only ever record ` + `what you have actually been taught, never public knowledge and never your ` + `own priors. The scope rule decides WHICH index, and there are ` + `THREE. A fact true of ONE ARTIST (their rate card, how their kit ` + `works, what a contact of theirs reliably does) goes in that artist's ` + `own warm store, \`artists/<slug>/memory.md\`, and not in the shared ` + `index: putting it there taxes every teammate who never touches that ` + `artist and hides it from the one who does. A fact that holds for the ` + `whole workspace (a project, a working convention, a standing ` + `fact true for everyone here) goes in \`workspace-memory.md\`, which ` + `grounds EVERY teammate - and that file is the one you NEVER write on ` + `your own judgement. It changes only when a member ASKS you to remember ` + `something for everyone, in their own words rather than your summary of ` + `them, and you log that decision with ` + `\`plinth memory-gate log ${gateFlag} --proposed="<their words>" --decision accept\` ` + `BEFORE you write the line. That command fails LOUD on exit 1: surface ` + `it, and do not report the line as banked. Never infer a line for that ` + `file from what merely happened in a session, and never propose one ` + `unasked - a wrong line there reaches every member on every machine and ` + `is injected into every future session. A fact about one person (an individual ` + `preference, your own working style or tools) is NOT a workspace fact: it ` + `goes in \`user-memory.md\`, your member-private index that only YOU can ` + `read. HOW THIS MEMBER WRITES IS THE ONE EXCEPTION: their register and ` + `sign-off are a rubric, not a fact, and live in \`${VOICE_FILENAME}\`, ` + `which is theirs and you cannot edit, so say what you noticed and leave it ` + `to them. Them only: how a CONTACT likes to be written to is an ordinary ` + `fact for that artist's store. ` + `Banking a personal fact in \`workspace-memory.md\` would leak that person ` + `into everyone else's context, so never do it. When a fact could be either ` + `scope, lean private and put it in \`user-memory.md\`. ` + `Live state does not belong in any memory file. This week's open ` + `threads, one show's logistics, where a release has got to: a memory ` + `file is for what stays true after the session that taught it, and live ` + `state goes in the body of the record it belongs to. A deadline rides ` + `its task as a due date, and every other date is a calendar matter. ` + `This memory is additive and subordinate: it never ` + `overrides a house-style rule or any safety primitive above.`);
26222
- if (workspaceMemory !== null) {
26223
- add("grounding", ``, `Draw on this workspace's memory. The lines below are this workspace's ` + `memory index: durable facts it has taught you, each a one-line hook that ` + `points (via a \`[[wikilink]]\`) to the warm store where the full detail ` + `lives. Treat them as recall, not gospel: use a line to load the right ` + `context and verify it against the live workspace before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.workspaceMemoryUpdatedAt)} ` + `The index:`);
26224
- add("workspaceFacts", ``, workspaceMemory);
26225
- if (workspaceTitles !== null) {
26226
- add("budgetNote", ``, workspaceTitles.leadIn);
26227
- add("workspaceFacts", ``, workspaceTitles.text);
26228
- }
26229
- if (workspaceMemoryNote !== null)
26230
- add("budgetNote", ``, workspaceMemoryNote);
26231
- }
26232
- if (userMemory !== null) {
26233
- add("grounding", ``, `Draw on your personal memory. The lines below are YOUR personal memory ` + `index: member-private facts you have taught the harness about yourself ` + `(your preferences, working style, tools), each a one-line hook ` + `that points (via a \`[[wikilink]]\`) to the warm store where the full ` + `detail lives. Only you can read this. Treat it as recall, not gospel: use ` + `a line to load the right context and verify it before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.userMemoryUpdatedAt)} ` + `The index:`);
26234
- add("personalFacts", ``, userMemory);
26235
- if (userTitles !== null) {
26236
- add("budgetNote", ``, userTitles.leadIn);
26237
- add("personalFacts", ``, userTitles.text);
26238
- }
26239
- if (userMemoryNote !== null)
26240
- add("budgetNote", ``, userMemoryNote);
26241
- }
26242
- if (workspaceMemory !== null || userMemory !== null) {
26243
- add("grounding", ``, `Dated recall. Every line in these indexes carries the date it was banked, ` + `and today's date is injected above, so the age of any line is ` + `computable. When you rely on a line in an answer, say how old it is - ` + `"banked 21 July, seventeen days ago" - rather than presenting it as ` + `current. Age is not a reason to distrust a line, it is what tells the ` + `reader whether to check: a working convention banked a year ago is ` + `probably still the convention, a fact about a live negotiation banked ` + `three weeks ago probably is not. And say what the indexes do NOT cover. ` + `If you are asked about something no line here addresses, say so plainly ` + `rather than answering only from what happens to be present - an index ` + `that is silent on a subject reads exactly like one that has settled it.`);
26244
- }
26245
- add("grounding", ``, `Declare a work product before you finish with it. Your scratchpad ` + `directory sits outside Plinth: temporary, scoped to this session, and ` + `invisible to the person who asked for the work. Prefer not to leave a ` + `deliverable there at all. If it belongs to this workspace, write it to ` + `a mirror file instead, where it syncs and is durable. When the ` + `scratchpad genuinely is a file's only home, meaning a document, ` + `profile, report or analysis that was the POINT of the work rather than ` + `a step toward it, declare it the moment you write it, with ` + `\`plinth declare "<path>" --scratchpad="<the scratchpad directory ` + `your harness told you to use>" --label='<short description>'\`. ` + `Declare only ` + `those. A file that already has another home is not a deliverable: not ` + `raw API dumps, thread JSON, helper scripts, or the body files you write ` + `for a tool that reads a path, where the durable deliverable is the ` + `draft the tool creates. Declaring everything you wrote buries the one ` + `that mattered, which is the failure this exists to prevent. ` + `Re-declaring a path replaces the earlier entry, so refine a label ` + `freely. This is about work products, not memory: durable facts still go ` + `to the index above. Like every rule here it is additive and ` + `subordinate, and never relaxes a safety primitive above.`);
26246
- if (filesPastBar > 0) {
26247
- add("sizeNote", ``, `${filesPastBar} of the files that guide you ${filesPastBar === 1 ? "is" : "are"} past ` + `${filesPastBar === 1 ? "its" : "their"} size bar. \`plinth status\` names ` + `${filesPastBar === 1 ? "it" : "them"} and which are yours alone to trim; ` + `${CLOSE_COMMAND_INVOCATION} proposes a trim for the ones it can, never unasked.`);
26248
- }
26249
- if (shouldOfferOnboarding(rosterIsEmpty, member)) {
26250
- add("grounding", ``, `This workspace has no artists in it yet. OFFER to set it up before you ` + `get into anything else, because an empty roster reported as a summary ` + `reads as a broken product rather than a new one. Say in one or two ` + `lines that ` + `${ONBOARD_COMMAND_INVOCATION} walks them through connecting their ` + `inbox, calendar and files, gets their roster in, and turns the work ` + `they already have running into projects and tasks, then ask whether ` + `to start now. Then STOP AND WAIT for their answer. Do not begin the ` + `interview, do not connect anything, and do not create an artist, a ` + `project or a task until they have said yes. If they say no, or they ` + `asked for something else in the same message, drop it and do the ` + `thing they asked for. Offer once, and never twice in one session. ` + `And a workspace can have no artists on purpose: if they have turned ` + `this down before, or this is not an artist workspace, leave it alone ` + `rather than raising it every time you open one.`);
26251
- }
26252
- const groundingText = block.filter((_, i) => cats[i] === "grounding").join(`
26253
- `);
26254
- return {
26255
- text: block.join(`
26256
- `),
26257
- split: segmentBytes(block, cats),
26258
- groundingParagraphs: countParagraphs(groundingText),
26259
- groundingRuleShaped: countRuleShapedLines(groundingText)
26260
- };
26261
- }
26262
- function buildGroundingReport(today, workspaceRules = null, workspaceMemory = null, userMemory = null, freshness = {}, rosterIsEmpty = false, voiceProfile = null, voiceMalformed = false, workspaceSlug = null, member = null, filesPastBar = 0) {
26263
- const compose = (wmBody, umBody, wmNote, umNote, wmTitles = null, umTitles = null) => composeGroundingBlock(today, workspaceRules, wmBody, umBody, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, wmNote, umNote, member, wmTitles, umTitles, filesPastBar);
26264
- const whole = compose(workspaceMemory, userMemory, null, null);
26265
- const untrimmed = () => {
26266
- const all = (body) => {
26267
- const n = groupFacts(body).length;
26268
- return { text: body, titles: "", kept: n, named: 0, total: n };
26269
- };
26270
- return {
26271
- block: whole.text,
26272
- split: whole.split,
26273
- groundingParagraphs: whole.groundingParagraphs,
26274
- groundingRuleShaped: whole.groundingRuleShaped,
26275
- picks: {
26276
- wmPick: workspaceMemory === null ? null : all(workspaceMemory),
26277
- umPick: userMemory === null ? null : all(userMemory)
26278
- }
26279
- };
26280
- };
26281
- if (Buffer.byteLength(whole.text) <= GROUNDING_BUDGET_BYTES)
26282
- return untrimmed();
26283
- if (workspaceMemory === null && userMemory === null)
26284
- return untrimmed();
26285
- const factCount = (body) => body === null ? 0 : groupFacts(body).length;
26286
- const wmFacts = factCount(workspaceMemory);
26287
- const umFacts = factCount(userMemory);
26288
- const titlesFor = (pick2, filename) => pick2 === null || pick2.named === 0 ? null : {
26289
- leadIn: titlesLeadIn(pick2.kept, pick2.named, pick2.total - pick2.kept - pick2.named, pick2.total, filename),
26290
- text: pick2.titles
26291
- };
26292
- const noteFor = (pick2, filename) => pick2 === null || pick2.kept === pick2.total ? null : trimmedIndexNote(pick2.kept, pick2.total, filename);
26293
- const measureFixed = (withNotes2, withTitles) => Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", withNotes2 && workspaceMemory !== null ? trimmedIndexNote(wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME) : null, withNotes2 && userMemory !== null ? trimmedIndexNote(umFacts, umFacts, USER_MEMORY_FILENAME) : null, withTitles && workspaceMemory !== null ? { leadIn: titlesLeadIn(wmFacts, wmFacts, wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME), text: "" } : null, withTitles && userMemory !== null ? { leadIn: titlesLeadIn(umFacts, umFacts, umFacts, umFacts, USER_MEMORY_FILENAME), text: "" } : null).text);
26294
- const wmSize = workspaceMemory === null ? 0 : Buffer.byteLength(workspaceMemory);
26295
- const umSize = userMemory === null ? 0 : Buffer.byteLength(userMemory);
26296
- const allocate = (remaining) => {
26297
- const half = Math.floor(remaining / 2);
26298
- let umCap;
26299
- let wmCap;
26300
- if (umSize <= half) {
26301
- umCap = umSize;
26302
- wmCap = remaining - umSize;
26303
- } else if (wmSize <= half) {
26304
- wmCap = wmSize;
26305
- umCap = remaining - wmSize;
26306
- } else {
26307
- umCap = half;
26308
- wmCap = remaining - half;
26309
- }
26310
- const umPick = userMemory === null ? null : selectIndexContent(userMemory, umCap);
26311
- const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text) + Buffer.byteLength(umPick.titles);
26312
- return {
26313
- wmPick: workspaceMemory === null ? null : selectIndexContent(workspaceMemory, Math.max(wmCap, remaining - umUsed)),
26314
- umPick
26315
- };
26316
- };
26317
- const build = (picks, withNotes2) => {
26318
- const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
26319
- return compose(picks.wmPick === null ? null : picks.wmPick.text, picks.umPick === null ? null : picks.umPick.text, keep(picks.wmPick) ? noteFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(picks.umPick) ? noteFor(picks.umPick, USER_MEMORY_FILENAME) : null, titlesFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME), titlesFor(picks.umPick, USER_MEMORY_FILENAME));
26320
- };
26321
- const refine2 = (seed, withNotes2) => {
26322
- const asReserve = (section) => section === null ? null : { leadIn: section.leadIn, text: "" };
26323
- const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
26324
- const fixed = Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", keep(seed.wmPick) ? noteFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(seed.umPick) ? noteFor(seed.umPick, USER_MEMORY_FILENAME) : null, asReserve(titlesFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME)), asReserve(titlesFor(seed.umPick, USER_MEMORY_FILENAME))).text);
26325
- return allocate(Math.max(0, GROUNDING_BUDGET_BYTES - fixed));
26326
- };
26327
- const noNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(false, true)));
26328
- const withNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(true, true)));
26329
- const candidates = [
26330
- { picks: withNotes, composed: build(withNotes, true) },
26331
- { picks: noNotes, composed: build(noNotes, false) },
26332
- { picks: withNotes, composed: build(withNotes, false) }
26333
- ].concat([
26334
- { seed: noNotes, notes: false },
26335
- { seed: withNotes, notes: true }
26336
- ].map(({ seed, notes }) => {
26337
- const picks = refine2(seed, notes);
26338
- return { picks, composed: build(picks, notes) };
26339
- })).map((c) => ({
26340
- ...c,
26341
- bytes: Buffer.byteLength(c.composed.text),
26342
- named: (c.picks.wmPick?.kept ?? 0) + (c.picks.wmPick?.named ?? 0) + (c.picks.umPick?.kept ?? 0) + (c.picks.umPick?.named ?? 0),
26343
- whole: (c.picks.wmPick?.kept ?? 0) + (c.picks.umPick?.kept ?? 0)
26344
- }));
26345
- const fitting = candidates.filter((c) => c.bytes <= GROUNDING_BUDGET_BYTES);
26346
- const better = (a, b) => b.named !== a.named ? b.named > a.named : b.whole > a.whole;
26347
- const best = fitting.length > 0 ? fitting.reduce((a, b) => better(a, b) ? b : a) : candidates.reduce((a, b) => b.bytes < a.bytes ? b : a);
26348
- return {
26349
- block: best.composed.text,
26350
- picks: best.picks,
26351
- split: best.composed.split,
26352
- groundingParagraphs: best.composed.groundingParagraphs,
26353
- groundingRuleShaped: best.composed.groundingRuleShaped
26354
- };
26355
- }
26356
- var buildGroundingReportFor = buildGroundingReport;
26357
- function formatLocalDate(d) {
26358
- const year = d.getFullYear();
26359
- const month = String(d.getMonth() + 1).padStart(2, "0");
26360
- const day = String(d.getDate()).padStart(2, "0");
26361
- return `${year}-${month}-${day}`;
26362
- }
26363
-
26364
- // src/lib/hash.ts
26365
- import { createHash } from "node:crypto";
26366
- function stableHash(value) {
26367
- return createHash("sha256").update(stableStringify(value)).digest("hex");
26368
- }
26369
- function stableStringify(value) {
26370
- if (value === null || typeof value !== "object") {
26371
- return JSON.stringify(value);
26372
- }
26373
- if (Array.isArray(value)) {
26374
- return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
26375
- }
26376
- const obj = value;
26377
- const keys = Object.keys(obj).sort();
26378
- return "{" + keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",") + "}";
26379
- }
26380
-
26381
- // src/lib/context/roster.ts
26382
- var import_gray_matter5 = __toESM(require_gray_matter(), 1);
26383
- import { readdirSync as readdirSync3, readFileSync as readFileSync7 } from "node:fs";
26384
- import { join as join6 } from "node:path";
26385
- function readRoster(workspaceSlug) {
26386
- let artistsDir;
26387
- try {
26388
- artistsDir = join6(workspaceMirrorPath(workspaceSlug), "artists");
26389
- } catch {
26390
- return [];
26391
- }
26392
- let slugs;
26393
- try {
26394
- slugs = readdirSync3(artistsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
26395
- } catch {
26396
- return [];
26397
- }
26398
- const roster = [];
26399
- for (const slug of slugs) {
26400
- let raw;
26401
- try {
26402
- raw = readFileSync7(join6(artistsDir, slug, "_artist.md"), "utf8");
26403
- } catch {
26404
- continue;
26405
- }
26406
- try {
26407
- const fm = import_gray_matter5.default(raw).data;
26408
- const name = typeof fm.name === "string" && fm.name.length > 0 ? fm.name : slug;
26409
- const status = typeof fm.status === "string" ? fm.status : null;
26410
- roster.push({ slug, name, status });
26411
- } catch {
26412
- roster.push({ slug, name: slug, status: null });
26413
- }
26414
- }
26415
- return roster;
26416
- }
26417
- function rosterIsEmpty(workspaceSlug) {
26418
- let artistsDir;
26419
- try {
26420
- artistsDir = join6(workspaceMirrorPath(workspaceSlug), "artists");
26421
- } catch {
26422
- return false;
26423
- }
26424
- let entries;
26425
- try {
26426
- entries = readdirSync3(artistsDir, { withFileTypes: true });
26427
- } catch (err) {
26428
- return err?.code === "ENOENT";
26429
- }
26430
- for (const entry of entries) {
26431
- if (!entry.isDirectory())
26432
- continue;
26433
- try {
26434
- readFileSync7(join6(artistsDir, entry.name, "_artist.md"), "utf8");
26435
- return false;
26436
- } catch (err) {
26437
- if (err?.code === "ENOENT")
26438
- continue;
26439
- return false;
26440
- }
26441
- }
26442
- return true;
26443
- }
26444
-
26445
26051
  // src/lib/state.ts
26446
26052
  import {
26447
26053
  mkdirSync as mkdirSync6,
26448
- readFileSync as readFileSync8,
26054
+ readFileSync as readFileSync7,
26449
26055
  renameSync as renameSync5,
26450
26056
  unlinkSync as unlinkSync5,
26451
26057
  writeFileSync as writeFileSync5
26452
26058
  } from "node:fs";
26453
- import { dirname as dirname4, join as join7 } from "node:path";
26059
+ import { dirname as dirname4, join as join6 } from "node:path";
26454
26060
  var StateEntrySchema = exports_external.object({
26455
26061
  id: exports_external.string().uuid(),
26456
26062
  slug: exports_external.string().min(1),
@@ -26460,7 +26066,8 @@ var StateEntrySchema = exports_external.object({
26460
26066
  frontmatter_hash: exports_external.string().min(1),
26461
26067
  inode: exports_external.number().int().nonnegative().optional(),
26462
26068
  body_len: exports_external.number().int().nonnegative().optional(),
26463
- memory_hash: exports_external.string().min(1).optional()
26069
+ memory_hash: exports_external.string().min(1).optional(),
26070
+ memory_generated: exports_external.boolean().optional()
26464
26071
  });
26465
26072
  var EntityEntriesSchema = exports_external.record(exports_external.string().uuid(), StateEntrySchema);
26466
26073
  var FileStateEntrySchema = exports_external.object({
@@ -26480,17 +26087,27 @@ var WorkspaceRulesStateSchema = exports_external.object({
26480
26087
  });
26481
26088
  var WorkspaceMemoryStateSchema = exports_external.object({
26482
26089
  body_hash: exports_external.string().min(1),
26483
- updated_at: exports_external.string().datetime({ offset: true }).nullable()
26090
+ updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26091
+ generated: exports_external.boolean().optional()
26484
26092
  });
26485
26093
  var UserMemoryStateSchema = exports_external.object({
26486
26094
  body_hash: exports_external.string().min(1),
26487
- updated_at: exports_external.string().datetime({ offset: true }).nullable()
26095
+ updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26096
+ generated: exports_external.boolean().optional()
26488
26097
  });
26489
26098
  var UserVoiceStateSchema = exports_external.object({
26490
26099
  body_hash: exports_external.string().min(1),
26491
26100
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26492
26101
  status: exports_external.enum(["absent", "malformed", "valid"]).optional()
26493
26102
  });
26103
+ var MemoryFactEntrySchema = exports_external.object({
26104
+ id: exports_external.string().min(1),
26105
+ body_hash: exports_external.string().min(1)
26106
+ });
26107
+ var MemoryFactsStateSchema = exports_external.object({
26108
+ fetched_at: exports_external.string().datetime({ offset: true }),
26109
+ facts: exports_external.record(exports_external.string(), MemoryFactEntrySchema).default({})
26110
+ });
26494
26111
  var UnsyncedOpSchema = exports_external.object({
26495
26112
  target: exports_external.enum(["entity", "file"]),
26496
26113
  op: exports_external.enum(["upsert", "delete"]),
@@ -26549,7 +26166,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26549
26166
  frontmatter_id_backfill_version: exports_external.number().int().nonnegative().optional(),
26550
26167
  team_rejected: TeamRejectedSchema.optional(),
26551
26168
  size_anchor: SizeAnchorSchema.optional(),
26552
- last_trim_at: exports_external.string().min(1).optional()
26169
+ last_trim_at: exports_external.string().min(1).optional(),
26170
+ memory_facts: MemoryFactsStateSchema.optional()
26553
26171
  });
26554
26172
  var WorkspaceStateV1Schema = exports_external.object({
26555
26173
  version: exports_external.literal(1),
@@ -26557,10 +26175,10 @@ var WorkspaceStateV1Schema = exports_external.object({
26557
26175
  documents: EntityEntriesSchema
26558
26176
  });
26559
26177
  function stateFilePath(workspaceSlug) {
26560
- return join7(plinthHome(), workspaceSlug, "state.json");
26178
+ return join6(plinthHome(), workspaceSlug, "state.json");
26561
26179
  }
26562
26180
  function stateTmpPath(workspaceSlug) {
26563
- return join7(plinthHome(), workspaceSlug, `state.json.${process.pid}.tmp`);
26181
+ return join6(plinthHome(), workspaceSlug, `state.json.${process.pid}.tmp`);
26564
26182
  }
26565
26183
  function emptyState(workspaceSlug) {
26566
26184
  return { version: 2, workspace_slug: workspaceSlug, entries: {}, files: {} };
@@ -26576,7 +26194,7 @@ function migrateV1(v1) {
26576
26194
  function loadState(workspaceSlug) {
26577
26195
  let raw;
26578
26196
  try {
26579
- raw = readFileSync8(stateFilePath(workspaceSlug), "utf8");
26197
+ raw = readFileSync7(stateFilePath(workspaceSlug), "utf8");
26580
26198
  } catch (err) {
26581
26199
  if (err?.code === "ENOENT") {
26582
26200
  return emptyState(workspaceSlug);
@@ -26741,7 +26359,8 @@ function commitState(before, after) {
26741
26359
  frontmatter_id_backfill_version: mergeScalar(fresh.frontmatter_id_backfill_version, before.frontmatter_id_backfill_version, after.frontmatter_id_backfill_version),
26742
26360
  team_rejected: mergeScalar(fresh.team_rejected, before.team_rejected, after.team_rejected),
26743
26361
  size_anchor: mergeAnchor(fresh.size_anchor, before.size_anchor, after.size_anchor),
26744
- last_trim_at: mergeScalar(fresh.last_trim_at, before.last_trim_at, after.last_trim_at)
26362
+ last_trim_at: mergeScalar(fresh.last_trim_at, before.last_trim_at, after.last_trim_at),
26363
+ memory_facts: mergeScalar(fresh.memory_facts, before.memory_facts, after.memory_facts)
26745
26364
  };
26746
26365
  writeState(merged);
26747
26366
  const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled || userVoice.overruled;
@@ -26970,109 +26589,693 @@ function listTeamRejected(state) {
26970
26589
  function teamRejectedFetchedAt(state) {
26971
26590
  return state.team_rejected?.fetched_at;
26972
26591
  }
26973
- function setTeamRejected(state, rows, fetchedAt) {
26974
- const existing = state.team_rejected;
26975
- if (existing && rowsEqual(existing.rows, rows)) {
26976
- return { state, changed: false };
26592
+ function setTeamRejected(state, rows, fetchedAt) {
26593
+ const existing = state.team_rejected;
26594
+ if (existing && rowsEqual(existing.rows, rows)) {
26595
+ return { state, changed: false };
26596
+ }
26597
+ return {
26598
+ state: { ...state, team_rejected: { fetched_at: fetchedAt, rows } },
26599
+ changed: true
26600
+ };
26601
+ }
26602
+ function rowsEqual(a, b) {
26603
+ if (a.length !== b.length)
26604
+ return false;
26605
+ return a.every((x, i) => {
26606
+ const y = b[i];
26607
+ return x.path === y.path && x.reason === y.reason && x.detail === y.detail && x.size_bytes === y.size_bytes && x.max_bytes === y.max_bytes && x.reported_by === y.reported_by && x.reported_by_name === y.reported_by_name && x.first_seen_at === y.first_seen_at && x.last_seen_at === y.last_seen_at;
26608
+ });
26609
+ }
26610
+ function getWorkspaceRulesState(state) {
26611
+ return state.workspace_rules;
26612
+ }
26613
+ function setWorkspaceRulesState(state, entry) {
26614
+ const existing = state.workspace_rules;
26615
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at) {
26616
+ return { state, changed: false };
26617
+ }
26618
+ return { state: { ...state, workspace_rules: entry }, changed: true };
26619
+ }
26620
+ function getWorkspaceMemoryState(state) {
26621
+ return state.workspace_memory;
26622
+ }
26623
+ function setWorkspaceMemoryState(state, entry) {
26624
+ const existing = state.workspace_memory;
26625
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.generated === entry.generated) {
26626
+ return { state, changed: false };
26627
+ }
26628
+ return { state: { ...state, workspace_memory: entry }, changed: true };
26629
+ }
26630
+ function getUserMemoryState(state) {
26631
+ return state.user_memory;
26632
+ }
26633
+ function setUserMemoryState(state, entry) {
26634
+ const existing = state.user_memory;
26635
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.generated === entry.generated) {
26636
+ return { state, changed: false };
26637
+ }
26638
+ return { state: { ...state, user_memory: entry }, changed: true };
26639
+ }
26640
+ function getMemoryFactEntries(state) {
26641
+ return state.memory_facts?.facts ?? {};
26642
+ }
26643
+ function getMemoryFactEntry(state, relPath) {
26644
+ return state.memory_facts?.facts[relPath];
26645
+ }
26646
+ function setMemoryFactsState(state, entry) {
26647
+ const existing = state.memory_facts;
26648
+ if (existing && sameFactRegistry(existing.facts, entry.facts)) {
26649
+ return { state, changed: false };
26650
+ }
26651
+ return { state: { ...state, memory_facts: entry }, changed: true };
26652
+ }
26653
+ function sameFactRegistry(a, b) {
26654
+ const aKeys = Object.keys(a);
26655
+ if (aKeys.length !== Object.keys(b).length)
26656
+ return false;
26657
+ for (const key of aKeys) {
26658
+ const left = a[key];
26659
+ const right = b[key];
26660
+ if (!right || left.id !== right.id || left.body_hash !== right.body_hash) {
26661
+ return false;
26662
+ }
26663
+ }
26664
+ return true;
26665
+ }
26666
+ function getUserVoiceState(state) {
26667
+ return state.user_voice;
26668
+ }
26669
+ function setUserVoiceState(state, entry) {
26670
+ const existing = state.user_voice;
26671
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.status === entry.status) {
26672
+ return { state, changed: false };
26673
+ }
26674
+ return { state: { ...state, user_voice: entry }, changed: true };
26675
+ }
26676
+ var ABSENT_FRESHNESS = {
26677
+ workspaceMemory: null,
26678
+ userMemory: null,
26679
+ userVoice: null
26680
+ };
26681
+ function readMemoryFreshness(workspaceSlug) {
26682
+ let json2;
26683
+ try {
26684
+ json2 = JSON.parse(readFileSync7(stateFilePath(workspaceSlug), "utf8"));
26685
+ } catch {
26686
+ return ABSENT_FRESHNESS;
26687
+ }
26688
+ if (json2 === null || typeof json2 !== "object")
26689
+ return ABSENT_FRESHNESS;
26690
+ const record2 = json2;
26691
+ const workspace = WorkspaceMemoryStateSchema.safeParse(record2.workspace_memory);
26692
+ const user = UserMemoryStateSchema.safeParse(record2.user_memory);
26693
+ const voice = UserVoiceStateSchema.safeParse(record2.user_voice);
26694
+ return {
26695
+ workspaceMemory: workspace.success ? workspace.data : null,
26696
+ userMemory: user.success ? user.data : null,
26697
+ userVoice: voice.success ? voice.data : null
26698
+ };
26699
+ }
26700
+ function setSizeAnchor(state, anchor) {
26701
+ if (anchor.kind === "sync" && state.size_anchor !== undefined) {
26702
+ return { state, changed: false };
26703
+ }
26704
+ const existing = state.size_anchor;
26705
+ if (existing !== undefined && existing.kind === anchor.kind && existing.at === anchor.at && sizesEqual(existing.sizes, anchor.sizes)) {
26706
+ return { state, changed: false };
26707
+ }
26708
+ return { state: { ...state, size_anchor: anchor }, changed: true };
26709
+ }
26710
+ function sizesEqual(a, b) {
26711
+ const ak = Object.keys(a);
26712
+ const bk = Object.keys(b);
26713
+ if (ak.length !== bk.length)
26714
+ return false;
26715
+ return ak.every((k) => a[k] === b[k]);
26716
+ }
26717
+ function setLastTrimAt(state, at) {
26718
+ if (state.last_trim_at === at)
26719
+ return { state, changed: false };
26720
+ return { state: { ...state, last_trim_at: at }, changed: true };
26721
+ }
26722
+
26723
+ // src/lib/memory-facts/stores.ts
26724
+ var BLOB_MEMORY_STORES = Object.freeze({
26725
+ workspace: false,
26726
+ user: false
26727
+ });
26728
+ function memoryStoreModesFrom(state) {
26729
+ return {
26730
+ workspace: state.workspace_memory?.generated === true,
26731
+ user: state.user_memory?.generated === true
26732
+ };
26733
+ }
26734
+ function readMemoryStoreModes(workspaceSlug) {
26735
+ try {
26736
+ return memoryStoreModesFrom(loadState(workspaceSlug));
26737
+ } catch {
26738
+ return BLOB_MEMORY_STORES;
26739
+ }
26740
+ }
26741
+ function anyGenerated(modes) {
26742
+ return modes.workspace || modes.user;
26743
+ }
26744
+ function generatedMemoryStashDepsFrom(state) {
26745
+ const artists = state.entries["artists"] ?? {};
26746
+ const generatedArtists = new Set;
26747
+ for (const entry of Object.values(artists)) {
26748
+ if (entry.memory_generated === true)
26749
+ generatedArtists.add(entry.slug);
26750
+ }
26751
+ return {
26752
+ artistMemoryGenerated: (slug) => generatedArtists.has(slug),
26753
+ workspaceMemoryGenerated: () => state.workspace_memory?.generated === true,
26754
+ userMemoryGenerated: () => state.user_memory?.generated === true
26755
+ };
26756
+ }
26757
+
26758
+ // src/lib/context/grounding.ts
26759
+ var WORKSPACE_RULES_FILENAME = "workspace-rules.md";
26760
+ var HOUSE_STYLE_LEGACY_FILENAME = "house-style.md";
26761
+ var WORKSPACE_MEMORY_FILENAME = "workspace-memory.md";
26762
+ var USER_MEMORY_FILENAME = "user-memory.md";
26763
+ var VOICE_FILENAME = "voice.md";
26764
+ var WORKSPACE_RULES_PRIVATE_HEADING = "## Workspace-specific (private, never promotes)";
26765
+ var WORKSPACE_RULES_GENERAL_HEADING = "## Generalisable manager-craft (may promote)";
26766
+ var WORKSPACE_RULES_SECTION_HEADINGS = [
26767
+ WORKSPACE_RULES_PRIVATE_HEADING,
26768
+ WORKSPACE_RULES_GENERAL_HEADING
26769
+ ];
26770
+ function extractWorkspaceRules(raw) {
26771
+ if (raw == null)
26772
+ return null;
26773
+ const withoutComments = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "");
26774
+ const stripped = withoutComments.split(`
26775
+ `).filter((line) => !WORKSPACE_RULES_SECTION_HEADINGS.includes(line.trim())).join(`
26776
+ `).trim();
26777
+ return stripped.length > 0 ? stripped : null;
26778
+ }
26779
+ function extractWorkspaceMemory(raw) {
26780
+ if (raw == null)
26781
+ return null;
26782
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
26783
+ return stripped.length > 0 ? stripped : null;
26784
+ }
26785
+ function extractUserMemory(raw) {
26786
+ if (raw == null)
26787
+ return null;
26788
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
26789
+ return stripped.length > 0 ? stripped : null;
26790
+ }
26791
+ function extractVoiceProfile(raw) {
26792
+ if (raw == null)
26793
+ return null;
26794
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").trim();
26795
+ return stripped.length > 0 ? stripped : null;
26796
+ }
26797
+ function daysBetween(from, to) {
26798
+ const parse6 = (d) => {
26799
+ const parts = d.split("-");
26800
+ return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
26801
+ };
26802
+ return Math.round((parse6(to) - parse6(from)) / 86400000);
26803
+ }
26804
+ function renderFreshnessStamp(today, updatedAt) {
26805
+ const UNKNOWN = `The age of this index is not known here, so treat its lines as undated ` + `rather than as fresh.`;
26806
+ if (updatedAt == null)
26807
+ return UNKNOWN;
26808
+ const banked = new Date(updatedAt);
26809
+ if (Number.isNaN(banked.getTime()))
26810
+ return UNKNOWN;
26811
+ const bankedDay = formatLocalDate(banked);
26812
+ const days = Math.max(0, daysBetween(bankedDay, today));
26813
+ const age = days === 0 ? "today" : days === 1 ? "1 day ago" : `${days} days ago`;
26814
+ return `This index was last updated on ${bankedDay}, ${age}.`;
26815
+ }
26816
+ var GROUNDING_BUDGET_BYTES = 20000;
26817
+ var GROUNDING_CATEGORY_ORDER = [
26818
+ "grounding",
26819
+ "identity",
26820
+ "budgetNote",
26821
+ "sizeNote",
26822
+ "voice",
26823
+ "rules",
26824
+ "workspaceFacts",
26825
+ "personalFacts"
26826
+ ];
26827
+ var GROUNDING_CATEGORY_LABELS = {
26828
+ grounding: "Plinth's own grounding",
26829
+ identity: "who you are",
26830
+ budgetNote: "the trim notes",
26831
+ sizeNote: "the size warning",
26832
+ voice: "voice pointer",
26833
+ rules: "this workspace's rules",
26834
+ workspaceFacts: "workspace facts",
26835
+ personalFacts: "your own facts"
26836
+ };
26837
+ var MEMBER_EDITABLE_CATEGORIES = [
26838
+ "rules",
26839
+ "workspaceFacts",
26840
+ "personalFacts"
26841
+ ];
26842
+ function segmentBytes(lines, cats) {
26843
+ const out = {
26844
+ grounding: 0,
26845
+ identity: 0,
26846
+ rules: 0,
26847
+ workspaceFacts: 0,
26848
+ personalFacts: 0,
26849
+ voice: 0,
26850
+ budgetNote: 0,
26851
+ sizeNote: 0
26852
+ };
26853
+ lines.forEach((text, i) => {
26854
+ const cat = cats[i];
26855
+ out[cat] += Buffer.byteLength(text) + (i < lines.length - 1 ? 1 : 0);
26856
+ });
26857
+ return out;
26858
+ }
26859
+ var IDENTITY_LINE_MAX_BYTES = 300;
26860
+ function renderMemberIdentity(member, workspaceSlug) {
26861
+ if (member === null)
26862
+ return null;
26863
+ const tail = `. Tasks you create are assigned to this member unless told otherwise.`;
26864
+ const budget = IDENTITY_LINE_MAX_BYTES - Buffer.byteLength(tail);
26865
+ const email3 = oneLine(member.email);
26866
+ const build = (withName, withRole, withZone) => {
26867
+ const who = withName && member.displayName ? `${oneLine(member.displayName)} (${email3})` : email3;
26868
+ const clauses = [`You are working for ${who}`];
26869
+ if (withRole && member.role)
26870
+ clauses.push(`role ${oneLine(member.role)}`);
26871
+ if (workspaceSlug !== null)
26872
+ clauses.push(`in workspace ${workspaceSlug}`);
26873
+ if (withZone && member.timezone)
26874
+ clauses.push(`timezone ${oneLine(member.timezone)}`);
26875
+ return clauses.join(", ");
26876
+ };
26877
+ const candidates = [
26878
+ build(true, true, true),
26879
+ build(true, true, false),
26880
+ build(true, false, false),
26881
+ build(false, false, false)
26882
+ ];
26883
+ if (member.displayName) {
26884
+ const slugClause = workspaceSlug === null ? `` : `, in workspace ${workspaceSlug}`;
26885
+ candidates.push(`You are working for ${oneLine(member.displayName)}${slugClause}`);
26886
+ }
26887
+ const head = candidates.find((c) => Buffer.byteLength(c) <= budget);
26888
+ if (head === undefined)
26889
+ return null;
26890
+ return head + tail;
26891
+ }
26892
+ function oneLine(value) {
26893
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
26894
+ }
26895
+ function shouldOfferOnboarding(rosterIsEmpty, member) {
26896
+ if (member?.onboardingCompletedAt)
26897
+ return false;
26898
+ return rosterIsEmpty;
26899
+ }
26900
+ function bankedDate(line) {
26901
+ const matches = line.match(/\d{4}-\d{2}-\d{2}/g);
26902
+ if (matches === null || matches.length === 0)
26903
+ return null;
26904
+ let latest = matches[0];
26905
+ for (const m of matches)
26906
+ if (m > latest)
26907
+ latest = m;
26908
+ return latest;
26909
+ }
26910
+ var POINTER_RE = /\[\[[^\]]+\]\]/g;
26911
+ var TITLE_TRUNCATION_MARK = " …";
26912
+ function factTitle(fact) {
26913
+ const firstLine = fact.split(`
26914
+ `)[0];
26915
+ const match = /[.?!](\s|$)/.exec(firstLine);
26916
+ const sentence = match === null ? firstLine : firstLine.slice(0, match.index + 1);
26917
+ const missing = [...fact.matchAll(POINTER_RE)].map((m) => m[0]).filter((p) => !sentence.includes(p));
26918
+ const unique = [...new Set(missing)];
26919
+ const named = unique.length === 0 ? sentence : `${sentence} ${unique.join(" ")}`;
26920
+ const truncated = named !== fact;
26921
+ return { text: truncated ? `${named}${TITLE_TRUNCATION_MARK}` : named, truncated };
26922
+ }
26923
+ function rankFacts(facts) {
26924
+ return facts.map((line, index) => ({ line, index, date: bankedDate(line) })).sort((a, b) => {
26925
+ const ad = a.date ?? "";
26926
+ const bd = b.date ?? "";
26927
+ if (ad !== bd)
26928
+ return ad < bd ? 1 : -1;
26929
+ return b.index - a.index;
26930
+ }).map(({ index, line }) => ({ index, line }));
26931
+ }
26932
+ function groupFacts(body) {
26933
+ const facts = [];
26934
+ for (const line of body.split(`
26935
+ `)) {
26936
+ if (line.trim().length === 0)
26937
+ continue;
26938
+ if (/^\s/.test(line) && facts.length > 0) {
26939
+ facts[facts.length - 1] = `${facts[facts.length - 1]}
26940
+ ${line}`;
26941
+ } else {
26942
+ facts.push(line);
26943
+ }
26944
+ }
26945
+ return facts;
26946
+ }
26947
+ function selectIndexContent(body, capBytes) {
26948
+ const lines = groupFacts(body);
26949
+ if (Buffer.byteLength(body) <= capBytes) {
26950
+ return { text: body, titles: "", kept: lines.length, named: 0, total: lines.length };
26951
+ }
26952
+ const ranked = rankFacts(lines);
26953
+ const titleOf = new Map;
26954
+ const info2 = new Map;
26955
+ for (const { index, line } of ranked) {
26956
+ const t = factTitle(line);
26957
+ info2.set(index, t);
26958
+ titleOf.set(index, t.text);
26959
+ }
26960
+ const carried = new Map;
26961
+ const used = () => {
26962
+ let bytes = 0;
26963
+ let bodies = 0;
26964
+ let titles = 0;
26965
+ for (const [index, kind] of carried) {
26966
+ const line = lines[index];
26967
+ if (kind === "body") {
26968
+ bytes += Buffer.byteLength(line);
26969
+ bodies++;
26970
+ } else {
26971
+ bytes += Buffer.byteLength(titleOf.get(index));
26972
+ titles++;
26973
+ }
26974
+ }
26975
+ return bytes + Math.max(0, bodies - 1) + Math.max(0, titles - 1);
26976
+ };
26977
+ for (const { index } of ranked) {
26978
+ if (info2.get(index)?.truncated !== true)
26979
+ continue;
26980
+ carried.set(index, "title");
26981
+ if (used() > capBytes)
26982
+ carried.delete(index);
26983
+ }
26984
+ for (const { index } of ranked) {
26985
+ const was = carried.get(index);
26986
+ carried.set(index, "body");
26987
+ if (used() > capBytes) {
26988
+ if (was === undefined)
26989
+ carried.delete(index);
26990
+ else
26991
+ carried.set(index, was);
26992
+ break;
26993
+ }
26994
+ }
26995
+ const inFileOrder = (kind) => [...carried.entries()].filter(([, k]) => k === kind).map(([i]) => i).sort((a, b) => a - b);
26996
+ const bodyIdx = inFileOrder("body");
26997
+ const titleIdx = inFileOrder("title");
26998
+ return {
26999
+ text: bodyIdx.map((i) => lines[i]).join(`
27000
+ `),
27001
+ titles: titleIdx.map((i) => titleOf.get(i)).join(`
27002
+ `),
27003
+ kept: bodyIdx.length,
27004
+ named: titleIdx.length,
27005
+ total: lines.length
27006
+ };
27007
+ }
27008
+ function trimmedIndexNote(kept, total, filename) {
27009
+ if (kept === 0) {
27010
+ return `None of this index is shown here: it is over the size this injection ` + `carries, so it lives in \`${filename}\` at the mirror root and nowhere ` + `else. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something.`;
27011
+ }
27012
+ return `Those are the ${kept} most recently banked of ${total} facts, not all of ` + `them: this injection carries a fixed budget and the rest of the index did ` + `not fit. The other ${total - kept} are in \`${filename}\` at the mirror ` + `root. READ THAT FILE IN FULL before you rely on what this workspace has ` + `taught you, and before you answer that no fact covers something. An index ` + `that is silent because it was trimmed reads exactly like one that has ` + `settled a subject.`;
27013
+ }
27014
+ function titlesLeadIn(kept, named, unnamed, total, filename) {
27015
+ const parts = [`${kept} above in full`];
27016
+ parts.push(`${named} below as ${named === 1 ? "its opening" : "their openings"} only`);
27017
+ if (unnamed > 0)
27018
+ parts.push(`${unnamed} not shown at all`);
27019
+ return `Not all of this index fits. It holds ${total} facts: ${parts.join(", ")}. ` + `Every line below ends \`…\` because it is an opening and not the whole ` + `fact. They are all in \`${filename}\` at the mirror root: READ THAT FILE ` + `IN FULL before you answer that no fact covers something.`;
27020
+ }
27021
+ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote, member, workspaceTitles = null, userTitles = null, filesPastBar = 0, memoryStores = BLOB_MEMORY_STORES) {
27022
+ const gateFlag = workspaceSlug === null ? `--workspace="<this workspace>"` : `--workspace ${workspaceSlug}`;
27023
+ const identity = renderMemberIdentity(member, workspaceSlug);
27024
+ const block = [];
27025
+ const cats = [];
27026
+ const add = (cat, ...lines) => {
27027
+ for (const line of lines) {
27028
+ block.push(line);
27029
+ cats.push(cat);
27030
+ }
27031
+ };
27032
+ add("grounding", `Plinth session grounding:`, ``, `Today's date: ${today}. You have no inherent sense of the current date and ` + `your training has a knowledge cutoff, so treat this injected date as ` + `authoritative for anything time-relative: deadlines, what counts as ` + `"recent" or "upcoming", and the age of any data or of your own knowledge.`);
27033
+ if (identity !== null)
27034
+ add("identity", ``, identity);
27035
+ add("grounding", ``, `Resolve Plinth entities before answering about them. For any answer grounded ` + `in a specific artist, project, task, or other workspace entity, first look ` + `the name up in THIS workspace with the Plinth MCP tools ` + `(\`mcp__plinth__search_entities\`, \`mcp__plinth__list_artists\`, or ` + `\`mcp__plinth__list_projects\`) and ground your answer in what they return. ` + `Do not answer from name recognition alone: a globally famous name may not ` + `exist in this workspace, and an entity here may differ from the public ` + `figure of the same name. If the lookup returns nothing, say so instead of ` + `inventing details.`, ``, `Plan before you write. The first time you call any mutating Plinth MCP tool ` + `in a session (create_*, update_*, delete_*, mark_*: anything that changes ` + `workspace data), do not commit silently: state exactly what you would write, ` + `naming the tool, the entity, and the fields and values, and proceed only after ` + `the user confirms or amends.`, ``, `Write through the canonical surface. In this cockpit the file tree is your edit ` + `surface, so reach for it before the most direct tool. Change prose (document ` + `bodies, free-form notes, anything textual) by editing the mirror markdown ` + `file; the daemon syncs it up. An artist's master context is the clearest case: ` + `it lives in the body of that artist's \`artists/<slug>/_artist.md\` mirror file, ` + `the file the web context pane renders, and one of only two files under ` + `\`artists/<slug>/\` the daemon syncs both ways (\`memory.md\` is the other), ` + `so change it by editing that body, never by creating ` + `a separate document or context entity (e.g. \`create_document\`) the pane won't ` + `render. One carve-out inside that file: a block labelled as rendered from ` + `Plinth is regenerated on every sync and your edits to it are discarded, so ` + `write those through the tool the block names. Change structured fields (task ` + `status, priority, assignees, due dates, a project's category) through the ` + `Plinth MCP (\`mcp__plinth__*\`) or the task file's frontmatter, which is ` + `row-canonical but still written through the validated API. Make a bulk or ` + `destructive change (a mass delete, a gutting overwrite) by editing the file ` + `path, so the daemon's confirm-gate can quarantine it for \`plinth confirm\`. ` + `Never write the database directly, which rules out raw SQL and ` + `\`execute_sql\`, \`apply_migration\` and \`psql\`: those bypass the daemon, the ` + `mirror, the confirm-gate, canonicality, and API validation.`, ``, `Dates belong on the calendar, not in \`key_dates\`. The date surface for an artist ` + `is their mapped Google Calendar; the \`key_dates\` table is retired. Reading ` + `dates is yours, and this mirror already holds them: the daemon projects every ` + `mapped calendar into \`views/calendar/upcoming.md\` and ` + `\`views/calendar/<slug>.md\`, so read those before reaching for a tool ` + `(\`list_artist_calendars\`). They are derived and overwritten, ` + `so they are current only as of the \`generated_at\` in their header - quote that ` + `stamp, never edit them, and check the status line, because a calendar Plinth ` + `could not reach is unknown rather than empty. When a tool is the only way, ` + `resolve the calendar through \`mcp__plinth__list_artist_calendars\` and use its ` + `\`calendar_id\` field, not the mapping row's \`id\`, because the default ` + `\`primary\` is almost never theirs.`, ``, `Writing a date is not yours, by any route: not a calendar tool, and not a ` + `shell command line either, the Google Workspace one included. The calendar ` + `tools are refused for you where Plinth has configured this workspace. ` + `Nothing refuses a shell, anywhere, and where Plinth has not configured ` + `this workspace nothing is refused at all: you are the only thing holding ` + `those. The way ` + `through is a person, not a workaround, so when a date needs adding or ` + `correcting say plainly what the change is, say that it goes in through ` + `Plinth's calendar view, and say that a person makes it. Say why, too: a date ` + `left looking handled is worse than one openly outstanding, and a calendar ` + `write goes out to everyone invited the moment it lands and cannot be recalled.`, ``, `Draft email. Never send it. Writing the draft is yours: use the draft tools ` + `(\`create_draft\`, \`update_draft\`), then say where the draft is and what it ` + `says, so the person can read it and send it in their own name. Sending, ` + `replying, forwarding and deleting are not yours, by any route: not a mail tool, and ` + `not a shell command line either. The mail tools are refused for you where ` + `Plinth has configured this workspace. Nothing refuses a shell, anywhere, ` + `and where Plinth has not configured this workspace nothing is refused at ` + `all: you are the only thing holding those. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and one deleted out of a mailbox is not yours to lose; the person whose name ` + `is on it is the one who decides.`);
27036
+ if (workspaceRules === null && voiceProfile === null) {
27037
+ add("voice", ``, `Check the voice yourself, because nothing else checks it now. Two files carry ` + `it: \`workspace-rules.md\` at the mirror root holds this workspace's house ` + `style, and \`${VOICE_FILENAME}\` beside it holds your own voice profile, the ` + `sign-off and the habits that belong to one member rather than the team. Read ` + `them before you write and follow them as written, rather than drafting in ` + `your own register and correcting after. Nothing inspects a draft between you ` + `and the person reading it, so the rules in those files are the whole of it.`);
27038
+ }
27039
+ add("grounding", ``, `Read the draft back before you say it is ready, and read what the tool actually ` + `produced rather than what you sent it. Do this every time you write one, not ` + `once a session: a later edit can undo what an earlier check confirmed. A ` + `draft surface takes your text and renders it, and it may escape what you ` + `wrote, wrap it, quote an earlier message underneath it, thread it somewhere ` + `you did not intend, or leave the first one in place and add a second. What ` + `the person opens is the rendered result, not your input, and those are not ` + `always the same thing. So fetch the staged draft, look at its body, its ` + `recipients, the thread it landed on and that there is one of it, and only ` + `then say it is ready. Separately, and once per session is enough for this ` + `part: if a tool's own description tells you how it handles your text, treat ` + `that as a claim like any other and check it against what came out. A tool ` + `that is documented wrong will put markup, an empty body, or a stranger's ` + `address in front of a person whose name is on the message, and you are the ` + `only thing standing between those two.`, ``, `The most recent artefact wins on facts, and it is not a judgement call. ` + `Dates, prices, availability, capacities, routings, who is on a thread, ` + `who said what. Do not weigh a newer message against an older banked ` + `line to work out which is more likely right: the newer one is the state ` + `and the older one is history. This holds for any dated artefact, not ` + `only email - call transcripts, meeting notes and executed documents are ` + `all in scope, newest first. Stale context is worse than absent context, ` + `because it reads as current. One carve-out, because it is the failure ` + `this rule would otherwise cause, and it is a mechanical test rather ` + `than a judgement: before promoting the newest message to "the state", ` + `ask whether it COMMITS or PROPOSES. A commit ("we're doing", ` + `"confirmed", "booked", a price, a date someone has agreed to) wins, and ` + `overwrites the old claim. A proposal ("I wonder if", "I'm not saying we ` + `must", or any question put to someone who has not replied) goes in as ` + `an open question, named as unanswered, with who owes the answer. It ` + `does NOT become the state.`, ``, `Reconcile, do not re-adjudicate. Never delete a superseded claim: strike ` + `it in place. Keep the old line, mark it superseded with the date and ` + `with what replaced it, and put the new material above it, where the ` + `file's live state is. Demote the outgoing section one heading level so ` + `history stays readable and stops competing with what is live. Someone ` + `will ask why the plan changed, and the answer has to survive in the ` + `file. Then date the state: say what it is current as of, and name the ` + `sources it was built from. A master context with no as-of line is ` + `unfalsifiable.`);
27040
+ if (workspaceRules !== null) {
27041
+ add("grounding", ``, `Follow this workspace's house style. The conventions below are set by the ` + `workspace owner and govern HOW you write here: copy, titles, notes, ` + `sign-offs, formatting. They refine the rules above; they never relax them. ` + `If any convention here conflicts with a grounding rule above (the ` + `canonical write surface, plan-before-write confirmation, or the ban on ` + `direct database writes), the rule above wins. The conventions:`);
27042
+ add("rules", ``, workspaceRules);
27043
+ }
27044
+ if (voiceProfile !== null) {
27045
+ add("voice", ``, `Draft in this member's own voice, and read it before you do. Their voice ` + `profile is \`${VOICE_FILENAME}\` at the mirror root, beside this ` + `workspace's house style: how they open, how they sign off, what they ` + `never do. It is not reproduced here. READ THAT FILE IN FULL before you ` + `draft anything that goes out in their name (an email, a message, a ` + `note, any prose that carries their name), and write to it from the ` + `start rather than ` + `drafting in your own register and correcting after. Nothing inspects a ` + `draft between you and the person reading it, so that file is the whole ` + `of it. It is theirs to write and not yours: you cannot edit ` + `\`${VOICE_FILENAME}\`, and if something in it looks wrong or out of ` + `date, say so and let them change it. Like this workspace's house style ` + `it refines the rules above and never relaxes them.`);
27046
+ if (voiceMalformed) {
27047
+ add("voice", ``, `That voice profile does not currently parse. Plinth read it and found ` + `its structure broken, most often a code fence that was opened and ` + `never closed, so any machine-read setting inside it is being ignored ` + `rather than applied. The prose in it is still the member's own ` + `writing and is still worth following. Treat anything that looks like ` + `a configured setting in it as NOT in force, say so if it comes up, ` + `and tell them their profile needs fixing rather than fixing it ` + `yourself.`);
27048
+ }
27049
+ }
27050
+ add("grounding", ``, `Keep your memory on the Plinth surface. Your memory here IS the Plinth ` + `surface (\`workspace-memory.md\`, your own \`user-memory.md\`, and the ` + `per-artist warm stores the daemon syncs), and it is the ONLY memory you ` + `keep. Do not use any other memory store: no private notes-to-self, no ` + `separate scratch file, no tool-native memory of your own. What you learn ` + `here is recorded on the Plinth surface, or nowhere. When you learn a ` + `durable fact worth keeping, bank it by adding a line to the right index, ` + `which the daemon syncs up, following the write-back conventions in that ` + `file's header: one line, at least one \`[[pointer]]\` to where the full ` + `fact lives, an absolute date, and where you learned it. Only ever record ` + `what you have actually been taught, never public knowledge and never your ` + `own priors. The scope rule decides WHICH index, and there are ` + `THREE. A fact true of ONE ARTIST (their rate card, how their kit ` + `works, what a contact of theirs reliably does) goes in that artist's ` + `own warm store, \`artists/<slug>/memory.md\`, and not in the shared ` + `index: putting it there taxes every teammate who never touches that ` + `artist and hides it from the one who does. A fact that holds for the ` + `whole workspace (a project, a working convention, a standing ` + `fact true for everyone here) goes in \`workspace-memory.md\`, which ` + `grounds EVERY teammate - and that file is the one you NEVER write on ` + `your own judgement. It changes only when a member ASKS you to remember ` + `something for everyone, in their own words rather than your summary of ` + `them, and you log that decision with ` + `\`plinth memory-gate log ${gateFlag} --proposed="<their words>" --decision accept\` ` + `BEFORE you write the line. That command fails LOUD on exit 1: surface ` + `it, and do not report the line as banked. Never infer a line for that ` + `file from what merely happened in a session, and never propose one ` + `unasked - a wrong line there reaches every member on every machine and ` + `is injected into every future session. A fact about one person (an individual ` + `preference, your own working style or tools) is NOT a workspace fact: it ` + `goes in \`user-memory.md\`, your member-private index that only YOU can ` + `read. HOW THIS MEMBER WRITES IS THE ONE EXCEPTION: their register and ` + `sign-off are a rubric, not a fact, and live in \`${VOICE_FILENAME}\`, ` + `which is theirs and you cannot edit, so say what you noticed and leave it ` + `to them. Them only: how a CONTACT likes to be written to is an ordinary ` + `fact for that artist's store. ` + `Banking a personal fact in \`workspace-memory.md\` would leak that person ` + `into everyone else's context, so never do it. When a fact could be either ` + `scope, lean private and put it in \`user-memory.md\`. ` + `Live state does not belong in any memory file. This week's open ` + `threads, one show's logistics, where a release has got to: a memory ` + `file is for what stays true after the session that taught it, and live ` + `state goes in the body of the record it belongs to. A deadline rides ` + `its task as a due date, and every other date is a calendar matter. ` + `This memory is additive and subordinate: it never ` + `overrides a house-style rule or any safety primitive above.` + generatedStoresClause(memoryStores));
27051
+ if (workspaceMemory !== null) {
27052
+ add("grounding", ``, `Draw on this workspace's memory. The lines below are this workspace's ` + `memory index: durable facts it has taught you, each a one-line hook that ` + `points (via a \`[[wikilink]]\`) to the warm store where the full detail ` + `lives. Treat them as recall, not gospel: use a line to load the right ` + `context and verify it against the live workspace before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.workspaceMemoryUpdatedAt)} ` + `The index:`);
27053
+ add("workspaceFacts", ``, workspaceMemory);
27054
+ if (workspaceTitles !== null) {
27055
+ add("budgetNote", ``, workspaceTitles.leadIn);
27056
+ add("workspaceFacts", ``, workspaceTitles.text);
27057
+ }
27058
+ if (workspaceMemoryNote !== null)
27059
+ add("budgetNote", ``, workspaceMemoryNote);
27060
+ }
27061
+ if (userMemory !== null) {
27062
+ add("grounding", ``, `Draw on your personal memory. The lines below are YOUR personal memory ` + `index: member-private facts you have taught the harness about yourself ` + `(your preferences, working style, tools), each a one-line hook ` + `that points (via a \`[[wikilink]]\`) to the warm store where the full ` + `detail lives. Only you can read this. Treat it as recall, not gospel: use ` + `a line to load the right context and verify it before you rely on it, ` + `never as a settled fact to assert. ` + `${renderFreshnessStamp(today, freshness.userMemoryUpdatedAt)} ` + `The index:`);
27063
+ add("personalFacts", ``, userMemory);
27064
+ if (userTitles !== null) {
27065
+ add("budgetNote", ``, userTitles.leadIn);
27066
+ add("personalFacts", ``, userTitles.text);
27067
+ }
27068
+ if (userMemoryNote !== null)
27069
+ add("budgetNote", ``, userMemoryNote);
27070
+ }
27071
+ if (workspaceMemory !== null || userMemory !== null) {
27072
+ add("grounding", ``, `Dated recall. Every line in these indexes carries the date it was banked, ` + `and today's date is injected above, so the age of any line is ` + `computable. When you rely on a line in an answer, say how old it is - ` + `"banked 21 July, seventeen days ago" - rather than presenting it as ` + `current. Age is not a reason to distrust a line, it is what tells the ` + `reader whether to check: a working convention banked a year ago is ` + `probably still the convention, a fact about a live negotiation banked ` + `three weeks ago probably is not. And say what the indexes do NOT cover. ` + `If you are asked about something no line here addresses, say so plainly ` + `rather than answering only from what happens to be present - an index ` + `that is silent on a subject reads exactly like one that has settled it.`);
26977
27073
  }
27074
+ add("grounding", ``, `Declare a work product before you finish with it. Your scratchpad ` + `directory sits outside Plinth: temporary, scoped to this session, and ` + `invisible to the person who asked for the work. Prefer not to leave a ` + `deliverable there at all. If it belongs to this workspace, write it to ` + `a mirror file instead, where it syncs and is durable. When the ` + `scratchpad genuinely is a file's only home, meaning a document, ` + `profile, report or analysis that was the POINT of the work rather than ` + `a step toward it, declare it the moment you write it, with ` + `\`plinth declare "<path>" --scratchpad="<the scratchpad directory ` + `your harness told you to use>" --label='<short description>'\`. ` + `Declare only ` + `those. A file that already has another home is not a deliverable: not ` + `raw API dumps, thread JSON, helper scripts, or the body files you write ` + `for a tool that reads a path, where the durable deliverable is the ` + `draft the tool creates. Declaring everything you wrote buries the one ` + `that mattered, which is the failure this exists to prevent. ` + `Re-declaring a path replaces the earlier entry, so refine a label ` + `freely. This is about work products, not memory: durable facts still go ` + `to the index above. Like every rule here it is additive and ` + `subordinate, and never relaxes a safety primitive above.`);
27075
+ if (filesPastBar > 0) {
27076
+ add("sizeNote", ``, `${filesPastBar} of the files that guide you ${filesPastBar === 1 ? "is" : "are"} past ` + `${filesPastBar === 1 ? "its" : "their"} size bar. \`plinth status\` names ` + `${filesPastBar === 1 ? "it" : "them"} and which are yours alone to trim; ` + `${CLOSE_COMMAND_INVOCATION} proposes a trim for the ones it can, never unasked.`);
27077
+ }
27078
+ if (shouldOfferOnboarding(rosterIsEmpty, member)) {
27079
+ add("grounding", ``, `This workspace has no artists in it yet. OFFER to set it up before you ` + `get into anything else, because an empty roster reported as a summary ` + `reads as a broken product rather than a new one. Say in one or two ` + `lines that ` + `${ONBOARD_COMMAND_INVOCATION} walks them through connecting their ` + `inbox, calendar and files, gets their roster in, and turns the work ` + `they already have running into projects and tasks, then ask whether ` + `to start now. Then STOP AND WAIT for their answer. Do not begin the ` + `interview, do not connect anything, and do not create an artist, a ` + `project or a task until they have said yes. If they say no, or they ` + `asked for something else in the same message, drop it and do the ` + `thing they asked for. Offer once, and never twice in one session. ` + `And a workspace can have no artists on purpose: if they have turned ` + `this down before, or this is not an artist workspace, leave it alone ` + `rather than raising it every time you open one.`);
27080
+ }
27081
+ const groundingText = block.filter((_, i) => cats[i] === "grounding").join(`
27082
+ `);
26978
27083
  return {
26979
- state: { ...state, team_rejected: { fetched_at: fetchedAt, rows } },
26980
- changed: true
27084
+ text: block.join(`
27085
+ `),
27086
+ split: segmentBytes(block, cats),
27087
+ groundingParagraphs: countParagraphs(groundingText),
27088
+ groundingRuleShaped: countRuleShapedLines(groundingText)
26981
27089
  };
26982
27090
  }
26983
- function rowsEqual(a, b) {
26984
- if (a.length !== b.length)
26985
- return false;
26986
- return a.every((x, i) => {
26987
- const y = b[i];
26988
- return x.path === y.path && x.reason === y.reason && x.detail === y.detail && x.size_bytes === y.size_bytes && x.max_bytes === y.max_bytes && x.reported_by === y.reported_by && x.reported_by_name === y.reported_by_name && x.first_seen_at === y.first_seen_at && x.last_seen_at === y.last_seen_at;
26989
- });
26990
- }
26991
- function getWorkspaceRulesState(state) {
26992
- return state.workspace_rules;
26993
- }
26994
- function setWorkspaceRulesState(state, entry) {
26995
- const existing = state.workspace_rules;
26996
- if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at) {
26997
- return { state, changed: false };
26998
- }
26999
- return { state: { ...state, workspace_rules: entry }, changed: true };
27091
+ function generatedStoresClause(modes) {
27092
+ if (!anyGenerated(modes))
27093
+ return "";
27094
+ const which = modes.workspace && modes.user ? `\`${WORKSPACE_MEMORY_FILENAME}\` and \`${USER_MEMORY_FILENAME}\` are` : modes.workspace ? `\`${WORKSPACE_MEMORY_FILENAME}\` is` : `\`${USER_MEMORY_FILENAME}\` is`;
27095
+ const tools = modes.user ? modes.workspace ? `\`create_user_memory_fact\` for a personal fact, which writes on one ` + `call, and \`create_memory_fact\` for a shared one, which shows you the ` + `fact first and needs a second call to save it` : `\`create_user_memory_fact\`, which writes on one call` : `\`create_memory_fact\`, which shows you the fact first and needs a ` + `second call to save it`;
27096
+ return ` HOW you bank a fact has changed for this workspace: ${which} now ` + `GENERATED from memory facts, so do not add a line to ${modes.workspace && modes.user ? "either file" : "that file"} - it is rewritten from the ` + `facts on every sync. Use ${tools}. Correct a fact with ` + `\`supersede_memory_fact\` or \`delete_memory_fact\` (\`_user_\` for your own), ` + `taking the id from the fact file's frontmatter. Each fact's full text lives ` + `in \`memory/<slug>.md\`, or \`memory/private/<slug>.md\` for your own, and ` + `the index lines above link to them - read the one you need rather than the ` + `whole folder. A hand edit to a generated file does not reach Plinth: it is ` + `set aside and \`plinth status\` says where.`;
27000
27097
  }
27001
- function getWorkspaceMemoryState(state) {
27002
- return state.workspace_memory;
27098
+ function buildGroundingReport(today, workspaceRules = null, workspaceMemory = null, userMemory = null, freshness = {}, rosterIsEmpty = false, voiceProfile = null, voiceMalformed = false, workspaceSlug = null, member = null, filesPastBar = 0, memoryStores = BLOB_MEMORY_STORES) {
27099
+ const compose = (wmBody, umBody, wmNote, umNote, wmTitles = null, umTitles = null) => composeGroundingBlock(today, workspaceRules, wmBody, umBody, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, wmNote, umNote, member, wmTitles, umTitles, filesPastBar, memoryStores);
27100
+ const whole = compose(workspaceMemory, userMemory, null, null);
27101
+ const untrimmed = () => {
27102
+ const all = (body) => {
27103
+ const n = groupFacts(body).length;
27104
+ return { text: body, titles: "", kept: n, named: 0, total: n };
27105
+ };
27106
+ return {
27107
+ block: whole.text,
27108
+ split: whole.split,
27109
+ groundingParagraphs: whole.groundingParagraphs,
27110
+ groundingRuleShaped: whole.groundingRuleShaped,
27111
+ picks: {
27112
+ wmPick: workspaceMemory === null ? null : all(workspaceMemory),
27113
+ umPick: userMemory === null ? null : all(userMemory)
27114
+ }
27115
+ };
27116
+ };
27117
+ if (Buffer.byteLength(whole.text) <= GROUNDING_BUDGET_BYTES)
27118
+ return untrimmed();
27119
+ if (workspaceMemory === null && userMemory === null)
27120
+ return untrimmed();
27121
+ const factCount = (body) => body === null ? 0 : groupFacts(body).length;
27122
+ const wmFacts = factCount(workspaceMemory);
27123
+ const umFacts = factCount(userMemory);
27124
+ const titlesFor = (pick2, filename) => pick2 === null || pick2.named === 0 ? null : {
27125
+ leadIn: titlesLeadIn(pick2.kept, pick2.named, pick2.total - pick2.kept - pick2.named, pick2.total, filename),
27126
+ text: pick2.titles
27127
+ };
27128
+ const noteFor = (pick2, filename) => pick2 === null || pick2.kept === pick2.total ? null : trimmedIndexNote(pick2.kept, pick2.total, filename);
27129
+ const measureFixed = (withNotes2, withTitles) => Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", withNotes2 && workspaceMemory !== null ? trimmedIndexNote(wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME) : null, withNotes2 && userMemory !== null ? trimmedIndexNote(umFacts, umFacts, USER_MEMORY_FILENAME) : null, withTitles && workspaceMemory !== null ? { leadIn: titlesLeadIn(wmFacts, wmFacts, wmFacts, wmFacts, WORKSPACE_MEMORY_FILENAME), text: "" } : null, withTitles && userMemory !== null ? { leadIn: titlesLeadIn(umFacts, umFacts, umFacts, umFacts, USER_MEMORY_FILENAME), text: "" } : null).text);
27130
+ const wmSize = workspaceMemory === null ? 0 : Buffer.byteLength(workspaceMemory);
27131
+ const umSize = userMemory === null ? 0 : Buffer.byteLength(userMemory);
27132
+ const allocate = (remaining) => {
27133
+ const half = Math.floor(remaining / 2);
27134
+ let umCap;
27135
+ let wmCap;
27136
+ if (umSize <= half) {
27137
+ umCap = umSize;
27138
+ wmCap = remaining - umSize;
27139
+ } else if (wmSize <= half) {
27140
+ wmCap = wmSize;
27141
+ umCap = remaining - wmSize;
27142
+ } else {
27143
+ umCap = half;
27144
+ wmCap = remaining - half;
27145
+ }
27146
+ const umPick = userMemory === null ? null : selectIndexContent(userMemory, umCap);
27147
+ const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text) + Buffer.byteLength(umPick.titles);
27148
+ return {
27149
+ wmPick: workspaceMemory === null ? null : selectIndexContent(workspaceMemory, Math.max(wmCap, remaining - umUsed)),
27150
+ umPick
27151
+ };
27152
+ };
27153
+ const build = (picks, withNotes2) => {
27154
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
27155
+ return compose(picks.wmPick === null ? null : picks.wmPick.text, picks.umPick === null ? null : picks.umPick.text, keep(picks.wmPick) ? noteFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(picks.umPick) ? noteFor(picks.umPick, USER_MEMORY_FILENAME) : null, titlesFor(picks.wmPick, WORKSPACE_MEMORY_FILENAME), titlesFor(picks.umPick, USER_MEMORY_FILENAME));
27156
+ };
27157
+ const refine2 = (seed, withNotes2) => {
27158
+ const asReserve = (section) => section === null ? null : { leadIn: section.leadIn, text: "" };
27159
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
27160
+ const fixed = Buffer.byteLength(compose(workspaceMemory === null ? null : "", userMemory === null ? null : "", keep(seed.wmPick) ? noteFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME) : null, keep(seed.umPick) ? noteFor(seed.umPick, USER_MEMORY_FILENAME) : null, asReserve(titlesFor(seed.wmPick, WORKSPACE_MEMORY_FILENAME)), asReserve(titlesFor(seed.umPick, USER_MEMORY_FILENAME))).text);
27161
+ return allocate(Math.max(0, GROUNDING_BUDGET_BYTES - fixed));
27162
+ };
27163
+ const noNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(false, true)));
27164
+ const withNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(true, true)));
27165
+ const candidates = [
27166
+ { picks: withNotes, composed: build(withNotes, true) },
27167
+ { picks: noNotes, composed: build(noNotes, false) },
27168
+ { picks: withNotes, composed: build(withNotes, false) }
27169
+ ].concat([
27170
+ { seed: noNotes, notes: false },
27171
+ { seed: withNotes, notes: true }
27172
+ ].map(({ seed, notes }) => {
27173
+ const picks = refine2(seed, notes);
27174
+ return { picks, composed: build(picks, notes) };
27175
+ })).map((c) => ({
27176
+ ...c,
27177
+ bytes: Buffer.byteLength(c.composed.text),
27178
+ named: (c.picks.wmPick?.kept ?? 0) + (c.picks.wmPick?.named ?? 0) + (c.picks.umPick?.kept ?? 0) + (c.picks.umPick?.named ?? 0),
27179
+ whole: (c.picks.wmPick?.kept ?? 0) + (c.picks.umPick?.kept ?? 0)
27180
+ }));
27181
+ const fitting = candidates.filter((c) => c.bytes <= GROUNDING_BUDGET_BYTES);
27182
+ const better = (a, b) => b.named !== a.named ? b.named > a.named : b.whole > a.whole;
27183
+ const best = fitting.length > 0 ? fitting.reduce((a, b) => better(a, b) ? b : a) : candidates.reduce((a, b) => b.bytes < a.bytes ? b : a);
27184
+ return {
27185
+ block: best.composed.text,
27186
+ picks: best.picks,
27187
+ split: best.composed.split,
27188
+ groundingParagraphs: best.composed.groundingParagraphs,
27189
+ groundingRuleShaped: best.composed.groundingRuleShaped
27190
+ };
27003
27191
  }
27004
- function setWorkspaceMemoryState(state, entry) {
27005
- const existing = state.workspace_memory;
27006
- if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at) {
27007
- return { state, changed: false };
27008
- }
27009
- return { state: { ...state, workspace_memory: entry }, changed: true };
27192
+ var buildGroundingReportFor = buildGroundingReport;
27193
+ function formatLocalDate(d) {
27194
+ const year = d.getFullYear();
27195
+ const month = String(d.getMonth() + 1).padStart(2, "0");
27196
+ const day = String(d.getDate()).padStart(2, "0");
27197
+ return `${year}-${month}-${day}`;
27010
27198
  }
27011
- function getUserMemoryState(state) {
27012
- return state.user_memory;
27199
+
27200
+ // src/lib/hash.ts
27201
+ import { createHash } from "node:crypto";
27202
+ function stableHash(value) {
27203
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
27013
27204
  }
27014
- function setUserMemoryState(state, entry) {
27015
- const existing = state.user_memory;
27016
- if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at) {
27017
- return { state, changed: false };
27205
+ function stableStringify(value) {
27206
+ if (value === null || typeof value !== "object") {
27207
+ return JSON.stringify(value);
27018
27208
  }
27019
- return { state: { ...state, user_memory: entry }, changed: true };
27020
- }
27021
- function getUserVoiceState(state) {
27022
- return state.user_voice;
27023
- }
27024
- function setUserVoiceState(state, entry) {
27025
- const existing = state.user_voice;
27026
- if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.status === entry.status) {
27027
- return { state, changed: false };
27209
+ if (Array.isArray(value)) {
27210
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
27028
27211
  }
27029
- return { state: { ...state, user_voice: entry }, changed: true };
27212
+ const obj = value;
27213
+ const keys = Object.keys(obj).sort();
27214
+ return "{" + keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",") + "}";
27030
27215
  }
27031
- var ABSENT_FRESHNESS = {
27032
- workspaceMemory: null,
27033
- userMemory: null,
27034
- userVoice: null
27035
- };
27036
- function readMemoryFreshness(workspaceSlug) {
27037
- let json2;
27216
+
27217
+ // src/lib/context/roster.ts
27218
+ var import_gray_matter5 = __toESM(require_gray_matter(), 1);
27219
+ import { readdirSync as readdirSync3, readFileSync as readFileSync8 } from "node:fs";
27220
+ import { join as join7 } from "node:path";
27221
+ function readRoster(workspaceSlug) {
27222
+ let artistsDir;
27038
27223
  try {
27039
- json2 = JSON.parse(readFileSync8(stateFilePath(workspaceSlug), "utf8"));
27224
+ artistsDir = join7(workspaceMirrorPath(workspaceSlug), "artists");
27040
27225
  } catch {
27041
- return ABSENT_FRESHNESS;
27226
+ return [];
27042
27227
  }
27043
- if (json2 === null || typeof json2 !== "object")
27044
- return ABSENT_FRESHNESS;
27045
- const record2 = json2;
27046
- const workspace = WorkspaceMemoryStateSchema.safeParse(record2.workspace_memory);
27047
- const user = UserMemoryStateSchema.safeParse(record2.user_memory);
27048
- const voice = UserVoiceStateSchema.safeParse(record2.user_voice);
27049
- return {
27050
- workspaceMemory: workspace.success ? workspace.data : null,
27051
- userMemory: user.success ? user.data : null,
27052
- userVoice: voice.success ? voice.data : null
27053
- };
27054
- }
27055
- function setSizeAnchor(state, anchor) {
27056
- if (anchor.kind === "sync" && state.size_anchor !== undefined) {
27057
- return { state, changed: false };
27228
+ let slugs;
27229
+ try {
27230
+ slugs = readdirSync3(artistsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
27231
+ } catch {
27232
+ return [];
27058
27233
  }
27059
- const existing = state.size_anchor;
27060
- if (existing !== undefined && existing.kind === anchor.kind && existing.at === anchor.at && sizesEqual(existing.sizes, anchor.sizes)) {
27061
- return { state, changed: false };
27234
+ const roster = [];
27235
+ for (const slug of slugs) {
27236
+ let raw;
27237
+ try {
27238
+ raw = readFileSync8(join7(artistsDir, slug, "_artist.md"), "utf8");
27239
+ } catch {
27240
+ continue;
27241
+ }
27242
+ try {
27243
+ const fm = import_gray_matter5.default(raw).data;
27244
+ const name = typeof fm.name === "string" && fm.name.length > 0 ? fm.name : slug;
27245
+ const status = typeof fm.status === "string" ? fm.status : null;
27246
+ roster.push({ slug, name, status });
27247
+ } catch {
27248
+ roster.push({ slug, name: slug, status: null });
27249
+ }
27062
27250
  }
27063
- return { state: { ...state, size_anchor: anchor }, changed: true };
27251
+ return roster;
27064
27252
  }
27065
- function sizesEqual(a, b) {
27066
- const ak = Object.keys(a);
27067
- const bk = Object.keys(b);
27068
- if (ak.length !== bk.length)
27253
+ function rosterIsEmpty(workspaceSlug) {
27254
+ let artistsDir;
27255
+ try {
27256
+ artistsDir = join7(workspaceMirrorPath(workspaceSlug), "artists");
27257
+ } catch {
27069
27258
  return false;
27070
- return ak.every((k) => a[k] === b[k]);
27071
- }
27072
- function setLastTrimAt(state, at) {
27073
- if (state.last_trim_at === at)
27074
- return { state, changed: false };
27075
- return { state: { ...state, last_trim_at: at }, changed: true };
27259
+ }
27260
+ let entries;
27261
+ try {
27262
+ entries = readdirSync3(artistsDir, { withFileTypes: true });
27263
+ } catch (err) {
27264
+ return err?.code === "ENOENT";
27265
+ }
27266
+ for (const entry of entries) {
27267
+ if (!entry.isDirectory())
27268
+ continue;
27269
+ try {
27270
+ readFileSync8(join7(artistsDir, entry.name, "_artist.md"), "utf8");
27271
+ return false;
27272
+ } catch (err) {
27273
+ if (err?.code === "ENOENT")
27274
+ continue;
27275
+ return false;
27276
+ }
27277
+ }
27278
+ return true;
27076
27279
  }
27077
27280
 
27078
27281
  // src/lib/context/guiding-files.ts
@@ -27081,6 +27284,7 @@ var CLAUDE_LOCAL_MD = "CLAUDE.local.md";
27081
27284
  var ARTIST_CLAUDE_MD = "CLAUDE.md";
27082
27285
  var SETTINGS_REL = ".claude/settings.json";
27083
27286
  var ARTIST_SLUG_PLACEHOLDER = "<slug>";
27287
+ var MEMORY_FACT_SLUG_PLACEHOLDER = "<slug>";
27084
27288
  var artistFile = (name) => `artists/${ARTIST_SLUG_PLACEHOLDER}/${name}`;
27085
27289
  function withThousands(n) {
27086
27290
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
@@ -27129,12 +27333,26 @@ var GUIDING_FILES = [
27129
27333
  reach: "you only",
27130
27334
  bar: `${withThousands(RULE_FILE_BAR_BYTES)} bytes`
27131
27335
  },
27336
+ {
27337
+ path: `${MEMORY_FACTS_DIR}/${MEMORY_FACT_SLUG_PLACEHOLDER}.md`,
27338
+ writtenBy: "Plinth, from a memory fact; read-only, edits are set aside",
27339
+ readWhen: "read on demand; only its index line is injected",
27340
+ reach: "everyone",
27341
+ bar: NO_BAR
27342
+ },
27343
+ {
27344
+ path: `${MEMORY_FACTS_DIR}/${MEMORY_FACTS_PRIVATE_SEGMENT}/${MEMORY_FACT_SLUG_PLACEHOLDER}.md`,
27345
+ writtenBy: "Plinth, from one of your memory facts; read-only",
27346
+ readWhen: "read on demand; only its index line is injected",
27347
+ reach: "you only",
27348
+ bar: NO_BAR
27349
+ },
27132
27350
  {
27133
27351
  path: artistFile(ARTIST_ANCHOR_FILENAME),
27134
27352
  writtenBy: "you and the agent; syncs both ways",
27135
27353
  readWhen: "read on demand",
27136
27354
  reach: "everyone",
27137
- bar: NO_BAR
27355
+ bar: `${withThousands(CONTEXT_BLOCK_BAR_CHARS)} characters per block`
27138
27356
  },
27139
27357
  {
27140
27358
  path: artistFile(ARTIST_MEMORY_ANCHOR),
@@ -27335,17 +27553,20 @@ function assessGeneratedFileBars(workspaceSlug, deps = {}) {
27335
27553
  return out;
27336
27554
  }
27337
27555
  var OPENING_MAX_BYTES = 90;
27338
- function terminalSafeOpening(fact) {
27339
- const stripped = fact.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
27556
+ function terminalSafeText(text, maxBytes) {
27557
+ const stripped = text.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
27340
27558
  const folded = stripped.replace(/\s+/g, " ").trim();
27341
27559
  let out = "";
27342
27560
  for (const ch of folded) {
27343
- if (Buffer.byteLength(out + ch) > OPENING_MAX_BYTES)
27561
+ if (Buffer.byteLength(out + ch) > maxBytes)
27344
27562
  return `${out.trimEnd()}…`;
27345
27563
  out += ch;
27346
27564
  }
27347
27565
  return out;
27348
27566
  }
27567
+ function terminalSafeOpening(fact) {
27568
+ return terminalSafeText(fact, OPENING_MAX_BYTES);
27569
+ }
27349
27570
  function assessIndexLines(workspaceSlug, deps = {}) {
27350
27571
  const readContext = deps.readContext ?? readContextFile;
27351
27572
  const specs = [
@@ -27577,7 +27798,7 @@ function runGroundingReport(deps = {}) {
27577
27798
  return buildGroundingReportFor(formatLocalDate(now()), rules, memory, userMemory, {
27578
27799
  workspaceMemoryUpdatedAt: resolveUpdatedAt(rawMemory, freshness.workspaceMemory),
27579
27800
  userMemoryUpdatedAt: resolveUpdatedAt(rawUserMemory, freshness.userMemory)
27580
- }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug, member, filesPastBar);
27801
+ }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug, member, filesPastBar, workspaceSlug ? readMemoryStoreModes(workspaceSlug) : BLOB_MEMORY_STORES);
27581
27802
  }
27582
27803
  var groundingCommand = defineCommand({
27583
27804
  meta: {
@@ -28911,6 +29132,15 @@ function artistUuidBySlug(state) {
28911
29132
  }
28912
29133
  return map2;
28913
29134
  }
29135
+ function isOpenTask(fm) {
29136
+ if (fm.done === true)
29137
+ return false;
29138
+ const status = strOrNull(fm.status);
29139
+ return status !== "done" && status !== "archived";
29140
+ }
29141
+ function isActiveProject(fm) {
29142
+ return strOrNull(fm.status) !== "done";
29143
+ }
28914
29144
  function readArtistProfile(workspaceSlug, slug) {
28915
29145
  const fm = readEntityFrontmatter(workspaceSlug, "artists", slug) ?? {};
28916
29146
  return {
@@ -28940,7 +29170,7 @@ function activeProjectCountsBySlug(workspaceSlug, state) {
28940
29170
  const artistSlug = slugByArtistId.get(artistId);
28941
29171
  if (!artistSlug)
28942
29172
  continue;
28943
- if (strOrNull(fm.status) === "done")
29173
+ if (!isActiveProject(fm))
28944
29174
  continue;
28945
29175
  counts.set(artistSlug, (counts.get(artistSlug) ?? 0) + 1);
28946
29176
  }
@@ -28972,9 +29202,9 @@ function buildArtistEntityIndex(workspaceSlug, state) {
28972
29202
  const artistSlug = slugByArtistId.get(artistId);
28973
29203
  if (!artistSlug)
28974
29204
  continue;
28975
- const status = strOrNull(fm.status);
28976
- if (status === "done")
29205
+ if (!isActiveProject(fm))
28977
29206
  continue;
29207
+ const status = strOrNull(fm.status);
28978
29208
  bucketFor(artistSlug).projects.push({
28979
29209
  id: entry2.id,
28980
29210
  slug: entry2.slug,
@@ -28994,7 +29224,7 @@ function buildArtistEntityIndex(workspaceSlug, state) {
28994
29224
  const artistSlug = slugByArtistId.get(artistId);
28995
29225
  if (!artistSlug)
28996
29226
  continue;
28997
- if (fm.done === true)
29227
+ if (!isOpenTask(fm))
28998
29228
  continue;
28999
29229
  bucketFor(artistSlug).tasks.push({
29000
29230
  id: entry2.id,
@@ -29617,7 +29847,7 @@ function withUserBlock(lines) {
29617
29847
  `) + `
29618
29848
  `;
29619
29849
  }
29620
- function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29850
+ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false, stores = BLOB_MEMORY_STORES) {
29621
29851
  return withUserBlock([
29622
29852
  "---",
29623
29853
  "description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, reads the projected calendar and anything the sync set aside, gives a three-line brief, then asks what you want to work on.",
@@ -29651,6 +29881,10 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29651
29881
  "",
29652
29882
  "The SessionStart hook injects **both memory indexes**, above: the workspace index (durable facts and conventions that hold for anyone here) and your personal index (facts true of **this member**: how they work, what they prefer). They may arrive trimmed to their newest lines, and the injection says so where it happens. **Do not re-read `workspace-memory.md` or `user-memory.md` wholesale** - they are already in this context window and re-reading them is the single most expensive thing this command could do for nothing - but if the injection says an index was trimmed and you need what is missing, read that file.",
29653
29883
  "",
29884
+ ...anyGenerated(stores) ? [
29885
+ "**Each index line links to the fact's full text; open only the one you need.** A line reads `- [[memory/<slug>]] - <description>`, and the description is a hook rather than the fact. The body is at `memory/<slug>.md`, or `memory/private/<slug>.md` for one of your own. **Do not read the folder** - that is the whole corpus, which is what the index exists to spare you.",
29886
+ ""
29887
+ ] : [],
29654
29888
  "**Who you are working for is in the grounding above, not in an index.** The hook renders the member, their email and the workspace from the credentials this machine logged in with, so do not go looking for identity in either memory file and do not ask the user for it. **Use the personal index for what it is actually for**, which is the half that gets skipped: how this member works and what they prefer.",
29655
29889
  "",
29656
29890
  "If neither index appears above, the hook did not run - then, and only then, read the two files at the **mirror root**, the workspace folder holding `CLAUDE.md` (if this session started inside `artists/<slug>/`, that is the parent). Read them silently; do not dump either file into the conversation.",
@@ -29710,7 +29944,7 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29710
29944
  `When the session is finished, \`${CLOSE_COMMAND_INVOCATION}\` is the other half of this one: it banks what was learned before the context is gone.`
29711
29945
  ]);
29712
29946
  }
29713
- function buildCloseCommandMd(workspaceSlug) {
29947
+ function buildCloseCommandMd(workspaceSlug, stores = BLOB_MEMORY_STORES) {
29714
29948
  const ws = `--workspace ${workspaceSlug}`;
29715
29949
  return withUserBlock([
29716
29950
  "---",
@@ -29754,7 +29988,7 @@ function buildCloseCommandMd(workspaceSlug) {
29754
29988
  "Each loose end goes to the surface that holds it, and nowhere else:",
29755
29989
  "",
29756
29990
  "- **Something to do** goes to a Plinth task, scoped to its artist or project, with an owner and a real due date where one exists. Fold it into the existing umbrella task rather than adding a second.",
29757
- "- **State or context that changed** goes to the artist's master context, the body of `artists/<slug>/_artist.md`, or to the project's summary. Prose goes through the mirror file.",
29991
+ "- **State or context that changed** goes to the artist's master context, the body of `artists/<slug>/_artist.md`, or to the project's summary. Prose goes through the mirror file. **If the `plinth status` from Step 2 named that artist's `_artist.md` on a `Walls`, `Played` or `As of` row, act on it while you are in the file**: split a wall into one short block per workstream, move what has played out from under its upcoming label, and re-date the current-state heading only once you have reconciled it.",
29758
29992
  "- **What happened this session** goes to the artist's own record: a dated section in the body of `artists/<slug>/_artist.md`, one per artist touched. Prose, the way a hub carries it - what was discussed, what was decided, what was done, what is still open. **This is the half a teammate can open.** That body syncs to the artist's Plinth record and the app renders it in the Knowledge Base context pane, so a session banked here is one somebody else can pick up cold. **It is a record of a session, not a statement of where things stand**: current state belongs in the prose above it, edited in place, which is the bullet before this one. A log that accumulates live state rots, and that is the failure this step was moved to escape.",
29759
29993
  "",
29760
29994
  "**Where the record goes in the file, and it is not the top.** One `## Session log` heading at the **bottom** of the body, newest section first beneath it. The pane renders the whole body, so the master context has to read first and the log sits under it. If the file carries a `<!-- BEGIN: contacts -->` block, the log goes **above** that marker: everything between the contacts markers is rendered from Plinth and is overwritten on the next sync.",
@@ -29812,6 +30046,10 @@ function buildCloseCommandMd(workspaceSlug) {
29812
30046
  "",
29813
30047
  "That artist's rate card, a contact of theirs whose behaviour you had to learn, how their kit actually works, what a venue reliably does. It syncs to that artist's Plinth record, so a teammate opening that artist gets it. **List what you wrote here in the closing summary.**",
29814
30048
  "",
30049
+ ...anyGenerated(stores) ? [
30050
+ "**Some artist stores are generated and some are not, so check before you write.** If `artists/<slug>/memory.md` opens with a `- [[memory/<slug>]] - <description>` list, it is generated from that artist's memory facts and a hand edit will not reach Plinth. Use `create_memory_fact` naming the artist; it shows you the fact first and needs a second call to save it, which is the same ask-first gate as the shared pile because an artist fact is a shared fact. If it reads as ordinary prose, write it directly as above.",
30051
+ ""
30052
+ ] : [],
29815
30053
  "**Follow the conventions in the file's own header comment**, which states them and is the version that stays current. Do not work from a copy of them here. **The source convention and the conflict order above are the exception**: they reach a header only on a file this command creates, so they hold wherever an existing header is silent.",
29816
30054
  "",
29817
30055
  "**Re-read the file at the moment you write it, not when you were planning.** The daemon can pull a `memory.md` down from Plinth at any point in a session, so a file that was missing minutes ago may exist now - and if it does, add your line to it rather than creating it over the top. This is not hypothetical: a close on 26 Aug 2026 checked, found nothing, and clobbered a copy the daemon had pulled in ninety seconds earlier.",
@@ -29834,10 +30072,17 @@ function buildCloseCommandMd(workspaceSlug) {
29834
30072
  "",
29835
30073
  "**Live state does not go here.** This week's open threads, one show's logistics, where a release has got to: that is Step 3's business, and it goes in the body of `artists/<slug>/_artist.md`. This file is for what stays true after the session that taught it.",
29836
30074
  "",
29837
- "#### True of this member: `user-memory.md` at the mirror root. Write it directly.",
29838
- "",
29839
- "Facts true of this member: what they prefer, how they work, their own identifiers. **List what you wrote in the closing summary**, so it is visible without opening the file.",
29840
- "",
30075
+ ...stores.user ? [
30076
+ "#### True of this member: `create_user_memory_fact`. The file is output.",
30077
+ "",
30078
+ "Facts true of this member: what they prefer, how they work, their own identifiers. **`user-memory.md` is GENERATED from this member's memory facts, so do not write it** - a hand edit does not reach Plinth, is set aside, and `plinth status` says where. Create the fact with `create_user_memory_fact`, which writes on one call because this pile is theirs alone; the index and the body file are rewritten from the facts on the next sync. Correct one with `supersede_user_memory_fact` or `delete_user_memory_fact`, taking the id from the fact file's frontmatter. **List what you wrote in the closing summary**, so it is visible without opening the file.",
30079
+ ""
30080
+ ] : [
30081
+ "#### True of this member: `user-memory.md` at the mirror root. Write it directly.",
30082
+ "",
30083
+ "Facts true of this member: what they prefer, how they work, their own identifiers. **List what you wrote in the closing summary**, so it is visible without opening the file.",
30084
+ ""
30085
+ ],
29841
30086
  "**How they WRITE does not go here, and it is not a fact.** Their register, greetings, sign-off and the things they never do are a rubric, and they live in `voice.md` at the mirror root - a file the member owns and you are denied from editing, because the profile you are judged against is not yours to rewrite. So do not bank a voice rule in this index or any other. Say what you noticed in the closing summary and leave it for them to put in their own profile.",
29842
30087
  "",
29843
30088
  "If the file is not there yet, create it the same way as the artist store: a one-line title, the same conventions in a comment, and a real fact in the same edit. **Check what the workspace already says before you write a personal line** - a convention that is already in `workspace-rules.md` or the workspace's own house-style file is in force for everyone, and copying it here duplicates a live rule into a second injected surface instead of adding anything.",
@@ -30087,9 +30332,10 @@ function ensureWorkspaceCommands(deps) {
30087
30332
  } catch {
30088
30333
  offerTrim = false;
30089
30334
  }
30335
+ const stores = (deps.readMemoryStores ?? readMemoryStoreModes)(workspaceSlug);
30090
30336
  const bodies = {
30091
- [START_COMMAND_REL]: buildStartCommandMd(noArtists, offerTrim),
30092
- [CLOSE_COMMAND_REL]: buildCloseCommandMd(workspaceSlug),
30337
+ [START_COMMAND_REL]: buildStartCommandMd(noArtists, offerTrim, stores),
30338
+ [CLOSE_COMMAND_REL]: buildCloseCommandMd(workspaceSlug, stores),
30093
30339
  [ONBOARD_COMMAND_REL]: buildOnboardCommandMd(workspaceSlug)
30094
30340
  };
30095
30341
  for (const rel of GENERATED_COMMAND_RELS) {
@@ -30831,6 +31077,37 @@ function clearMemorySecretHeld(state, workspaceSlug, absolutePath) {
30831
31077
  return state;
30832
31078
  return commitState(state, { ...state, rejected: next });
30833
31079
  }
31080
+ var MEMORY_GENERATED_REJECT_REASON = "memory_generated";
31081
+ function recordMemoryGeneratedRefusal(args) {
31082
+ let path3;
31083
+ try {
31084
+ path3 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
31085
+ } catch {
31086
+ path3 = args.absolutePath;
31087
+ }
31088
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
31089
+ return persist(withoutStale, upsertRejected(withoutStale, {
31090
+ target: "entity",
31091
+ op: "upsert",
31092
+ path: path3,
31093
+ reason: MEMORY_GENERATED_REJECT_REASON,
31094
+ message: args.record,
31095
+ rejected_at: args.now().toISOString()
31096
+ }));
31097
+ }
31098
+ function clearMemoryGeneratedRefusal(state, workspaceSlug, absolutePath) {
31099
+ let path3;
31100
+ try {
31101
+ path3 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
31102
+ } catch {
31103
+ path3 = absolutePath;
31104
+ }
31105
+ const list = state.rejected ?? [];
31106
+ const next = list.filter((o) => !(o.target === "entity" && o.path === path3 && o.reason === MEMORY_GENERATED_REJECT_REASON));
31107
+ if (next.length === list.length)
31108
+ return state;
31109
+ return commitState(state, { ...state, rejected: next });
31110
+ }
30834
31111
 
30835
31112
  // src/lib/context/workspace-claude-md.ts
30836
31113
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -33957,6 +34234,349 @@ function classifyFilesPath(args) {
33957
34234
  return validateFilesRelPath(posix) ? { relPath: posix } : null;
33958
34235
  }
33959
34236
 
34237
+ // src/lib/memory-facts/sync.ts
34238
+ import { isAbsolute as isAbsolute2, relative as relative6, sep as sep4 } from "node:path";
34239
+
34240
+ // src/lib/memory-facts/api.ts
34241
+ var FactCommonSchema = exports_external.object({
34242
+ id: exports_external.string(),
34243
+ slug: exports_external.string(),
34244
+ description: exports_external.string(),
34245
+ body_md: exports_external.string(),
34246
+ fact_type: exports_external.string(),
34247
+ status: exports_external.string(),
34248
+ superseded_by: exports_external.string().nullish(),
34249
+ generality: exports_external.string().nullish(),
34250
+ source: exports_external.string().nullish(),
34251
+ created_at: exports_external.string().nullish(),
34252
+ updated_at: exports_external.string().nullish()
34253
+ });
34254
+ var WorkspaceFactWireSchema = FactCommonSchema.extend({
34255
+ artist_id: exports_external.string().nullish(),
34256
+ revision: exports_external.number().nullish()
34257
+ });
34258
+ var UserFactWireSchema = FactCommonSchema;
34259
+ var MemoryFactsFeedSchema = exports_external.object({
34260
+ workspace_facts: exports_external.array(WorkspaceFactWireSchema),
34261
+ user_facts: exports_external.array(UserFactWireSchema)
34262
+ });
34263
+ var ROUTE = "/api/sync/memory-facts";
34264
+ function baseHeaders2(pat) {
34265
+ return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
34266
+ }
34267
+ async function getMemoryFacts(args) {
34268
+ const { baseUrl, pat, fetchImpl = fetch } = args;
34269
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
34270
+ let res;
34271
+ try {
34272
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders2(pat) });
34273
+ } catch (err) {
34274
+ const msg = err instanceof Error ? err.message : "network error";
34275
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
34276
+ }
34277
+ if (res.status === 401) {
34278
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
34279
+ }
34280
+ if (res.status === 429) {
34281
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
34282
+ }
34283
+ if (!res.ok) {
34284
+ throw new ApiError(res.status, `sync memory-facts failed with HTTP ${res.status}`);
34285
+ }
34286
+ const body = await res.json();
34287
+ const parsed = MemoryFactsFeedSchema.safeParse(body);
34288
+ if (!parsed.success) {
34289
+ throw new ApiError(res.status, `sync memory-facts response did not match the expected shape: ${parsed.error.message}`);
34290
+ }
34291
+ return parsed.data;
34292
+ }
34293
+
34294
+ // src/lib/memory-facts/refusal.ts
34295
+ var MEMORY_IS_GENERATED_CODE = "memory_is_generated";
34296
+ var GeneratedRefusalSchema = exports_external.object({
34297
+ error: exports_external.string(),
34298
+ code: exports_external.literal(MEMORY_IS_GENERATED_CODE)
34299
+ });
34300
+ function factToolsFor(pile) {
34301
+ return pile === "private" ? "`create_user_memory_fact`, `supersede_user_memory_fact` and `delete_user_memory_fact`" : "`create_memory_fact`, `supersede_memory_fact` and `delete_memory_fact`";
34302
+ }
34303
+ function generatedMemoryRefusalRecord(args) {
34304
+ const which = args.factId ? ` (fact \`${args.factId}\`)` : "";
34305
+ return `\`${args.relPath}\`${which} is generated from memory facts, so your edit was ` + `not sent to Plinth. It is kept at ${args.stashPath}. To make the change, ` + `use ${factToolsFor(args.pile)} - the file is rewritten from the facts on ` + `the next sync.`;
34306
+ }
34307
+ var MEMORY_FACT_CONFLICT_ENTITY = "memory";
34308
+ function isGeneratedMemoryStash(label, deps) {
34309
+ if (label.startsWith(`${MEMORY_FACT_CONFLICT_ENTITY}/`)) {
34310
+ const tail = label.slice(MEMORY_FACT_CONFLICT_ENTITY.length + 1);
34311
+ if (tail.startsWith("private."))
34312
+ return "private";
34313
+ if (tail.startsWith("shared."))
34314
+ return "shared";
34315
+ return null;
34316
+ }
34317
+ if (label === "workspace-memory/workspace-memory") {
34318
+ return deps.workspaceMemoryGenerated() ? "shared" : null;
34319
+ }
34320
+ if (label === "user-memory/user-memory") {
34321
+ return deps.userMemoryGenerated() ? "private" : null;
34322
+ }
34323
+ const artist = label.match(/^artists\/(.+)\.memory$/);
34324
+ if (artist) {
34325
+ return deps.artistMemoryGenerated(artist[1]) ? "shared" : null;
34326
+ }
34327
+ return null;
34328
+ }
34329
+
34330
+ // src/lib/memory-facts/refuse.ts
34331
+ function refuseGeneratedMemoryEdit(args) {
34332
+ const stashPath = stashConflict({
34333
+ workspaceSlug: args.workspaceSlug,
34334
+ entityType: args.conflictEntity,
34335
+ entitySlug: args.conflictSlug,
34336
+ timestamp: args.now(),
34337
+ contents: args.localContent,
34338
+ side: "local"
34339
+ });
34340
+ args.writeCanonical(args.canonicalBody);
34341
+ const record2 = generatedMemoryRefusalRecord({
34342
+ relPath: args.relPath,
34343
+ pile: args.pile,
34344
+ stashPath,
34345
+ factId: args.factId
34346
+ });
34347
+ args.output?.warn(record2);
34348
+ args.output?.log(`[MEMORY_GENERATED_REFUSED ws=${args.workspaceSlug} path=${args.relPath} stashed]`);
34349
+ const state = recordMemoryGeneratedRefusal({
34350
+ state: args.state,
34351
+ workspaceSlug: args.workspaceSlug,
34352
+ absolutePath: args.absolutePath,
34353
+ record: record2,
34354
+ now: args.now
34355
+ });
34356
+ return { state, stashPath };
34357
+ }
34358
+
34359
+ // src/lib/memory-facts/render.ts
34360
+ function yamlString(value) {
34361
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t").replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
34362
+ return `"${escaped}"`;
34363
+ }
34364
+ var SOURCE_UNKNOWN = "unknown";
34365
+ function toRenderableWorkspaceFact(row, artistSlugById) {
34366
+ const artistId = row.artist_id ?? null;
34367
+ return {
34368
+ pile: "shared",
34369
+ id: row.id,
34370
+ slug: row.slug,
34371
+ description: row.description,
34372
+ fact_type: row.fact_type,
34373
+ status: row.status,
34374
+ source: row.source,
34375
+ superseded_by: row.superseded_by,
34376
+ generality: row.generality,
34377
+ body_md: row.body_md,
34378
+ artist_id: artistId,
34379
+ artist_slug: artistId ? artistSlugById(artistId) : undefined
34380
+ };
34381
+ }
34382
+ function toRenderableUserFact(row) {
34383
+ return {
34384
+ pile: "private",
34385
+ id: row.id,
34386
+ slug: row.slug,
34387
+ description: row.description,
34388
+ fact_type: row.fact_type,
34389
+ status: row.status,
34390
+ source: row.source,
34391
+ superseded_by: row.superseded_by,
34392
+ generality: row.generality,
34393
+ body_md: row.body_md
34394
+ };
34395
+ }
34396
+ function renderFactFile(fact) {
34397
+ const lines = ["---"];
34398
+ lines.push(`id: ${yamlString(fact.id)}`);
34399
+ lines.push(`name: ${yamlString(fact.slug)}`);
34400
+ lines.push(`description: ${yamlString(fact.description)}`);
34401
+ lines.push(`type: ${yamlString(fact.fact_type)}`);
34402
+ lines.push(`status: ${yamlString(fact.status)}`);
34403
+ lines.push(`source: ${yamlString(fact.source ?? SOURCE_UNKNOWN)}`);
34404
+ if (fact.generality)
34405
+ lines.push(`generality: ${yamlString(fact.generality)}`);
34406
+ if (fact.artist_id)
34407
+ lines.push(`artist_id: ${yamlString(fact.artist_id)}`);
34408
+ if (fact.artist_slug)
34409
+ lines.push(`artist: ${yamlString(fact.artist_slug)}`);
34410
+ if (fact.superseded_by) {
34411
+ lines.push(`superseded_by: ${yamlString(fact.superseded_by)}`);
34412
+ }
34413
+ lines.push("---");
34414
+ lines.push("");
34415
+ lines.push(...headerComment(fact));
34416
+ lines.push("");
34417
+ const body = fact.body_md.replace(/\s+$/, "");
34418
+ if (body.length > 0)
34419
+ lines.push(body);
34420
+ return lines.join(`
34421
+ `).replace(/\n*$/, "") + `
34422
+ `;
34423
+ }
34424
+ function headerComment(fact) {
34425
+ const rel = memoryFactRelPath(fact.pile, fact.slug);
34426
+ const out = [
34427
+ "<!-- GENERATED by Plinth from a memory fact. Edits here are not saved:",
34428
+ ` the next sync rewrites this file. To change this fact, use`,
34429
+ ` ${factToolsFor(fact.pile)}`,
34430
+ ` with id ${fact.id}. This file is ${rel}.`
34431
+ ];
34432
+ if (fact.status !== "active") {
34433
+ out.push(` STATUS ${fact.status.toUpperCase()}: this fact is no longer current and is not in any index.`);
34434
+ }
34435
+ out.push("-->");
34436
+ return out;
34437
+ }
34438
+
34439
+ // src/lib/memory-facts/sync.ts
34440
+ function artistSlugLookup(state) {
34441
+ const entries = state.entries["artists"] ?? {};
34442
+ const byId = new Map;
34443
+ for (const entry2 of Object.values(entries))
34444
+ byId.set(entry2.id, entry2.slug);
34445
+ return (id) => byId.get(id);
34446
+ }
34447
+ async function syncMemoryFacts(args) {
34448
+ const { workspaceSlug, baseUrl, pat, fetchImpl, output, suppressor } = args;
34449
+ const now = args.now ?? (() => new Date);
34450
+ let state = args.state;
34451
+ let feed;
34452
+ try {
34453
+ feed = await getMemoryFacts({ baseUrl, pat, fetchImpl });
34454
+ } catch (err) {
34455
+ if (err instanceof ApiError && err.status === 404) {
34456
+ output?.log(`[MEMORY_FACTS ws=${workspaceSlug} skipped reason=route_absent]`);
34457
+ return { state, written: 0, deleted: 0, refused: 0, facts: 0 };
34458
+ }
34459
+ throw err;
34460
+ }
34461
+ const slugFor = artistSlugLookup(state);
34462
+ const facts = [
34463
+ ...feed.workspace_facts.map((row) => toRenderableWorkspaceFact(row, slugFor)),
34464
+ ...feed.user_facts.map(toRenderableUserFact)
34465
+ ];
34466
+ const acked = getMemoryFactEntries(state);
34467
+ const registry3 = {};
34468
+ const live = new Set;
34469
+ let written = 0;
34470
+ let refused = 0;
34471
+ for (const fact of facts) {
34472
+ let relPath;
34473
+ try {
34474
+ relPath = memoryFactRelPath(fact.pile, fact.slug);
34475
+ } catch {
34476
+ output?.warn(`Memory fact "${fact.slug}" has a slug this mirror cannot write - skipped.`);
34477
+ continue;
34478
+ }
34479
+ live.add(relPath);
34480
+ const rendered = renderFactFile(fact);
34481
+ const renderedHash = stableHash(rendered);
34482
+ const onDisk = readMemoryFactFile(workspaceSlug, fact.pile, fact.slug);
34483
+ const ackedHash = acked[relPath]?.body_hash;
34484
+ if (onDisk !== null && stableHash(onDisk) === renderedHash) {
34485
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, memoryFactFilePath(workspaceSlug, fact.pile, fact.slug));
34486
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34487
+ continue;
34488
+ }
34489
+ if (onDisk !== null && stableHash(onDisk) !== ackedHash) {
34490
+ const refusal = refuseGeneratedMemoryEdit({
34491
+ state,
34492
+ workspaceSlug,
34493
+ absolutePath: memoryFactFilePath(workspaceSlug, fact.pile, fact.slug),
34494
+ relPath,
34495
+ pile: fact.pile,
34496
+ conflictEntity: "memory",
34497
+ conflictSlug: `${fact.pile}.${fact.slug}`,
34498
+ localContent: onDisk,
34499
+ canonicalBody: rendered,
34500
+ writeCanonical: (body) => writeFact(args, fact.pile, fact.slug, body),
34501
+ factId: fact.id,
34502
+ now,
34503
+ output
34504
+ });
34505
+ state = refusal.state;
34506
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34507
+ refused++;
34508
+ continue;
34509
+ }
34510
+ writeFact(args, fact.pile, fact.slug, rendered);
34511
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34512
+ written++;
34513
+ }
34514
+ let deleted = 0;
34515
+ for (const { pile, slug } of listMemoryFactFiles(workspaceSlug)) {
34516
+ const relPath = memoryFactRelPath(pile, slug);
34517
+ if (live.has(relPath))
34518
+ continue;
34519
+ if (acked[relPath] === undefined)
34520
+ continue;
34521
+ const abs = memoryFactFilePath(workspaceSlug, pile, slug);
34522
+ suppressor?.registerDelete(abs);
34523
+ if (deleteMemoryFactFile(workspaceSlug, pile, slug))
34524
+ deleted++;
34525
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, abs);
34526
+ }
34527
+ const { state: next, changed } = setMemoryFactsState(state, {
34528
+ fetched_at: now().toISOString(),
34529
+ facts: registry3
34530
+ });
34531
+ state = changed ? commitState(state, next) : state;
34532
+ if (written > 0 || deleted > 0 || refused > 0) {
34533
+ output?.log(`[MEMORY_FACTS ws=${workspaceSlug} facts=${facts.length} written=${written} deleted=${deleted} refused=${refused}]`);
34534
+ }
34535
+ return { state, written, deleted, refused, facts: facts.length };
34536
+ }
34537
+ function writeFact(args, pile, slug, content) {
34538
+ const abs = memoryFactFilePath(args.workspaceSlug, pile, slug);
34539
+ args.suppressor?.registerWrite(abs, {
34540
+ bodyHash: stableHash(content),
34541
+ frontmatterHash: ""
34542
+ });
34543
+ writeMemoryFactFile(args.workspaceSlug, pile, slug, content);
34544
+ }
34545
+ function classifyMemoryFactPath(args) {
34546
+ let root;
34547
+ try {
34548
+ root = memoryFactsSubtreeRoot(args.workspaceSlug);
34549
+ } catch {
34550
+ return null;
34551
+ }
34552
+ const rel = relative6(root, args.absolutePath);
34553
+ if (rel === "" || rel.startsWith("..") || isAbsolute2(rel))
34554
+ return null;
34555
+ const mirrorRel = `${MEMORY_FACTS_DIR}/${rel.split(sep4).join("/")}`;
34556
+ return { relPath: mirrorRel, fact: parseMemoryFactRelPath(mirrorRel) };
34557
+ }
34558
+ function classifyMemoryFactEvent(args) {
34559
+ if (args.path === null)
34560
+ return { kind: "ignore" };
34561
+ if (args.ownEcho)
34562
+ return { kind: "echo" };
34563
+ if (args.kind !== "upsert")
34564
+ return { kind: "ignore" };
34565
+ if (args.path.fact !== null && args.known) {
34566
+ return { kind: "generated", relPath: args.path.relPath, pile: args.path.fact.pile };
34567
+ }
34568
+ return { kind: "unowned", relPath: args.path.relPath };
34569
+ }
34570
+ function memoryFactEventWarning(v) {
34571
+ if (v.kind === "generated") {
34572
+ return `${v.relPath} is generated from a memory fact - your edit will be replaced ` + `on the next sync and kept in your conflicts folder. Change the fact with ` + `${factToolsFor(v.pile)}.`;
34573
+ }
34574
+ if (v.kind === "unowned") {
34575
+ return `${v.relPath} is not synced: \`memory/\` holds files Plinth generates from ` + `memory facts. Put your own notes in \`files/\` instead.`;
34576
+ }
34577
+ return null;
34578
+ }
34579
+
33960
34580
  // src/lib/files/destructive-gate.ts
33961
34581
  function isFileGutting(priorSize, newSize) {
33962
34582
  if (priorSize === undefined)
@@ -34091,7 +34711,7 @@ class FileDestructiveGate {
34091
34711
  import { readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
34092
34712
 
34093
34713
  // src/lib/unsynced.ts
34094
- import { join as join12, relative as relative6 } from "node:path";
34714
+ import { join as join12, relative as relative7 } from "node:path";
34095
34715
  var RECORDABLE_REASONS = new Set([
34096
34716
  "rate_limit",
34097
34717
  "server",
@@ -34111,7 +34731,7 @@ function recordEntityUnsynced(args) {
34111
34731
  const cat = categorizeError(args.err);
34112
34732
  let path5;
34113
34733
  try {
34114
- path5 = relative6(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
34734
+ path5 = relative7(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
34115
34735
  } catch {
34116
34736
  path5 = args.absolutePath;
34117
34737
  }
@@ -35210,11 +35830,11 @@ function createPattern(matcher) {
35210
35830
  if (matcher.path === string4)
35211
35831
  return true;
35212
35832
  if (matcher.recursive) {
35213
- const relative9 = sp2.relative(matcher.path, string4);
35214
- if (!relative9) {
35833
+ const relative10 = sp2.relative(matcher.path, string4);
35834
+ if (!relative10) {
35215
35835
  return false;
35216
35836
  }
35217
- return !relative9.startsWith("..") && !sp2.isAbsolute(relative9);
35837
+ return !relative10.startsWith("..") && !sp2.isAbsolute(relative10);
35218
35838
  }
35219
35839
  return false;
35220
35840
  };
@@ -35844,7 +36464,7 @@ var chokidar_default = { watch, FSWatcher };
35844
36464
 
35845
36465
  // src/lib/watcher.ts
35846
36466
  import { statSync as statSync9 } from "node:fs";
35847
- import { sep as sep4 } from "node:path";
36467
+ import { sep as sep5 } from "node:path";
35848
36468
 
35849
36469
  // src/lib/rename-classifier.ts
35850
36470
  class RenameClassifier {
@@ -35975,7 +36595,7 @@ function watcherIgnoreMatcher(workspaceRoot) {
35975
36595
  return (absolutePath) => patterns.some((re) => re.test(absolutePath));
35976
36596
  }
35977
36597
  function escSep() {
35978
- return sep4 === "/" ? "/" : "\\\\";
36598
+ return sep5 === "/" ? "/" : "\\\\";
35979
36599
  }
35980
36600
 
35981
36601
  class DebounceMap {
@@ -36326,7 +36946,7 @@ async function reconcileFilesSubtree(args) {
36326
36946
  }
36327
36947
 
36328
36948
  // src/lib/artist-memory/sync.ts
36329
- import { relative as relative9 } from "node:path";
36949
+ import { relative as relative10 } from "node:path";
36330
36950
 
36331
36951
  // src/lib/context/secret-gate.ts
36332
36952
  function applySecretGate(args) {
@@ -36367,6 +36987,10 @@ var ENTITY_TYPE = "artists";
36367
36987
  function conflictSlug(slug) {
36368
36988
  return `${slug}.memory`;
36369
36989
  }
36990
+ var GENERATED_PILE = "shared";
36991
+ function memoryRelPath(slug) {
36992
+ return `artists/${slug}/${ARTIST_MEMORY_ANCHOR}`;
36993
+ }
36370
36994
  function persistEntry2(state, entry2) {
36371
36995
  const { state: next, changed } = upsertEntry(state, ENTITY_TYPE, entry2);
36372
36996
  if (!changed)
@@ -36393,7 +37017,7 @@ function classifyArtistMemoryPath(args) {
36393
37017
  } catch {
36394
37018
  return null;
36395
37019
  }
36396
- const rel = relative9(root, args.absolutePath);
37020
+ const rel = relative10(root, args.absolutePath);
36397
37021
  const parts = rel.split("/");
36398
37022
  if (parts.length !== 3)
36399
37023
  return null;
@@ -36415,25 +37039,55 @@ function classifyArtistMemoryPath(args) {
36415
37039
  }
36416
37040
  function applyArtistMemoryOnPull(args) {
36417
37041
  const { workspaceSlug, live, ackedMemoryHash, suppressor, now, out } = args;
37042
+ let state = args.state;
36418
37043
  const slug = live.slug;
36419
37044
  const cloudMemory = live.memory;
37045
+ const generated = live.memory_generated === true;
36420
37046
  const local = readArtistMemoryFile(workspaceSlug, slug);
37047
+ const refuse = (localContent, canonical) => {
37048
+ const refused = refuseGeneratedMemoryEdit({
37049
+ state,
37050
+ workspaceSlug,
37051
+ absolutePath: artistMemoryFilePath(workspaceSlug, slug),
37052
+ relPath: memoryRelPath(slug),
37053
+ pile: GENERATED_PILE,
37054
+ conflictEntity: ENTITY_TYPE,
37055
+ conflictSlug: conflictSlug(slug),
37056
+ localContent,
37057
+ canonicalBody: canonical,
37058
+ writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
37059
+ now,
37060
+ output: out
37061
+ });
37062
+ state = refused.state;
37063
+ };
36421
37064
  if (cloudMemory === null) {
36422
37065
  if (local !== null && isPristineMemoryStub(local)) {
36423
37066
  const abs = artistMemoryFilePath(workspaceSlug, slug);
36424
37067
  suppressor?.registerDelete(abs);
36425
37068
  deleteArtistMemoryFile(workspaceSlug, slug);
36426
- return { memoryHash: undefined };
37069
+ return { memoryHash: undefined, state };
36427
37070
  }
36428
- return { memoryHash: ackedMemoryHash };
37071
+ return { memoryHash: ackedMemoryHash, state };
36429
37072
  }
36430
37073
  const cloudHash = stableHash(cloudMemory);
36431
37074
  if (cloudHash === ackedMemoryHash && local !== null) {
36432
- return { memoryHash: ackedMemoryHash };
37075
+ if (generated && stableHash(local) !== ackedMemoryHash) {
37076
+ refuse(local, cloudMemory);
37077
+ return { memoryHash: cloudHash, state };
37078
+ }
37079
+ if (generated) {
37080
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, artistMemoryFilePath(workspaceSlug, slug));
37081
+ }
37082
+ return { memoryHash: ackedMemoryHash, state };
36433
37083
  }
36434
37084
  if (local !== null) {
36435
37085
  const localHash = stableHash(local);
36436
37086
  if (localHash !== ackedMemoryHash && localHash !== cloudHash) {
37087
+ if (generated) {
37088
+ refuse(local, cloudMemory);
37089
+ return { memoryHash: cloudHash, state };
37090
+ }
36437
37091
  const stashPath = stashConflict({
36438
37092
  workspaceSlug,
36439
37093
  entityType: ENTITY_TYPE,
@@ -36446,7 +37100,7 @@ function applyArtistMemoryOnPull(args) {
36446
37100
  }
36447
37101
  }
36448
37102
  writeMemory(workspaceSlug, slug, cloudMemory, suppressor);
36449
- return { memoryHash: cloudHash };
37103
+ return { memoryHash: cloudHash, state };
36450
37104
  }
36451
37105
  function writeMemory(workspaceSlug, slug, content, suppressor) {
36452
37106
  const abs = artistMemoryFilePath(workspaceSlug, slug);
@@ -36489,8 +37143,46 @@ async function pushArtistMemory(args) {
36489
37143
  if (gate.held)
36490
37144
  return { state: base2, outcome: { kind: "secret_held", slug } };
36491
37145
  if (alreadyAcked) {
37146
+ if (entry2.memory_generated === true) {
37147
+ base2 = clearMemoryGeneratedRefusal(base2, workspaceSlug, artistMemoryFilePath(workspaceSlug, slug));
37148
+ }
36492
37149
  return { state: base2, outcome: { kind: "noop", slug } };
36493
37150
  }
37151
+ if (entry2.memory_generated === true) {
37152
+ const row = await getEntity({
37153
+ strategy: artistsStrategy,
37154
+ baseUrl,
37155
+ pat,
37156
+ id: entry2.id,
37157
+ fetchImpl
37158
+ });
37159
+ const canonical = row.memory ?? "";
37160
+ const refused = refuseGeneratedMemoryEdit({
37161
+ state: base2,
37162
+ workspaceSlug,
37163
+ absolutePath: artistMemoryFilePath(workspaceSlug, slug),
37164
+ relPath: memoryRelPath(slug),
37165
+ pile: GENERATED_PILE,
37166
+ conflictEntity: ENTITY_TYPE,
37167
+ conflictSlug: conflictSlug(slug),
37168
+ localContent: local,
37169
+ canonicalBody: canonical,
37170
+ writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
37171
+ now,
37172
+ output
37173
+ });
37174
+ const stamped = {
37175
+ ...entry2,
37176
+ updated_at: row.updated_at,
37177
+ revision: row.revision,
37178
+ memory_hash: stableHash(canonical),
37179
+ memory_generated: row.memory_generated
37180
+ };
37181
+ return {
37182
+ state: persistEntry2(refused.state, stamped),
37183
+ outcome: { kind: "generated_refused", slug, stashPath: refused.stashPath }
37184
+ };
37185
+ }
36494
37186
  const acked = entry2.memory_hash;
36495
37187
  let cas = entry2.updated_at;
36496
37188
  const landed = (doc2, note = "") => {
@@ -36998,16 +37690,7 @@ async function fetchCalendar(args) {
36998
37690
  return parsed.data;
36999
37691
  }
37000
37692
 
37001
- // src/lib/calendar/render.ts
37002
- var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
37003
- var OWNERSHIP_NOTE2 = [
37004
- "<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
37005
- " is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
37006
- " Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
37007
- " person - not from this file and not by an agent. -->"
37008
- ].join(`
37009
- `);
37010
- var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
37693
+ // src/lib/calendar/day.ts
37011
37694
  var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
37012
37695
  var MONTHS = [
37013
37696
  "Jan",
@@ -37024,32 +37707,12 @@ var MONTHS = [
37024
37707
  "Dec"
37025
37708
  ];
37026
37709
  var DAY_MS = 86400000;
37027
- var FULL_BAND_DAYS = 14;
37028
- function yamlString(value) {
37029
- return JSON.stringify(value);
37030
- }
37031
- function oneLine2(value, maxLength = 200) {
37032
- const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
37033
- if (flattened.length === 0)
37034
- return "(untitled)";
37035
- if (flattened.length <= maxLength)
37036
- return flattened;
37037
- return `${flattened.slice(0, maxLength - 3)}...`;
37038
- }
37039
37710
  function dateParts(value) {
37040
37711
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
37041
37712
  if (!m)
37042
37713
  return null;
37043
37714
  return { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
37044
37715
  }
37045
- function timeParts(value) {
37046
- const m = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
37047
- if (!m)
37048
- return null;
37049
- const rawOffset = m[3];
37050
- const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
37051
- return { hh: m[1], mi: m[2], offset };
37052
- }
37053
37716
  function utcMs(parts) {
37054
37717
  return Date.UTC(parts.y, parts.m - 1, parts.d);
37055
37718
  }
@@ -37063,6 +37726,116 @@ function formatDay(parts, referenceYear) {
37063
37726
  const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
37064
37727
  return `${weekday} ${parts.d} ${month}${year}`;
37065
37728
  }
37729
+ function localDayOf(now) {
37730
+ return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
37731
+ }
37732
+ function daysBetween2(from, to) {
37733
+ return Math.round((utcMs(to) - utcMs(from)) / DAY_MS);
37734
+ }
37735
+ function isRealDay(parts) {
37736
+ if (parts.m < 1 || parts.m > 12 || parts.d < 1 || parts.d > 31)
37737
+ return false;
37738
+ const d = new Date(utcMs(parts));
37739
+ return d.getUTCFullYear() === parts.y && d.getUTCMonth() + 1 === parts.m && d.getUTCDate() === parts.d;
37740
+ }
37741
+ var WEEKDAY_WORD = "(Sun(?:day)?|Mon(?:day)?|Tue(?:s|sday)?|Wed(?:nesday)?|Thu(?:r|rs|rsday)?|Fri(?:day)?|Sat(?:urday)?)";
37742
+ var MONTH_WORD = "(Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|June?|July?|Aug(?:ust)?|Sep(?:t(?:ember)?)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)";
37743
+ var MENTION_RE = new RegExp(`\\b(\\d{4})-(\\d{2})-(\\d{2})\\b` + `|` + `(?:\\b${WEEKDAY_WORD},?\\s+)?\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+${MONTH_WORD}\\b\\.?(?:,?\\s+(\\d{4})\\b)?`, "gi");
37744
+ var YEAR_WINDOW = 10;
37745
+ function weekdayIndex(word) {
37746
+ const stem = word.slice(0, 3).toLowerCase();
37747
+ return WEEKDAYS.findIndex((w) => w.toLowerCase() === stem);
37748
+ }
37749
+ function monthIndex(word) {
37750
+ const stem = word.slice(0, 3).toLowerCase();
37751
+ return MONTHS.findIndex((m) => m.toLowerCase() === stem);
37752
+ }
37753
+ function inferYear(day, month, weekday, today) {
37754
+ const candidates = [today.y, today.y - 1, today.y + 1].map((y) => ({ y, parts: { y, m: month, d: day } })).filter((c) => isRealDay(c.parts)).sort((a, b) => Math.abs(utcMs(a.parts) - utcMs(today)) - Math.abs(utcMs(b.parts) - utcMs(today)));
37755
+ if (weekday !== null) {
37756
+ const match = candidates.find((c) => new Date(utcMs(c.parts)).getUTCDay() === weekday);
37757
+ if (match)
37758
+ return match.y;
37759
+ }
37760
+ return candidates[0]?.y ?? today.y;
37761
+ }
37762
+ function readDayMentions(text, today) {
37763
+ const out = [];
37764
+ for (const m of text.matchAll(MENTION_RE)) {
37765
+ let parts;
37766
+ let yearInferred = false;
37767
+ let length = m[0].length;
37768
+ if (m[1] !== undefined) {
37769
+ parts = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
37770
+ } else {
37771
+ if (m[6] === "may")
37772
+ continue;
37773
+ const weekday = m[4] === undefined ? null : weekdayIndex(m[4]);
37774
+ const day = Number(m[5]);
37775
+ const month = monthIndex(m[6]) + 1;
37776
+ if (month < 1)
37777
+ continue;
37778
+ const written = m[7] === undefined ? null : Number(m[7]);
37779
+ if (written !== null && Math.abs(written - today.y) <= YEAR_WINDOW) {
37780
+ parts = { y: written, m: month, d: day };
37781
+ } else {
37782
+ if (written !== null)
37783
+ length -= /,?\s+\d{4}$/.exec(m[0])?.[0].length ?? 0;
37784
+ parts = { y: inferYear(day, month, weekday, today), m: month, d: day };
37785
+ yearInferred = true;
37786
+ }
37787
+ }
37788
+ if (!isRealDay(parts))
37789
+ continue;
37790
+ out.push({ parts, index: m.index, length, yearInferred });
37791
+ }
37792
+ return out;
37793
+ }
37794
+ function readAsOf(text, today) {
37795
+ const at = /\bas\s+of\b/i.exec(text);
37796
+ if (!at)
37797
+ return null;
37798
+ const after = text.slice(at.index + at[0].length);
37799
+ const first = readDayMentions(after, today)[0];
37800
+ if (!first)
37801
+ return null;
37802
+ if (!/^[\s:,\-(]*$/.test(after.slice(0, first.index)))
37803
+ return null;
37804
+ const ahead = utcMs(first.parts) > utcMs(today);
37805
+ const parts = first.yearInferred && ahead ? { ...first.parts, y: first.parts.y - 1 } : first.parts;
37806
+ return { parts, yearInferred: first.yearInferred };
37807
+ }
37808
+
37809
+ // src/lib/calendar/render.ts
37810
+ var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
37811
+ var OWNERSHIP_NOTE2 = [
37812
+ "<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
37813
+ " is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
37814
+ " Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
37815
+ " person - not from this file and not by an agent. -->"
37816
+ ].join(`
37817
+ `);
37818
+ var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
37819
+ var FULL_BAND_DAYS = 14;
37820
+ function yamlString2(value) {
37821
+ return JSON.stringify(value);
37822
+ }
37823
+ function oneLine2(value, maxLength = 200) {
37824
+ const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
37825
+ if (flattened.length === 0)
37826
+ return "(untitled)";
37827
+ if (flattened.length <= maxLength)
37828
+ return flattened;
37829
+ return `${flattened.slice(0, maxLength - 3)}...`;
37830
+ }
37831
+ function timeParts(value) {
37832
+ const m = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
37833
+ if (!m)
37834
+ return null;
37835
+ const rawOffset = m[3];
37836
+ const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
37837
+ return { hh: m[1], mi: m[2], offset };
37838
+ }
37066
37839
  function sortInstant(event) {
37067
37840
  if (!event.all_day) {
37068
37841
  const ms = Date.parse(event.start);
@@ -37199,12 +37972,12 @@ function renderArtistCalendar(args) {
37199
37972
  "---",
37200
37973
  "plinth_generated: true",
37201
37974
  "view: calendar",
37202
- `artist_slug: ${row.artist_slug === null ? "null" : yamlString(row.artist_slug)}`,
37203
- `artist_name: ${row.artist_name === null ? "null" : yamlString(row.artist_name)}`,
37204
- `calendar_name: ${yamlString(row.calendar_name)}`,
37205
- `generated_at: ${yamlString(generatedAt)}`,
37206
- `window_start: ${yamlString(window2.time_min)}`,
37207
- `window_end: ${yamlString(window2.time_max)}`,
37975
+ `artist_slug: ${row.artist_slug === null ? "null" : yamlString2(row.artist_slug)}`,
37976
+ `artist_name: ${row.artist_name === null ? "null" : yamlString2(row.artist_name)}`,
37977
+ `calendar_name: ${yamlString2(row.calendar_name)}`,
37978
+ `generated_at: ${yamlString2(generatedAt)}`,
37979
+ `window_start: ${yamlString2(window2.time_min)}`,
37980
+ `window_end: ${yamlString2(window2.time_max)}`,
37208
37981
  `status: ${row.status}`,
37209
37982
  "fetch_state: fresh",
37210
37983
  `entry_count: ${events.length}`,
@@ -37285,9 +38058,9 @@ function renderUpcoming(args) {
37285
38058
  "plinth_generated: true",
37286
38059
  "view: calendar",
37287
38060
  "scope: workspace",
37288
- `generated_at: ${yamlString(generatedAt)}`,
37289
- `window_start: ${yamlString(window2.time_min)}`,
37290
- `window_end: ${yamlString(window2.time_max)}`,
38061
+ `generated_at: ${yamlString2(generatedAt)}`,
38062
+ `window_start: ${yamlString2(window2.time_min)}`,
38063
+ `window_end: ${yamlString2(window2.time_max)}`,
37291
38064
  `artist_count: ${artistCount}`,
37292
38065
  `mapped_artist_count: ${mappedArtistCount}`,
37293
38066
  `calendars_read: ${fetched.length}`,
@@ -37383,8 +38156,8 @@ function renderStaleFile(existing, args) {
37383
38156
  "---",
37384
38157
  ...carried,
37385
38158
  "fetch_state: stale",
37386
- `last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
37387
- `last_refresh_error: ${yamlString(oneLine2(args.reason, 300))}`,
38159
+ `last_refresh_attempt: ${yamlString2(args.attemptedAt)}`,
38160
+ `last_refresh_error: ${yamlString2(oneLine2(args.reason, 300))}`,
37388
38161
  "---",
37389
38162
  "",
37390
38163
  OWNERSHIP_NOTE2,
@@ -38287,7 +39060,7 @@ async function reconcileDivergedRows(args) {
38287
39060
 
38288
39061
  // src/lib/workspace-rules/sync.ts
38289
39062
  import { existsSync as existsSync4 } from "node:fs";
38290
- import { relative as relative10 } from "node:path";
39063
+ import { relative as relative11 } from "node:path";
38291
39064
 
38292
39065
  // src/lib/workspace-rules/api.ts
38293
39066
  var MAX_WORKSPACE_RULES_BYTES = 256 * 1024;
@@ -38302,16 +39075,16 @@ var PreconditionSchema2 = exports_external.object({
38302
39075
  code: exports_external.literal("precondition_failed"),
38303
39076
  current_updated_at: exports_external.string()
38304
39077
  });
38305
- function baseHeaders2(pat) {
39078
+ function baseHeaders3(pat) {
38306
39079
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38307
39080
  }
38308
- var ROUTE = "/api/sync/workspace-rules";
39081
+ var ROUTE2 = "/api/sync/workspace-rules";
38309
39082
  async function getWorkspaceRules(args) {
38310
39083
  const { baseUrl, pat, fetchImpl = fetch } = args;
38311
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
39084
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
38312
39085
  let res;
38313
39086
  try {
38314
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders2(pat) });
39087
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders3(pat) });
38315
39088
  } catch (err) {
38316
39089
  const msg = err instanceof Error ? err.message : "network error";
38317
39090
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38334,9 +39107,9 @@ async function getWorkspaceRules(args) {
38334
39107
  }
38335
39108
  async function putWorkspaceRules(args) {
38336
39109
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38337
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
39110
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
38338
39111
  const headers = {
38339
- ...baseHeaders2(pat),
39112
+ ...baseHeaders3(pat),
38340
39113
  "Content-Type": "application/json"
38341
39114
  };
38342
39115
  if (expectedUpdatedAt)
@@ -38422,7 +39195,7 @@ function classifyWorkspaceRulesPath(args) {
38422
39195
  } catch {
38423
39196
  return false;
38424
39197
  }
38425
- return relative10(root, args.absolutePath) === WORKSPACE_RULES_FILENAME;
39198
+ return relative11(root, args.absolutePath) === WORKSPACE_RULES_FILENAME;
38426
39199
  }
38427
39200
  function migrateHouseStyleFile(workspaceSlug) {
38428
39201
  let target;
@@ -38573,7 +39346,7 @@ async function reconcileWorkspaceRules(args) {
38573
39346
  var WORKSPACE_RULES_MUTEX_KEY = "workspace-rules";
38574
39347
 
38575
39348
  // src/lib/workspace-memory/sync.ts
38576
- import { relative as relative11 } from "node:path";
39349
+ import { relative as relative12 } from "node:path";
38577
39350
 
38578
39351
  // src/lib/workspace-memory/api.ts
38579
39352
  var MAX_WORKSPACE_MEMORY_BYTES = 256 * 1024;
@@ -38582,22 +39355,23 @@ var WorkspaceMemoryProjectionSchema = exports_external.object({
38582
39355
  is_empty: exports_external.boolean(),
38583
39356
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
38584
39357
  created_at: exports_external.string().datetime({ offset: true }).nullable(),
38585
- last_edited_by: exports_external.string().uuid().nullable()
39358
+ last_edited_by: exports_external.string().uuid().nullable(),
39359
+ generated: exports_external.boolean().optional().catch(undefined)
38586
39360
  });
38587
39361
  var PreconditionSchema3 = exports_external.object({
38588
39362
  code: exports_external.literal("precondition_failed"),
38589
39363
  current_updated_at: exports_external.string()
38590
39364
  });
38591
- function baseHeaders3(pat) {
39365
+ function baseHeaders4(pat) {
38592
39366
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38593
39367
  }
38594
- var ROUTE2 = "/api/sync/workspace-memory";
39368
+ var ROUTE3 = "/api/sync/workspace-memory";
38595
39369
  async function getWorkspaceMemory(args) {
38596
39370
  const { baseUrl, pat, fetchImpl = fetch } = args;
38597
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
39371
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE3}`;
38598
39372
  let res;
38599
39373
  try {
38600
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders3(pat) });
39374
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders4(pat) });
38601
39375
  } catch (err) {
38602
39376
  const msg = err instanceof Error ? err.message : "network error";
38603
39377
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38620,9 +39394,9 @@ async function getWorkspaceMemory(args) {
38620
39394
  }
38621
39395
  async function putWorkspaceMemory(args) {
38622
39396
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38623
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
39397
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE3}`;
38624
39398
  const headers = {
38625
- ...baseHeaders3(pat),
39399
+ ...baseHeaders4(pat),
38626
39400
  "Content-Type": "application/json"
38627
39401
  };
38628
39402
  if (expectedUpdatedAt)
@@ -38657,6 +39431,14 @@ async function putWorkspaceMemory(args) {
38657
39431
  }
38658
39432
  if (res.status === 409) {
38659
39433
  const json2 = await res.json();
39434
+ const generated = GeneratedRefusalSchema.safeParse(json2);
39435
+ if (generated.success) {
39436
+ return {
39437
+ ok: false,
39438
+ code: "memory_is_generated",
39439
+ serverMessage: generated.data.error
39440
+ };
39441
+ }
38660
39442
  const parsed = PreconditionSchema3.safeParse(json2);
38661
39443
  if (!parsed.success) {
38662
39444
  throw new ApiError(409, `put workspace-memory 409 body did not match precondition_failed shape`);
@@ -38684,6 +39466,7 @@ async function putWorkspaceMemory(args) {
38684
39466
  // src/lib/workspace-memory/sync.ts
38685
39467
  var CONFLICT_ENTITY2 = "workspace-memory";
38686
39468
  var CONFLICT_SLUG2 = "workspace-memory";
39469
+ var GENERATED_PILE2 = "shared";
38687
39470
  function memoryFilePath(workspaceSlug) {
38688
39471
  return contextFilePath(workspaceSlug, WORKSPACE_MEMORY_FILENAME);
38689
39472
  }
@@ -38708,7 +39491,7 @@ function classifyWorkspaceMemoryPath(args) {
38708
39491
  } catch {
38709
39492
  return false;
38710
39493
  }
38711
- return relative11(root, args.absolutePath) === WORKSPACE_MEMORY_FILENAME;
39494
+ return relative12(root, args.absolutePath) === WORKSPACE_MEMORY_FILENAME;
38712
39495
  }
38713
39496
  async function pullWorkspaceMemory(args) {
38714
39497
  const { workspaceSlug, baseUrl, pat, state, fetchImpl, output, suppressor } = args;
@@ -38722,12 +39505,37 @@ async function pullWorkspaceMemory(args) {
38722
39505
  const cloudHash = stableHash(cloud.body_md);
38723
39506
  const ackedHash = getWorkspaceMemoryState(state)?.body_hash;
38724
39507
  if (local !== null && stableHash(local) === cloudHash) {
38725
- const { state: next2 } = setWorkspaceMemoryState(state, {
39508
+ const { state: stamped } = setWorkspaceMemoryState(state, {
38726
39509
  body_hash: cloudHash,
38727
- updated_at: cloud.updated_at
39510
+ updated_at: cloud.updated_at,
39511
+ generated: cloud.generated
38728
39512
  });
39513
+ const next2 = clearMemoryGeneratedRefusal(stamped, workspaceSlug, memoryFilePath(workspaceSlug));
38729
39514
  return { state: next2, action: "noop" };
38730
39515
  }
39516
+ if (cloud.generated === true && local !== null && !localEmpty && stableHash(local) !== ackedHash) {
39517
+ const refused = refuseGeneratedMemoryEdit({
39518
+ state,
39519
+ workspaceSlug,
39520
+ absolutePath: memoryFilePath(workspaceSlug),
39521
+ relPath: WORKSPACE_MEMORY_FILENAME,
39522
+ pile: GENERATED_PILE2,
39523
+ conflictEntity: CONFLICT_ENTITY2,
39524
+ conflictSlug: CONFLICT_SLUG2,
39525
+ localContent: local,
39526
+ canonicalBody: cloud.body_md,
39527
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, suppressor),
39528
+ now,
39529
+ output
39530
+ });
39531
+ const { state: next2 } = setWorkspaceMemoryState(refused.state, {
39532
+ body_hash: cloudHash,
39533
+ updated_at: cloud.updated_at,
39534
+ generated: true
39535
+ });
39536
+ output?.log(`[WORKSPACE_MEMORY_PULL_GENERATED_REFUSED ws=${workspaceSlug} stashed]`);
39537
+ return { state: next2, action: "written" };
39538
+ }
38731
39539
  if (local !== null && ackedHash !== undefined && cloudHash === ackedHash) {
38732
39540
  output?.log(`[WORKSPACE_MEMORY_PULL_PRESERVED ws=${workspaceSlug} reason=pending_push]`);
38733
39541
  return { state, action: "preserved" };
@@ -38750,7 +39558,8 @@ async function pullWorkspaceMemory(args) {
38750
39558
  writeLocal2(workspaceSlug, cloud.body_md, suppressor);
38751
39559
  const { state: next } = setWorkspaceMemoryState(state, {
38752
39560
  body_hash: cloudHash,
38753
- updated_at: cloud.updated_at
39561
+ updated_at: cloud.updated_at,
39562
+ generated: cloud.generated
38754
39563
  });
38755
39564
  return { state: next, action: cloud.is_empty ? "materialised" : "written" };
38756
39565
  }
@@ -38760,7 +39569,8 @@ async function restoreFromCloud2(args) {
38760
39569
  writeLocal2(workspaceSlug, cloud.body_md, suppressor);
38761
39570
  return persistMemory(state, {
38762
39571
  body_hash: stableHash(cloud.body_md),
38763
- updated_at: cloud.updated_at
39572
+ updated_at: cloud.updated_at,
39573
+ generated: cloud.generated
38764
39574
  });
38765
39575
  }
38766
39576
  async function pushWorkspaceMemory(args) {
@@ -38773,6 +39583,32 @@ async function pushWorkspaceMemory(args) {
38773
39583
  const localHash = stableHash(local);
38774
39584
  const acked = getWorkspaceMemoryState(state);
38775
39585
  const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
39586
+ if (acked?.generated === true && !alreadyAcked) {
39587
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39588
+ const refused = refuseGeneratedMemoryEdit({
39589
+ state,
39590
+ workspaceSlug,
39591
+ absolutePath: memoryFilePath(workspaceSlug),
39592
+ relPath: WORKSPACE_MEMORY_FILENAME,
39593
+ pile: GENERATED_PILE2,
39594
+ conflictEntity: CONFLICT_ENTITY2,
39595
+ conflictSlug: CONFLICT_SLUG2,
39596
+ localContent: local,
39597
+ canonicalBody: cloud.body_md,
39598
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39599
+ now,
39600
+ output
39601
+ });
39602
+ const next2 = persistMemory(refused.state, {
39603
+ body_hash: stableHash(cloud.body_md),
39604
+ updated_at: cloud.updated_at,
39605
+ generated: cloud.generated
39606
+ });
39607
+ return {
39608
+ state: next2,
39609
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
39610
+ };
39611
+ }
38776
39612
  const gate = applySecretGate({
38777
39613
  state,
38778
39614
  workspaceSlug,
@@ -38804,7 +39640,8 @@ async function pushWorkspaceMemory(args) {
38804
39640
  if (res.ok) {
38805
39641
  const next2 = persistMemory(gated, {
38806
39642
  body_hash: localHash,
38807
- updated_at: res.row.updated_at
39643
+ updated_at: res.row.updated_at,
39644
+ generated: res.row.generated
38808
39645
  });
38809
39646
  output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${res.row.is_empty}]`);
38810
39647
  return { state: next2, outcome: { kind: "pushed" } };
@@ -38813,6 +39650,33 @@ async function pushWorkspaceMemory(args) {
38813
39650
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38814
39651
  return { state: gated, outcome: { kind: "over_cap" } };
38815
39652
  }
39653
+ if (res.code === "memory_is_generated") {
39654
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39655
+ const refused = refuseGeneratedMemoryEdit({
39656
+ state: gated,
39657
+ workspaceSlug,
39658
+ absolutePath: memoryFilePath(workspaceSlug),
39659
+ relPath: WORKSPACE_MEMORY_FILENAME,
39660
+ pile: GENERATED_PILE2,
39661
+ conflictEntity: CONFLICT_ENTITY2,
39662
+ conflictSlug: CONFLICT_SLUG2,
39663
+ localContent: local,
39664
+ canonicalBody: cloud.body_md,
39665
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39666
+ now,
39667
+ output
39668
+ });
39669
+ output?.log(`[WORKSPACE_MEMORY_PUT_REFUSED ws=${workspaceSlug} code=memory_is_generated]`);
39670
+ const next2 = persistMemory(refused.state, {
39671
+ body_hash: stableHash(cloud.body_md),
39672
+ updated_at: cloud.updated_at,
39673
+ generated: cloud.generated
39674
+ });
39675
+ return {
39676
+ state: next2,
39677
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
39678
+ };
39679
+ }
38816
39680
  {
38817
39681
  const probe2 = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
38818
39682
  if (acked && stableHash(probe2.body_md) === acked.body_hash) {
@@ -38826,7 +39690,8 @@ async function pushWorkspaceMemory(args) {
38826
39690
  if (retry.ok) {
38827
39691
  const next2 = persistMemory(gated, {
38828
39692
  body_hash: localHash,
38829
- updated_at: retry.row.updated_at
39693
+ updated_at: retry.row.updated_at,
39694
+ generated: retry.row.generated
38830
39695
  });
38831
39696
  output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
38832
39697
  return { state: next2, outcome: { kind: "pushed" } };
@@ -38835,6 +39700,32 @@ async function pushWorkspaceMemory(args) {
38835
39700
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38836
39701
  return { state: gated, outcome: { kind: "over_cap" } };
38837
39702
  }
39703
+ if (retry.code === "memory_is_generated") {
39704
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39705
+ const refused = refuseGeneratedMemoryEdit({
39706
+ state: gated,
39707
+ workspaceSlug,
39708
+ absolutePath: memoryFilePath(workspaceSlug),
39709
+ relPath: WORKSPACE_MEMORY_FILENAME,
39710
+ pile: GENERATED_PILE2,
39711
+ conflictEntity: CONFLICT_ENTITY2,
39712
+ conflictSlug: CONFLICT_SLUG2,
39713
+ localContent: local,
39714
+ canonicalBody: cloud.body_md,
39715
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39716
+ now,
39717
+ output
39718
+ });
39719
+ const next2 = persistMemory(refused.state, {
39720
+ body_hash: stableHash(cloud.body_md),
39721
+ updated_at: cloud.updated_at,
39722
+ generated: cloud.generated
39723
+ });
39724
+ return {
39725
+ state: next2,
39726
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
39727
+ };
39728
+ }
38838
39729
  }
38839
39730
  }
38840
39731
  const stashPath = stashConflict({
@@ -38860,6 +39751,7 @@ var SingletonProjectionSchema = exports_external.object({
38860
39751
  body_md: exports_external.string(),
38861
39752
  is_empty: exports_external.boolean(),
38862
39753
  status: SingletonStatusSchema.optional().catch(undefined),
39754
+ generated: exports_external.boolean().optional().catch(undefined),
38863
39755
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
38864
39756
  created_at: exports_external.string().datetime({ offset: true }).nullable(),
38865
39757
  last_edited_by: exports_external.string().uuid().nullable()
@@ -38870,7 +39762,7 @@ var PreconditionSchema4 = exports_external.object({
38870
39762
  code: exports_external.literal("precondition_failed"),
38871
39763
  current_updated_at: exports_external.string()
38872
39764
  });
38873
- function baseHeaders4(pat) {
39765
+ function baseHeaders5(pat) {
38874
39766
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38875
39767
  }
38876
39768
  async function serverErrorMessage(res, fallback) {
@@ -38888,7 +39780,7 @@ function createSingletonApi(config2) {
38888
39780
  const url2 = `${baseUrl.replace(/\/$/, "")}${route}`;
38889
39781
  let res;
38890
39782
  try {
38891
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders4(pat) });
39783
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders5(pat) });
38892
39784
  } catch (err) {
38893
39785
  const msg = err instanceof Error ? err.message : "network error";
38894
39786
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38913,7 +39805,7 @@ function createSingletonApi(config2) {
38913
39805
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38914
39806
  const url2 = `${baseUrl.replace(/\/$/, "")}${route}`;
38915
39807
  const headers = {
38916
- ...baseHeaders4(pat),
39808
+ ...baseHeaders5(pat),
38917
39809
  "Content-Type": "application/json"
38918
39810
  };
38919
39811
  if (expectedUpdatedAt)
@@ -38948,6 +39840,14 @@ function createSingletonApi(config2) {
38948
39840
  }
38949
39841
  if (res.status === 409) {
38950
39842
  const json2 = await res.json();
39843
+ const generated = GeneratedRefusalSchema.safeParse(json2);
39844
+ if (generated.success) {
39845
+ return {
39846
+ ok: false,
39847
+ code: "memory_is_generated",
39848
+ serverMessage: generated.data.error
39849
+ };
39850
+ }
38951
39851
  const parsed = PreconditionSchema4.safeParse(json2);
38952
39852
  if (!parsed.success) {
38953
39853
  throw new ApiError(409, `put ${surfaceName} 409 body did not match precondition_failed shape`);
@@ -38970,7 +39870,7 @@ function createSingletonApi(config2) {
38970
39870
  }
38971
39871
 
38972
39872
  // src/lib/singleton/engine.ts
38973
- import { relative as relative12 } from "node:path";
39873
+ import { relative as relative13 } from "node:path";
38974
39874
  function filePath(descriptor, workspaceSlug) {
38975
39875
  return contextFilePath(workspaceSlug, descriptor.filename);
38976
39876
  }
@@ -38986,7 +39886,8 @@ function ackedFrom(projection, bodyHash) {
38986
39886
  return {
38987
39887
  body_hash: bodyHash,
38988
39888
  updated_at: projection.updated_at,
38989
- status: projection.status
39889
+ status: projection.status,
39890
+ generated: projection.generated
38990
39891
  };
38991
39892
  }
38992
39893
  function persist2(descriptor, state, entry2) {
@@ -39002,7 +39903,7 @@ function classifySingletonPath(descriptor, args) {
39002
39903
  } catch {
39003
39904
  return false;
39004
39905
  }
39005
- return relative12(root, args.absolutePath) === descriptor.filename;
39906
+ return relative13(root, args.absolutePath) === descriptor.filename;
39006
39907
  }
39007
39908
  async function pullSingleton(descriptor, args) {
39008
39909
  const { workspaceSlug, baseUrl, pat, state, fetchImpl, output, suppressor } = args;
@@ -39017,9 +39918,29 @@ async function pullSingleton(descriptor, args) {
39017
39918
  const ackedHash = descriptor.getState(state)?.body_hash;
39018
39919
  const localHash = local !== null ? stableHash(local) : null;
39019
39920
  if (localHash !== null && localHash === cloudHash) {
39020
- const { state: next2 } = descriptor.setState(state, ackedFrom(cloud, cloudHash));
39921
+ const { state: stamped } = descriptor.setState(state, ackedFrom(cloud, cloudHash));
39922
+ const next2 = descriptor.generatedPile === null ? stamped : clearMemoryGeneratedRefusal(stamped, workspaceSlug, filePath(descriptor, workspaceSlug));
39021
39923
  return { state: next2, action: "noop" };
39022
39924
  }
39925
+ if (descriptor.generatedPile !== null && cloud.generated === true && local !== null && !localEmpty && localHash !== ackedHash) {
39926
+ const refused = refuseGeneratedMemoryEdit({
39927
+ state,
39928
+ workspaceSlug,
39929
+ absolutePath: filePath(descriptor, workspaceSlug),
39930
+ relPath: descriptor.filename,
39931
+ pile: descriptor.generatedPile,
39932
+ conflictEntity: descriptor.conflictEntity,
39933
+ conflictSlug: descriptor.conflictSlug,
39934
+ localContent: local,
39935
+ canonicalBody: cloud.body_md,
39936
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, suppressor),
39937
+ now,
39938
+ output
39939
+ });
39940
+ const { state: next2 } = descriptor.setState(refused.state, ackedFrom(cloud, cloudHash));
39941
+ output?.log(`[${descriptor.logTag}_PULL_GENERATED_REFUSED ws=${workspaceSlug} stashed]`);
39942
+ return { state: next2, action: "written" };
39943
+ }
39023
39944
  if (local !== null && ackedHash !== undefined && cloudHash === ackedHash) {
39024
39945
  output?.log(`[${descriptor.logTag}_PULL_PRESERVED ws=${workspaceSlug} reason=pending_push]`);
39025
39946
  return { state, action: "preserved" };
@@ -39058,6 +39979,28 @@ async function pushSingleton(descriptor, args) {
39058
39979
  const localHash = stableHash(local);
39059
39980
  const acked = descriptor.getState(state);
39060
39981
  const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
39982
+ if (descriptor.generatedPile !== null && acked?.generated === true && !alreadyAcked) {
39983
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
39984
+ const refused = refuseGeneratedMemoryEdit({
39985
+ state,
39986
+ workspaceSlug,
39987
+ absolutePath: filePath(descriptor, workspaceSlug),
39988
+ relPath: descriptor.filename,
39989
+ pile: descriptor.generatedPile,
39990
+ conflictEntity: descriptor.conflictEntity,
39991
+ conflictSlug: descriptor.conflictSlug,
39992
+ localContent: local,
39993
+ canonicalBody: cloud.body_md,
39994
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
39995
+ now,
39996
+ output
39997
+ });
39998
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
39999
+ return {
40000
+ state: next2,
40001
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40002
+ };
40003
+ }
39061
40004
  let gated = state;
39062
40005
  if (descriptor.secretScanned) {
39063
40006
  const gate = applySecretGate({
@@ -39098,6 +40041,29 @@ async function pushSingleton(descriptor, args) {
39098
40041
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
39099
40042
  return { state: gated, outcome: { kind: "over_cap" } };
39100
40043
  }
40044
+ if (res.code === "memory_is_generated") {
40045
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
40046
+ const refused = refuseGeneratedMemoryEdit({
40047
+ state: gated,
40048
+ workspaceSlug,
40049
+ absolutePath: filePath(descriptor, workspaceSlug),
40050
+ relPath: descriptor.filename,
40051
+ pile: descriptor.generatedPile ?? "shared",
40052
+ conflictEntity: descriptor.conflictEntity,
40053
+ conflictSlug: descriptor.conflictSlug,
40054
+ localContent: local,
40055
+ canonicalBody: cloud.body_md,
40056
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
40057
+ now,
40058
+ output
40059
+ });
40060
+ output?.log(`[${descriptor.logTag}_PUT_REFUSED ws=${workspaceSlug} code=memory_is_generated]`);
40061
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
40062
+ return {
40063
+ state: next2,
40064
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40065
+ };
40066
+ }
39101
40067
  {
39102
40068
  const probe2 = await descriptor.api.get({ baseUrl, pat, fetchImpl });
39103
40069
  if (acked && stableHash(probe2.body_md) === acked.body_hash) {
@@ -39117,6 +40083,28 @@ async function pushSingleton(descriptor, args) {
39117
40083
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
39118
40084
  return { state: gated, outcome: { kind: "over_cap" } };
39119
40085
  }
40086
+ if (retry.code === "memory_is_generated") {
40087
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
40088
+ const refused = refuseGeneratedMemoryEdit({
40089
+ state: gated,
40090
+ workspaceSlug,
40091
+ absolutePath: filePath(descriptor, workspaceSlug),
40092
+ relPath: descriptor.filename,
40093
+ pile: descriptor.generatedPile ?? "shared",
40094
+ conflictEntity: descriptor.conflictEntity,
40095
+ conflictSlug: descriptor.conflictSlug,
40096
+ localContent: local,
40097
+ canonicalBody: cloud.body_md,
40098
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
40099
+ now,
40100
+ output
40101
+ });
40102
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
40103
+ return {
40104
+ state: next2,
40105
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40106
+ };
40107
+ }
39120
40108
  }
39121
40109
  }
39122
40110
  const stashPath = stashConflict({
@@ -39150,6 +40138,7 @@ var USER_MEMORY_DESCRIPTOR = {
39150
40138
  logTag: "USER_MEMORY",
39151
40139
  maxBytes: MAX_USER_MEMORY_BYTES,
39152
40140
  secretScanned: true,
40141
+ generatedPile: "private",
39153
40142
  extractActive: extractUserMemory,
39154
40143
  getState: getUserMemoryState,
39155
40144
  setState: setUserMemoryState,
@@ -39183,6 +40172,7 @@ var VOICE_DESCRIPTOR = {
39183
40172
  logTag: "VOICE",
39184
40173
  maxBytes: MAX_USER_VOICE_BYTES,
39185
40174
  secretScanned: false,
40175
+ generatedPile: null,
39186
40176
  extractActive: extractVoiceProfile,
39187
40177
  getState: getUserVoiceState,
39188
40178
  setState: setUserVoiceState,
@@ -39329,6 +40319,11 @@ import { join as join16 } from "node:path";
39329
40319
  // src/lib/conflicts-view.ts
39330
40320
  import { existsSync as existsSync5 } from "node:fs";
39331
40321
  import { join as join15 } from "node:path";
40322
+ var NO_GENERATED_MEMORY = {
40323
+ artistMemoryGenerated: () => false,
40324
+ workspaceMemoryGenerated: () => false,
40325
+ userMemoryGenerated: () => false
40326
+ };
39332
40327
  var FOOTER = [
39333
40328
  "---",
39334
40329
  "",
@@ -39351,7 +40346,7 @@ function absolute(date5) {
39351
40346
  function shellQuote2(s) {
39352
40347
  return `'${s.replace(/'/g, `'\\''`)}'`;
39353
40348
  }
39354
- function resolveConflictRows(workspaceSlug, stashes) {
40349
+ function resolveConflictRows(workspaceSlug, stashes, generatedMemory = NO_GENERATED_MEMORY) {
39355
40350
  let root;
39356
40351
  try {
39357
40352
  root = workspaceMirrorPath(workspaceSlug);
@@ -39359,6 +40354,9 @@ function resolveConflictRows(workspaceSlug, stashes) {
39359
40354
  root = null;
39360
40355
  }
39361
40356
  return stashes.map((stash) => {
40357
+ const pile = isGeneratedMemoryStash(stash.label, generatedMemory);
40358
+ if (pile !== null)
40359
+ return { kind: "generated-memory", stash, pile };
39362
40360
  if (root === null || !stash.label.startsWith("files/")) {
39363
40361
  return { kind: "entity", stash };
39364
40362
  }
@@ -39424,6 +40422,8 @@ function renderRow(row) {
39424
40422
  out.push("Your version is at:", "", "```", stash.absPath, "```", "");
39425
40423
  if (row.kind === "unsafe-path") {
39426
40424
  out.push("**Plinth has not written a copy command for this one, on purpose.** The", "path it would have to write to is not one Plinth can vouch for: either the", "stash filename does not describe a path in this workspace, or a folder on", "the way to it is a link pointing somewhere else. Running a copy built from", "that would write your file somewhere you did not choose.", "", "Open the stash, see what is in it, and put it back by hand.");
40425
+ } else if (row.kind === "generated-memory") {
40426
+ out.push("**This file is generated from memory facts, so putting the text back", "will not keep it.** The next sync rewrites the file and sets your copy", "aside again.", "", `Make the change with ${factToolsFor(row.pile)},`, "then delete the stash. The file is rewritten from the facts.");
39427
40427
  } else {
39428
40428
  out.push("Compare it with the file in your mirror and re-apply anything you still", "want by editing that file. Then delete the stash.");
39429
40429
  }
@@ -39476,7 +40476,7 @@ ${FOOTER}
39476
40476
  function refreshConflictsView(workspaceSlug) {
39477
40477
  try {
39478
40478
  const stashes = listConflictStashes(workspaceSlug);
39479
- const rows = resolveConflictRows(workspaceSlug, stashes);
40479
+ const rows = resolveConflictRows(workspaceSlug, stashes, generatedMemoryStashDepsFrom(loadState(workspaceSlug)));
39480
40480
  const body = renderConflictsView(rows);
39481
40481
  if (readConflictsView(workspaceSlug) === body) {
39482
40482
  return { stashes: rows.length, written: false };
@@ -39499,7 +40499,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync22, statSync as
39499
40499
  // src/lib/files/extracted-text.ts
39500
40500
  import { createHash as createHash3 } from "node:crypto";
39501
40501
  var DERIVED_TEXT_VERSION = 2;
39502
- function yamlString2(value) {
40502
+ function yamlString3(value) {
39503
40503
  return JSON.stringify(value);
39504
40504
  }
39505
40505
  var PARTIAL_WARNING = [
@@ -39538,8 +40538,8 @@ function renderExtractedText(doc2) {
39538
40538
  const frontmatter = [
39539
40539
  "---",
39540
40540
  "plinth_generated: true",
39541
- `source_file: ${doc2.bytesInMirror ? yamlString2(`files/${doc2.relPath}`) : "null"}`,
39542
- `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString2(doc2.sourceUri)}`,
40541
+ `source_file: ${doc2.bytesInMirror ? yamlString3(`files/${doc2.relPath}`) : "null"}`,
40542
+ `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString3(doc2.sourceUri)}`,
39543
40543
  `content_quality: ${doc2.quality ?? "unchecked"}`,
39544
40544
  `body_withheld: ${withheld}`,
39545
40545
  `extracted_chars: ${withheld ? 0 : doc2.text.length}`,
@@ -40526,6 +41526,27 @@ async function runSync(workspaceSlug, deps = {}) {
40526
41526
  const voiceWarning = voiceMalformedWarning(state, slug);
40527
41527
  if (voiceWarning)
40528
41528
  out.warn(voiceWarning);
41529
+ const factsResult = await syncMemoryFacts({
41530
+ workspaceSlug: slug,
41531
+ baseUrl: entry2.api_base_url,
41532
+ pat,
41533
+ state,
41534
+ fetchImpl,
41535
+ now,
41536
+ output: out,
41537
+ suppressor: deps.suppressor
41538
+ });
41539
+ if (factsResult.state !== state) {
41540
+ state = factsResult.state;
41541
+ }
41542
+ commitPending();
41543
+ if (factsResult.written > 0 || factsResult.deleted > 0) {
41544
+ counters["memory-facts"] = {
41545
+ fetched: factsResult.facts,
41546
+ written: factsResult.written,
41547
+ deleted: factsResult.deleted
41548
+ };
41549
+ }
40529
41550
  } catch (err) {
40530
41551
  out.log(pullFailedLine(categorizeError(err)));
40531
41552
  if (err instanceof ApiError) {
@@ -40687,14 +41708,24 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
40687
41708
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
40688
41709
  if (existsSync7(path5)) {
40689
41710
  if (strategy.type === "artists") {
40690
- applyArtistMemoryOnPull({
41711
+ const artistLive = live;
41712
+ state = applyArtistMemoryOnPull({
40691
41713
  workspaceSlug,
40692
- live,
41714
+ live: artistLive,
40693
41715
  ackedMemoryHash: existingEntry.memory_hash,
41716
+ state,
40694
41717
  suppressor,
40695
41718
  now,
40696
41719
  out
40697
- });
41720
+ }).state;
41721
+ if (existingEntry.memory_generated !== artistLive.memory_generated) {
41722
+ const bumped = upsertEntry(state, strategy.type, {
41723
+ ...existingEntry,
41724
+ memory_generated: artistLive.memory_generated
41725
+ });
41726
+ if (bumped.changed)
41727
+ state = bumped.state;
41728
+ }
40698
41729
  }
40699
41730
  return { state, action: "noop_overwrite" };
40700
41731
  }
@@ -40731,15 +41762,21 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
40731
41762
  inode = undefined;
40732
41763
  }
40733
41764
  let memoryHash;
41765
+ let memoryGenerated;
40734
41766
  if (strategy.type === "artists") {
40735
- memoryHash = applyArtistMemoryOnPull({
41767
+ const artistLive = live;
41768
+ const applied = applyArtistMemoryOnPull({
40736
41769
  workspaceSlug,
40737
- live,
41770
+ live: artistLive,
40738
41771
  ackedMemoryHash: existingEntry?.memory_hash,
41772
+ state,
40739
41773
  suppressor,
40740
41774
  now,
40741
41775
  out
40742
- }).memoryHash;
41776
+ });
41777
+ memoryHash = applied.memoryHash;
41778
+ state = applied.state;
41779
+ memoryGenerated = artistLive.memory_generated;
40743
41780
  }
40744
41781
  const { state: nextState } = upsertEntry(state, strategy.type, {
40745
41782
  id: live.id,
@@ -40750,7 +41787,8 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
40750
41787
  frontmatter_hash: hashes.frontmatterHash,
40751
41788
  inode,
40752
41789
  body_len: hashes.bodyLen,
40753
- memory_hash: memoryHash
41790
+ memory_hash: memoryHash,
41791
+ memory_generated: memoryGenerated
40754
41792
  });
40755
41793
  return { state: nextState, action: "written" };
40756
41794
  }
@@ -41272,6 +42310,30 @@ function makeWatchHandler(deps) {
41272
42310
  await handleArtistMemoryEvent(memoryMatch.slug, absolutePath, event.kind === "upsert");
41273
42311
  return;
41274
42312
  }
42313
+ const factPath = classifyMemoryFactPath({ workspaceSlug, absolutePath });
42314
+ if (factPath) {
42315
+ let ownEcho = false;
42316
+ if (event.kind === "upsert") {
42317
+ try {
42318
+ ownEcho = suppressor?.shouldSuppressUpsert(absolutePath, {
42319
+ bodyHash: stableHash(readFileSync25(absolutePath, "utf8")),
42320
+ frontmatterHash: ""
42321
+ }) === true;
42322
+ } catch {
42323
+ ownEcho = true;
42324
+ }
42325
+ }
42326
+ const warning2 = memoryFactEventWarning(classifyMemoryFactEvent({
42327
+ path: factPath,
42328
+ kind: event.kind === "upsert" ? "upsert" : "unlink",
42329
+ ownEcho,
42330
+ known: getMemoryFactEntry(getState(), factPath.relPath) !== undefined
42331
+ }));
42332
+ if (warning2)
42333
+ spinner.warn(warning2);
42334
+ spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
42335
+ return;
42336
+ }
41275
42337
  const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
41276
42338
  if (!classified) {
41277
42339
  const key = entitySymlinkRefusalKey({ workspaceSlug, absolutePath });
@@ -42459,6 +43521,305 @@ var startCommand = defineCommand({
42459
43521
  // src/commands/status.ts
42460
43522
  import { statSync as statSync19 } from "node:fs";
42461
43523
 
43524
+ // src/lib/context/context-walls.ts
43525
+ function prepare(raw) {
43526
+ const text = stripContactsBlock(raw.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---\r?\n/, ""));
43527
+ return text.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "");
43528
+ }
43529
+ function indentOf(line) {
43530
+ let cols = 0;
43531
+ for (const ch of line) {
43532
+ if (ch === " ")
43533
+ cols += 1;
43534
+ else if (ch === "\t")
43535
+ cols += 4;
43536
+ else
43537
+ break;
43538
+ }
43539
+ return cols;
43540
+ }
43541
+ var HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
43542
+ var ITEM_RE = /^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/;
43543
+ var FENCE_RE = /^\s*(?:`{3,}|~{3,})/;
43544
+ var TABLE_RE = /^\s*\|/;
43545
+ var LABEL_RE = /^[^\p{L}\p{N}*_`]*\*\*(.+?)\*\*/u;
43546
+ var UPCOMING_RE = /upcoming/i;
43547
+ var CURRENT_STATE_RE = /^current[\s-]+state\b/i;
43548
+ var SESSION_LOG_RE = /^session[\s-]+log$/i;
43549
+ function latestOf(dates) {
43550
+ let latest = null;
43551
+ for (const d of dates) {
43552
+ if (latest === null || utcMs(d) > utcMs(latest))
43553
+ latest = d;
43554
+ }
43555
+ return latest;
43556
+ }
43557
+ function openingOf(text) {
43558
+ const safe = terminalSafeText(text, 400);
43559
+ const chars = [...safe];
43560
+ if (chars.length <= 60)
43561
+ return safe;
43562
+ return `${chars.slice(0, 60).join("").trimEnd()}…`;
43563
+ }
43564
+ function safeLabel(text) {
43565
+ return terminalSafeText(text, 120);
43566
+ }
43567
+ function scanContextText(raw, today, opts = {}) {
43568
+ const scanner = new ContextScanner(today, opts.initialHeading ?? null);
43569
+ for (const line of prepare(raw).split(/\r?\n/))
43570
+ scanner.line(line);
43571
+ return scanner.finish();
43572
+ }
43573
+
43574
+ class ContextScanner {
43575
+ today;
43576
+ todayMs;
43577
+ heading;
43578
+ block = null;
43579
+ blockDates = [];
43580
+ blockRegion = null;
43581
+ blockIsLabel = false;
43582
+ open = [];
43583
+ fence = false;
43584
+ lastBlank = true;
43585
+ sessionLogLevel = null;
43586
+ walls = [];
43587
+ played = [];
43588
+ asOf = [];
43589
+ constructor(today, initialHeading) {
43590
+ this.today = today;
43591
+ this.todayMs = utcMs(today);
43592
+ this.heading = initialHeading;
43593
+ }
43594
+ line(line) {
43595
+ if (FENCE_RE.test(line)) {
43596
+ this.fence = !this.fence;
43597
+ this.flushBlock();
43598
+ this.lastBlank = false;
43599
+ return;
43600
+ }
43601
+ if (this.fence)
43602
+ return;
43603
+ if (/^\s*$/.test(line)) {
43604
+ this.flushBlock();
43605
+ this.lastBlank = true;
43606
+ return;
43607
+ }
43608
+ const h = HEADING_RE.exec(line);
43609
+ if (h) {
43610
+ this.onHeading(h[1].length, h[2]);
43611
+ return;
43612
+ }
43613
+ if (this.sessionLogLevel !== null)
43614
+ return;
43615
+ if (TABLE_RE.test(line)) {
43616
+ this.onTableRow(line);
43617
+ return;
43618
+ }
43619
+ const item = ITEM_RE.exec(line);
43620
+ if (item) {
43621
+ this.onItem(line, item[2]);
43622
+ return;
43623
+ }
43624
+ this.onText(line);
43625
+ }
43626
+ finish() {
43627
+ this.flushBlock();
43628
+ this.closeWhere(() => true);
43629
+ return { walls: this.walls, played: this.played, asOf: this.asOf };
43630
+ }
43631
+ onHeading(level, label) {
43632
+ this.flushBlock();
43633
+ this.closeWhere((r) => r.kind === "item" || r.depth >= level);
43634
+ this.lastBlank = false;
43635
+ if (this.sessionLogLevel !== null && level > this.sessionLogLevel)
43636
+ return;
43637
+ this.sessionLogLevel = SESSION_LOG_RE.test(label.trim()) ? level : null;
43638
+ if (this.sessionLogLevel !== null)
43639
+ return;
43640
+ this.open.push({
43641
+ kind: "heading",
43642
+ depth: level,
43643
+ label,
43644
+ upcoming: UPCOMING_RE.test(label),
43645
+ played: [],
43646
+ childBlocks: 0,
43647
+ ownLatest: null
43648
+ });
43649
+ this.heading = label;
43650
+ if (CURRENT_STATE_RE.test(label))
43651
+ this.noteAsOf(label);
43652
+ }
43653
+ noteAsOf(label) {
43654
+ const dated = readAsOf(label, this.today);
43655
+ if (dated === null) {
43656
+ this.asOf.push({ heading: safeLabel(label), daysOld: null });
43657
+ return;
43658
+ }
43659
+ const age = daysBetween2(dated.parts, this.today);
43660
+ if (age > CURRENT_STATE_AS_OF_MAX_DAYS || age < 0) {
43661
+ this.asOf.push({ heading: safeLabel(label), daysOld: age });
43662
+ }
43663
+ }
43664
+ onTableRow(line) {
43665
+ this.flushBlock();
43666
+ if (this.lastBlank) {
43667
+ const indent = indentOf(line);
43668
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
43669
+ }
43670
+ this.judgeEntry(this.upcomingRegion(), readDayMentions(line, this.today).map((m) => m.parts), false);
43671
+ this.lastBlank = false;
43672
+ }
43673
+ onItem(line, body) {
43674
+ this.flushBlock();
43675
+ const indent = indentOf(line);
43676
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
43677
+ const labelMatch = LABEL_RE.exec(body);
43678
+ const label = labelMatch ? labelMatch[1] : null;
43679
+ const upcoming = label !== null && UPCOMING_RE.test(label);
43680
+ const region = { kind: "item", depth: indent, label, upcoming, played: [], childBlocks: 0, ownLatest: null };
43681
+ this.open.push(region);
43682
+ this.block = { heading: this.heading, text: line.trim() };
43683
+ this.blockRegion = this.upcomingRegion();
43684
+ this.blockIsLabel = this.blockRegion === region;
43685
+ this.collectDates(labelMatch && upcoming ? body.slice(labelMatch[0].length) : body);
43686
+ this.lastBlank = false;
43687
+ }
43688
+ onText(line) {
43689
+ const indent = indentOf(line);
43690
+ if (this.lastBlank) {
43691
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
43692
+ }
43693
+ if (this.block === null) {
43694
+ this.block = { heading: this.heading, text: line.trim() };
43695
+ this.blockRegion = this.upcomingRegion();
43696
+ this.blockIsLabel = false;
43697
+ } else {
43698
+ this.block.text += `
43699
+ ${line.trim()}`;
43700
+ }
43701
+ this.collectDates(line);
43702
+ this.lastBlank = false;
43703
+ }
43704
+ flushBlock() {
43705
+ if (this.block === null)
43706
+ return;
43707
+ const chars = [...this.block.text].length;
43708
+ if (chars > CONTEXT_BLOCK_BAR_CHARS) {
43709
+ this.walls.push({
43710
+ heading: this.block.heading === null ? null : safeLabel(this.block.heading),
43711
+ opening: openingOf(this.block.text),
43712
+ chars
43713
+ });
43714
+ }
43715
+ this.judgeEntry(this.blockRegion, this.blockDates, this.blockIsLabel);
43716
+ this.block = null;
43717
+ this.blockDates = [];
43718
+ this.blockRegion = null;
43719
+ this.blockIsLabel = false;
43720
+ }
43721
+ judgeEntry(region, dates, isLabel) {
43722
+ if (region === null)
43723
+ return;
43724
+ const latest = latestOf(dates);
43725
+ const played = latest !== null && utcMs(latest) < this.todayMs;
43726
+ if (isLabel) {
43727
+ region.ownLatest = played ? latest : null;
43728
+ return;
43729
+ }
43730
+ region.childBlocks += 1;
43731
+ if (played)
43732
+ region.played.push(latest);
43733
+ }
43734
+ closeRegion(r) {
43735
+ if (!r.upcoming)
43736
+ return;
43737
+ const entries = r.childBlocks === 0 && r.ownLatest !== null ? [r.ownLatest] : r.played;
43738
+ const latest = latestOf(entries);
43739
+ if (latest === null)
43740
+ return;
43741
+ this.played.push({ label: safeLabel(r.label ?? ""), playedEntries: entries.length, latest });
43742
+ }
43743
+ closeWhere(reject) {
43744
+ for (let i = this.open.length - 1;i >= 0; i -= 1) {
43745
+ const r = this.open[i];
43746
+ if (reject(r)) {
43747
+ this.closeRegion(r);
43748
+ this.open.splice(i, 1);
43749
+ }
43750
+ }
43751
+ }
43752
+ upcomingRegion() {
43753
+ for (let i = this.open.length - 1;i >= 0; i -= 1) {
43754
+ if (this.open[i].upcoming)
43755
+ return this.open[i];
43756
+ }
43757
+ return null;
43758
+ }
43759
+ collectDates(segment) {
43760
+ if (this.blockRegion === null)
43761
+ return;
43762
+ for (const mention of readDayMentions(segment, this.today))
43763
+ this.blockDates.push(mention.parts);
43764
+ }
43765
+ }
43766
+ function assessContextWalls(workspaceSlug, deps = {}) {
43767
+ const readAnchor = deps.readArtistAnchor ?? readArtistAnchorFile;
43768
+ const readContext = deps.readContext ?? readContextFile;
43769
+ const rosterOf = deps.readRosterFn ?? readRoster;
43770
+ const today = localDayOf((deps.now ?? (() => new Date))());
43771
+ const walls = [];
43772
+ const played = [];
43773
+ const asOf = [];
43774
+ let unreadable = 0;
43775
+ const take = (path5, result) => {
43776
+ for (const w of result.walls)
43777
+ walls.push({ path: path5, ...w });
43778
+ for (const p of result.played)
43779
+ played.push({ path: path5, ...p });
43780
+ for (const a of result.asOf)
43781
+ asOf.push({ path: path5, ...a });
43782
+ };
43783
+ let roster;
43784
+ try {
43785
+ roster = rosterOf(workspaceSlug);
43786
+ } catch {
43787
+ roster = [];
43788
+ }
43789
+ for (const artist of roster) {
43790
+ let raw = null;
43791
+ try {
43792
+ raw = readAnchor(workspaceSlug, artist.slug);
43793
+ } catch {
43794
+ unreadable += 1;
43795
+ continue;
43796
+ }
43797
+ if (raw === null)
43798
+ continue;
43799
+ take(`artists/${artist.slug}/${ARTIST_ANCHOR_FILENAME}`, scanContextText(raw, today));
43800
+ }
43801
+ let root = null;
43802
+ try {
43803
+ root = readContext(workspaceSlug, CLAUDE_MD);
43804
+ } catch {
43805
+ root = null;
43806
+ }
43807
+ if (root !== null) {
43808
+ const block = extractUserBlock(root);
43809
+ if (block !== null) {
43810
+ take(CLAUDE_MD, scanContextText(block, today, { initialHeading: "the customisable block" }));
43811
+ }
43812
+ }
43813
+ walls.sort((a, b) => b.chars - a.chars || a.path.localeCompare(b.path));
43814
+ played.sort((a, b) => b.playedEntries - a.playedEntries || a.path.localeCompare(b.path));
43815
+ asOf.sort((a, b) => {
43816
+ if (a.daysOld === null !== (b.daysOld === null))
43817
+ return a.daysOld === null ? -1 : 1;
43818
+ return (b.daysOld ?? 0) - (a.daysOld ?? 0) || a.path.localeCompare(b.path);
43819
+ });
43820
+ return { walls, played, asOf, unreadable };
43821
+ }
43822
+
42462
43823
  // src/lib/build-stamp.ts
42463
43824
  import { execFileSync as execFileSync4 } from "node:child_process";
42464
43825
  import { existsSync as existsSync9, mkdirSync as mkdirSync11, readFileSync as readFileSync26, writeFileSync as writeFileSync8 } from "node:fs";
@@ -42547,8 +43908,8 @@ function checkBuildStaleness(deps = {}) {
42547
43908
 
42548
43909
  // src/lib/memory-links.ts
42549
43910
  import { readFileSync as readFileSync27 } from "node:fs";
42550
- import { relative as relative13, sep as sep5 } from "node:path";
42551
- var MEMORY_FILE_RE = /^(workspace-memory\.md|artists\/[^/]+\/memory\.md)$/i;
43911
+ import { relative as relative14, sep as sep6 } from "node:path";
43912
+ var MEMORY_FILE_RE = /^(workspace-memory\.md|user-memory\.md|artists\/[^/]+\/memory\.md|memory\/[^/]+\.md|memory\/private\/[^/]+\.md)$/i;
42552
43913
  function scanMemoryLinks(deps) {
42553
43914
  const listFiles = deps.listFiles ?? ((r) => walkSubtreeFiles(r, isVcsInternal));
42554
43915
  const readFile = deps.readFile ?? ((p) => readFileSync27(p, "utf8"));
@@ -42560,7 +43921,7 @@ function scanMemoryLinks(deps) {
42560
43921
  } catch {
42561
43922
  return { scanned: 0, unreadable: [], dead: [] };
42562
43923
  }
42563
- const relPaths = absPaths.map((p) => relative13(root, p).split(sep5).join("/"));
43924
+ const relPaths = absPaths.map((p) => relative14(root, p).split(sep6).join("/"));
42564
43925
  const artists = buildArtistIndex(relPaths, root, (abs) => readFile(abs));
42565
43926
  const dead = [];
42566
43927
  const unreadable = [];
@@ -42596,7 +43957,7 @@ init_output();
42596
43957
 
42597
43958
  // src/lib/pending-writes.ts
42598
43959
  import { readFileSync as readFileSync28, statSync as statSync18 } from "node:fs";
42599
- import { relative as relative14 } from "node:path";
43960
+ import { relative as relative15 } from "node:path";
42600
43961
  var import_gray_matter14 = __toESM(require_gray_matter(), 1);
42601
43962
  var EMPTY2 = { writes: [], count: 0 };
42602
43963
  function reportedElsewhere(state) {
@@ -42625,7 +43986,7 @@ function scanPendingWrites(args) {
42625
43986
  const writes = [];
42626
43987
  const discovered = [];
42627
43988
  for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
42628
- const relPath = relative14(mirrorRootPath, absolutePath);
43989
+ const relPath = relative15(mirrorRootPath, absolutePath);
42629
43990
  const fileMatch = classifyFilesPath({ workspaceSlug, absolutePath });
42630
43991
  if (fileMatch) {
42631
43992
  if (frozenFiles.has(fileMatch.relPath))
@@ -43102,8 +44463,11 @@ async function runStatus(args, deps = {}) {
43102
44463
  if (stashes.length === 0) {
43103
44464
  out.info(row("Conflicts", "No conflict stashes."));
43104
44465
  } else {
43105
- const cloudSide = stashes.filter((s) => s.side === "cloud");
43106
- const localSide = stashes.filter((s) => s.side !== "cloud");
44466
+ const generatedDeps = generatedMemoryStashDepsFrom(localState);
44467
+ const generatedSide = stashes.filter((s) => isGeneratedMemoryStash(s.label, generatedDeps) !== null);
44468
+ const generatedLabels = new Set(generatedSide.map((s) => s.label));
44469
+ const cloudSide = stashes.filter((s) => s.side === "cloud" && !generatedLabels.has(s.label));
44470
+ const localSide = stashes.filter((s) => s.side !== "cloud" && !generatedLabels.has(s.label));
43107
44471
  out.info(row("Conflicts", `${stashes.length} stashed cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
43108
44472
  const newest = stashes[0];
43109
44473
  const when = newest.stashedAt ? `${formatAbsolute(newest.stashedAt)} (${formatRelative(now.getTime() - newest.stashedAt.getTime())})` : "time unknown";
@@ -43112,6 +44476,9 @@ async function runStatus(args, deps = {}) {
43112
44476
  if (localSide.length > 0) {
43113
44477
  out.info(cont(`${lead(localSide.length)} your version of a file that was replaced by the cloud copy. Compare it with the file in your mirror, re-apply anything you still want, then delete the stash.`));
43114
44478
  }
44479
+ if (generatedSide.length > 0) {
44480
+ out.info(cont(`${lead(generatedSide.length)} an edit to a file Plinth generates from memory facts. Putting the text back will not keep it - change the fact instead, and the file is rewritten. The Rejected rows above name the tool for each.`));
44481
+ }
43115
44482
  if (cloudSide.length > 0) {
43116
44483
  out.info(cont(`${lead(cloudSide.length)} the cloud's copy from a merge that went field by field: your writing won and is in your file, the cloud's other fields won, and the stash is what your writing replaced. Read it, fold in anything you still want, then delete the stash.`));
43117
44484
  }
@@ -43359,6 +44726,16 @@ async function runStatus(args, deps = {}) {
43359
44726
  }
43360
44727
  out.info(cont("The convention is one line per fact: a hook and a [[pointer]], not a " + "paragraph. A line this long is usually several facts, so splitting it " + "keeps all of them and cutting it loses some."));
43361
44728
  }
44729
+ let context = { walls: [], played: [], asOf: [], unreadable: 0 };
44730
+ try {
44731
+ context = assessContextWalls(slug, { now: () => now });
44732
+ } catch {
44733
+ context = { walls: [], played: [], asOf: [], unreadable: 0 };
44734
+ }
44735
+ renderWalls(out, context);
44736
+ renderPlayed(out, context);
44737
+ renderAsOf(out, context);
44738
+ renderUnchecked(out, context);
43362
44739
  const anchor = localState.size_anchor;
43363
44740
  if (anchor !== undefined) {
43364
44741
  let now2 = {
@@ -43399,6 +44776,53 @@ async function runStatus(args, deps = {}) {
43399
44776
  out.info("");
43400
44777
  return { exitCode: 0 };
43401
44778
  }
44779
+ var num = (v) => v.toLocaleString("en-GB");
44780
+ function renderWalls(out, context) {
44781
+ const total = context.walls.length;
44782
+ if (total === 0)
44783
+ return;
44784
+ const named = context.walls.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
44785
+ out.info(row("Walls", `${total} block${total === 1 ? "" : "s"} of master context ${total === 1 ? "has" : "have"} ` + `grown past ${num(CONTEXT_BLOCK_BAR_CHARS)} characters.`));
44786
+ for (const w of named) {
44787
+ const where = w.heading === null ? "before its first heading" : `under "${w.heading}"`;
44788
+ out.info(cont(`${w.path}, ${where}: ${num(w.chars)} characters, opening "${w.opening}"`));
44789
+ }
44790
+ if (total > named.length)
44791
+ out.info(cont(`And ${total - named.length} more, not named here.`));
44792
+ out.info(cont("A block this long is one nobody reads whole. Split it into short blocks, " + "one per thing it covers - in master context, one per workstream, each " + "pointing at the record that holds its detail."));
44793
+ }
44794
+ function renderPlayed(out, context) {
44795
+ const total = context.played.length;
44796
+ if (total === 0)
44797
+ return;
44798
+ const named = context.played.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
44799
+ out.info(row("Played", `${total} label${total === 1 ? "" : "s"} that ${total === 1 ? "says" : "say"} upcoming still ` + `${total === 1 ? "holds" : "hold"} entries that have played.`));
44800
+ for (const p of named) {
44801
+ out.info(cont(`${p.path}, under "${p.label}": ${num(p.playedEntries)} ` + `${p.playedEntries === 1 ? "entry has" : "entries have"} played, the latest ${formatDay(p.latest, -1)}.`));
44802
+ }
44803
+ if (total > named.length)
44804
+ out.info(cont(`And ${total - named.length} more, not named here.`));
44805
+ out.info(cont("An entry has played when every date in it is behind us. Move it out from " + "under the upcoming label, and put what it taught into the record it concerns."));
44806
+ }
44807
+ function renderAsOf(out, context) {
44808
+ const total = context.asOf.length;
44809
+ if (total === 0)
44810
+ return;
44811
+ const named = context.asOf.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
44812
+ out.info(row("As of", `${total} current-state section${total === 1 ? "" : "s"} cannot be checked against a date.`));
44813
+ for (const a of named) {
44814
+ out.info(cont(a.daysOld === null ? `${a.path}: "${a.heading}" carries no as-of date.` : a.daysOld < 0 ? `${a.path}: "${a.heading}" is dated ${num(-a.daysOld)} days ahead of today, which an as-of cannot be.` : `${a.path}: "${a.heading}" is ${num(a.daysOld)} days old, past the ` + `${CURRENT_STATE_AS_OF_MAX_DAYS}-day bar.`));
44815
+ }
44816
+ if (total > named.length)
44817
+ out.info(cont(`And ${total - named.length} more, not named here.`));
44818
+ out.info(cont("Without a date a current-state section reads as current whatever has " + "happened since. Re-date it when you reconcile it against what has arrived, " + "and not before."));
44819
+ }
44820
+ function renderUnchecked(out, context) {
44821
+ const n = context.unreadable;
44822
+ if (n === 0)
44823
+ return;
44824
+ out.info(row("Unchecked", `${n} artist ${n === 1 ? "file" : "files"} could not be read, so the walls, played ` + `and as-of checks did not see ${n === 1 ? "it" : "them"}.`));
44825
+ }
43402
44826
  var statusCommand = defineCommand({
43403
44827
  meta: {
43404
44828
  name: "status",
@@ -43737,7 +45161,7 @@ function denyUngated(detail) {
43737
45161
  // src/lib/voice-gate/payload.ts
43738
45162
  import { readFileSync as readFileSync30, statSync as statSync20 } from "node:fs";
43739
45163
  import { homedir as homedir2 } from "node:os";
43740
- import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
45164
+ import { isAbsolute as isAbsolute4, resolve as resolve4 } from "node:path";
43741
45165
  var MAX_BODY_BYTES = 256 * 1024;
43742
45166
  var HELPER_BASENAME = "push_gmail_draft.py";
43743
45167
  var DRAFT_FLAGS = ["--to", "--body-file", "--body-stdin"];
@@ -43927,7 +45351,7 @@ function applyCd(tokens, state) {
43927
45351
  return { dir: state.dir, known: false };
43928
45352
  }
43929
45353
  const expanded = expandHome(target);
43930
- if (isAbsolute3(expanded))
45354
+ if (isAbsolute4(expanded))
43931
45355
  return { dir: expanded, known: true };
43932
45356
  if (!state.known)
43933
45357
  return state;
@@ -43958,12 +45382,12 @@ function classifyHelperSegment(tokens, dirState, readBodyFile) {
43958
45382
  };
43959
45383
  }
43960
45384
  const expanded = expandHome(bodyFile);
43961
- if (!isAbsolute3(expanded) && !dirState.known) {
45385
+ if (!isAbsolute4(expanded) && !dirState.known) {
43962
45386
  return {
43963
45387
  unreadable: `the draft push uses a relative \`--body-file\` (${bodyFile}) after a \`cd\` ` + `the voice gate cannot resolve, so it cannot tell which file ships. Pass an ` + `absolute \`--body-file\` path.`
43964
45388
  };
43965
45389
  }
43966
- const resolved = isAbsolute3(expanded) ? expanded : resolve4(dirState.dir, expanded);
45390
+ const resolved = isAbsolute4(expanded) ? expanded : resolve4(dirState.dir, expanded);
43967
45391
  let body;
43968
45392
  try {
43969
45393
  body = readBodyFile(resolved);