@plinth-music/cli 0.15.0 → 0.17.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.17.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",
@@ -23652,6 +23654,46 @@ function writeMirrorFile(workspaceSlug, entityType, entitySlug, content) {
23652
23654
  throw err;
23653
23655
  }
23654
23656
  }
23657
+
23658
+ class MirrorDestinationOccupiedError extends MirrorPathError {
23659
+ fromSlug;
23660
+ toSlug;
23661
+ constructor(fromSlug, toSlug) {
23662
+ super(`Cannot move ${JSON.stringify(fromSlug)} to ${JSON.stringify(toSlug)}: ` + `a file already exists there`);
23663
+ this.name = "MirrorDestinationOccupiedError";
23664
+ this.fromSlug = fromSlug;
23665
+ this.toSlug = toSlug;
23666
+ }
23667
+ }
23668
+ function moveEntityFile(workspaceSlug, entityType, fromSlug, toSlug, beforeMove) {
23669
+ const strategy = getStrategy(entityType);
23670
+ const rel = strategy ? strategy.mirrorRelativePath(fromSlug) : flatMirrorRelativePath(entityType)(fromSlug);
23671
+ if (rel.split("/").length !== 2) {
23672
+ throw new MirrorPathError(`Entity type ${JSON.stringify(entityType)} keeps its rows in a folder; ` + `moving one is not supported`);
23673
+ }
23674
+ const from = entityFilePath(workspaceSlug, entityType, fromSlug);
23675
+ const to = entityFilePath(workspaceSlug, entityType, toSlug);
23676
+ if (from === to)
23677
+ return "absent";
23678
+ try {
23679
+ lstatSync(to);
23680
+ throw new MirrorDestinationOccupiedError(fromSlug, toSlug);
23681
+ } catch (err) {
23682
+ if (err instanceof MirrorDestinationOccupiedError)
23683
+ throw err;
23684
+ if (err?.code !== "ENOENT")
23685
+ throw err;
23686
+ }
23687
+ beforeMove?.();
23688
+ try {
23689
+ renameSync(from, to);
23690
+ return "moved";
23691
+ } catch (err) {
23692
+ if (err?.code === "ENOENT")
23693
+ return "absent";
23694
+ throw err;
23695
+ }
23696
+ }
23655
23697
  var ARTIST_MEMORY_ANCHOR = "memory.md";
23656
23698
  function artistMemoryFilePath(workspaceSlug, entitySlug) {
23657
23699
  assertSlug("entity slug", entitySlug);
@@ -23688,6 +23730,16 @@ function readArtistMemoryFile(workspaceSlug, entitySlug) {
23688
23730
  throw err;
23689
23731
  }
23690
23732
  }
23733
+ function readArtistAnchorFile(workspaceSlug, entitySlug) {
23734
+ const target = entityFilePath(workspaceSlug, "artists", entitySlug);
23735
+ try {
23736
+ return readFileSync(target, "utf8");
23737
+ } catch (err) {
23738
+ if (err?.code === "ENOENT")
23739
+ return null;
23740
+ throw err;
23741
+ }
23742
+ }
23691
23743
  function deleteArtistMemoryFile(workspaceSlug, entitySlug) {
23692
23744
  const target = artistMemoryFilePath(workspaceSlug, entitySlug);
23693
23745
  try {
@@ -23699,6 +23751,113 @@ function deleteArtistMemoryFile(workspaceSlug, entitySlug) {
23699
23751
  throw err;
23700
23752
  }
23701
23753
  }
23754
+ var MEMORY_FACTS_DIR = "memory";
23755
+ var MEMORY_FACTS_PRIVATE_SEGMENT = "private";
23756
+ var MEMORY_FACT_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
23757
+ var MEMORY_FACT_SLUG_MAX = 64;
23758
+ function assertFactSlug(slug) {
23759
+ if (!MEMORY_FACT_SLUG_RE.test(slug) || slug.length > MEMORY_FACT_SLUG_MAX) {
23760
+ throw new MirrorPathError(`Invalid memory fact slug: ${JSON.stringify(slug)}`);
23761
+ }
23762
+ }
23763
+ function memoryFactRelPath(pile, slug) {
23764
+ assertFactSlug(slug);
23765
+ return pile === "private" ? `${MEMORY_FACTS_DIR}/${MEMORY_FACTS_PRIVATE_SEGMENT}/${slug}.md` : `${MEMORY_FACTS_DIR}/${slug}.md`;
23766
+ }
23767
+ function parseMemoryFactRelPath(rel) {
23768
+ const posix = rel.split(sep).join("/");
23769
+ if (!posix.startsWith(`${MEMORY_FACTS_DIR}/`) || !posix.endsWith(".md")) {
23770
+ return null;
23771
+ }
23772
+ const tail = posix.slice(MEMORY_FACTS_DIR.length + 1, -".md".length);
23773
+ const privatePrefix = `${MEMORY_FACTS_PRIVATE_SEGMENT}/`;
23774
+ if (tail.startsWith(privatePrefix)) {
23775
+ const slug = tail.slice(privatePrefix.length);
23776
+ return MEMORY_FACT_SLUG_RE.test(slug) && slug.length <= MEMORY_FACT_SLUG_MAX ? { pile: "private", slug } : null;
23777
+ }
23778
+ return MEMORY_FACT_SLUG_RE.test(tail) && tail.length <= MEMORY_FACT_SLUG_MAX ? { pile: "shared", slug: tail } : null;
23779
+ }
23780
+ function memoryFactFilePath(workspaceSlug, pile, slug) {
23781
+ const ws = workspaceMirrorPath(workspaceSlug);
23782
+ const posix = memoryFactRelPath(pile, slug);
23783
+ const native = sep === "/" ? posix : posix.split("/").join(sep);
23784
+ const target = resolve(join2(ws, native));
23785
+ const prefix = join2(ws, MEMORY_FACTS_DIR) + sep;
23786
+ if (!target.startsWith(prefix)) {
23787
+ throw new MirrorPathError(`Resolved memory fact path escapes the memory subtree`);
23788
+ }
23789
+ assertNoSymlinkedComponent(ws, target);
23790
+ return target;
23791
+ }
23792
+ function memoryFactsSubtreeRoot(workspaceSlug) {
23793
+ const ws = workspaceMirrorPath(workspaceSlug);
23794
+ const target = join2(ws, MEMORY_FACTS_DIR);
23795
+ assertNoSymlinkedComponent(ws, target);
23796
+ return target;
23797
+ }
23798
+ function writeMemoryFactFile(workspaceSlug, pile, slug, content) {
23799
+ const target = memoryFactFilePath(workspaceSlug, pile, slug);
23800
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
23801
+ mkdirSync(dirname(target), { recursive: true, mode: 448 });
23802
+ writeFileSync(tmp, content, { mode: 384 });
23803
+ try {
23804
+ renameSync(tmp, target);
23805
+ } catch (err) {
23806
+ try {
23807
+ unlinkSync(tmp);
23808
+ } catch {}
23809
+ throw err;
23810
+ }
23811
+ }
23812
+ function readMemoryFactFile(workspaceSlug, pile, slug) {
23813
+ try {
23814
+ return readFileSync(memoryFactFilePath(workspaceSlug, pile, slug), "utf8");
23815
+ } catch (err) {
23816
+ if (err?.code === "ENOENT")
23817
+ return null;
23818
+ throw err;
23819
+ }
23820
+ }
23821
+ function deleteMemoryFactFile(workspaceSlug, pile, slug) {
23822
+ const target = memoryFactFilePath(workspaceSlug, pile, slug);
23823
+ try {
23824
+ unlinkSync(target);
23825
+ return true;
23826
+ } catch (err) {
23827
+ if (err?.code === "ENOENT")
23828
+ return false;
23829
+ throw err;
23830
+ }
23831
+ }
23832
+ function listMemoryFactFiles(workspaceSlug) {
23833
+ let root;
23834
+ try {
23835
+ root = memoryFactsSubtreeRoot(workspaceSlug);
23836
+ } catch {
23837
+ return [];
23838
+ }
23839
+ const found = [];
23840
+ const read = (dir, pile) => {
23841
+ let entries;
23842
+ try {
23843
+ entries = readdirSync(dir, { withFileTypes: true });
23844
+ } catch {
23845
+ return;
23846
+ }
23847
+ for (const e of entries) {
23848
+ if (!e.isFile() || !e.name.endsWith(".md"))
23849
+ continue;
23850
+ const slug = e.name.slice(0, -3);
23851
+ if (!MEMORY_FACT_SLUG_RE.test(slug) || slug.length > MEMORY_FACT_SLUG_MAX) {
23852
+ continue;
23853
+ }
23854
+ found.push({ pile, slug });
23855
+ }
23856
+ };
23857
+ read(root, "shared");
23858
+ read(join2(root, MEMORY_FACTS_PRIVATE_SEGMENT), "private");
23859
+ 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);
23860
+ }
23702
23861
  var FILES_SUBTREE_DIR = "files";
23703
23862
  var FILES_CONTROL_CHAR_RE = new RegExp("[\\x00-\\x1f\\x7f]");
23704
23863
  function validateFilesRelPath(relPath) {
@@ -25910,6 +26069,9 @@ var COMMAND_BAR_BYTES = 24000;
25910
26069
  var INDEX_LINE_MAX_BYTES = 300;
25911
26070
  var MAX_NAMED_FACT_STORES = 5;
25912
26071
  var MAX_NAMED_LONG_INDEX_LINES = 3;
26072
+ var CONTEXT_BLOCK_BAR_CHARS = 2000;
26073
+ var CURRENT_STATE_AS_OF_MAX_DAYS = 30;
26074
+ var MAX_NAMED_CONTEXT_FINDINGS = 5;
25913
26075
  function countRuleShapedLines(body) {
25914
26076
  let count = 0;
25915
26077
  for (const line of body.split(`
@@ -25926,531 +26088,15 @@ function countParagraphs(body) {
25926
26088
  return body.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0).length;
25927
26089
  }
25928
26090
 
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
26091
  // src/lib/state.ts
26446
26092
  import {
26447
26093
  mkdirSync as mkdirSync6,
26448
- readFileSync as readFileSync8,
26094
+ readFileSync as readFileSync7,
26449
26095
  renameSync as renameSync5,
26450
26096
  unlinkSync as unlinkSync5,
26451
26097
  writeFileSync as writeFileSync5
26452
26098
  } from "node:fs";
26453
- import { dirname as dirname4, join as join7 } from "node:path";
26099
+ import { dirname as dirname4, join as join6 } from "node:path";
26454
26100
  var StateEntrySchema = exports_external.object({
26455
26101
  id: exports_external.string().uuid(),
26456
26102
  slug: exports_external.string().min(1),
@@ -26460,7 +26106,8 @@ var StateEntrySchema = exports_external.object({
26460
26106
  frontmatter_hash: exports_external.string().min(1),
26461
26107
  inode: exports_external.number().int().nonnegative().optional(),
26462
26108
  body_len: exports_external.number().int().nonnegative().optional(),
26463
- memory_hash: exports_external.string().min(1).optional()
26109
+ memory_hash: exports_external.string().min(1).optional(),
26110
+ memory_generated: exports_external.boolean().optional()
26464
26111
  });
26465
26112
  var EntityEntriesSchema = exports_external.record(exports_external.string().uuid(), StateEntrySchema);
26466
26113
  var FileStateEntrySchema = exports_external.object({
@@ -26480,17 +26127,27 @@ var WorkspaceRulesStateSchema = exports_external.object({
26480
26127
  });
26481
26128
  var WorkspaceMemoryStateSchema = exports_external.object({
26482
26129
  body_hash: exports_external.string().min(1),
26483
- updated_at: exports_external.string().datetime({ offset: true }).nullable()
26130
+ updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26131
+ generated: exports_external.boolean().optional()
26484
26132
  });
26485
26133
  var UserMemoryStateSchema = exports_external.object({
26486
26134
  body_hash: exports_external.string().min(1),
26487
- updated_at: exports_external.string().datetime({ offset: true }).nullable()
26135
+ updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26136
+ generated: exports_external.boolean().optional()
26488
26137
  });
26489
26138
  var UserVoiceStateSchema = exports_external.object({
26490
26139
  body_hash: exports_external.string().min(1),
26491
26140
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
26492
26141
  status: exports_external.enum(["absent", "malformed", "valid"]).optional()
26493
26142
  });
26143
+ var MemoryFactEntrySchema = exports_external.object({
26144
+ id: exports_external.string().min(1),
26145
+ body_hash: exports_external.string().min(1)
26146
+ });
26147
+ var MemoryFactsStateSchema = exports_external.object({
26148
+ fetched_at: exports_external.string().datetime({ offset: true }),
26149
+ facts: exports_external.record(exports_external.string(), MemoryFactEntrySchema).default({})
26150
+ });
26494
26151
  var UnsyncedOpSchema = exports_external.object({
26495
26152
  target: exports_external.enum(["entity", "file"]),
26496
26153
  op: exports_external.enum(["upsert", "delete"]),
@@ -26549,7 +26206,8 @@ var WorkspaceStateV2Schema = exports_external.object({
26549
26206
  frontmatter_id_backfill_version: exports_external.number().int().nonnegative().optional(),
26550
26207
  team_rejected: TeamRejectedSchema.optional(),
26551
26208
  size_anchor: SizeAnchorSchema.optional(),
26552
- last_trim_at: exports_external.string().min(1).optional()
26209
+ last_trim_at: exports_external.string().min(1).optional(),
26210
+ memory_facts: MemoryFactsStateSchema.optional()
26553
26211
  });
26554
26212
  var WorkspaceStateV1Schema = exports_external.object({
26555
26213
  version: exports_external.literal(1),
@@ -26557,10 +26215,10 @@ var WorkspaceStateV1Schema = exports_external.object({
26557
26215
  documents: EntityEntriesSchema
26558
26216
  });
26559
26217
  function stateFilePath(workspaceSlug) {
26560
- return join7(plinthHome(), workspaceSlug, "state.json");
26218
+ return join6(plinthHome(), workspaceSlug, "state.json");
26561
26219
  }
26562
26220
  function stateTmpPath(workspaceSlug) {
26563
- return join7(plinthHome(), workspaceSlug, `state.json.${process.pid}.tmp`);
26221
+ return join6(plinthHome(), workspaceSlug, `state.json.${process.pid}.tmp`);
26564
26222
  }
26565
26223
  function emptyState(workspaceSlug) {
26566
26224
  return { version: 2, workspace_slug: workspaceSlug, entries: {}, files: {} };
@@ -26576,7 +26234,7 @@ function migrateV1(v1) {
26576
26234
  function loadState(workspaceSlug) {
26577
26235
  let raw;
26578
26236
  try {
26579
- raw = readFileSync8(stateFilePath(workspaceSlug), "utf8");
26237
+ raw = readFileSync7(stateFilePath(workspaceSlug), "utf8");
26580
26238
  } catch (err) {
26581
26239
  if (err?.code === "ENOENT") {
26582
26240
  return emptyState(workspaceSlug);
@@ -26741,7 +26399,8 @@ function commitState(before, after) {
26741
26399
  frontmatter_id_backfill_version: mergeScalar(fresh.frontmatter_id_backfill_version, before.frontmatter_id_backfill_version, after.frontmatter_id_backfill_version),
26742
26400
  team_rejected: mergeScalar(fresh.team_rejected, before.team_rejected, after.team_rejected),
26743
26401
  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)
26402
+ last_trim_at: mergeScalar(fresh.last_trim_at, before.last_trim_at, after.last_trim_at),
26403
+ memory_facts: mergeScalar(fresh.memory_facts, before.memory_facts, after.memory_facts)
26745
26404
  };
26746
26405
  writeState(merged);
26747
26406
  const singletonOverruled = rules.overruled || memory.overruled || userMemory.overruled || userVoice.overruled;
@@ -26934,145 +26593,729 @@ function upsertRejected(state, candidate) {
26934
26593
  next.push(merged);
26935
26594
  return { state: { ...state, rejected: next }, changed: true };
26936
26595
  }
26937
- function removeRejected(state, target, path3) {
26938
- const list = state.rejected ?? [];
26939
- const next = list.filter((o) => !(o.target === target && o.path === path3));
26940
- if (next.length === list.length)
26941
- return { state, changed: false };
26942
- return { state: { ...state, rejected: next }, changed: true };
26596
+ function removeRejected(state, target, path3) {
26597
+ const list = state.rejected ?? [];
26598
+ const next = list.filter((o) => !(o.target === target && o.path === path3));
26599
+ if (next.length === list.length)
26600
+ return { state, changed: false };
26601
+ return { state: { ...state, rejected: next }, changed: true };
26602
+ }
26603
+ function markRejectedReported(state, path3, reportedAt) {
26604
+ const list = state.rejected ?? [];
26605
+ const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26606
+ if (idx < 0)
26607
+ return { state, changed: false };
26608
+ if (list[idx].reported_at === reportedAt)
26609
+ return { state, changed: false };
26610
+ const next = [...list];
26611
+ next[idx] = { ...list[idx], reported_at: reportedAt };
26612
+ return { state: { ...state, rejected: next }, changed: true };
26613
+ }
26614
+ function clearRejectedReported(state, path3) {
26615
+ const list = state.rejected ?? [];
26616
+ const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26617
+ if (idx < 0)
26618
+ return { state, changed: false };
26619
+ if (list[idx].reported_at === undefined)
26620
+ return { state, changed: false };
26621
+ const next = [...list];
26622
+ const { reported_at: _dropped, ...rest } = list[idx];
26623
+ next[idx] = rest;
26624
+ return { state: { ...state, rejected: next }, changed: true };
26625
+ }
26626
+ function listTeamRejected(state) {
26627
+ return state.team_rejected?.rows ?? [];
26628
+ }
26629
+ function teamRejectedFetchedAt(state) {
26630
+ return state.team_rejected?.fetched_at;
26631
+ }
26632
+ function setTeamRejected(state, rows, fetchedAt) {
26633
+ const existing = state.team_rejected;
26634
+ if (existing && rowsEqual(existing.rows, rows)) {
26635
+ return { state, changed: false };
26636
+ }
26637
+ return {
26638
+ state: { ...state, team_rejected: { fetched_at: fetchedAt, rows } },
26639
+ changed: true
26640
+ };
26641
+ }
26642
+ function rowsEqual(a, b) {
26643
+ if (a.length !== b.length)
26644
+ return false;
26645
+ return a.every((x, i) => {
26646
+ const y = b[i];
26647
+ 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;
26648
+ });
26649
+ }
26650
+ function getWorkspaceRulesState(state) {
26651
+ return state.workspace_rules;
26652
+ }
26653
+ function setWorkspaceRulesState(state, entry) {
26654
+ const existing = state.workspace_rules;
26655
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at) {
26656
+ return { state, changed: false };
26657
+ }
26658
+ return { state: { ...state, workspace_rules: entry }, changed: true };
26659
+ }
26660
+ function getWorkspaceMemoryState(state) {
26661
+ return state.workspace_memory;
26662
+ }
26663
+ function setWorkspaceMemoryState(state, entry) {
26664
+ const existing = state.workspace_memory;
26665
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.generated === entry.generated) {
26666
+ return { state, changed: false };
26667
+ }
26668
+ return { state: { ...state, workspace_memory: entry }, changed: true };
26669
+ }
26670
+ function getUserMemoryState(state) {
26671
+ return state.user_memory;
26672
+ }
26673
+ function setUserMemoryState(state, entry) {
26674
+ const existing = state.user_memory;
26675
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.generated === entry.generated) {
26676
+ return { state, changed: false };
26677
+ }
26678
+ return { state: { ...state, user_memory: entry }, changed: true };
26679
+ }
26680
+ function getMemoryFactEntries(state) {
26681
+ return state.memory_facts?.facts ?? {};
26682
+ }
26683
+ function getMemoryFactEntry(state, relPath) {
26684
+ return state.memory_facts?.facts[relPath];
26685
+ }
26686
+ function setMemoryFactsState(state, entry) {
26687
+ const existing = state.memory_facts;
26688
+ if (existing && sameFactRegistry(existing.facts, entry.facts)) {
26689
+ return { state, changed: false };
26690
+ }
26691
+ return { state: { ...state, memory_facts: entry }, changed: true };
26692
+ }
26693
+ function sameFactRegistry(a, b) {
26694
+ const aKeys = Object.keys(a);
26695
+ if (aKeys.length !== Object.keys(b).length)
26696
+ return false;
26697
+ for (const key of aKeys) {
26698
+ const left = a[key];
26699
+ const right = b[key];
26700
+ if (!right || left.id !== right.id || left.body_hash !== right.body_hash) {
26701
+ return false;
26702
+ }
26703
+ }
26704
+ return true;
26705
+ }
26706
+ function getUserVoiceState(state) {
26707
+ return state.user_voice;
26708
+ }
26709
+ function setUserVoiceState(state, entry) {
26710
+ const existing = state.user_voice;
26711
+ if (existing && existing.body_hash === entry.body_hash && existing.updated_at === entry.updated_at && existing.status === entry.status) {
26712
+ return { state, changed: false };
26713
+ }
26714
+ return { state: { ...state, user_voice: entry }, changed: true };
26715
+ }
26716
+ var ABSENT_FRESHNESS = {
26717
+ workspaceMemory: null,
26718
+ userMemory: null,
26719
+ userVoice: null
26720
+ };
26721
+ function readMemoryFreshness(workspaceSlug) {
26722
+ let json2;
26723
+ try {
26724
+ json2 = JSON.parse(readFileSync7(stateFilePath(workspaceSlug), "utf8"));
26725
+ } catch {
26726
+ return ABSENT_FRESHNESS;
26727
+ }
26728
+ if (json2 === null || typeof json2 !== "object")
26729
+ return ABSENT_FRESHNESS;
26730
+ const record2 = json2;
26731
+ const workspace = WorkspaceMemoryStateSchema.safeParse(record2.workspace_memory);
26732
+ const user = UserMemoryStateSchema.safeParse(record2.user_memory);
26733
+ const voice = UserVoiceStateSchema.safeParse(record2.user_voice);
26734
+ return {
26735
+ workspaceMemory: workspace.success ? workspace.data : null,
26736
+ userMemory: user.success ? user.data : null,
26737
+ userVoice: voice.success ? voice.data : null
26738
+ };
26739
+ }
26740
+ function setSizeAnchor(state, anchor) {
26741
+ if (anchor.kind === "sync" && state.size_anchor !== undefined) {
26742
+ return { state, changed: false };
26743
+ }
26744
+ const existing = state.size_anchor;
26745
+ if (existing !== undefined && existing.kind === anchor.kind && existing.at === anchor.at && sizesEqual(existing.sizes, anchor.sizes)) {
26746
+ return { state, changed: false };
26747
+ }
26748
+ return { state: { ...state, size_anchor: anchor }, changed: true };
26749
+ }
26750
+ function sizesEqual(a, b) {
26751
+ const ak = Object.keys(a);
26752
+ const bk = Object.keys(b);
26753
+ if (ak.length !== bk.length)
26754
+ return false;
26755
+ return ak.every((k) => a[k] === b[k]);
26756
+ }
26757
+ function setLastTrimAt(state, at) {
26758
+ if (state.last_trim_at === at)
26759
+ return { state, changed: false };
26760
+ return { state: { ...state, last_trim_at: at }, changed: true };
26761
+ }
26762
+
26763
+ // src/lib/memory-facts/stores.ts
26764
+ var BLOB_MEMORY_STORES = Object.freeze({
26765
+ workspace: false,
26766
+ user: false
26767
+ });
26768
+ function memoryStoreModesFrom(state) {
26769
+ return {
26770
+ workspace: state.workspace_memory?.generated === true,
26771
+ user: state.user_memory?.generated === true
26772
+ };
26773
+ }
26774
+ function readMemoryStoreModes(workspaceSlug) {
26775
+ try {
26776
+ return memoryStoreModesFrom(loadState(workspaceSlug));
26777
+ } catch {
26778
+ return BLOB_MEMORY_STORES;
26779
+ }
26780
+ }
26781
+ function anyGenerated(modes) {
26782
+ return modes.workspace || modes.user;
26783
+ }
26784
+ function generatedMemoryStashDepsFrom(state) {
26785
+ const artists = state.entries["artists"] ?? {};
26786
+ const generatedArtists = new Set;
26787
+ for (const entry of Object.values(artists)) {
26788
+ if (entry.memory_generated === true)
26789
+ generatedArtists.add(entry.slug);
26790
+ }
26791
+ return {
26792
+ artistMemoryGenerated: (slug) => generatedArtists.has(slug),
26793
+ workspaceMemoryGenerated: () => state.workspace_memory?.generated === true,
26794
+ userMemoryGenerated: () => state.user_memory?.generated === true
26795
+ };
26796
+ }
26797
+
26798
+ // src/lib/context/grounding.ts
26799
+ var WORKSPACE_RULES_FILENAME = "workspace-rules.md";
26800
+ var HOUSE_STYLE_LEGACY_FILENAME = "house-style.md";
26801
+ var WORKSPACE_MEMORY_FILENAME = "workspace-memory.md";
26802
+ var USER_MEMORY_FILENAME = "user-memory.md";
26803
+ var VOICE_FILENAME = "voice.md";
26804
+ var WORKSPACE_RULES_PRIVATE_HEADING = "## Workspace-specific (private, never promotes)";
26805
+ var WORKSPACE_RULES_GENERAL_HEADING = "## Generalisable manager-craft (may promote)";
26806
+ var WORKSPACE_RULES_SECTION_HEADINGS = [
26807
+ WORKSPACE_RULES_PRIVATE_HEADING,
26808
+ WORKSPACE_RULES_GENERAL_HEADING
26809
+ ];
26810
+ function extractWorkspaceRules(raw) {
26811
+ if (raw == null)
26812
+ return null;
26813
+ const withoutComments = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "");
26814
+ const stripped = withoutComments.split(`
26815
+ `).filter((line) => !WORKSPACE_RULES_SECTION_HEADINGS.includes(line.trim())).join(`
26816
+ `).trim();
26817
+ return stripped.length > 0 ? stripped : null;
26818
+ }
26819
+ function extractWorkspaceMemory(raw) {
26820
+ if (raw == null)
26821
+ return null;
26822
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
26823
+ return stripped.length > 0 ? stripped : null;
26824
+ }
26825
+ function extractUserMemory(raw) {
26826
+ if (raw == null)
26827
+ return null;
26828
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "").trim();
26829
+ return stripped.length > 0 ? stripped : null;
26830
+ }
26831
+ function extractVoiceProfile(raw) {
26832
+ if (raw == null)
26833
+ return null;
26834
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, "").trim();
26835
+ return stripped.length > 0 ? stripped : null;
26836
+ }
26837
+ function daysBetween(from, to) {
26838
+ const parse6 = (d) => {
26839
+ const parts = d.split("-");
26840
+ return Date.UTC(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
26841
+ };
26842
+ return Math.round((parse6(to) - parse6(from)) / 86400000);
26843
+ }
26844
+ function renderFreshnessStamp(today, updatedAt) {
26845
+ const UNKNOWN = `The age of this index is not known here, so treat its lines as undated ` + `rather than as fresh.`;
26846
+ if (updatedAt == null)
26847
+ return UNKNOWN;
26848
+ const banked = new Date(updatedAt);
26849
+ if (Number.isNaN(banked.getTime()))
26850
+ return UNKNOWN;
26851
+ const bankedDay = formatLocalDate(banked);
26852
+ const days = Math.max(0, daysBetween(bankedDay, today));
26853
+ const age = days === 0 ? "today" : days === 1 ? "1 day ago" : `${days} days ago`;
26854
+ return `This index was last updated on ${bankedDay}, ${age}.`;
26855
+ }
26856
+ var GROUNDING_BUDGET_BYTES = 20000;
26857
+ var GROUNDING_CATEGORY_ORDER = [
26858
+ "grounding",
26859
+ "identity",
26860
+ "budgetNote",
26861
+ "sizeNote",
26862
+ "voice",
26863
+ "rules",
26864
+ "workspaceFacts",
26865
+ "personalFacts"
26866
+ ];
26867
+ var GROUNDING_CATEGORY_LABELS = {
26868
+ grounding: "Plinth's own grounding",
26869
+ identity: "who you are",
26870
+ budgetNote: "the trim notes",
26871
+ sizeNote: "the size warning",
26872
+ voice: "voice pointer",
26873
+ rules: "this workspace's rules",
26874
+ workspaceFacts: "workspace facts",
26875
+ personalFacts: "your own facts"
26876
+ };
26877
+ var MEMBER_EDITABLE_CATEGORIES = [
26878
+ "rules",
26879
+ "workspaceFacts",
26880
+ "personalFacts"
26881
+ ];
26882
+ function segmentBytes(lines, cats) {
26883
+ const out = {
26884
+ grounding: 0,
26885
+ identity: 0,
26886
+ rules: 0,
26887
+ workspaceFacts: 0,
26888
+ personalFacts: 0,
26889
+ voice: 0,
26890
+ budgetNote: 0,
26891
+ sizeNote: 0
26892
+ };
26893
+ lines.forEach((text, i) => {
26894
+ const cat = cats[i];
26895
+ out[cat] += Buffer.byteLength(text) + (i < lines.length - 1 ? 1 : 0);
26896
+ });
26897
+ return out;
26898
+ }
26899
+ var IDENTITY_LINE_MAX_BYTES = 300;
26900
+ function renderMemberIdentity(member, workspaceSlug) {
26901
+ if (member === null)
26902
+ return null;
26903
+ const tail = `. Tasks you create are assigned to this member unless told otherwise.`;
26904
+ const budget = IDENTITY_LINE_MAX_BYTES - Buffer.byteLength(tail);
26905
+ const email3 = oneLine(member.email);
26906
+ const build = (withName, withRole, withZone) => {
26907
+ const who = withName && member.displayName ? `${oneLine(member.displayName)} (${email3})` : email3;
26908
+ const clauses = [`You are working for ${who}`];
26909
+ if (withRole && member.role)
26910
+ clauses.push(`role ${oneLine(member.role)}`);
26911
+ if (workspaceSlug !== null)
26912
+ clauses.push(`in workspace ${workspaceSlug}`);
26913
+ if (withZone && member.timezone)
26914
+ clauses.push(`timezone ${oneLine(member.timezone)}`);
26915
+ return clauses.join(", ");
26916
+ };
26917
+ const candidates = [
26918
+ build(true, true, true),
26919
+ build(true, true, false),
26920
+ build(true, false, false),
26921
+ build(false, false, false)
26922
+ ];
26923
+ if (member.displayName) {
26924
+ const slugClause = workspaceSlug === null ? `` : `, in workspace ${workspaceSlug}`;
26925
+ candidates.push(`You are working for ${oneLine(member.displayName)}${slugClause}`);
26926
+ }
26927
+ const head = candidates.find((c) => Buffer.byteLength(c) <= budget);
26928
+ if (head === undefined)
26929
+ return null;
26930
+ return head + tail;
26931
+ }
26932
+ function oneLine(value) {
26933
+ return value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
26943
26934
  }
26944
- function markRejectedReported(state, path3, reportedAt) {
26945
- const list = state.rejected ?? [];
26946
- const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26947
- if (idx < 0)
26948
- return { state, changed: false };
26949
- if (list[idx].reported_at === reportedAt)
26950
- return { state, changed: false };
26951
- const next = [...list];
26952
- next[idx] = { ...list[idx], reported_at: reportedAt };
26953
- return { state: { ...state, rejected: next }, changed: true };
26935
+ function shouldOfferOnboarding(rosterIsEmpty, member) {
26936
+ if (member?.onboardingCompletedAt)
26937
+ return false;
26938
+ return rosterIsEmpty;
26954
26939
  }
26955
- function clearRejectedReported(state, path3) {
26956
- const list = state.rejected ?? [];
26957
- const idx = list.findIndex((o) => o.target === "file" && o.path === path3);
26958
- if (idx < 0)
26959
- return { state, changed: false };
26960
- if (list[idx].reported_at === undefined)
26961
- return { state, changed: false };
26962
- const next = [...list];
26963
- const { reported_at: _dropped, ...rest } = list[idx];
26964
- next[idx] = rest;
26965
- return { state: { ...state, rejected: next }, changed: true };
26940
+ function bankedDate(line) {
26941
+ const matches = line.match(/\d{4}-\d{2}-\d{2}/g);
26942
+ if (matches === null || matches.length === 0)
26943
+ return null;
26944
+ let latest = matches[0];
26945
+ for (const m of matches)
26946
+ if (m > latest)
26947
+ latest = m;
26948
+ return latest;
26966
26949
  }
26967
- function listTeamRejected(state) {
26968
- return state.team_rejected?.rows ?? [];
26950
+ var POINTER_RE = /\[\[[^\]]+\]\]/g;
26951
+ var TITLE_TRUNCATION_MARK = " …";
26952
+ function factTitle(fact) {
26953
+ const firstLine = fact.split(`
26954
+ `)[0];
26955
+ const match = /[.?!](\s|$)/.exec(firstLine);
26956
+ const sentence = match === null ? firstLine : firstLine.slice(0, match.index + 1);
26957
+ const missing = [...fact.matchAll(POINTER_RE)].map((m) => m[0]).filter((p) => !sentence.includes(p));
26958
+ const unique = [...new Set(missing)];
26959
+ const named = unique.length === 0 ? sentence : `${sentence} ${unique.join(" ")}`;
26960
+ const truncated = named !== fact;
26961
+ return { text: truncated ? `${named}${TITLE_TRUNCATION_MARK}` : named, truncated };
26969
26962
  }
26970
- function teamRejectedFetchedAt(state) {
26971
- return state.team_rejected?.fetched_at;
26963
+ function rankFacts(facts) {
26964
+ return facts.map((line, index) => ({ line, index, date: bankedDate(line) })).sort((a, b) => {
26965
+ const ad = a.date ?? "";
26966
+ const bd = b.date ?? "";
26967
+ if (ad !== bd)
26968
+ return ad < bd ? 1 : -1;
26969
+ return b.index - a.index;
26970
+ }).map(({ index, line }) => ({ index, line }));
26972
26971
  }
26973
- function setTeamRejected(state, rows, fetchedAt) {
26974
- const existing = state.team_rejected;
26975
- if (existing && rowsEqual(existing.rows, rows)) {
26976
- return { state, changed: false };
26972
+ function groupFacts(body) {
26973
+ const facts = [];
26974
+ for (const line of body.split(`
26975
+ `)) {
26976
+ if (line.trim().length === 0)
26977
+ continue;
26978
+ if (/^\s/.test(line) && facts.length > 0) {
26979
+ facts[facts.length - 1] = `${facts[facts.length - 1]}
26980
+ ${line}`;
26981
+ } else {
26982
+ facts.push(line);
26983
+ }
26984
+ }
26985
+ return facts;
26986
+ }
26987
+ function selectIndexContent(body, capBytes) {
26988
+ const lines = groupFacts(body);
26989
+ if (Buffer.byteLength(body) <= capBytes) {
26990
+ return { text: body, titles: "", kept: lines.length, named: 0, total: lines.length };
26991
+ }
26992
+ const ranked = rankFacts(lines);
26993
+ const titleOf = new Map;
26994
+ const info2 = new Map;
26995
+ for (const { index, line } of ranked) {
26996
+ const t = factTitle(line);
26997
+ info2.set(index, t);
26998
+ titleOf.set(index, t.text);
26999
+ }
27000
+ const carried = new Map;
27001
+ const used = () => {
27002
+ let bytes = 0;
27003
+ let bodies = 0;
27004
+ let titles = 0;
27005
+ for (const [index, kind] of carried) {
27006
+ const line = lines[index];
27007
+ if (kind === "body") {
27008
+ bytes += Buffer.byteLength(line);
27009
+ bodies++;
27010
+ } else {
27011
+ bytes += Buffer.byteLength(titleOf.get(index));
27012
+ titles++;
27013
+ }
27014
+ }
27015
+ return bytes + Math.max(0, bodies - 1) + Math.max(0, titles - 1);
27016
+ };
27017
+ for (const { index } of ranked) {
27018
+ if (info2.get(index)?.truncated !== true)
27019
+ continue;
27020
+ carried.set(index, "title");
27021
+ if (used() > capBytes)
27022
+ carried.delete(index);
27023
+ }
27024
+ for (const { index } of ranked) {
27025
+ const was = carried.get(index);
27026
+ carried.set(index, "body");
27027
+ if (used() > capBytes) {
27028
+ if (was === undefined)
27029
+ carried.delete(index);
27030
+ else
27031
+ carried.set(index, was);
27032
+ break;
27033
+ }
26977
27034
  }
27035
+ const inFileOrder = (kind) => [...carried.entries()].filter(([, k]) => k === kind).map(([i]) => i).sort((a, b) => a - b);
27036
+ const bodyIdx = inFileOrder("body");
27037
+ const titleIdx = inFileOrder("title");
26978
27038
  return {
26979
- state: { ...state, team_rejected: { fetched_at: fetchedAt, rows } },
26980
- changed: true
27039
+ text: bodyIdx.map((i) => lines[i]).join(`
27040
+ `),
27041
+ titles: titleIdx.map((i) => titleOf.get(i)).join(`
27042
+ `),
27043
+ kept: bodyIdx.length,
27044
+ named: titleIdx.length,
27045
+ total: lines.length
26981
27046
  };
26982
27047
  }
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 };
27048
+ function trimmedIndexNote(kept, total, filename) {
27049
+ if (kept === 0) {
27050
+ 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.`;
26998
27051
  }
26999
- return { state: { ...state, workspace_rules: entry }, changed: true };
27052
+ 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.`;
27000
27053
  }
27001
- function getWorkspaceMemoryState(state) {
27002
- return state.workspace_memory;
27054
+ function titlesLeadIn(kept, named, unnamed, total, filename) {
27055
+ const parts = [`${kept} above in full`];
27056
+ parts.push(`${named} below as ${named === 1 ? "its opening" : "their openings"} only`);
27057
+ if (unnamed > 0)
27058
+ parts.push(`${unnamed} not shown at all`);
27059
+ 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.`;
27003
27060
  }
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 };
27061
+ function composeGroundingBlock(today, workspaceRules, workspaceMemory, userMemory, freshness, rosterIsEmpty, voiceProfile, voiceMalformed, workspaceSlug, workspaceMemoryNote, userMemoryNote, member, workspaceTitles = null, userTitles = null, filesPastBar = 0, memoryStores = BLOB_MEMORY_STORES) {
27062
+ const gateFlag = workspaceSlug === null ? `--workspace="<this workspace>"` : `--workspace ${workspaceSlug}`;
27063
+ const identity = renderMemberIdentity(member, workspaceSlug);
27064
+ const block = [];
27065
+ const cats = [];
27066
+ const add = (cat, ...lines) => {
27067
+ for (const line of lines) {
27068
+ block.push(line);
27069
+ cats.push(cat);
27070
+ }
27071
+ };
27072
+ 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.`);
27073
+ if (identity !== null)
27074
+ add("identity", ``, identity);
27075
+ 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.`);
27076
+ if (workspaceRules === null && voiceProfile === null) {
27077
+ 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.`);
27008
27078
  }
27009
- return { state: { ...state, workspace_memory: entry }, changed: true };
27079
+ 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.`);
27080
+ if (workspaceRules !== null) {
27081
+ 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:`);
27082
+ add("rules", ``, workspaceRules);
27083
+ }
27084
+ if (voiceProfile !== null) {
27085
+ 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.`);
27086
+ if (voiceMalformed) {
27087
+ 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.`);
27088
+ }
27089
+ }
27090
+ 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));
27091
+ if (workspaceMemory !== null) {
27092
+ 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:`);
27093
+ add("workspaceFacts", ``, workspaceMemory);
27094
+ if (workspaceTitles !== null) {
27095
+ add("budgetNote", ``, workspaceTitles.leadIn);
27096
+ add("workspaceFacts", ``, workspaceTitles.text);
27097
+ }
27098
+ if (workspaceMemoryNote !== null)
27099
+ add("budgetNote", ``, workspaceMemoryNote);
27100
+ }
27101
+ if (userMemory !== null) {
27102
+ 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:`);
27103
+ add("personalFacts", ``, userMemory);
27104
+ if (userTitles !== null) {
27105
+ add("budgetNote", ``, userTitles.leadIn);
27106
+ add("personalFacts", ``, userTitles.text);
27107
+ }
27108
+ if (userMemoryNote !== null)
27109
+ add("budgetNote", ``, userMemoryNote);
27110
+ }
27111
+ if (workspaceMemory !== null || userMemory !== null) {
27112
+ 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.`);
27113
+ }
27114
+ 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.`);
27115
+ if (filesPastBar > 0) {
27116
+ 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.`);
27117
+ }
27118
+ if (shouldOfferOnboarding(rosterIsEmpty, member)) {
27119
+ 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.`);
27120
+ }
27121
+ const groundingText = block.filter((_, i) => cats[i] === "grounding").join(`
27122
+ `);
27123
+ return {
27124
+ text: block.join(`
27125
+ `),
27126
+ split: segmentBytes(block, cats),
27127
+ groundingParagraphs: countParagraphs(groundingText),
27128
+ groundingRuleShaped: countRuleShapedLines(groundingText)
27129
+ };
27010
27130
  }
27011
- function getUserMemoryState(state) {
27012
- return state.user_memory;
27131
+ function generatedStoresClause(modes) {
27132
+ if (!anyGenerated(modes))
27133
+ return "";
27134
+ const which = modes.workspace && modes.user ? `\`${WORKSPACE_MEMORY_FILENAME}\` and \`${USER_MEMORY_FILENAME}\` are` : modes.workspace ? `\`${WORKSPACE_MEMORY_FILENAME}\` is` : `\`${USER_MEMORY_FILENAME}\` is`;
27135
+ 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`;
27136
+ 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.`;
27013
27137
  }
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 };
27018
- }
27019
- return { state: { ...state, user_memory: entry }, changed: true };
27138
+ 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) {
27139
+ 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);
27140
+ const whole = compose(workspaceMemory, userMemory, null, null);
27141
+ const untrimmed = () => {
27142
+ const all = (body) => {
27143
+ const n = groupFacts(body).length;
27144
+ return { text: body, titles: "", kept: n, named: 0, total: n };
27145
+ };
27146
+ return {
27147
+ block: whole.text,
27148
+ split: whole.split,
27149
+ groundingParagraphs: whole.groundingParagraphs,
27150
+ groundingRuleShaped: whole.groundingRuleShaped,
27151
+ picks: {
27152
+ wmPick: workspaceMemory === null ? null : all(workspaceMemory),
27153
+ umPick: userMemory === null ? null : all(userMemory)
27154
+ }
27155
+ };
27156
+ };
27157
+ if (Buffer.byteLength(whole.text) <= GROUNDING_BUDGET_BYTES)
27158
+ return untrimmed();
27159
+ if (workspaceMemory === null && userMemory === null)
27160
+ return untrimmed();
27161
+ const factCount = (body) => body === null ? 0 : groupFacts(body).length;
27162
+ const wmFacts = factCount(workspaceMemory);
27163
+ const umFacts = factCount(userMemory);
27164
+ const titlesFor = (pick2, filename) => pick2 === null || pick2.named === 0 ? null : {
27165
+ leadIn: titlesLeadIn(pick2.kept, pick2.named, pick2.total - pick2.kept - pick2.named, pick2.total, filename),
27166
+ text: pick2.titles
27167
+ };
27168
+ const noteFor = (pick2, filename) => pick2 === null || pick2.kept === pick2.total ? null : trimmedIndexNote(pick2.kept, pick2.total, filename);
27169
+ 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);
27170
+ const wmSize = workspaceMemory === null ? 0 : Buffer.byteLength(workspaceMemory);
27171
+ const umSize = userMemory === null ? 0 : Buffer.byteLength(userMemory);
27172
+ const allocate = (remaining) => {
27173
+ const half = Math.floor(remaining / 2);
27174
+ let umCap;
27175
+ let wmCap;
27176
+ if (umSize <= half) {
27177
+ umCap = umSize;
27178
+ wmCap = remaining - umSize;
27179
+ } else if (wmSize <= half) {
27180
+ wmCap = wmSize;
27181
+ umCap = remaining - wmSize;
27182
+ } else {
27183
+ umCap = half;
27184
+ wmCap = remaining - half;
27185
+ }
27186
+ const umPick = userMemory === null ? null : selectIndexContent(userMemory, umCap);
27187
+ const umUsed = umPick === null ? 0 : Buffer.byteLength(umPick.text) + Buffer.byteLength(umPick.titles);
27188
+ return {
27189
+ wmPick: workspaceMemory === null ? null : selectIndexContent(workspaceMemory, Math.max(wmCap, remaining - umUsed)),
27190
+ umPick
27191
+ };
27192
+ };
27193
+ const build = (picks, withNotes2) => {
27194
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
27195
+ 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));
27196
+ };
27197
+ const refine2 = (seed, withNotes2) => {
27198
+ const asReserve = (section) => section === null ? null : { leadIn: section.leadIn, text: "" };
27199
+ const keep = (pick2) => withNotes2 || pick2 !== null && pick2.named === 0;
27200
+ 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);
27201
+ return allocate(Math.max(0, GROUNDING_BUDGET_BYTES - fixed));
27202
+ };
27203
+ const noNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(false, true)));
27204
+ const withNotes = allocate(Math.max(0, GROUNDING_BUDGET_BYTES - measureFixed(true, true)));
27205
+ const candidates = [
27206
+ { picks: withNotes, composed: build(withNotes, true) },
27207
+ { picks: noNotes, composed: build(noNotes, false) },
27208
+ { picks: withNotes, composed: build(withNotes, false) }
27209
+ ].concat([
27210
+ { seed: noNotes, notes: false },
27211
+ { seed: withNotes, notes: true }
27212
+ ].map(({ seed, notes }) => {
27213
+ const picks = refine2(seed, notes);
27214
+ return { picks, composed: build(picks, notes) };
27215
+ })).map((c) => ({
27216
+ ...c,
27217
+ bytes: Buffer.byteLength(c.composed.text),
27218
+ named: (c.picks.wmPick?.kept ?? 0) + (c.picks.wmPick?.named ?? 0) + (c.picks.umPick?.kept ?? 0) + (c.picks.umPick?.named ?? 0),
27219
+ whole: (c.picks.wmPick?.kept ?? 0) + (c.picks.umPick?.kept ?? 0)
27220
+ }));
27221
+ const fitting = candidates.filter((c) => c.bytes <= GROUNDING_BUDGET_BYTES);
27222
+ const better = (a, b) => b.named !== a.named ? b.named > a.named : b.whole > a.whole;
27223
+ const best = fitting.length > 0 ? fitting.reduce((a, b) => better(a, b) ? b : a) : candidates.reduce((a, b) => b.bytes < a.bytes ? b : a);
27224
+ return {
27225
+ block: best.composed.text,
27226
+ picks: best.picks,
27227
+ split: best.composed.split,
27228
+ groundingParagraphs: best.composed.groundingParagraphs,
27229
+ groundingRuleShaped: best.composed.groundingRuleShaped
27230
+ };
27020
27231
  }
27021
- function getUserVoiceState(state) {
27022
- return state.user_voice;
27232
+ var buildGroundingReportFor = buildGroundingReport;
27233
+ function formatLocalDate(d) {
27234
+ const year = d.getFullYear();
27235
+ const month = String(d.getMonth() + 1).padStart(2, "0");
27236
+ const day = String(d.getDate()).padStart(2, "0");
27237
+ return `${year}-${month}-${day}`;
27023
27238
  }
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 };
27239
+
27240
+ // src/lib/hash.ts
27241
+ import { createHash } from "node:crypto";
27242
+ function stableHash(value) {
27243
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
27244
+ }
27245
+ function stableStringify(value) {
27246
+ if (value === null || typeof value !== "object") {
27247
+ return JSON.stringify(value);
27028
27248
  }
27029
- return { state: { ...state, user_voice: entry }, changed: true };
27249
+ if (Array.isArray(value)) {
27250
+ return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
27251
+ }
27252
+ const obj = value;
27253
+ const keys = Object.keys(obj).sort();
27254
+ return "{" + keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",") + "}";
27030
27255
  }
27031
- var ABSENT_FRESHNESS = {
27032
- workspaceMemory: null,
27033
- userMemory: null,
27034
- userVoice: null
27035
- };
27036
- function readMemoryFreshness(workspaceSlug) {
27037
- let json2;
27256
+
27257
+ // src/lib/context/roster.ts
27258
+ var import_gray_matter5 = __toESM(require_gray_matter(), 1);
27259
+ import { readdirSync as readdirSync3, readFileSync as readFileSync8 } from "node:fs";
27260
+ import { join as join7 } from "node:path";
27261
+ function readRoster(workspaceSlug) {
27262
+ let artistsDir;
27038
27263
  try {
27039
- json2 = JSON.parse(readFileSync8(stateFilePath(workspaceSlug), "utf8"));
27264
+ artistsDir = join7(workspaceMirrorPath(workspaceSlug), "artists");
27040
27265
  } catch {
27041
- return ABSENT_FRESHNESS;
27266
+ return [];
27042
27267
  }
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 };
27268
+ let slugs;
27269
+ try {
27270
+ slugs = readdirSync3(artistsDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
27271
+ } catch {
27272
+ return [];
27058
27273
  }
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 };
27274
+ const roster = [];
27275
+ for (const slug of slugs) {
27276
+ let raw;
27277
+ try {
27278
+ raw = readFileSync8(join7(artistsDir, slug, "_artist.md"), "utf8");
27279
+ } catch {
27280
+ continue;
27281
+ }
27282
+ try {
27283
+ const fm = import_gray_matter5.default(raw).data;
27284
+ const name = typeof fm.name === "string" && fm.name.length > 0 ? fm.name : slug;
27285
+ const status = typeof fm.status === "string" ? fm.status : null;
27286
+ roster.push({ slug, name, status });
27287
+ } catch {
27288
+ roster.push({ slug, name: slug, status: null });
27289
+ }
27062
27290
  }
27063
- return { state: { ...state, size_anchor: anchor }, changed: true };
27291
+ return roster;
27064
27292
  }
27065
- function sizesEqual(a, b) {
27066
- const ak = Object.keys(a);
27067
- const bk = Object.keys(b);
27068
- if (ak.length !== bk.length)
27293
+ function rosterIsEmpty(workspaceSlug) {
27294
+ let artistsDir;
27295
+ try {
27296
+ artistsDir = join7(workspaceMirrorPath(workspaceSlug), "artists");
27297
+ } catch {
27069
27298
  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 };
27299
+ }
27300
+ let entries;
27301
+ try {
27302
+ entries = readdirSync3(artistsDir, { withFileTypes: true });
27303
+ } catch (err) {
27304
+ return err?.code === "ENOENT";
27305
+ }
27306
+ for (const entry of entries) {
27307
+ if (!entry.isDirectory())
27308
+ continue;
27309
+ try {
27310
+ readFileSync8(join7(artistsDir, entry.name, "_artist.md"), "utf8");
27311
+ return false;
27312
+ } catch (err) {
27313
+ if (err?.code === "ENOENT")
27314
+ continue;
27315
+ return false;
27316
+ }
27317
+ }
27318
+ return true;
27076
27319
  }
27077
27320
 
27078
27321
  // src/lib/context/guiding-files.ts
@@ -27081,6 +27324,7 @@ var CLAUDE_LOCAL_MD = "CLAUDE.local.md";
27081
27324
  var ARTIST_CLAUDE_MD = "CLAUDE.md";
27082
27325
  var SETTINGS_REL = ".claude/settings.json";
27083
27326
  var ARTIST_SLUG_PLACEHOLDER = "<slug>";
27327
+ var MEMORY_FACT_SLUG_PLACEHOLDER = "<slug>";
27084
27328
  var artistFile = (name) => `artists/${ARTIST_SLUG_PLACEHOLDER}/${name}`;
27085
27329
  function withThousands(n) {
27086
27330
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
@@ -27129,12 +27373,26 @@ var GUIDING_FILES = [
27129
27373
  reach: "you only",
27130
27374
  bar: `${withThousands(RULE_FILE_BAR_BYTES)} bytes`
27131
27375
  },
27376
+ {
27377
+ path: `${MEMORY_FACTS_DIR}/${MEMORY_FACT_SLUG_PLACEHOLDER}.md`,
27378
+ writtenBy: "Plinth, from a memory fact; read-only, edits are set aside",
27379
+ readWhen: "read on demand; only its index line is injected",
27380
+ reach: "everyone",
27381
+ bar: NO_BAR
27382
+ },
27383
+ {
27384
+ path: `${MEMORY_FACTS_DIR}/${MEMORY_FACTS_PRIVATE_SEGMENT}/${MEMORY_FACT_SLUG_PLACEHOLDER}.md`,
27385
+ writtenBy: "Plinth, from one of your memory facts; read-only",
27386
+ readWhen: "read on demand; only its index line is injected",
27387
+ reach: "you only",
27388
+ bar: NO_BAR
27389
+ },
27132
27390
  {
27133
27391
  path: artistFile(ARTIST_ANCHOR_FILENAME),
27134
27392
  writtenBy: "you and the agent; syncs both ways",
27135
27393
  readWhen: "read on demand",
27136
27394
  reach: "everyone",
27137
- bar: NO_BAR
27395
+ bar: `${withThousands(CONTEXT_BLOCK_BAR_CHARS)} characters per block`
27138
27396
  },
27139
27397
  {
27140
27398
  path: artistFile(ARTIST_MEMORY_ANCHOR),
@@ -27335,17 +27593,20 @@ function assessGeneratedFileBars(workspaceSlug, deps = {}) {
27335
27593
  return out;
27336
27594
  }
27337
27595
  var OPENING_MAX_BYTES = 90;
27338
- function terminalSafeOpening(fact) {
27339
- const stripped = fact.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
27596
+ function terminalSafeText(text, maxBytes) {
27597
+ const stripped = text.replace(/[\x00-\x1F\x7F-\x9F]+/g, " ");
27340
27598
  const folded = stripped.replace(/\s+/g, " ").trim();
27341
27599
  let out = "";
27342
27600
  for (const ch of folded) {
27343
- if (Buffer.byteLength(out + ch) > OPENING_MAX_BYTES)
27601
+ if (Buffer.byteLength(out + ch) > maxBytes)
27344
27602
  return `${out.trimEnd()}…`;
27345
27603
  out += ch;
27346
27604
  }
27347
27605
  return out;
27348
27606
  }
27607
+ function terminalSafeOpening(fact) {
27608
+ return terminalSafeText(fact, OPENING_MAX_BYTES);
27609
+ }
27349
27610
  function assessIndexLines(workspaceSlug, deps = {}) {
27350
27611
  const readContext = deps.readContext ?? readContextFile;
27351
27612
  const specs = [
@@ -27577,7 +27838,7 @@ function runGroundingReport(deps = {}) {
27577
27838
  return buildGroundingReportFor(formatLocalDate(now()), rules, memory, userMemory, {
27578
27839
  workspaceMemoryUpdatedAt: resolveUpdatedAt(rawMemory, freshness.workspaceMemory),
27579
27840
  userMemoryUpdatedAt: resolveUpdatedAt(rawUserMemory, freshness.userMemory)
27580
- }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug, member, filesPastBar);
27841
+ }, noArtists, voiceProfile, resolveVoiceMalformed(rawVoiceProfile, freshness.userVoice), workspaceSlug, member, filesPastBar, workspaceSlug ? readMemoryStoreModes(workspaceSlug) : BLOB_MEMORY_STORES);
27581
27842
  }
27582
27843
  var groundingCommand = defineCommand({
27583
27844
  meta: {
@@ -28911,6 +29172,15 @@ function artistUuidBySlug(state) {
28911
29172
  }
28912
29173
  return map2;
28913
29174
  }
29175
+ function isOpenTask(fm) {
29176
+ if (fm.done === true)
29177
+ return false;
29178
+ const status = strOrNull(fm.status);
29179
+ return status !== "done" && status !== "archived";
29180
+ }
29181
+ function isActiveProject(fm) {
29182
+ return strOrNull(fm.status) !== "done";
29183
+ }
28914
29184
  function readArtistProfile(workspaceSlug, slug) {
28915
29185
  const fm = readEntityFrontmatter(workspaceSlug, "artists", slug) ?? {};
28916
29186
  return {
@@ -28940,7 +29210,7 @@ function activeProjectCountsBySlug(workspaceSlug, state) {
28940
29210
  const artistSlug = slugByArtistId.get(artistId);
28941
29211
  if (!artistSlug)
28942
29212
  continue;
28943
- if (strOrNull(fm.status) === "done")
29213
+ if (!isActiveProject(fm))
28944
29214
  continue;
28945
29215
  counts.set(artistSlug, (counts.get(artistSlug) ?? 0) + 1);
28946
29216
  }
@@ -28972,9 +29242,9 @@ function buildArtistEntityIndex(workspaceSlug, state) {
28972
29242
  const artistSlug = slugByArtistId.get(artistId);
28973
29243
  if (!artistSlug)
28974
29244
  continue;
28975
- const status = strOrNull(fm.status);
28976
- if (status === "done")
29245
+ if (!isActiveProject(fm))
28977
29246
  continue;
29247
+ const status = strOrNull(fm.status);
28978
29248
  bucketFor(artistSlug).projects.push({
28979
29249
  id: entry2.id,
28980
29250
  slug: entry2.slug,
@@ -28994,7 +29264,7 @@ function buildArtistEntityIndex(workspaceSlug, state) {
28994
29264
  const artistSlug = slugByArtistId.get(artistId);
28995
29265
  if (!artistSlug)
28996
29266
  continue;
28997
- if (fm.done === true)
29267
+ if (!isOpenTask(fm))
28998
29268
  continue;
28999
29269
  bucketFor(artistSlug).tasks.push({
29000
29270
  id: entry2.id,
@@ -29617,7 +29887,7 @@ function withUserBlock(lines) {
29617
29887
  `) + `
29618
29888
  `;
29619
29889
  }
29620
- function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29890
+ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false, stores = BLOB_MEMORY_STORES) {
29621
29891
  return withUserBlock([
29622
29892
  "---",
29623
29893
  "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 +29921,10 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29651
29921
  "",
29652
29922
  "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
29923
  "",
29924
+ ...anyGenerated(stores) ? [
29925
+ "**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.",
29926
+ ""
29927
+ ] : [],
29654
29928
  "**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
29929
  "",
29656
29930
  "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 +29984,7 @@ function buildStartCommandMd(rosterIsEmpty2, offerTrim = false) {
29710
29984
  `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
29985
  ]);
29712
29986
  }
29713
- function buildCloseCommandMd(workspaceSlug) {
29987
+ function buildCloseCommandMd(workspaceSlug, stores = BLOB_MEMORY_STORES) {
29714
29988
  const ws = `--workspace ${workspaceSlug}`;
29715
29989
  return withUserBlock([
29716
29990
  "---",
@@ -29754,7 +30028,7 @@ function buildCloseCommandMd(workspaceSlug) {
29754
30028
  "Each loose end goes to the surface that holds it, and nowhere else:",
29755
30029
  "",
29756
30030
  "- **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.",
30031
+ "- **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
30032
  "- **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
30033
  "",
29760
30034
  "**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 +30086,10 @@ function buildCloseCommandMd(workspaceSlug) {
29812
30086
  "",
29813
30087
  "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
30088
  "",
30089
+ ...anyGenerated(stores) ? [
30090
+ "**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.",
30091
+ ""
30092
+ ] : [],
29815
30093
  "**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
30094
  "",
29817
30095
  "**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 +30112,17 @@ function buildCloseCommandMd(workspaceSlug) {
29834
30112
  "",
29835
30113
  "**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
30114
  "",
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
- "",
30115
+ ...stores.user ? [
30116
+ "#### True of this member: `create_user_memory_fact`. The file is output.",
30117
+ "",
30118
+ "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.",
30119
+ ""
30120
+ ] : [
30121
+ "#### True of this member: `user-memory.md` at the mirror root. Write it directly.",
30122
+ "",
30123
+ "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.",
30124
+ ""
30125
+ ],
29841
30126
  "**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
30127
  "",
29843
30128
  "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 +30372,10 @@ function ensureWorkspaceCommands(deps) {
30087
30372
  } catch {
30088
30373
  offerTrim = false;
30089
30374
  }
30375
+ const stores = (deps.readMemoryStores ?? readMemoryStoreModes)(workspaceSlug);
30090
30376
  const bodies = {
30091
- [START_COMMAND_REL]: buildStartCommandMd(noArtists, offerTrim),
30092
- [CLOSE_COMMAND_REL]: buildCloseCommandMd(workspaceSlug),
30377
+ [START_COMMAND_REL]: buildStartCommandMd(noArtists, offerTrim, stores),
30378
+ [CLOSE_COMMAND_REL]: buildCloseCommandMd(workspaceSlug, stores),
30093
30379
  [ONBOARD_COMMAND_REL]: buildOnboardCommandMd(workspaceSlug)
30094
30380
  };
30095
30381
  for (const rel of GENERATED_COMMAND_RELS) {
@@ -30761,6 +31047,31 @@ function recordEntitySymlinkRefused(args) {
30761
31047
  rejected_at: args.now().toISOString()
30762
31048
  }));
30763
31049
  }
31050
+ function recordEntitySlugClash(args) {
31051
+ const path3 = args.mirrorRelativePath;
31052
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
31053
+ const held = `Until then new ${args.entityType} changes are held on this machine.`;
31054
+ const message = args.reason === "source_unreadable" ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${path3} could not be read, so it was left exactly ` + `where it is. Check the file's permissions; Plinth will move it once ` + `it can read it. ${held}` : args.occupantTracked ? `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md belongs to ` + `another ${args.entityType}, so nothing was moved or overwritten. ` + `This usually clears itself once that record's own rename arrives. ` + `Do not delete that file — it would delete that record in Plinth. ` + `${held}` : `${args.entityType}/${args.fromSlug} was renamed in Plinth to ` + `${args.toSlug}, but ${args.entityType}/${args.toSlug}.md already ` + `exists here, so nothing was moved or overwritten. Rename or remove ` + `that file and Plinth will move this one into place. ${held}`;
31055
+ const existing = (withoutStale.rejected ?? []).find((r) => r.target === "entity" && r.path === path3 && r.reason === "slug_clash");
31056
+ if (existing && existing.message === message)
31057
+ return args.state;
31058
+ return persist(withoutStale, upsertRejected(withoutStale, {
31059
+ target: "entity",
31060
+ op: "upsert",
31061
+ path: path3,
31062
+ status: 0,
31063
+ reason: "slug_clash",
31064
+ message,
31065
+ rejected_at: args.now().toISOString()
31066
+ }));
31067
+ }
31068
+ function clearSlugClash(state, mirrorRelativePaths) {
31069
+ const list = state.rejected ?? [];
31070
+ const next = list.filter((o) => !(o.target === "entity" && o.reason === "slug_clash" && mirrorRelativePaths.includes(o.path)));
31071
+ if (next.length === list.length)
31072
+ return state;
31073
+ return commitState(state, { ...state, rejected: next });
31074
+ }
30764
31075
  function clearSymlinkRefused(state, target, path3) {
30765
31076
  const list = state.rejected ?? [];
30766
31077
  const next = list.filter((o) => !(o.target === target && o.path === path3 && o.reason === "symlink"));
@@ -30831,6 +31142,37 @@ function clearMemorySecretHeld(state, workspaceSlug, absolutePath) {
30831
31142
  return state;
30832
31143
  return commitState(state, { ...state, rejected: next });
30833
31144
  }
31145
+ var MEMORY_GENERATED_REJECT_REASON = "memory_generated";
31146
+ function recordMemoryGeneratedRefusal(args) {
31147
+ let path3;
31148
+ try {
31149
+ path3 = relative3(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
31150
+ } catch {
31151
+ path3 = args.absolutePath;
31152
+ }
31153
+ const withoutStale = removeUnsynced(args.state, "entity", path3).state;
31154
+ return persist(withoutStale, upsertRejected(withoutStale, {
31155
+ target: "entity",
31156
+ op: "upsert",
31157
+ path: path3,
31158
+ reason: MEMORY_GENERATED_REJECT_REASON,
31159
+ message: args.record,
31160
+ rejected_at: args.now().toISOString()
31161
+ }));
31162
+ }
31163
+ function clearMemoryGeneratedRefusal(state, workspaceSlug, absolutePath) {
31164
+ let path3;
31165
+ try {
31166
+ path3 = relative3(workspaceMirrorPath(workspaceSlug), absolutePath);
31167
+ } catch {
31168
+ path3 = absolutePath;
31169
+ }
31170
+ const list = state.rejected ?? [];
31171
+ const next = list.filter((o) => !(o.target === "entity" && o.path === path3 && o.reason === MEMORY_GENERATED_REJECT_REASON));
31172
+ if (next.length === list.length)
31173
+ return state;
31174
+ return commitState(state, { ...state, rejected: next });
31175
+ }
30834
31176
 
30835
31177
  // src/lib/context/workspace-claude-md.ts
30836
31178
  var EXTRACTED_TEXT_DIR = extractedTextRelPath("x").replace(/x\.md$/, "");
@@ -32450,10 +32792,102 @@ function computeMirrorHashes(strategy, content) {
32450
32792
  function hashableBody(strategy, body) {
32451
32793
  return strategy.stripLocalBlocks?.(body) ?? body;
32452
32794
  }
32453
- function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor) {
32795
+ function relocateForSlugChange(args) {
32796
+ const { workspaceSlug, strategy, fromSlug, toSlug, acked, echo, now } = args;
32797
+ if (fromSlug === toSlug)
32798
+ return { kind: "not_needed", writeSlug: toSlug };
32799
+ if (!echo) {
32800
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "unguarded" };
32801
+ }
32802
+ if (strategy.mirrorRelativePath(fromSlug).split("/").length !== 2) {
32803
+ return { kind: "skipped", writeSlug: toSlug, fromSlug, reason: "foldered" };
32804
+ }
32805
+ let fromPath;
32806
+ try {
32807
+ fromPath = entityFilePath(workspaceSlug, strategy.type, fromSlug);
32808
+ entityFilePath(workspaceSlug, strategy.type, toSlug);
32809
+ } catch (err) {
32810
+ if (err instanceof MirrorPathError) {
32811
+ return {
32812
+ kind: "skipped",
32813
+ writeSlug: toSlug,
32814
+ fromSlug,
32815
+ reason: "unresolvable"
32816
+ };
32817
+ }
32818
+ throw err;
32819
+ }
32820
+ let raw = null;
32821
+ try {
32822
+ raw = readFileSync15(fromPath, "utf8");
32823
+ } catch (err) {
32824
+ const code = err?.code;
32825
+ if (code === "ENOENT") {
32826
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32827
+ }
32828
+ return {
32829
+ kind: "refused",
32830
+ writeSlug: fromSlug,
32831
+ fromSlug,
32832
+ reason: "source_unreadable"
32833
+ };
32834
+ }
32835
+ let diverged = true;
32836
+ try {
32837
+ const hashes = computeMirrorHashes(strategy, raw);
32838
+ diverged = hashes.bodyHash !== acked.bodyHash || hashes.frontmatterHash !== acked.frontmatterHash;
32839
+ } catch {
32840
+ diverged = true;
32841
+ }
32842
+ const stashPath = diverged ? stashConflict({
32843
+ workspaceSlug,
32844
+ entityType: strategy.type,
32845
+ entitySlug: fromSlug,
32846
+ timestamp: now(),
32847
+ contents: raw,
32848
+ side: "local"
32849
+ }) : undefined;
32850
+ try {
32851
+ let registered = false;
32852
+ const result = moveEntityFile(workspaceSlug, strategy.type, fromSlug, toSlug, () => {
32853
+ if (echo.watcher === "live") {
32854
+ echo.suppressor.registerDelete(fromPath);
32855
+ registered = true;
32856
+ }
32857
+ });
32858
+ if (result === "moved") {
32859
+ return { kind: "moved", writeSlug: toSlug, fromSlug, stashPath };
32860
+ }
32861
+ if (registered && echo.watcher === "live") {
32862
+ echo.suppressor.cancelDelete(fromPath);
32863
+ }
32864
+ return { kind: "absent", writeSlug: toSlug, fromSlug };
32865
+ } catch (err) {
32866
+ if (err instanceof MirrorDestinationOccupiedError) {
32867
+ return {
32868
+ kind: "refused",
32869
+ writeSlug: fromSlug,
32870
+ fromSlug,
32871
+ reason: "destination_occupied",
32872
+ stashPath
32873
+ };
32874
+ }
32875
+ if (err instanceof MirrorPathError) {
32876
+ return {
32877
+ kind: "skipped",
32878
+ writeSlug: toSlug,
32879
+ fromSlug,
32880
+ reason: "unresolvable"
32881
+ };
32882
+ }
32883
+ throw err;
32884
+ }
32885
+ }
32886
+ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor, pathSlug) {
32887
+ const targetSlug = pathSlug ?? doc2.slug;
32454
32888
  let content = strategy.serialise(doc2);
32455
32889
  if (strategy.preserveLocalBodyOnPull) {
32456
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32890
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32457
32891
  let existing = null;
32458
32892
  try {
32459
32893
  existing = readFileSync15(target, "utf8");
@@ -32471,10 +32905,10 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
32471
32905
  }
32472
32906
  }
32473
32907
  if (suppressor) {
32474
- const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
32908
+ const target = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32475
32909
  suppressor.registerWrite(target, computeMirrorHashes(strategy, content));
32476
32910
  }
32477
- writeMirrorFile(workspaceSlug, strategy.type, doc2.slug, content);
32911
+ writeMirrorFile(workspaceSlug, strategy.type, targetSlug, content);
32478
32912
  return content;
32479
32913
  }
32480
32914
 
@@ -32582,7 +33016,8 @@ function cloudMatchesAcked(strategy, cloud, acked) {
32582
33016
  }
32583
33017
  function stampStateFromMirror(args) {
32584
33018
  const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
32585
- const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
33019
+ const targetSlug = args.pathSlug ?? doc2.slug;
33020
+ const mirrorPath = entityFilePath(workspaceSlug, strategy.type, targetSlug);
32586
33021
  const mirrorRaw = readFileSync16(mirrorPath, "utf8");
32587
33022
  const mirrorParsed = import_gray_matter10.default(mirrorRaw);
32588
33023
  const mirrorFm = mirrorParsed.data ?? {};
@@ -32591,7 +33026,7 @@ function stampStateFromMirror(args) {
32591
33026
  const mirrorHashBody = hashableBody(strategy, mirrorParsed.content);
32592
33027
  const refreshed = {
32593
33028
  id: doc2.id,
32594
- slug: doc2.slug,
33029
+ slug: targetSlug,
32595
33030
  updated_at: doc2.updated_at,
32596
33031
  revision: doc2.revision,
32597
33032
  body_hash: stableHash(mirrorHashBody),
@@ -32602,6 +33037,73 @@ function stampStateFromMirror(args) {
32602
33037
  };
32603
33038
  return persistEntry(state, strategy.type, refreshed);
32604
33039
  }
33040
+ function adoptCloudCopy(args) {
33041
+ const { strategy, workspaceSlug, doc: doc2, output } = args;
33042
+ const now = args.now ?? (() => new Date);
33043
+ let state = args.state;
33044
+ const prior = getEntry(state, strategy.type, doc2.id);
33045
+ let writeSlug = doc2.slug;
33046
+ let refused = false;
33047
+ if (prior && prior.slug !== doc2.slug) {
33048
+ const relocation = relocateForSlugChange({
33049
+ workspaceSlug,
33050
+ strategy,
33051
+ fromSlug: prior.slug,
33052
+ toSlug: doc2.slug,
33053
+ acked: {
33054
+ bodyHash: prior.body_hash,
33055
+ frontmatterHash: prior.frontmatter_hash
33056
+ },
33057
+ echo: args.echo,
33058
+ now
33059
+ });
33060
+ writeSlug = relocation.writeSlug;
33061
+ const move = `entityType=${strategy.type} from=${prior.slug} to=${doc2.slug} id=${doc2.id}`;
33062
+ if (relocation.kind === "moved") {
33063
+ output?.log(`[SLUG_MOVE ${move} source=push]`);
33064
+ state = clearSlugClash(state, [
33065
+ strategy.mirrorRelativePath(prior.slug),
33066
+ strategy.mirrorRelativePath(doc2.slug)
33067
+ ]);
33068
+ } else if (relocation.kind === "refused") {
33069
+ output?.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason} source=push]`);
33070
+ refused = true;
33071
+ state = recordEntitySlugClash({
33072
+ state,
33073
+ mirrorRelativePath: strategy.mirrorRelativePath(prior.slug),
33074
+ entityType: strategy.type,
33075
+ fromSlug: prior.slug,
33076
+ toSlug: doc2.slug,
33077
+ reason: relocation.reason,
33078
+ occupantTracked: getEntryBySlug(state, strategy.type, doc2.slug) !== undefined,
33079
+ now
33080
+ });
33081
+ } else if (relocation.kind === "skipped") {
33082
+ output?.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason} source=push]`);
33083
+ }
33084
+ if (relocation.kind === "moved" || relocation.kind === "refused") {
33085
+ if (relocation.stashPath) {
33086
+ output?.warn(`Unsynced local edit on ${strategy.type}/${prior.slug} → stashed to ` + `${relocation.stashPath} (the row was renamed in Plinth; the cloud ` + `copy is authoritative).`);
33087
+ }
33088
+ }
33089
+ }
33090
+ if (refused) {
33091
+ return prior ? persistEntry(state, strategy.type, {
33092
+ ...prior,
33093
+ updated_at: doc2.updated_at,
33094
+ revision: doc2.revision
33095
+ }) : state;
33096
+ }
33097
+ writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, undefined, writeSlug);
33098
+ return stampStateFromMirror({
33099
+ strategy,
33100
+ workspaceSlug,
33101
+ doc: doc2,
33102
+ state,
33103
+ priorMemoryHash: args.priorMemoryHash,
33104
+ pathSlug: writeSlug
33105
+ });
33106
+ }
32605
33107
  async function pullAndWriteMirror(args) {
32606
33108
  const cloud = await getEntity({
32607
33109
  strategy: args.strategy,
@@ -32610,13 +33112,15 @@ async function pullAndWriteMirror(args) {
32610
33112
  id: args.id,
32611
33113
  fetchImpl: args.fetchImpl
32612
33114
  });
32613
- writeMirrorWithBodyPreserve(args.workspaceSlug, args.strategy, cloud);
32614
- return stampStateFromMirror({
33115
+ return adoptCloudCopy({
32615
33116
  strategy: args.strategy,
32616
33117
  workspaceSlug: args.workspaceSlug,
32617
33118
  doc: cloud,
32618
33119
  state: args.state,
32619
- priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash
33120
+ priorMemoryHash: getEntry(args.state, args.strategy.type, args.id)?.memory_hash,
33121
+ echo: args.echo,
33122
+ output: args.output,
33123
+ now: args.now
32620
33124
  });
32621
33125
  }
32622
33126
  async function pushFile(args) {
@@ -32637,6 +33141,7 @@ async function pushFile(args) {
32637
33141
  const mutex = deps?.mutex ?? new PerIdMutex;
32638
33142
  const output = deps?.output;
32639
33143
  const suppressor = deps?.suppressor;
33144
+ const echo = deps?.echo;
32640
33145
  const strategy = getStrategy(entityType);
32641
33146
  if (!strategy) {
32642
33147
  return {
@@ -32650,6 +33155,8 @@ async function pushFile(args) {
32650
33155
  }
32651
33156
  if (!exists) {
32652
33157
  return runPushDelete({
33158
+ echo,
33159
+ now,
32653
33160
  strategy,
32654
33161
  workspaceSlug,
32655
33162
  baseUrl,
@@ -32668,6 +33175,8 @@ async function pushFile(args) {
32668
33175
  } catch (err) {
32669
33176
  if (err?.code === "ENOENT") {
32670
33177
  return runPushDelete({
33178
+ echo,
33179
+ now,
32671
33180
  strategy,
32672
33181
  workspaceSlug,
32673
33182
  baseUrl,
@@ -32727,6 +33236,7 @@ async function pushFile(args) {
32727
33236
  const stateEntry = identity === "path" ? byInode : byInode ?? getEntryBySlug(state, entityType, slug);
32728
33237
  if (!stateEntry) {
32729
33238
  return runPushCreate({
33239
+ echo,
32730
33240
  strategy,
32731
33241
  workspaceSlug,
32732
33242
  baseUrl,
@@ -32747,6 +33257,7 @@ async function pushFile(args) {
32747
33257
  });
32748
33258
  }
32749
33259
  return runPushUpsert({
33260
+ echo,
32750
33261
  strategy,
32751
33262
  workspaceSlug,
32752
33263
  baseUrl,
@@ -32805,7 +33316,10 @@ async function reconcileContextDocConflict(args) {
32805
33316
  pat,
32806
33317
  id: existingId,
32807
33318
  state,
32808
- fetchImpl
33319
+ fetchImpl,
33320
+ echo: args.echo,
33321
+ output,
33322
+ now
32809
33323
  });
32810
33324
  output?.log(contextDocConflictLine({
32811
33325
  entityType: strategy.type,
@@ -32922,7 +33436,10 @@ async function reconcileContextDocConflict(args) {
32922
33436
  pat,
32923
33437
  id: existingId,
32924
33438
  state,
32925
- fetchImpl
33439
+ fetchImpl,
33440
+ echo: args.echo,
33441
+ output,
33442
+ now
32926
33443
  });
32927
33444
  output?.log(contextDocConflictLine({
32928
33445
  entityType: strategy.type,
@@ -33144,6 +33661,7 @@ async function runPushUpsert(args) {
33144
33661
  }
33145
33662
  if (patchResult.reason === "context_doc_conflict") {
33146
33663
  return reconcileContextDocConflict({
33664
+ echo: args.echo,
33147
33665
  strategy,
33148
33666
  workspaceSlug: args.workspaceSlug,
33149
33667
  baseUrl: args.baseUrl,
@@ -33162,6 +33680,7 @@ async function runPushUpsert(args) {
33162
33680
  }
33163
33681
  if (strategy.fieldTypedConflict) {
33164
33682
  return reconcileTaskConflict({
33683
+ echo: args.echo,
33165
33684
  strategy,
33166
33685
  workspaceSlug: args.workspaceSlug,
33167
33686
  baseUrl: args.baseUrl,
@@ -33192,7 +33711,10 @@ async function runPushUpsert(args) {
33192
33711
  pat: args.pat,
33193
33712
  id: liveEntry.id,
33194
33713
  state: workingState,
33195
- fetchImpl: args.fetchImpl
33714
+ fetchImpl: args.fetchImpl,
33715
+ echo: args.echo,
33716
+ output: args.output,
33717
+ now: args.now
33196
33718
  });
33197
33719
  return {
33198
33720
  outcome: {
@@ -33240,7 +33762,10 @@ async function reconcileTaskConflict(args) {
33240
33762
  pat,
33241
33763
  id: liveEntry.id,
33242
33764
  state,
33243
- fetchImpl
33765
+ fetchImpl,
33766
+ echo: args.echo,
33767
+ output,
33768
+ now
33244
33769
  });
33245
33770
  return {
33246
33771
  outcome: {
@@ -33289,13 +33814,15 @@ async function reconcileTaskConflict(args) {
33289
33814
  });
33290
33815
  if (patchResult.ok) {
33291
33816
  const stashPath2 = stashCloudSide();
33292
- writeMirrorWithBodyPreserve(workspaceSlug, strategy, patchResult.doc);
33293
- state = stampStateFromMirror({
33817
+ state = adoptCloudCopy({
33294
33818
  strategy,
33295
33819
  workspaceSlug,
33296
33820
  doc: patchResult.doc,
33297
33821
  state,
33298
- priorMemoryHash: liveEntry.memory_hash
33822
+ priorMemoryHash: liveEntry.memory_hash,
33823
+ echo: args.echo,
33824
+ output,
33825
+ now
33299
33826
  });
33300
33827
  output?.log(`[PATCH entityType=${strategy.type} slug=${patchResult.doc.slug} id=${patchResult.doc.id} revision=${patchResult.doc.revision} note=field_typed_conflict]`);
33301
33828
  return {
@@ -33327,7 +33854,10 @@ async function reconcileTaskConflict(args) {
33327
33854
  pat,
33328
33855
  id: liveEntry.id,
33329
33856
  state,
33330
- fetchImpl
33857
+ fetchImpl,
33858
+ echo: args.echo,
33859
+ output,
33860
+ now
33331
33861
  });
33332
33862
  return {
33333
33863
  outcome: {
@@ -33364,6 +33894,7 @@ async function runPushCreate(args) {
33364
33894
  const inLock = args.identity === "path" ? undefined : getEntryBySlug(args.state, strategy.type, args.slug);
33365
33895
  if (inLock) {
33366
33896
  return runPushUpsert({
33897
+ echo: args.echo,
33367
33898
  strategy,
33368
33899
  workspaceSlug: args.workspaceSlug,
33369
33900
  baseUrl: args.baseUrl,
@@ -33460,6 +33991,7 @@ async function runPushCreate(args) {
33460
33991
  }
33461
33992
  if (createResult.reason === "context_doc_conflict") {
33462
33993
  return reconcileContextDocConflict({
33994
+ echo: args.echo,
33463
33995
  strategy,
33464
33996
  workspaceSlug: args.workspaceSlug,
33465
33997
  baseUrl: args.baseUrl,
@@ -33491,7 +34023,10 @@ async function runPushCreate(args) {
33491
34023
  pat: args.pat,
33492
34024
  id: createResult.existing.id,
33493
34025
  state: args.state,
33494
- fetchImpl: args.fetchImpl
34026
+ fetchImpl: args.fetchImpl,
34027
+ echo: args.echo,
34028
+ output: args.output,
34029
+ now: args.now
33495
34030
  });
33496
34031
  return {
33497
34032
  outcome: {
@@ -33579,7 +34114,10 @@ async function runPushDelete(args) {
33579
34114
  pat: args.pat,
33580
34115
  id,
33581
34116
  state: args.state,
33582
- fetchImpl: args.fetchImpl
34117
+ fetchImpl: args.fetchImpl,
34118
+ echo: args.echo,
34119
+ output: args.output,
34120
+ now: args.now
33583
34121
  });
33584
34122
  return {
33585
34123
  outcome: {
@@ -33610,6 +34148,7 @@ async function pushRename(args) {
33610
34148
  const now = deps?.now ?? (() => new Date);
33611
34149
  const mutex = deps?.mutex ?? new PerIdMutex;
33612
34150
  const output = deps?.output;
34151
+ const echo = deps?.echo;
33613
34152
  const strategy = requireStrategy(entityType);
33614
34153
  let toSlug;
33615
34154
  try {
@@ -33718,7 +34257,10 @@ async function pushRename(args) {
33718
34257
  pat,
33719
34258
  id: patchResult.existing.id,
33720
34259
  state,
33721
- fetchImpl
34260
+ fetchImpl,
34261
+ echo,
34262
+ output,
34263
+ now
33722
34264
  });
33723
34265
  const finalState = await pullAndWriteMirror({
33724
34266
  strategy,
@@ -33727,7 +34269,10 @@ async function pushRename(args) {
33727
34269
  pat,
33728
34270
  id,
33729
34271
  state: afterColliding,
33730
- fetchImpl
34272
+ fetchImpl,
34273
+ echo,
34274
+ output,
34275
+ now
33731
34276
  });
33732
34277
  return {
33733
34278
  outcome: {
@@ -33759,7 +34304,10 @@ async function pushRename(args) {
33759
34304
  pat,
33760
34305
  id,
33761
34306
  state,
33762
- fetchImpl
34307
+ fetchImpl,
34308
+ echo,
34309
+ output,
34310
+ now
33763
34311
  });
33764
34312
  return {
33765
34313
  outcome: {
@@ -33817,7 +34365,8 @@ async function applyBatch(batch, deps, mutex) {
33817
34365
  fetchImpl: deps.fetchImpl,
33818
34366
  now: deps.now,
33819
34367
  mutex,
33820
- output: deps.output
34368
+ output: deps.output,
34369
+ echo: deps.echo
33821
34370
  }
33822
34371
  });
33823
34372
  deps.setState(result.state);
@@ -33886,7 +34435,10 @@ async function discardBatchOps(batch, deps) {
33886
34435
  pat: deps.pat,
33887
34436
  id: op.id,
33888
34437
  state: deps.getState(),
33889
- fetchImpl: deps.fetchImpl
34438
+ fetchImpl: deps.fetchImpl,
34439
+ echo: deps.echo,
34440
+ output: deps.output,
34441
+ now: deps.now
33890
34442
  });
33891
34443
  deps.setState(next);
33892
34444
  } catch (err) {
@@ -33957,6 +34509,349 @@ function classifyFilesPath(args) {
33957
34509
  return validateFilesRelPath(posix) ? { relPath: posix } : null;
33958
34510
  }
33959
34511
 
34512
+ // src/lib/memory-facts/sync.ts
34513
+ import { isAbsolute as isAbsolute2, relative as relative6, sep as sep4 } from "node:path";
34514
+
34515
+ // src/lib/memory-facts/api.ts
34516
+ var FactCommonSchema = exports_external.object({
34517
+ id: exports_external.string(),
34518
+ slug: exports_external.string(),
34519
+ description: exports_external.string(),
34520
+ body_md: exports_external.string(),
34521
+ fact_type: exports_external.string(),
34522
+ status: exports_external.string(),
34523
+ superseded_by: exports_external.string().nullish(),
34524
+ generality: exports_external.string().nullish(),
34525
+ source: exports_external.string().nullish(),
34526
+ created_at: exports_external.string().nullish(),
34527
+ updated_at: exports_external.string().nullish()
34528
+ });
34529
+ var WorkspaceFactWireSchema = FactCommonSchema.extend({
34530
+ artist_id: exports_external.string().nullish(),
34531
+ revision: exports_external.number().nullish()
34532
+ });
34533
+ var UserFactWireSchema = FactCommonSchema;
34534
+ var MemoryFactsFeedSchema = exports_external.object({
34535
+ workspace_facts: exports_external.array(WorkspaceFactWireSchema),
34536
+ user_facts: exports_external.array(UserFactWireSchema)
34537
+ });
34538
+ var ROUTE = "/api/sync/memory-facts";
34539
+ function baseHeaders2(pat) {
34540
+ return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
34541
+ }
34542
+ async function getMemoryFacts(args) {
34543
+ const { baseUrl, pat, fetchImpl = fetch } = args;
34544
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
34545
+ let res;
34546
+ try {
34547
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders2(pat) });
34548
+ } catch (err) {
34549
+ const msg = err instanceof Error ? err.message : "network error";
34550
+ throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
34551
+ }
34552
+ if (res.status === 401) {
34553
+ throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
34554
+ }
34555
+ if (res.status === 429) {
34556
+ throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
34557
+ }
34558
+ if (!res.ok) {
34559
+ throw new ApiError(res.status, `sync memory-facts failed with HTTP ${res.status}`);
34560
+ }
34561
+ const body = await res.json();
34562
+ const parsed = MemoryFactsFeedSchema.safeParse(body);
34563
+ if (!parsed.success) {
34564
+ throw new ApiError(res.status, `sync memory-facts response did not match the expected shape: ${parsed.error.message}`);
34565
+ }
34566
+ return parsed.data;
34567
+ }
34568
+
34569
+ // src/lib/memory-facts/refusal.ts
34570
+ var MEMORY_IS_GENERATED_CODE = "memory_is_generated";
34571
+ var GeneratedRefusalSchema = exports_external.object({
34572
+ error: exports_external.string(),
34573
+ code: exports_external.literal(MEMORY_IS_GENERATED_CODE)
34574
+ });
34575
+ function factToolsFor(pile) {
34576
+ 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`";
34577
+ }
34578
+ function generatedMemoryRefusalRecord(args) {
34579
+ const which = args.factId ? ` (fact \`${args.factId}\`)` : "";
34580
+ 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.`;
34581
+ }
34582
+ var MEMORY_FACT_CONFLICT_ENTITY = "memory";
34583
+ function isGeneratedMemoryStash(label, deps) {
34584
+ if (label.startsWith(`${MEMORY_FACT_CONFLICT_ENTITY}/`)) {
34585
+ const tail = label.slice(MEMORY_FACT_CONFLICT_ENTITY.length + 1);
34586
+ if (tail.startsWith("private."))
34587
+ return "private";
34588
+ if (tail.startsWith("shared."))
34589
+ return "shared";
34590
+ return null;
34591
+ }
34592
+ if (label === "workspace-memory/workspace-memory") {
34593
+ return deps.workspaceMemoryGenerated() ? "shared" : null;
34594
+ }
34595
+ if (label === "user-memory/user-memory") {
34596
+ return deps.userMemoryGenerated() ? "private" : null;
34597
+ }
34598
+ const artist = label.match(/^artists\/(.+)\.memory$/);
34599
+ if (artist) {
34600
+ return deps.artistMemoryGenerated(artist[1]) ? "shared" : null;
34601
+ }
34602
+ return null;
34603
+ }
34604
+
34605
+ // src/lib/memory-facts/refuse.ts
34606
+ function refuseGeneratedMemoryEdit(args) {
34607
+ const stashPath = stashConflict({
34608
+ workspaceSlug: args.workspaceSlug,
34609
+ entityType: args.conflictEntity,
34610
+ entitySlug: args.conflictSlug,
34611
+ timestamp: args.now(),
34612
+ contents: args.localContent,
34613
+ side: "local"
34614
+ });
34615
+ args.writeCanonical(args.canonicalBody);
34616
+ const record2 = generatedMemoryRefusalRecord({
34617
+ relPath: args.relPath,
34618
+ pile: args.pile,
34619
+ stashPath,
34620
+ factId: args.factId
34621
+ });
34622
+ args.output?.warn(record2);
34623
+ args.output?.log(`[MEMORY_GENERATED_REFUSED ws=${args.workspaceSlug} path=${args.relPath} stashed]`);
34624
+ const state = recordMemoryGeneratedRefusal({
34625
+ state: args.state,
34626
+ workspaceSlug: args.workspaceSlug,
34627
+ absolutePath: args.absolutePath,
34628
+ record: record2,
34629
+ now: args.now
34630
+ });
34631
+ return { state, stashPath };
34632
+ }
34633
+
34634
+ // src/lib/memory-facts/render.ts
34635
+ function yamlString(value) {
34636
+ 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")}`);
34637
+ return `"${escaped}"`;
34638
+ }
34639
+ var SOURCE_UNKNOWN = "unknown";
34640
+ function toRenderableWorkspaceFact(row, artistSlugById) {
34641
+ const artistId = row.artist_id ?? null;
34642
+ return {
34643
+ pile: "shared",
34644
+ id: row.id,
34645
+ slug: row.slug,
34646
+ description: row.description,
34647
+ fact_type: row.fact_type,
34648
+ status: row.status,
34649
+ source: row.source,
34650
+ superseded_by: row.superseded_by,
34651
+ generality: row.generality,
34652
+ body_md: row.body_md,
34653
+ artist_id: artistId,
34654
+ artist_slug: artistId ? artistSlugById(artistId) : undefined
34655
+ };
34656
+ }
34657
+ function toRenderableUserFact(row) {
34658
+ return {
34659
+ pile: "private",
34660
+ id: row.id,
34661
+ slug: row.slug,
34662
+ description: row.description,
34663
+ fact_type: row.fact_type,
34664
+ status: row.status,
34665
+ source: row.source,
34666
+ superseded_by: row.superseded_by,
34667
+ generality: row.generality,
34668
+ body_md: row.body_md
34669
+ };
34670
+ }
34671
+ function renderFactFile(fact) {
34672
+ const lines = ["---"];
34673
+ lines.push(`id: ${yamlString(fact.id)}`);
34674
+ lines.push(`name: ${yamlString(fact.slug)}`);
34675
+ lines.push(`description: ${yamlString(fact.description)}`);
34676
+ lines.push(`type: ${yamlString(fact.fact_type)}`);
34677
+ lines.push(`status: ${yamlString(fact.status)}`);
34678
+ lines.push(`source: ${yamlString(fact.source ?? SOURCE_UNKNOWN)}`);
34679
+ if (fact.generality)
34680
+ lines.push(`generality: ${yamlString(fact.generality)}`);
34681
+ if (fact.artist_id)
34682
+ lines.push(`artist_id: ${yamlString(fact.artist_id)}`);
34683
+ if (fact.artist_slug)
34684
+ lines.push(`artist: ${yamlString(fact.artist_slug)}`);
34685
+ if (fact.superseded_by) {
34686
+ lines.push(`superseded_by: ${yamlString(fact.superseded_by)}`);
34687
+ }
34688
+ lines.push("---");
34689
+ lines.push("");
34690
+ lines.push(...headerComment(fact));
34691
+ lines.push("");
34692
+ const body = fact.body_md.replace(/\s+$/, "");
34693
+ if (body.length > 0)
34694
+ lines.push(body);
34695
+ return lines.join(`
34696
+ `).replace(/\n*$/, "") + `
34697
+ `;
34698
+ }
34699
+ function headerComment(fact) {
34700
+ const rel = memoryFactRelPath(fact.pile, fact.slug);
34701
+ const out = [
34702
+ "<!-- GENERATED by Plinth from a memory fact. Edits here are not saved:",
34703
+ ` the next sync rewrites this file. To change this fact, use`,
34704
+ ` ${factToolsFor(fact.pile)}`,
34705
+ ` with id ${fact.id}. This file is ${rel}.`
34706
+ ];
34707
+ if (fact.status !== "active") {
34708
+ out.push(` STATUS ${fact.status.toUpperCase()}: this fact is no longer current and is not in any index.`);
34709
+ }
34710
+ out.push("-->");
34711
+ return out;
34712
+ }
34713
+
34714
+ // src/lib/memory-facts/sync.ts
34715
+ function artistSlugLookup(state) {
34716
+ const entries = state.entries["artists"] ?? {};
34717
+ const byId = new Map;
34718
+ for (const entry2 of Object.values(entries))
34719
+ byId.set(entry2.id, entry2.slug);
34720
+ return (id) => byId.get(id);
34721
+ }
34722
+ async function syncMemoryFacts(args) {
34723
+ const { workspaceSlug, baseUrl, pat, fetchImpl, output, suppressor } = args;
34724
+ const now = args.now ?? (() => new Date);
34725
+ let state = args.state;
34726
+ let feed;
34727
+ try {
34728
+ feed = await getMemoryFacts({ baseUrl, pat, fetchImpl });
34729
+ } catch (err) {
34730
+ if (err instanceof ApiError && err.status === 404) {
34731
+ output?.log(`[MEMORY_FACTS ws=${workspaceSlug} skipped reason=route_absent]`);
34732
+ return { state, written: 0, deleted: 0, refused: 0, facts: 0 };
34733
+ }
34734
+ throw err;
34735
+ }
34736
+ const slugFor = artistSlugLookup(state);
34737
+ const facts = [
34738
+ ...feed.workspace_facts.map((row) => toRenderableWorkspaceFact(row, slugFor)),
34739
+ ...feed.user_facts.map(toRenderableUserFact)
34740
+ ];
34741
+ const acked = getMemoryFactEntries(state);
34742
+ const registry3 = {};
34743
+ const live = new Set;
34744
+ let written = 0;
34745
+ let refused = 0;
34746
+ for (const fact of facts) {
34747
+ let relPath;
34748
+ try {
34749
+ relPath = memoryFactRelPath(fact.pile, fact.slug);
34750
+ } catch {
34751
+ output?.warn(`Memory fact "${fact.slug}" has a slug this mirror cannot write - skipped.`);
34752
+ continue;
34753
+ }
34754
+ live.add(relPath);
34755
+ const rendered = renderFactFile(fact);
34756
+ const renderedHash = stableHash(rendered);
34757
+ const onDisk = readMemoryFactFile(workspaceSlug, fact.pile, fact.slug);
34758
+ const ackedHash = acked[relPath]?.body_hash;
34759
+ if (onDisk !== null && stableHash(onDisk) === renderedHash) {
34760
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, memoryFactFilePath(workspaceSlug, fact.pile, fact.slug));
34761
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34762
+ continue;
34763
+ }
34764
+ if (onDisk !== null && stableHash(onDisk) !== ackedHash) {
34765
+ const refusal = refuseGeneratedMemoryEdit({
34766
+ state,
34767
+ workspaceSlug,
34768
+ absolutePath: memoryFactFilePath(workspaceSlug, fact.pile, fact.slug),
34769
+ relPath,
34770
+ pile: fact.pile,
34771
+ conflictEntity: "memory",
34772
+ conflictSlug: `${fact.pile}.${fact.slug}`,
34773
+ localContent: onDisk,
34774
+ canonicalBody: rendered,
34775
+ writeCanonical: (body) => writeFact(args, fact.pile, fact.slug, body),
34776
+ factId: fact.id,
34777
+ now,
34778
+ output
34779
+ });
34780
+ state = refusal.state;
34781
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34782
+ refused++;
34783
+ continue;
34784
+ }
34785
+ writeFact(args, fact.pile, fact.slug, rendered);
34786
+ registry3[relPath] = { id: fact.id, body_hash: renderedHash };
34787
+ written++;
34788
+ }
34789
+ let deleted = 0;
34790
+ for (const { pile, slug } of listMemoryFactFiles(workspaceSlug)) {
34791
+ const relPath = memoryFactRelPath(pile, slug);
34792
+ if (live.has(relPath))
34793
+ continue;
34794
+ if (acked[relPath] === undefined)
34795
+ continue;
34796
+ const abs = memoryFactFilePath(workspaceSlug, pile, slug);
34797
+ suppressor?.registerDelete(abs);
34798
+ if (deleteMemoryFactFile(workspaceSlug, pile, slug))
34799
+ deleted++;
34800
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, abs);
34801
+ }
34802
+ const { state: next, changed } = setMemoryFactsState(state, {
34803
+ fetched_at: now().toISOString(),
34804
+ facts: registry3
34805
+ });
34806
+ state = changed ? commitState(state, next) : state;
34807
+ if (written > 0 || deleted > 0 || refused > 0) {
34808
+ output?.log(`[MEMORY_FACTS ws=${workspaceSlug} facts=${facts.length} written=${written} deleted=${deleted} refused=${refused}]`);
34809
+ }
34810
+ return { state, written, deleted, refused, facts: facts.length };
34811
+ }
34812
+ function writeFact(args, pile, slug, content) {
34813
+ const abs = memoryFactFilePath(args.workspaceSlug, pile, slug);
34814
+ args.suppressor?.registerWrite(abs, {
34815
+ bodyHash: stableHash(content),
34816
+ frontmatterHash: ""
34817
+ });
34818
+ writeMemoryFactFile(args.workspaceSlug, pile, slug, content);
34819
+ }
34820
+ function classifyMemoryFactPath(args) {
34821
+ let root;
34822
+ try {
34823
+ root = memoryFactsSubtreeRoot(args.workspaceSlug);
34824
+ } catch {
34825
+ return null;
34826
+ }
34827
+ const rel = relative6(root, args.absolutePath);
34828
+ if (rel === "" || rel.startsWith("..") || isAbsolute2(rel))
34829
+ return null;
34830
+ const mirrorRel = `${MEMORY_FACTS_DIR}/${rel.split(sep4).join("/")}`;
34831
+ return { relPath: mirrorRel, fact: parseMemoryFactRelPath(mirrorRel) };
34832
+ }
34833
+ function classifyMemoryFactEvent(args) {
34834
+ if (args.path === null)
34835
+ return { kind: "ignore" };
34836
+ if (args.ownEcho)
34837
+ return { kind: "echo" };
34838
+ if (args.kind !== "upsert")
34839
+ return { kind: "ignore" };
34840
+ if (args.path.fact !== null && args.known) {
34841
+ return { kind: "generated", relPath: args.path.relPath, pile: args.path.fact.pile };
34842
+ }
34843
+ return { kind: "unowned", relPath: args.path.relPath };
34844
+ }
34845
+ function memoryFactEventWarning(v) {
34846
+ if (v.kind === "generated") {
34847
+ 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)}.`;
34848
+ }
34849
+ if (v.kind === "unowned") {
34850
+ return `${v.relPath} is not synced: \`memory/\` holds files Plinth generates from ` + `memory facts. Put your own notes in \`files/\` instead.`;
34851
+ }
34852
+ return null;
34853
+ }
34854
+
33960
34855
  // src/lib/files/destructive-gate.ts
33961
34856
  function isFileGutting(priorSize, newSize) {
33962
34857
  if (priorSize === undefined)
@@ -34091,7 +34986,7 @@ class FileDestructiveGate {
34091
34986
  import { readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
34092
34987
 
34093
34988
  // src/lib/unsynced.ts
34094
- import { join as join12, relative as relative6 } from "node:path";
34989
+ import { join as join12, relative as relative7 } from "node:path";
34095
34990
  var RECORDABLE_REASONS = new Set([
34096
34991
  "rate_limit",
34097
34992
  "server",
@@ -34111,7 +35006,7 @@ function recordEntityUnsynced(args) {
34111
35006
  const cat = categorizeError(args.err);
34112
35007
  let path5;
34113
35008
  try {
34114
- path5 = relative6(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
35009
+ path5 = relative7(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
34115
35010
  } catch {
34116
35011
  path5 = args.absolutePath;
34117
35012
  }
@@ -34136,7 +35031,7 @@ function recordFileUnsynced(args) {
34136
35031
  });
34137
35032
  }
34138
35033
  async function drainUnsyncedOps(args) {
34139
- const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output } = args;
35034
+ const { workspaceSlug, baseUrl, pat, getState, setState, fetchImpl, mutex, output, echo } = args;
34140
35035
  const now = args.now ?? (() => new Date);
34141
35036
  const ops = listUnsynced(getState());
34142
35037
  let attempted = 0;
@@ -34169,7 +35064,7 @@ async function drainUnsyncedOps(args) {
34169
35064
  state: getState(),
34170
35065
  exists: op.op === "upsert",
34171
35066
  unlinkContext: null,
34172
- deps: { fetchImpl, now, mutex, output }
35067
+ deps: { fetchImpl, now, mutex, output, echo }
34173
35068
  });
34174
35069
  setState(result.state);
34175
35070
  } else {
@@ -35210,11 +36105,11 @@ function createPattern(matcher) {
35210
36105
  if (matcher.path === string4)
35211
36106
  return true;
35212
36107
  if (matcher.recursive) {
35213
- const relative9 = sp2.relative(matcher.path, string4);
35214
- if (!relative9) {
36108
+ const relative10 = sp2.relative(matcher.path, string4);
36109
+ if (!relative10) {
35215
36110
  return false;
35216
36111
  }
35217
- return !relative9.startsWith("..") && !sp2.isAbsolute(relative9);
36112
+ return !relative10.startsWith("..") && !sp2.isAbsolute(relative10);
35218
36113
  }
35219
36114
  return false;
35220
36115
  };
@@ -35844,7 +36739,7 @@ var chokidar_default = { watch, FSWatcher };
35844
36739
 
35845
36740
  // src/lib/watcher.ts
35846
36741
  import { statSync as statSync9 } from "node:fs";
35847
- import { sep as sep4 } from "node:path";
36742
+ import { sep as sep5 } from "node:path";
35848
36743
 
35849
36744
  // src/lib/rename-classifier.ts
35850
36745
  class RenameClassifier {
@@ -35975,7 +36870,7 @@ function watcherIgnoreMatcher(workspaceRoot) {
35975
36870
  return (absolutePath) => patterns.some((re) => re.test(absolutePath));
35976
36871
  }
35977
36872
  function escSep() {
35978
- return sep4 === "/" ? "/" : "\\\\";
36873
+ return sep5 === "/" ? "/" : "\\\\";
35979
36874
  }
35980
36875
 
35981
36876
  class DebounceMap {
@@ -36326,7 +37221,7 @@ async function reconcileFilesSubtree(args) {
36326
37221
  }
36327
37222
 
36328
37223
  // src/lib/artist-memory/sync.ts
36329
- import { relative as relative9 } from "node:path";
37224
+ import { relative as relative10 } from "node:path";
36330
37225
 
36331
37226
  // src/lib/context/secret-gate.ts
36332
37227
  function applySecretGate(args) {
@@ -36367,6 +37262,10 @@ var ENTITY_TYPE = "artists";
36367
37262
  function conflictSlug(slug) {
36368
37263
  return `${slug}.memory`;
36369
37264
  }
37265
+ var GENERATED_PILE = "shared";
37266
+ function memoryRelPath(slug) {
37267
+ return `artists/${slug}/${ARTIST_MEMORY_ANCHOR}`;
37268
+ }
36370
37269
  function persistEntry2(state, entry2) {
36371
37270
  const { state: next, changed } = upsertEntry(state, ENTITY_TYPE, entry2);
36372
37271
  if (!changed)
@@ -36393,7 +37292,7 @@ function classifyArtistMemoryPath(args) {
36393
37292
  } catch {
36394
37293
  return null;
36395
37294
  }
36396
- const rel = relative9(root, args.absolutePath);
37295
+ const rel = relative10(root, args.absolutePath);
36397
37296
  const parts = rel.split("/");
36398
37297
  if (parts.length !== 3)
36399
37298
  return null;
@@ -36415,25 +37314,55 @@ function classifyArtistMemoryPath(args) {
36415
37314
  }
36416
37315
  function applyArtistMemoryOnPull(args) {
36417
37316
  const { workspaceSlug, live, ackedMemoryHash, suppressor, now, out } = args;
37317
+ let state = args.state;
36418
37318
  const slug = live.slug;
36419
37319
  const cloudMemory = live.memory;
37320
+ const generated = live.memory_generated === true;
36420
37321
  const local = readArtistMemoryFile(workspaceSlug, slug);
37322
+ const refuse = (localContent, canonical) => {
37323
+ const refused = refuseGeneratedMemoryEdit({
37324
+ state,
37325
+ workspaceSlug,
37326
+ absolutePath: artistMemoryFilePath(workspaceSlug, slug),
37327
+ relPath: memoryRelPath(slug),
37328
+ pile: GENERATED_PILE,
37329
+ conflictEntity: ENTITY_TYPE,
37330
+ conflictSlug: conflictSlug(slug),
37331
+ localContent,
37332
+ canonicalBody: canonical,
37333
+ writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
37334
+ now,
37335
+ output: out
37336
+ });
37337
+ state = refused.state;
37338
+ };
36421
37339
  if (cloudMemory === null) {
36422
37340
  if (local !== null && isPristineMemoryStub(local)) {
36423
37341
  const abs = artistMemoryFilePath(workspaceSlug, slug);
36424
37342
  suppressor?.registerDelete(abs);
36425
37343
  deleteArtistMemoryFile(workspaceSlug, slug);
36426
- return { memoryHash: undefined };
37344
+ return { memoryHash: undefined, state };
36427
37345
  }
36428
- return { memoryHash: ackedMemoryHash };
37346
+ return { memoryHash: ackedMemoryHash, state };
36429
37347
  }
36430
37348
  const cloudHash = stableHash(cloudMemory);
36431
37349
  if (cloudHash === ackedMemoryHash && local !== null) {
36432
- return { memoryHash: ackedMemoryHash };
37350
+ if (generated && stableHash(local) !== ackedMemoryHash) {
37351
+ refuse(local, cloudMemory);
37352
+ return { memoryHash: cloudHash, state };
37353
+ }
37354
+ if (generated) {
37355
+ state = clearMemoryGeneratedRefusal(state, workspaceSlug, artistMemoryFilePath(workspaceSlug, slug));
37356
+ }
37357
+ return { memoryHash: ackedMemoryHash, state };
36433
37358
  }
36434
37359
  if (local !== null) {
36435
37360
  const localHash = stableHash(local);
36436
37361
  if (localHash !== ackedMemoryHash && localHash !== cloudHash) {
37362
+ if (generated) {
37363
+ refuse(local, cloudMemory);
37364
+ return { memoryHash: cloudHash, state };
37365
+ }
36437
37366
  const stashPath = stashConflict({
36438
37367
  workspaceSlug,
36439
37368
  entityType: ENTITY_TYPE,
@@ -36446,7 +37375,7 @@ function applyArtistMemoryOnPull(args) {
36446
37375
  }
36447
37376
  }
36448
37377
  writeMemory(workspaceSlug, slug, cloudMemory, suppressor);
36449
- return { memoryHash: cloudHash };
37378
+ return { memoryHash: cloudHash, state };
36450
37379
  }
36451
37380
  function writeMemory(workspaceSlug, slug, content, suppressor) {
36452
37381
  const abs = artistMemoryFilePath(workspaceSlug, slug);
@@ -36489,8 +37418,46 @@ async function pushArtistMemory(args) {
36489
37418
  if (gate.held)
36490
37419
  return { state: base2, outcome: { kind: "secret_held", slug } };
36491
37420
  if (alreadyAcked) {
37421
+ if (entry2.memory_generated === true) {
37422
+ base2 = clearMemoryGeneratedRefusal(base2, workspaceSlug, artistMemoryFilePath(workspaceSlug, slug));
37423
+ }
36492
37424
  return { state: base2, outcome: { kind: "noop", slug } };
36493
37425
  }
37426
+ if (entry2.memory_generated === true) {
37427
+ const row = await getEntity({
37428
+ strategy: artistsStrategy,
37429
+ baseUrl,
37430
+ pat,
37431
+ id: entry2.id,
37432
+ fetchImpl
37433
+ });
37434
+ const canonical = row.memory ?? "";
37435
+ const refused = refuseGeneratedMemoryEdit({
37436
+ state: base2,
37437
+ workspaceSlug,
37438
+ absolutePath: artistMemoryFilePath(workspaceSlug, slug),
37439
+ relPath: memoryRelPath(slug),
37440
+ pile: GENERATED_PILE,
37441
+ conflictEntity: ENTITY_TYPE,
37442
+ conflictSlug: conflictSlug(slug),
37443
+ localContent: local,
37444
+ canonicalBody: canonical,
37445
+ writeCanonical: (body) => writeMemory(workspaceSlug, slug, body, suppressor),
37446
+ now,
37447
+ output
37448
+ });
37449
+ const stamped = {
37450
+ ...entry2,
37451
+ updated_at: row.updated_at,
37452
+ revision: row.revision,
37453
+ memory_hash: stableHash(canonical),
37454
+ memory_generated: row.memory_generated
37455
+ };
37456
+ return {
37457
+ state: persistEntry2(refused.state, stamped),
37458
+ outcome: { kind: "generated_refused", slug, stashPath: refused.stashPath }
37459
+ };
37460
+ }
36494
37461
  const acked = entry2.memory_hash;
36495
37462
  let cas = entry2.updated_at;
36496
37463
  const landed = (doc2, note = "") => {
@@ -36998,16 +37965,7 @@ async function fetchCalendar(args) {
36998
37965
  return parsed.data;
36999
37966
  }
37000
37967
 
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";
37968
+ // src/lib/calendar/day.ts
37011
37969
  var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
37012
37970
  var MONTHS = [
37013
37971
  "Jan",
@@ -37024,32 +37982,12 @@ var MONTHS = [
37024
37982
  "Dec"
37025
37983
  ];
37026
37984
  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
37985
  function dateParts(value) {
37040
37986
  const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
37041
37987
  if (!m)
37042
37988
  return null;
37043
37989
  return { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
37044
37990
  }
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
37991
  function utcMs(parts) {
37054
37992
  return Date.UTC(parts.y, parts.m - 1, parts.d);
37055
37993
  }
@@ -37063,6 +38001,116 @@ function formatDay(parts, referenceYear) {
37063
38001
  const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
37064
38002
  return `${weekday} ${parts.d} ${month}${year}`;
37065
38003
  }
38004
+ function localDayOf(now) {
38005
+ return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
38006
+ }
38007
+ function daysBetween2(from, to) {
38008
+ return Math.round((utcMs(to) - utcMs(from)) / DAY_MS);
38009
+ }
38010
+ function isRealDay(parts) {
38011
+ if (parts.m < 1 || parts.m > 12 || parts.d < 1 || parts.d > 31)
38012
+ return false;
38013
+ const d = new Date(utcMs(parts));
38014
+ return d.getUTCFullYear() === parts.y && d.getUTCMonth() + 1 === parts.m && d.getUTCDate() === parts.d;
38015
+ }
38016
+ var WEEKDAY_WORD = "(Sun(?:day)?|Mon(?:day)?|Tue(?:s|sday)?|Wed(?:nesday)?|Thu(?:r|rs|rsday)?|Fri(?:day)?|Sat(?:urday)?)";
38017
+ 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)?)";
38018
+ 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");
38019
+ var YEAR_WINDOW = 10;
38020
+ function weekdayIndex(word) {
38021
+ const stem = word.slice(0, 3).toLowerCase();
38022
+ return WEEKDAYS.findIndex((w) => w.toLowerCase() === stem);
38023
+ }
38024
+ function monthIndex(word) {
38025
+ const stem = word.slice(0, 3).toLowerCase();
38026
+ return MONTHS.findIndex((m) => m.toLowerCase() === stem);
38027
+ }
38028
+ function inferYear(day, month, weekday, today) {
38029
+ 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)));
38030
+ if (weekday !== null) {
38031
+ const match = candidates.find((c) => new Date(utcMs(c.parts)).getUTCDay() === weekday);
38032
+ if (match)
38033
+ return match.y;
38034
+ }
38035
+ return candidates[0]?.y ?? today.y;
38036
+ }
38037
+ function readDayMentions(text, today) {
38038
+ const out = [];
38039
+ for (const m of text.matchAll(MENTION_RE)) {
38040
+ let parts;
38041
+ let yearInferred = false;
38042
+ let length = m[0].length;
38043
+ if (m[1] !== undefined) {
38044
+ parts = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) };
38045
+ } else {
38046
+ if (m[6] === "may")
38047
+ continue;
38048
+ const weekday = m[4] === undefined ? null : weekdayIndex(m[4]);
38049
+ const day = Number(m[5]);
38050
+ const month = monthIndex(m[6]) + 1;
38051
+ if (month < 1)
38052
+ continue;
38053
+ const written = m[7] === undefined ? null : Number(m[7]);
38054
+ if (written !== null && Math.abs(written - today.y) <= YEAR_WINDOW) {
38055
+ parts = { y: written, m: month, d: day };
38056
+ } else {
38057
+ if (written !== null)
38058
+ length -= /,?\s+\d{4}$/.exec(m[0])?.[0].length ?? 0;
38059
+ parts = { y: inferYear(day, month, weekday, today), m: month, d: day };
38060
+ yearInferred = true;
38061
+ }
38062
+ }
38063
+ if (!isRealDay(parts))
38064
+ continue;
38065
+ out.push({ parts, index: m.index, length, yearInferred });
38066
+ }
38067
+ return out;
38068
+ }
38069
+ function readAsOf(text, today) {
38070
+ const at = /\bas\s+of\b/i.exec(text);
38071
+ if (!at)
38072
+ return null;
38073
+ const after = text.slice(at.index + at[0].length);
38074
+ const first = readDayMentions(after, today)[0];
38075
+ if (!first)
38076
+ return null;
38077
+ if (!/^[\s:,\-(]*$/.test(after.slice(0, first.index)))
38078
+ return null;
38079
+ const ahead = utcMs(first.parts) > utcMs(today);
38080
+ const parts = first.yearInferred && ahead ? { ...first.parts, y: first.parts.y - 1 } : first.parts;
38081
+ return { parts, yearInferred: first.yearInferred };
38082
+ }
38083
+
38084
+ // src/lib/calendar/render.ts
38085
+ var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
38086
+ var OWNERSHIP_NOTE2 = [
38087
+ "<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
38088
+ " is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
38089
+ " Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
38090
+ " person - not from this file and not by an agent. -->"
38091
+ ].join(`
38092
+ `);
38093
+ var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
38094
+ var FULL_BAND_DAYS = 14;
38095
+ function yamlString2(value) {
38096
+ return JSON.stringify(value);
38097
+ }
38098
+ function oneLine2(value, maxLength = 200) {
38099
+ const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
38100
+ if (flattened.length === 0)
38101
+ return "(untitled)";
38102
+ if (flattened.length <= maxLength)
38103
+ return flattened;
38104
+ return `${flattened.slice(0, maxLength - 3)}...`;
38105
+ }
38106
+ function timeParts(value) {
38107
+ const m = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
38108
+ if (!m)
38109
+ return null;
38110
+ const rawOffset = m[3];
38111
+ const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
38112
+ return { hh: m[1], mi: m[2], offset };
38113
+ }
37066
38114
  function sortInstant(event) {
37067
38115
  if (!event.all_day) {
37068
38116
  const ms = Date.parse(event.start);
@@ -37199,12 +38247,12 @@ function renderArtistCalendar(args) {
37199
38247
  "---",
37200
38248
  "plinth_generated: true",
37201
38249
  "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)}`,
38250
+ `artist_slug: ${row.artist_slug === null ? "null" : yamlString2(row.artist_slug)}`,
38251
+ `artist_name: ${row.artist_name === null ? "null" : yamlString2(row.artist_name)}`,
38252
+ `calendar_name: ${yamlString2(row.calendar_name)}`,
38253
+ `generated_at: ${yamlString2(generatedAt)}`,
38254
+ `window_start: ${yamlString2(window2.time_min)}`,
38255
+ `window_end: ${yamlString2(window2.time_max)}`,
37208
38256
  `status: ${row.status}`,
37209
38257
  "fetch_state: fresh",
37210
38258
  `entry_count: ${events.length}`,
@@ -37285,9 +38333,9 @@ function renderUpcoming(args) {
37285
38333
  "plinth_generated: true",
37286
38334
  "view: calendar",
37287
38335
  "scope: workspace",
37288
- `generated_at: ${yamlString(generatedAt)}`,
37289
- `window_start: ${yamlString(window2.time_min)}`,
37290
- `window_end: ${yamlString(window2.time_max)}`,
38336
+ `generated_at: ${yamlString2(generatedAt)}`,
38337
+ `window_start: ${yamlString2(window2.time_min)}`,
38338
+ `window_end: ${yamlString2(window2.time_max)}`,
37291
38339
  `artist_count: ${artistCount}`,
37292
38340
  `mapped_artist_count: ${mappedArtistCount}`,
37293
38341
  `calendars_read: ${fetched.length}`,
@@ -37383,8 +38431,8 @@ function renderStaleFile(existing, args) {
37383
38431
  "---",
37384
38432
  ...carried,
37385
38433
  "fetch_state: stale",
37386
- `last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
37387
- `last_refresh_error: ${yamlString(oneLine2(args.reason, 300))}`,
38434
+ `last_refresh_attempt: ${yamlString2(args.attemptedAt)}`,
38435
+ `last_refresh_error: ${yamlString2(oneLine2(args.reason, 300))}`,
37388
38436
  "---",
37389
38437
  "",
37390
38438
  OWNERSHIP_NOTE2,
@@ -37531,6 +38579,11 @@ class PullSuppressor {
37531
38579
  expiresAt: this.now() + this.ttlMs
37532
38580
  });
37533
38581
  }
38582
+ cancelDelete(absPath) {
38583
+ const entry2 = this.entries.get(absPath);
38584
+ if (entry2?.kind === "delete")
38585
+ this.entries.delete(absPath);
38586
+ }
37534
38587
  shouldSuppressUpsert(absPath, hashes) {
37535
38588
  const entry2 = this.entries.get(absPath);
37536
38589
  if (!entry2)
@@ -38099,7 +39152,7 @@ async function reconcileDivergedRows(args) {
38099
39152
  state,
38100
39153
  exists: true,
38101
39154
  unlinkContext: null,
38102
- deps: { fetchImpl, now, output }
39155
+ deps: { fetchImpl, now, output, echo: args.echo }
38103
39156
  });
38104
39157
  state = result.state;
38105
39158
  switch (result.outcome.kind) {
@@ -38287,7 +39340,7 @@ async function reconcileDivergedRows(args) {
38287
39340
 
38288
39341
  // src/lib/workspace-rules/sync.ts
38289
39342
  import { existsSync as existsSync4 } from "node:fs";
38290
- import { relative as relative10 } from "node:path";
39343
+ import { relative as relative11 } from "node:path";
38291
39344
 
38292
39345
  // src/lib/workspace-rules/api.ts
38293
39346
  var MAX_WORKSPACE_RULES_BYTES = 256 * 1024;
@@ -38302,16 +39355,16 @@ var PreconditionSchema2 = exports_external.object({
38302
39355
  code: exports_external.literal("precondition_failed"),
38303
39356
  current_updated_at: exports_external.string()
38304
39357
  });
38305
- function baseHeaders2(pat) {
39358
+ function baseHeaders3(pat) {
38306
39359
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38307
39360
  }
38308
- var ROUTE = "/api/sync/workspace-rules";
39361
+ var ROUTE2 = "/api/sync/workspace-rules";
38309
39362
  async function getWorkspaceRules(args) {
38310
39363
  const { baseUrl, pat, fetchImpl = fetch } = args;
38311
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
39364
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
38312
39365
  let res;
38313
39366
  try {
38314
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders2(pat) });
39367
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders3(pat) });
38315
39368
  } catch (err) {
38316
39369
  const msg = err instanceof Error ? err.message : "network error";
38317
39370
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38334,9 +39387,9 @@ async function getWorkspaceRules(args) {
38334
39387
  }
38335
39388
  async function putWorkspaceRules(args) {
38336
39389
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38337
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE}`;
39390
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
38338
39391
  const headers = {
38339
- ...baseHeaders2(pat),
39392
+ ...baseHeaders3(pat),
38340
39393
  "Content-Type": "application/json"
38341
39394
  };
38342
39395
  if (expectedUpdatedAt)
@@ -38422,7 +39475,7 @@ function classifyWorkspaceRulesPath(args) {
38422
39475
  } catch {
38423
39476
  return false;
38424
39477
  }
38425
- return relative10(root, args.absolutePath) === WORKSPACE_RULES_FILENAME;
39478
+ return relative11(root, args.absolutePath) === WORKSPACE_RULES_FILENAME;
38426
39479
  }
38427
39480
  function migrateHouseStyleFile(workspaceSlug) {
38428
39481
  let target;
@@ -38573,7 +39626,7 @@ async function reconcileWorkspaceRules(args) {
38573
39626
  var WORKSPACE_RULES_MUTEX_KEY = "workspace-rules";
38574
39627
 
38575
39628
  // src/lib/workspace-memory/sync.ts
38576
- import { relative as relative11 } from "node:path";
39629
+ import { relative as relative12 } from "node:path";
38577
39630
 
38578
39631
  // src/lib/workspace-memory/api.ts
38579
39632
  var MAX_WORKSPACE_MEMORY_BYTES = 256 * 1024;
@@ -38582,22 +39635,23 @@ var WorkspaceMemoryProjectionSchema = exports_external.object({
38582
39635
  is_empty: exports_external.boolean(),
38583
39636
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
38584
39637
  created_at: exports_external.string().datetime({ offset: true }).nullable(),
38585
- last_edited_by: exports_external.string().uuid().nullable()
39638
+ last_edited_by: exports_external.string().uuid().nullable(),
39639
+ generated: exports_external.boolean().optional().catch(undefined)
38586
39640
  });
38587
39641
  var PreconditionSchema3 = exports_external.object({
38588
39642
  code: exports_external.literal("precondition_failed"),
38589
39643
  current_updated_at: exports_external.string()
38590
39644
  });
38591
- function baseHeaders3(pat) {
39645
+ function baseHeaders4(pat) {
38592
39646
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38593
39647
  }
38594
- var ROUTE2 = "/api/sync/workspace-memory";
39648
+ var ROUTE3 = "/api/sync/workspace-memory";
38595
39649
  async function getWorkspaceMemory(args) {
38596
39650
  const { baseUrl, pat, fetchImpl = fetch } = args;
38597
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
39651
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE3}`;
38598
39652
  let res;
38599
39653
  try {
38600
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders3(pat) });
39654
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders4(pat) });
38601
39655
  } catch (err) {
38602
39656
  const msg = err instanceof Error ? err.message : "network error";
38603
39657
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38620,9 +39674,9 @@ async function getWorkspaceMemory(args) {
38620
39674
  }
38621
39675
  async function putWorkspaceMemory(args) {
38622
39676
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38623
- const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE2}`;
39677
+ const url2 = `${baseUrl.replace(/\/$/, "")}${ROUTE3}`;
38624
39678
  const headers = {
38625
- ...baseHeaders3(pat),
39679
+ ...baseHeaders4(pat),
38626
39680
  "Content-Type": "application/json"
38627
39681
  };
38628
39682
  if (expectedUpdatedAt)
@@ -38657,6 +39711,14 @@ async function putWorkspaceMemory(args) {
38657
39711
  }
38658
39712
  if (res.status === 409) {
38659
39713
  const json2 = await res.json();
39714
+ const generated = GeneratedRefusalSchema.safeParse(json2);
39715
+ if (generated.success) {
39716
+ return {
39717
+ ok: false,
39718
+ code: "memory_is_generated",
39719
+ serverMessage: generated.data.error
39720
+ };
39721
+ }
38660
39722
  const parsed = PreconditionSchema3.safeParse(json2);
38661
39723
  if (!parsed.success) {
38662
39724
  throw new ApiError(409, `put workspace-memory 409 body did not match precondition_failed shape`);
@@ -38684,6 +39746,7 @@ async function putWorkspaceMemory(args) {
38684
39746
  // src/lib/workspace-memory/sync.ts
38685
39747
  var CONFLICT_ENTITY2 = "workspace-memory";
38686
39748
  var CONFLICT_SLUG2 = "workspace-memory";
39749
+ var GENERATED_PILE2 = "shared";
38687
39750
  function memoryFilePath(workspaceSlug) {
38688
39751
  return contextFilePath(workspaceSlug, WORKSPACE_MEMORY_FILENAME);
38689
39752
  }
@@ -38708,7 +39771,7 @@ function classifyWorkspaceMemoryPath(args) {
38708
39771
  } catch {
38709
39772
  return false;
38710
39773
  }
38711
- return relative11(root, args.absolutePath) === WORKSPACE_MEMORY_FILENAME;
39774
+ return relative12(root, args.absolutePath) === WORKSPACE_MEMORY_FILENAME;
38712
39775
  }
38713
39776
  async function pullWorkspaceMemory(args) {
38714
39777
  const { workspaceSlug, baseUrl, pat, state, fetchImpl, output, suppressor } = args;
@@ -38722,12 +39785,37 @@ async function pullWorkspaceMemory(args) {
38722
39785
  const cloudHash = stableHash(cloud.body_md);
38723
39786
  const ackedHash = getWorkspaceMemoryState(state)?.body_hash;
38724
39787
  if (local !== null && stableHash(local) === cloudHash) {
38725
- const { state: next2 } = setWorkspaceMemoryState(state, {
39788
+ const { state: stamped } = setWorkspaceMemoryState(state, {
38726
39789
  body_hash: cloudHash,
38727
- updated_at: cloud.updated_at
39790
+ updated_at: cloud.updated_at,
39791
+ generated: cloud.generated
38728
39792
  });
39793
+ const next2 = clearMemoryGeneratedRefusal(stamped, workspaceSlug, memoryFilePath(workspaceSlug));
38729
39794
  return { state: next2, action: "noop" };
38730
39795
  }
39796
+ if (cloud.generated === true && local !== null && !localEmpty && stableHash(local) !== ackedHash) {
39797
+ const refused = refuseGeneratedMemoryEdit({
39798
+ state,
39799
+ workspaceSlug,
39800
+ absolutePath: memoryFilePath(workspaceSlug),
39801
+ relPath: WORKSPACE_MEMORY_FILENAME,
39802
+ pile: GENERATED_PILE2,
39803
+ conflictEntity: CONFLICT_ENTITY2,
39804
+ conflictSlug: CONFLICT_SLUG2,
39805
+ localContent: local,
39806
+ canonicalBody: cloud.body_md,
39807
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, suppressor),
39808
+ now,
39809
+ output
39810
+ });
39811
+ const { state: next2 } = setWorkspaceMemoryState(refused.state, {
39812
+ body_hash: cloudHash,
39813
+ updated_at: cloud.updated_at,
39814
+ generated: true
39815
+ });
39816
+ output?.log(`[WORKSPACE_MEMORY_PULL_GENERATED_REFUSED ws=${workspaceSlug} stashed]`);
39817
+ return { state: next2, action: "written" };
39818
+ }
38731
39819
  if (local !== null && ackedHash !== undefined && cloudHash === ackedHash) {
38732
39820
  output?.log(`[WORKSPACE_MEMORY_PULL_PRESERVED ws=${workspaceSlug} reason=pending_push]`);
38733
39821
  return { state, action: "preserved" };
@@ -38750,7 +39838,8 @@ async function pullWorkspaceMemory(args) {
38750
39838
  writeLocal2(workspaceSlug, cloud.body_md, suppressor);
38751
39839
  const { state: next } = setWorkspaceMemoryState(state, {
38752
39840
  body_hash: cloudHash,
38753
- updated_at: cloud.updated_at
39841
+ updated_at: cloud.updated_at,
39842
+ generated: cloud.generated
38754
39843
  });
38755
39844
  return { state: next, action: cloud.is_empty ? "materialised" : "written" };
38756
39845
  }
@@ -38760,7 +39849,8 @@ async function restoreFromCloud2(args) {
38760
39849
  writeLocal2(workspaceSlug, cloud.body_md, suppressor);
38761
39850
  return persistMemory(state, {
38762
39851
  body_hash: stableHash(cloud.body_md),
38763
- updated_at: cloud.updated_at
39852
+ updated_at: cloud.updated_at,
39853
+ generated: cloud.generated
38764
39854
  });
38765
39855
  }
38766
39856
  async function pushWorkspaceMemory(args) {
@@ -38773,6 +39863,32 @@ async function pushWorkspaceMemory(args) {
38773
39863
  const localHash = stableHash(local);
38774
39864
  const acked = getWorkspaceMemoryState(state);
38775
39865
  const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
39866
+ if (acked?.generated === true && !alreadyAcked) {
39867
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39868
+ const refused = refuseGeneratedMemoryEdit({
39869
+ state,
39870
+ workspaceSlug,
39871
+ absolutePath: memoryFilePath(workspaceSlug),
39872
+ relPath: WORKSPACE_MEMORY_FILENAME,
39873
+ pile: GENERATED_PILE2,
39874
+ conflictEntity: CONFLICT_ENTITY2,
39875
+ conflictSlug: CONFLICT_SLUG2,
39876
+ localContent: local,
39877
+ canonicalBody: cloud.body_md,
39878
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39879
+ now,
39880
+ output
39881
+ });
39882
+ const next2 = persistMemory(refused.state, {
39883
+ body_hash: stableHash(cloud.body_md),
39884
+ updated_at: cloud.updated_at,
39885
+ generated: cloud.generated
39886
+ });
39887
+ return {
39888
+ state: next2,
39889
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
39890
+ };
39891
+ }
38776
39892
  const gate = applySecretGate({
38777
39893
  state,
38778
39894
  workspaceSlug,
@@ -38804,7 +39920,8 @@ async function pushWorkspaceMemory(args) {
38804
39920
  if (res.ok) {
38805
39921
  const next2 = persistMemory(gated, {
38806
39922
  body_hash: localHash,
38807
- updated_at: res.row.updated_at
39923
+ updated_at: res.row.updated_at,
39924
+ generated: res.row.generated
38808
39925
  });
38809
39926
  output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${res.row.is_empty}]`);
38810
39927
  return { state: next2, outcome: { kind: "pushed" } };
@@ -38813,6 +39930,33 @@ async function pushWorkspaceMemory(args) {
38813
39930
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38814
39931
  return { state: gated, outcome: { kind: "over_cap" } };
38815
39932
  }
39933
+ if (res.code === "memory_is_generated") {
39934
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39935
+ const refused = refuseGeneratedMemoryEdit({
39936
+ state: gated,
39937
+ workspaceSlug,
39938
+ absolutePath: memoryFilePath(workspaceSlug),
39939
+ relPath: WORKSPACE_MEMORY_FILENAME,
39940
+ pile: GENERATED_PILE2,
39941
+ conflictEntity: CONFLICT_ENTITY2,
39942
+ conflictSlug: CONFLICT_SLUG2,
39943
+ localContent: local,
39944
+ canonicalBody: cloud.body_md,
39945
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39946
+ now,
39947
+ output
39948
+ });
39949
+ output?.log(`[WORKSPACE_MEMORY_PUT_REFUSED ws=${workspaceSlug} code=memory_is_generated]`);
39950
+ const next2 = persistMemory(refused.state, {
39951
+ body_hash: stableHash(cloud.body_md),
39952
+ updated_at: cloud.updated_at,
39953
+ generated: cloud.generated
39954
+ });
39955
+ return {
39956
+ state: next2,
39957
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
39958
+ };
39959
+ }
38816
39960
  {
38817
39961
  const probe2 = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
38818
39962
  if (acked && stableHash(probe2.body_md) === acked.body_hash) {
@@ -38826,7 +39970,8 @@ async function pushWorkspaceMemory(args) {
38826
39970
  if (retry.ok) {
38827
39971
  const next2 = persistMemory(gated, {
38828
39972
  body_hash: localHash,
38829
- updated_at: retry.row.updated_at
39973
+ updated_at: retry.row.updated_at,
39974
+ generated: retry.row.generated
38830
39975
  });
38831
39976
  output?.log(`[WORKSPACE_MEMORY_PUT ws=${workspaceSlug} is_empty=${retry.row.is_empty}]`);
38832
39977
  return { state: next2, outcome: { kind: "pushed" } };
@@ -38835,6 +39980,32 @@ async function pushWorkspaceMemory(args) {
38835
39980
  output?.warn(`${WORKSPACE_MEMORY_FILENAME} is over the ${Math.round(MAX_WORKSPACE_MEMORY_BYTES / 1024)}KB cap — not synced.`);
38836
39981
  return { state: gated, outcome: { kind: "over_cap" } };
38837
39982
  }
39983
+ if (retry.code === "memory_is_generated") {
39984
+ const cloud = await getWorkspaceMemory({ baseUrl, pat, fetchImpl });
39985
+ const refused = refuseGeneratedMemoryEdit({
39986
+ state: gated,
39987
+ workspaceSlug,
39988
+ absolutePath: memoryFilePath(workspaceSlug),
39989
+ relPath: WORKSPACE_MEMORY_FILENAME,
39990
+ pile: GENERATED_PILE2,
39991
+ conflictEntity: CONFLICT_ENTITY2,
39992
+ conflictSlug: CONFLICT_SLUG2,
39993
+ localContent: local,
39994
+ canonicalBody: cloud.body_md,
39995
+ writeCanonical: (body) => writeLocal2(workspaceSlug, body, args.suppressor),
39996
+ now,
39997
+ output
39998
+ });
39999
+ const next2 = persistMemory(refused.state, {
40000
+ body_hash: stableHash(cloud.body_md),
40001
+ updated_at: cloud.updated_at,
40002
+ generated: cloud.generated
40003
+ });
40004
+ return {
40005
+ state: next2,
40006
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40007
+ };
40008
+ }
38838
40009
  }
38839
40010
  }
38840
40011
  const stashPath = stashConflict({
@@ -38860,6 +40031,7 @@ var SingletonProjectionSchema = exports_external.object({
38860
40031
  body_md: exports_external.string(),
38861
40032
  is_empty: exports_external.boolean(),
38862
40033
  status: SingletonStatusSchema.optional().catch(undefined),
40034
+ generated: exports_external.boolean().optional().catch(undefined),
38863
40035
  updated_at: exports_external.string().datetime({ offset: true }).nullable(),
38864
40036
  created_at: exports_external.string().datetime({ offset: true }).nullable(),
38865
40037
  last_edited_by: exports_external.string().uuid().nullable()
@@ -38870,7 +40042,7 @@ var PreconditionSchema4 = exports_external.object({
38870
40042
  code: exports_external.literal("precondition_failed"),
38871
40043
  current_updated_at: exports_external.string()
38872
40044
  });
38873
- function baseHeaders4(pat) {
40045
+ function baseHeaders5(pat) {
38874
40046
  return { Authorization: `Bearer ${pat}`, Accept: "application/json" };
38875
40047
  }
38876
40048
  async function serverErrorMessage(res, fallback) {
@@ -38888,7 +40060,7 @@ function createSingletonApi(config2) {
38888
40060
  const url2 = `${baseUrl.replace(/\/$/, "")}${route}`;
38889
40061
  let res;
38890
40062
  try {
38891
- res = await fetchImpl(url2, { method: "GET", headers: baseHeaders4(pat) });
40063
+ res = await fetchImpl(url2, { method: "GET", headers: baseHeaders5(pat) });
38892
40064
  } catch (err) {
38893
40065
  const msg = err instanceof Error ? err.message : "network error";
38894
40066
  throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
@@ -38913,7 +40085,7 @@ function createSingletonApi(config2) {
38913
40085
  const { baseUrl, pat, bodyMd, expectedUpdatedAt, fetchImpl = fetch } = args;
38914
40086
  const url2 = `${baseUrl.replace(/\/$/, "")}${route}`;
38915
40087
  const headers = {
38916
- ...baseHeaders4(pat),
40088
+ ...baseHeaders5(pat),
38917
40089
  "Content-Type": "application/json"
38918
40090
  };
38919
40091
  if (expectedUpdatedAt)
@@ -38948,6 +40120,14 @@ function createSingletonApi(config2) {
38948
40120
  }
38949
40121
  if (res.status === 409) {
38950
40122
  const json2 = await res.json();
40123
+ const generated = GeneratedRefusalSchema.safeParse(json2);
40124
+ if (generated.success) {
40125
+ return {
40126
+ ok: false,
40127
+ code: "memory_is_generated",
40128
+ serverMessage: generated.data.error
40129
+ };
40130
+ }
38951
40131
  const parsed = PreconditionSchema4.safeParse(json2);
38952
40132
  if (!parsed.success) {
38953
40133
  throw new ApiError(409, `put ${surfaceName} 409 body did not match precondition_failed shape`);
@@ -38970,7 +40150,7 @@ function createSingletonApi(config2) {
38970
40150
  }
38971
40151
 
38972
40152
  // src/lib/singleton/engine.ts
38973
- import { relative as relative12 } from "node:path";
40153
+ import { relative as relative13 } from "node:path";
38974
40154
  function filePath(descriptor, workspaceSlug) {
38975
40155
  return contextFilePath(workspaceSlug, descriptor.filename);
38976
40156
  }
@@ -38986,7 +40166,8 @@ function ackedFrom(projection, bodyHash) {
38986
40166
  return {
38987
40167
  body_hash: bodyHash,
38988
40168
  updated_at: projection.updated_at,
38989
- status: projection.status
40169
+ status: projection.status,
40170
+ generated: projection.generated
38990
40171
  };
38991
40172
  }
38992
40173
  function persist2(descriptor, state, entry2) {
@@ -39002,7 +40183,7 @@ function classifySingletonPath(descriptor, args) {
39002
40183
  } catch {
39003
40184
  return false;
39004
40185
  }
39005
- return relative12(root, args.absolutePath) === descriptor.filename;
40186
+ return relative13(root, args.absolutePath) === descriptor.filename;
39006
40187
  }
39007
40188
  async function pullSingleton(descriptor, args) {
39008
40189
  const { workspaceSlug, baseUrl, pat, state, fetchImpl, output, suppressor } = args;
@@ -39017,9 +40198,29 @@ async function pullSingleton(descriptor, args) {
39017
40198
  const ackedHash = descriptor.getState(state)?.body_hash;
39018
40199
  const localHash = local !== null ? stableHash(local) : null;
39019
40200
  if (localHash !== null && localHash === cloudHash) {
39020
- const { state: next2 } = descriptor.setState(state, ackedFrom(cloud, cloudHash));
40201
+ const { state: stamped } = descriptor.setState(state, ackedFrom(cloud, cloudHash));
40202
+ const next2 = descriptor.generatedPile === null ? stamped : clearMemoryGeneratedRefusal(stamped, workspaceSlug, filePath(descriptor, workspaceSlug));
39021
40203
  return { state: next2, action: "noop" };
39022
40204
  }
40205
+ if (descriptor.generatedPile !== null && cloud.generated === true && local !== null && !localEmpty && localHash !== ackedHash) {
40206
+ const refused = refuseGeneratedMemoryEdit({
40207
+ state,
40208
+ workspaceSlug,
40209
+ absolutePath: filePath(descriptor, workspaceSlug),
40210
+ relPath: descriptor.filename,
40211
+ pile: descriptor.generatedPile,
40212
+ conflictEntity: descriptor.conflictEntity,
40213
+ conflictSlug: descriptor.conflictSlug,
40214
+ localContent: local,
40215
+ canonicalBody: cloud.body_md,
40216
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, suppressor),
40217
+ now,
40218
+ output
40219
+ });
40220
+ const { state: next2 } = descriptor.setState(refused.state, ackedFrom(cloud, cloudHash));
40221
+ output?.log(`[${descriptor.logTag}_PULL_GENERATED_REFUSED ws=${workspaceSlug} stashed]`);
40222
+ return { state: next2, action: "written" };
40223
+ }
39023
40224
  if (local !== null && ackedHash !== undefined && cloudHash === ackedHash) {
39024
40225
  output?.log(`[${descriptor.logTag}_PULL_PRESERVED ws=${workspaceSlug} reason=pending_push]`);
39025
40226
  return { state, action: "preserved" };
@@ -39058,6 +40259,28 @@ async function pushSingleton(descriptor, args) {
39058
40259
  const localHash = stableHash(local);
39059
40260
  const acked = descriptor.getState(state);
39060
40261
  const alreadyAcked = Boolean(acked && acked.body_hash === localHash);
40262
+ if (descriptor.generatedPile !== null && acked?.generated === true && !alreadyAcked) {
40263
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
40264
+ const refused = refuseGeneratedMemoryEdit({
40265
+ state,
40266
+ workspaceSlug,
40267
+ absolutePath: filePath(descriptor, workspaceSlug),
40268
+ relPath: descriptor.filename,
40269
+ pile: descriptor.generatedPile,
40270
+ conflictEntity: descriptor.conflictEntity,
40271
+ conflictSlug: descriptor.conflictSlug,
40272
+ localContent: local,
40273
+ canonicalBody: cloud.body_md,
40274
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
40275
+ now,
40276
+ output
40277
+ });
40278
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
40279
+ return {
40280
+ state: next2,
40281
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40282
+ };
40283
+ }
39061
40284
  let gated = state;
39062
40285
  if (descriptor.secretScanned) {
39063
40286
  const gate = applySecretGate({
@@ -39098,6 +40321,29 @@ async function pushSingleton(descriptor, args) {
39098
40321
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
39099
40322
  return { state: gated, outcome: { kind: "over_cap" } };
39100
40323
  }
40324
+ if (res.code === "memory_is_generated") {
40325
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
40326
+ const refused = refuseGeneratedMemoryEdit({
40327
+ state: gated,
40328
+ workspaceSlug,
40329
+ absolutePath: filePath(descriptor, workspaceSlug),
40330
+ relPath: descriptor.filename,
40331
+ pile: descriptor.generatedPile ?? "shared",
40332
+ conflictEntity: descriptor.conflictEntity,
40333
+ conflictSlug: descriptor.conflictSlug,
40334
+ localContent: local,
40335
+ canonicalBody: cloud.body_md,
40336
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
40337
+ now,
40338
+ output
40339
+ });
40340
+ output?.log(`[${descriptor.logTag}_PUT_REFUSED ws=${workspaceSlug} code=memory_is_generated]`);
40341
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
40342
+ return {
40343
+ state: next2,
40344
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40345
+ };
40346
+ }
39101
40347
  {
39102
40348
  const probe2 = await descriptor.api.get({ baseUrl, pat, fetchImpl });
39103
40349
  if (acked && stableHash(probe2.body_md) === acked.body_hash) {
@@ -39117,6 +40363,28 @@ async function pushSingleton(descriptor, args) {
39117
40363
  output?.warn(`${descriptor.filename} is over the ${Math.round(descriptor.maxBytes / 1024)}KB cap — not synced.`);
39118
40364
  return { state: gated, outcome: { kind: "over_cap" } };
39119
40365
  }
40366
+ if (retry.code === "memory_is_generated") {
40367
+ const cloud = await descriptor.api.get({ baseUrl, pat, fetchImpl });
40368
+ const refused = refuseGeneratedMemoryEdit({
40369
+ state: gated,
40370
+ workspaceSlug,
40371
+ absolutePath: filePath(descriptor, workspaceSlug),
40372
+ relPath: descriptor.filename,
40373
+ pile: descriptor.generatedPile ?? "shared",
40374
+ conflictEntity: descriptor.conflictEntity,
40375
+ conflictSlug: descriptor.conflictSlug,
40376
+ localContent: local,
40377
+ canonicalBody: cloud.body_md,
40378
+ writeCanonical: (body) => writeLocal3(descriptor, workspaceSlug, body, args.suppressor),
40379
+ now,
40380
+ output
40381
+ });
40382
+ const next2 = persist2(descriptor, refused.state, ackedFrom(cloud, stableHash(cloud.body_md)));
40383
+ return {
40384
+ state: next2,
40385
+ outcome: { kind: "generated_refused", stashPath: refused.stashPath }
40386
+ };
40387
+ }
39120
40388
  }
39121
40389
  }
39122
40390
  const stashPath = stashConflict({
@@ -39150,6 +40418,7 @@ var USER_MEMORY_DESCRIPTOR = {
39150
40418
  logTag: "USER_MEMORY",
39151
40419
  maxBytes: MAX_USER_MEMORY_BYTES,
39152
40420
  secretScanned: true,
40421
+ generatedPile: "private",
39153
40422
  extractActive: extractUserMemory,
39154
40423
  getState: getUserMemoryState,
39155
40424
  setState: setUserMemoryState,
@@ -39183,6 +40452,7 @@ var VOICE_DESCRIPTOR = {
39183
40452
  logTag: "VOICE",
39184
40453
  maxBytes: MAX_USER_VOICE_BYTES,
39185
40454
  secretScanned: false,
40455
+ generatedPile: null,
39186
40456
  extractActive: extractVoiceProfile,
39187
40457
  getState: getUserVoiceState,
39188
40458
  setState: setUserVoiceState,
@@ -39329,6 +40599,11 @@ import { join as join16 } from "node:path";
39329
40599
  // src/lib/conflicts-view.ts
39330
40600
  import { existsSync as existsSync5 } from "node:fs";
39331
40601
  import { join as join15 } from "node:path";
40602
+ var NO_GENERATED_MEMORY = {
40603
+ artistMemoryGenerated: () => false,
40604
+ workspaceMemoryGenerated: () => false,
40605
+ userMemoryGenerated: () => false
40606
+ };
39332
40607
  var FOOTER = [
39333
40608
  "---",
39334
40609
  "",
@@ -39351,7 +40626,7 @@ function absolute(date5) {
39351
40626
  function shellQuote2(s) {
39352
40627
  return `'${s.replace(/'/g, `'\\''`)}'`;
39353
40628
  }
39354
- function resolveConflictRows(workspaceSlug, stashes) {
40629
+ function resolveConflictRows(workspaceSlug, stashes, generatedMemory = NO_GENERATED_MEMORY) {
39355
40630
  let root;
39356
40631
  try {
39357
40632
  root = workspaceMirrorPath(workspaceSlug);
@@ -39359,6 +40634,9 @@ function resolveConflictRows(workspaceSlug, stashes) {
39359
40634
  root = null;
39360
40635
  }
39361
40636
  return stashes.map((stash) => {
40637
+ const pile = isGeneratedMemoryStash(stash.label, generatedMemory);
40638
+ if (pile !== null)
40639
+ return { kind: "generated-memory", stash, pile };
39362
40640
  if (root === null || !stash.label.startsWith("files/")) {
39363
40641
  return { kind: "entity", stash };
39364
40642
  }
@@ -39424,6 +40702,8 @@ function renderRow(row) {
39424
40702
  out.push("Your version is at:", "", "```", stash.absPath, "```", "");
39425
40703
  if (row.kind === "unsafe-path") {
39426
40704
  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.");
40705
+ } else if (row.kind === "generated-memory") {
40706
+ 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
40707
  } else {
39428
40708
  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
40709
  }
@@ -39476,7 +40756,7 @@ ${FOOTER}
39476
40756
  function refreshConflictsView(workspaceSlug) {
39477
40757
  try {
39478
40758
  const stashes = listConflictStashes(workspaceSlug);
39479
- const rows = resolveConflictRows(workspaceSlug, stashes);
40759
+ const rows = resolveConflictRows(workspaceSlug, stashes, generatedMemoryStashDepsFrom(loadState(workspaceSlug)));
39480
40760
  const body = renderConflictsView(rows);
39481
40761
  if (readConflictsView(workspaceSlug) === body) {
39482
40762
  return { stashes: rows.length, written: false };
@@ -39499,7 +40779,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync22, statSync as
39499
40779
  // src/lib/files/extracted-text.ts
39500
40780
  import { createHash as createHash3 } from "node:crypto";
39501
40781
  var DERIVED_TEXT_VERSION = 2;
39502
- function yamlString2(value) {
40782
+ function yamlString3(value) {
39503
40783
  return JSON.stringify(value);
39504
40784
  }
39505
40785
  var PARTIAL_WARNING = [
@@ -39538,8 +40818,8 @@ function renderExtractedText(doc2) {
39538
40818
  const frontmatter = [
39539
40819
  "---",
39540
40820
  "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)}`,
40821
+ `source_file: ${doc2.bytesInMirror ? yamlString3(`files/${doc2.relPath}`) : "null"}`,
40822
+ `source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString3(doc2.sourceUri)}`,
39543
40823
  `content_quality: ${doc2.quality ?? "unchecked"}`,
39544
40824
  `body_withheld: ${withheld}`,
39545
40825
  `extracted_chars: ${withheld ? 0 : doc2.text.length}`,
@@ -40373,7 +41653,13 @@ async function runSync(workspaceSlug, deps = {}) {
40373
41653
  let cursor = startCursor;
40374
41654
  let lastAdvancedCursor = startCursor;
40375
41655
  let cursorBlocked = false;
40376
- const ctr = { fetched: 0, written: 0, deleted: 0 };
41656
+ const ctr = {
41657
+ fetched: 0,
41658
+ written: 0,
41659
+ deleted: 0,
41660
+ moved: 0,
41661
+ refused: 0
41662
+ };
40377
41663
  while (true) {
40378
41664
  const page = await syncEntity({
40379
41665
  strategy,
@@ -40407,6 +41693,12 @@ async function runSync(workspaceSlug, deps = {}) {
40407
41693
  ctr.written++;
40408
41694
  else if (outcome.action === "deleted")
40409
41695
  ctr.deleted++;
41696
+ else if (outcome.action === "moved")
41697
+ ctr.moved = (ctr.moved ?? 0) + 1;
41698
+ else if (outcome.action === "slug_refused") {
41699
+ ctr.refused = (ctr.refused ?? 0) + 1;
41700
+ cursorBlocked = true;
41701
+ }
40410
41702
  }
40411
41703
  commitPending();
40412
41704
  if (page.next_cursor === null)
@@ -40526,6 +41818,27 @@ async function runSync(workspaceSlug, deps = {}) {
40526
41818
  const voiceWarning = voiceMalformedWarning(state, slug);
40527
41819
  if (voiceWarning)
40528
41820
  out.warn(voiceWarning);
41821
+ const factsResult = await syncMemoryFacts({
41822
+ workspaceSlug: slug,
41823
+ baseUrl: entry2.api_base_url,
41824
+ pat,
41825
+ state,
41826
+ fetchImpl,
41827
+ now,
41828
+ output: out,
41829
+ suppressor: deps.suppressor
41830
+ });
41831
+ if (factsResult.state !== state) {
41832
+ state = factsResult.state;
41833
+ }
41834
+ commitPending();
41835
+ if (factsResult.written > 0 || factsResult.deleted > 0) {
41836
+ counters["memory-facts"] = {
41837
+ fetched: factsResult.facts,
41838
+ written: factsResult.written,
41839
+ deleted: factsResult.deleted
41840
+ };
41841
+ }
40529
41842
  } catch (err) {
40530
41843
  out.log(pullFailedLine(categorizeError(err)));
40531
41844
  if (err instanceof ApiError) {
@@ -40607,7 +41920,9 @@ async function runSync(workspaceSlug, deps = {}) {
40607
41920
  await syncFileRejections();
40608
41921
  const totalWritten = Object.values(counters).reduce((sum, c) => sum + c.written, 0);
40609
41922
  const totalDeleted = Object.values(counters).reduce((sum, c) => sum + c.deleted, 0);
40610
- if (totalWritten === 0 && totalDeleted === 0) {
41923
+ const totalMoved = Object.values(counters).reduce((sum, c) => sum + (c.moved ?? 0), 0);
41924
+ const totalRefused = Object.values(counters).reduce((sum, c) => sum + (c.refused ?? 0), 0);
41925
+ if (totalWritten === 0 && totalDeleted === 0 && totalMoved === 0 && totalRefused === 0) {
40611
41926
  out.success("Already up to date.");
40612
41927
  return { exitCode: 0, applied: 0 };
40613
41928
  }
@@ -40618,12 +41933,16 @@ async function runSync(workspaceSlug, deps = {}) {
40618
41933
  const parts = [`${c.fetched} fetched`];
40619
41934
  if (c.written > 0)
40620
41935
  parts.push(`${c.written} written`);
41936
+ if ((c.moved ?? 0) > 0)
41937
+ parts.push(`${c.moved} renamed`);
41938
+ if ((c.refused ?? 0) > 0)
41939
+ parts.push(`${c.refused} rename held`);
40621
41940
  if (c.deleted > 0)
40622
41941
  parts.push(`${c.deleted} deleted`);
40623
41942
  out.info(` ${type}: ${parts.join(", ")}`);
40624
41943
  }
40625
41944
  out.success(`Done in ${elapsed}s.`);
40626
- return { exitCode: 0, applied: totalWritten + totalDeleted };
41945
+ return { exitCode: 0, applied: totalWritten + totalMoved + totalDeleted };
40627
41946
  }
40628
41947
  function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = () => new Date, isFrozen, loggedTombstoneSkips) {
40629
41948
  if (isFrozen?.(strategy.type, row.id)) {
@@ -40683,26 +42002,88 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
40683
42002
  if (strategy.shouldMirror && !strategy.shouldMirror(live) && !existingEntry) {
40684
42003
  return { state, action: "skipped" };
40685
42004
  }
40686
- if (existingEntry && existingEntry.updated_at === live.updated_at) {
42005
+ let relocation = { kind: "not_needed", writeSlug: live.slug };
42006
+ if (existingEntry && existingEntry.slug !== live.slug) {
42007
+ const echo = suppressor ? { watcher: "live", suppressor } : { watcher: "none" };
42008
+ relocation = relocateForSlugChange({
42009
+ workspaceSlug,
42010
+ strategy,
42011
+ fromSlug: existingEntry.slug,
42012
+ toSlug: live.slug,
42013
+ acked: {
42014
+ bodyHash: existingEntry.body_hash,
42015
+ frontmatterHash: existingEntry.frontmatter_hash
42016
+ },
42017
+ echo,
42018
+ now
42019
+ });
42020
+ const move = `entityType=${strategy.type} from=${existingEntry.slug} to=${live.slug} id=${live.id}`;
42021
+ switch (relocation.kind) {
42022
+ case "moved":
42023
+ out.log(`[SLUG_MOVE ${move}]`);
42024
+ state = clearSlugClash(state, [
42025
+ strategy.mirrorRelativePath(existingEntry.slug),
42026
+ strategy.mirrorRelativePath(live.slug)
42027
+ ]);
42028
+ break;
42029
+ case "refused":
42030
+ out.log(`[SLUG_MOVE_REFUSED ${move} reason=${relocation.reason}]`);
42031
+ const occupantTracked = getEntryBySlug(state, strategy.type, live.slug) !== undefined;
42032
+ state = recordEntitySlugClash({
42033
+ state,
42034
+ mirrorRelativePath: strategy.mirrorRelativePath(existingEntry.slug),
42035
+ entityType: strategy.type,
42036
+ fromSlug: existingEntry.slug,
42037
+ toSlug: live.slug,
42038
+ reason: relocation.reason,
42039
+ occupantTracked,
42040
+ now
42041
+ });
42042
+ out.warn(relocation.reason === "source_unreadable" ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(existingEntry.slug)} could ` + `not be read — it was left exactly where it is. Check its permissions.` : occupantTracked ? `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} belongs to ` + `another ${strategy.type} — nothing was moved or overwritten. This ` + `usually clears itself once that row's own rename arrives; do not ` + `delete the file, which would delete that record in Plinth.` : `${strategy.type}/${existingEntry.slug} was renamed to ${live.slug} in ` + `Plinth, but ${strategy.mirrorRelativePath(live.slug)} already exists ` + `here — nothing was moved or overwritten. Rename or remove that file ` + `and Plinth will move this one into place.`);
42043
+ return { state, action: "slug_refused" };
42044
+ case "skipped":
42045
+ out.log(`[SLUG_MOVE_SKIP ${move} reason=${relocation.reason}]`);
42046
+ break;
42047
+ case "absent":
42048
+ case "not_needed":
42049
+ break;
42050
+ }
42051
+ const stashPath = relocation.kind === "moved" ? relocation.stashPath : undefined;
42052
+ if (stashPath) {
42053
+ out.warn(`Unsynced local edit on ${strategy.type}/${existingEntry.slug} → stashed ` + `to ${stashPath} (the row was renamed in Plinth; the cloud copy is ` + `authoritative).`);
42054
+ }
42055
+ }
42056
+ const writeSlug = relocation.writeSlug;
42057
+ if (existingEntry && existingEntry.updated_at === live.updated_at && relocation.kind === "not_needed") {
40687
42058
  const path5 = entityFilePath(workspaceSlug, strategy.type, live.slug);
40688
42059
  if (existsSync7(path5)) {
40689
42060
  if (strategy.type === "artists") {
40690
- applyArtistMemoryOnPull({
42061
+ const artistLive = live;
42062
+ state = applyArtistMemoryOnPull({
40691
42063
  workspaceSlug,
40692
- live,
42064
+ live: artistLive,
40693
42065
  ackedMemoryHash: existingEntry.memory_hash,
42066
+ state,
40694
42067
  suppressor,
40695
42068
  now,
40696
42069
  out
40697
- });
42070
+ }).state;
42071
+ if (existingEntry.memory_generated !== artistLive.memory_generated) {
42072
+ const bumped = upsertEntry(state, strategy.type, {
42073
+ ...existingEntry,
42074
+ memory_generated: artistLive.memory_generated
42075
+ });
42076
+ if (bumped.changed)
42077
+ state = bumped.state;
42078
+ }
40698
42079
  }
40699
42080
  return { state, action: "noop_overwrite" };
40700
42081
  }
40701
42082
  }
40702
- if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
42083
+ if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at && relocation.kind !== "moved") {
40703
42084
  let localRaw = null;
40704
42085
  try {
40705
- localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
42086
+ localRaw = readFileSync24(entityFilePath(workspaceSlug, strategy.type, writeSlug), "utf8");
40706
42087
  } catch {
40707
42088
  localRaw = null;
40708
42089
  }
@@ -40722,37 +42103,45 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
40722
42103
  }
40723
42104
  }
40724
42105
  }
40725
- const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor);
42106
+ const content = writeMirrorWithBodyPreserve(workspaceSlug, strategy, live, suppressor, writeSlug);
40726
42107
  const hashes = computeMirrorHashes(strategy, content);
40727
42108
  let inode;
40728
42109
  try {
40729
- inode = statSync16(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
42110
+ inode = statSync16(entityFilePath(workspaceSlug, strategy.type, writeSlug)).ino;
40730
42111
  } catch {
40731
42112
  inode = undefined;
40732
42113
  }
40733
42114
  let memoryHash;
42115
+ let memoryGenerated;
40734
42116
  if (strategy.type === "artists") {
40735
- memoryHash = applyArtistMemoryOnPull({
42117
+ const artistLive = live;
42118
+ const applied = applyArtistMemoryOnPull({
40736
42119
  workspaceSlug,
40737
- live,
42120
+ live: artistLive,
40738
42121
  ackedMemoryHash: existingEntry?.memory_hash,
42122
+ state,
40739
42123
  suppressor,
40740
42124
  now,
40741
42125
  out
40742
- }).memoryHash;
42126
+ });
42127
+ memoryHash = applied.memoryHash;
42128
+ state = applied.state;
42129
+ memoryGenerated = artistLive.memory_generated;
40743
42130
  }
40744
42131
  const { state: nextState } = upsertEntry(state, strategy.type, {
40745
42132
  id: live.id,
40746
- slug: live.slug,
42133
+ slug: writeSlug,
40747
42134
  updated_at: live.updated_at,
40748
42135
  revision: live.revision,
40749
42136
  body_hash: hashes.bodyHash,
40750
42137
  frontmatter_hash: hashes.frontmatterHash,
40751
42138
  inode,
40752
42139
  body_len: hashes.bodyLen,
40753
- memory_hash: memoryHash
42140
+ memory_hash: memoryHash,
42141
+ memory_generated: memoryGenerated
40754
42142
  });
40755
- return { state: nextState, action: "written" };
42143
+ const action = relocation.kind === "moved" ? "moved" : "written";
42144
+ return { state: nextState, action };
40756
42145
  }
40757
42146
  var syncCommand = defineCommand({
40758
42147
  meta: {
@@ -41228,6 +42617,22 @@ function makeWatchHandler(deps) {
41228
42617
  });
41229
42618
  if (!classifiedFrom)
41230
42619
  return;
42620
+ if (suppressor?.shouldSuppressDelete(event.fromPath)) {
42621
+ const strategy = getStrategy(classifiedFrom.entityType);
42622
+ let toRaw = null;
42623
+ try {
42624
+ toRaw = readFileSync25(event.toPath, "utf8");
42625
+ } catch {
42626
+ toRaw = null;
42627
+ }
42628
+ const isEcho = strategy !== undefined && toRaw !== null && suppressor.shouldSuppressUpsert(event.toPath, computeMirrorHashes(strategy, toRaw));
42629
+ if (isEcho) {
42630
+ output?.log(`[RENAME_SUPPRESSED entityType=${classifiedFrom.entityType} ` + `from=${event.fromSlug} id=${event.id} reason=pull_echo]`);
42631
+ return;
42632
+ }
42633
+ await handle({ kind: "upsert", absolutePath: event.toPath });
42634
+ return;
42635
+ }
41231
42636
  const result2 = await pushRename({
41232
42637
  workspaceSlug,
41233
42638
  baseUrl,
@@ -41239,7 +42644,13 @@ function makeWatchHandler(deps) {
41239
42644
  expectedUpdatedAt: event.expectedUpdatedAt,
41240
42645
  fromSlug: event.fromSlug,
41241
42646
  state: getState(),
41242
- deps: { fetchImpl, now, mutex, output }
42647
+ deps: {
42648
+ fetchImpl,
42649
+ now,
42650
+ mutex,
42651
+ output,
42652
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42653
+ }
41243
42654
  });
41244
42655
  setState(result2.state);
41245
42656
  reportOutcome(spinner, workspaceSlug, result2.state, result2.outcome);
@@ -41272,6 +42683,30 @@ function makeWatchHandler(deps) {
41272
42683
  await handleArtistMemoryEvent(memoryMatch.slug, absolutePath, event.kind === "upsert");
41273
42684
  return;
41274
42685
  }
42686
+ const factPath = classifyMemoryFactPath({ workspaceSlug, absolutePath });
42687
+ if (factPath) {
42688
+ let ownEcho = false;
42689
+ if (event.kind === "upsert") {
42690
+ try {
42691
+ ownEcho = suppressor?.shouldSuppressUpsert(absolutePath, {
42692
+ bodyHash: stableHash(readFileSync25(absolutePath, "utf8")),
42693
+ frontmatterHash: ""
42694
+ }) === true;
42695
+ } catch {
42696
+ ownEcho = true;
42697
+ }
42698
+ }
42699
+ const warning2 = memoryFactEventWarning(classifyMemoryFactEvent({
42700
+ path: factPath,
42701
+ kind: event.kind === "upsert" ? "upsert" : "unlink",
42702
+ ownEcho,
42703
+ known: getMemoryFactEntry(getState(), factPath.relPath) !== undefined
42704
+ }));
42705
+ if (warning2)
42706
+ spinner.warn(warning2);
42707
+ spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
42708
+ return;
42709
+ }
41275
42710
  const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
41276
42711
  if (!classified) {
41277
42712
  const key = entitySymlinkRefusalKey({ workspaceSlug, absolutePath });
@@ -41347,7 +42782,14 @@ function makeWatchHandler(deps) {
41347
42782
  slug: event.stateEntry.slug,
41348
42783
  updated_at: event.stateEntry.updated_at
41349
42784
  } : null,
41350
- deps: { fetchImpl, now, mutex, output, suppressor }
42785
+ deps: {
42786
+ fetchImpl,
42787
+ now,
42788
+ mutex,
42789
+ output,
42790
+ suppressor,
42791
+ echo: suppressor ? { watcher: "live", suppressor } : undefined
42792
+ }
41351
42793
  });
41352
42794
  setState(clearEntityRejected(result.state, workspaceSlug, absolutePath));
41353
42795
  reportOutcome(spinner, workspaceSlug, getState(), result.outcome);
@@ -41408,7 +42850,8 @@ async function flushHeldEntityOp(deps) {
41408
42850
  fetchImpl: deps.fetchImpl,
41409
42851
  now,
41410
42852
  mutex: deps.mutex,
41411
- output: deps.output
42853
+ output: deps.output,
42854
+ echo: deps.echo
41412
42855
  }
41413
42856
  });
41414
42857
  deps.setState(result.state);
@@ -41544,6 +42987,7 @@ async function runStart(workspaceSlug, deps = {}) {
41544
42987
  frozenFiles = computeFrozenFiles(slug);
41545
42988
  };
41546
42989
  const recon = await reconcileDivergedRows({
42990
+ echo: { watcher: "none" },
41547
42991
  workspaceSlug: slug,
41548
42992
  baseUrl: entry2.api_base_url,
41549
42993
  pat,
@@ -41757,6 +43201,7 @@ async function runStart(workspaceSlug, deps = {}) {
41757
43201
  spinner.start(`Watching ${slug} · ${watchingSummary(state)}`);
41758
43202
  const mutex = new PerIdMutex;
41759
43203
  const bootDrain = await drainResolvedBatches({
43204
+ echo: { watcher: "none" },
41760
43205
  workspaceSlug: slug,
41761
43206
  baseUrl: entry2.api_base_url,
41762
43207
  pat,
@@ -41774,6 +43219,7 @@ async function runStart(workspaceSlug, deps = {}) {
41774
43219
  out.info(`Resolved ${bootDrain.applied + bootDrain.discarded} held batch${bootDrain.applied + bootDrain.discarded === 1 ? "" : "es"}: ${bootDrain.applied} applied, ${bootDrain.discarded} discarded.`);
41775
43220
  }
41776
43221
  const bootUnsynced = await drainUnsyncedOps({
43222
+ echo: { watcher: "none" },
41777
43223
  workspaceSlug: slug,
41778
43224
  baseUrl: entry2.api_base_url,
41779
43225
  pat,
@@ -41811,6 +43257,7 @@ async function runStart(workspaceSlug, deps = {}) {
41811
43257
  };
41812
43258
  const suppressor = new PullSuppressor;
41813
43259
  const flushHeldOp = (op) => flushHeldEntityOp({
43260
+ echo: { watcher: "live", suppressor },
41814
43261
  workspaceSlug: slug,
41815
43262
  baseUrl: entry2.api_base_url,
41816
43263
  pat,
@@ -41998,6 +43445,7 @@ async function runStart(workspaceSlug, deps = {}) {
41998
43445
  draining = true;
41999
43446
  try {
42000
43447
  const drained = await drainResolvedBatches({
43448
+ echo: { watcher: "live", suppressor },
42001
43449
  workspaceSlug: slug,
42002
43450
  baseUrl: entry2.api_base_url,
42003
43451
  pat,
@@ -42015,6 +43463,7 @@ async function runStart(workspaceSlug, deps = {}) {
42015
43463
  spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
42016
43464
  }
42017
43465
  const redriven = await drainUnsyncedOps({
43466
+ echo: { watcher: "live", suppressor },
42018
43467
  workspaceSlug: slug,
42019
43468
  baseUrl: entry2.api_base_url,
42020
43469
  pat,
@@ -42459,6 +43908,305 @@ var startCommand = defineCommand({
42459
43908
  // src/commands/status.ts
42460
43909
  import { statSync as statSync19 } from "node:fs";
42461
43910
 
43911
+ // src/lib/context/context-walls.ts
43912
+ function prepare(raw) {
43913
+ const text = stripContactsBlock(raw.replace(/^---\r?\n(?:[\s\S]*?\r?\n)?---\r?\n/, ""));
43914
+ return text.replace(/<!--[\s\S]*?-->/g, "").replace(/<!--[\s\S]*$/, "");
43915
+ }
43916
+ function indentOf(line) {
43917
+ let cols = 0;
43918
+ for (const ch of line) {
43919
+ if (ch === " ")
43920
+ cols += 1;
43921
+ else if (ch === "\t")
43922
+ cols += 4;
43923
+ else
43924
+ break;
43925
+ }
43926
+ return cols;
43927
+ }
43928
+ var HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
43929
+ var ITEM_RE = /^(\s*)(?:[-*+]|\d{1,3}[.)])\s+(.*)$/;
43930
+ var FENCE_RE = /^\s*(?:`{3,}|~{3,})/;
43931
+ var TABLE_RE = /^\s*\|/;
43932
+ var LABEL_RE = /^[^\p{L}\p{N}*_`]*\*\*(.+?)\*\*/u;
43933
+ var UPCOMING_RE = /upcoming/i;
43934
+ var CURRENT_STATE_RE = /^current[\s-]+state\b/i;
43935
+ var SESSION_LOG_RE = /^session[\s-]+log$/i;
43936
+ function latestOf(dates) {
43937
+ let latest = null;
43938
+ for (const d of dates) {
43939
+ if (latest === null || utcMs(d) > utcMs(latest))
43940
+ latest = d;
43941
+ }
43942
+ return latest;
43943
+ }
43944
+ function openingOf(text) {
43945
+ const safe = terminalSafeText(text, 400);
43946
+ const chars = [...safe];
43947
+ if (chars.length <= 60)
43948
+ return safe;
43949
+ return `${chars.slice(0, 60).join("").trimEnd()}…`;
43950
+ }
43951
+ function safeLabel(text) {
43952
+ return terminalSafeText(text, 120);
43953
+ }
43954
+ function scanContextText(raw, today, opts = {}) {
43955
+ const scanner = new ContextScanner(today, opts.initialHeading ?? null);
43956
+ for (const line of prepare(raw).split(/\r?\n/))
43957
+ scanner.line(line);
43958
+ return scanner.finish();
43959
+ }
43960
+
43961
+ class ContextScanner {
43962
+ today;
43963
+ todayMs;
43964
+ heading;
43965
+ block = null;
43966
+ blockDates = [];
43967
+ blockRegion = null;
43968
+ blockIsLabel = false;
43969
+ open = [];
43970
+ fence = false;
43971
+ lastBlank = true;
43972
+ sessionLogLevel = null;
43973
+ walls = [];
43974
+ played = [];
43975
+ asOf = [];
43976
+ constructor(today, initialHeading) {
43977
+ this.today = today;
43978
+ this.todayMs = utcMs(today);
43979
+ this.heading = initialHeading;
43980
+ }
43981
+ line(line) {
43982
+ if (FENCE_RE.test(line)) {
43983
+ this.fence = !this.fence;
43984
+ this.flushBlock();
43985
+ this.lastBlank = false;
43986
+ return;
43987
+ }
43988
+ if (this.fence)
43989
+ return;
43990
+ if (/^\s*$/.test(line)) {
43991
+ this.flushBlock();
43992
+ this.lastBlank = true;
43993
+ return;
43994
+ }
43995
+ const h = HEADING_RE.exec(line);
43996
+ if (h) {
43997
+ this.onHeading(h[1].length, h[2]);
43998
+ return;
43999
+ }
44000
+ if (this.sessionLogLevel !== null)
44001
+ return;
44002
+ if (TABLE_RE.test(line)) {
44003
+ this.onTableRow(line);
44004
+ return;
44005
+ }
44006
+ const item = ITEM_RE.exec(line);
44007
+ if (item) {
44008
+ this.onItem(line, item[2]);
44009
+ return;
44010
+ }
44011
+ this.onText(line);
44012
+ }
44013
+ finish() {
44014
+ this.flushBlock();
44015
+ this.closeWhere(() => true);
44016
+ return { walls: this.walls, played: this.played, asOf: this.asOf };
44017
+ }
44018
+ onHeading(level, label) {
44019
+ this.flushBlock();
44020
+ this.closeWhere((r) => r.kind === "item" || r.depth >= level);
44021
+ this.lastBlank = false;
44022
+ if (this.sessionLogLevel !== null && level > this.sessionLogLevel)
44023
+ return;
44024
+ this.sessionLogLevel = SESSION_LOG_RE.test(label.trim()) ? level : null;
44025
+ if (this.sessionLogLevel !== null)
44026
+ return;
44027
+ this.open.push({
44028
+ kind: "heading",
44029
+ depth: level,
44030
+ label,
44031
+ upcoming: UPCOMING_RE.test(label),
44032
+ played: [],
44033
+ childBlocks: 0,
44034
+ ownLatest: null
44035
+ });
44036
+ this.heading = label;
44037
+ if (CURRENT_STATE_RE.test(label))
44038
+ this.noteAsOf(label);
44039
+ }
44040
+ noteAsOf(label) {
44041
+ const dated = readAsOf(label, this.today);
44042
+ if (dated === null) {
44043
+ this.asOf.push({ heading: safeLabel(label), daysOld: null });
44044
+ return;
44045
+ }
44046
+ const age = daysBetween2(dated.parts, this.today);
44047
+ if (age > CURRENT_STATE_AS_OF_MAX_DAYS || age < 0) {
44048
+ this.asOf.push({ heading: safeLabel(label), daysOld: age });
44049
+ }
44050
+ }
44051
+ onTableRow(line) {
44052
+ this.flushBlock();
44053
+ if (this.lastBlank) {
44054
+ const indent = indentOf(line);
44055
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
44056
+ }
44057
+ this.judgeEntry(this.upcomingRegion(), readDayMentions(line, this.today).map((m) => m.parts), false);
44058
+ this.lastBlank = false;
44059
+ }
44060
+ onItem(line, body) {
44061
+ this.flushBlock();
44062
+ const indent = indentOf(line);
44063
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
44064
+ const labelMatch = LABEL_RE.exec(body);
44065
+ const label = labelMatch ? labelMatch[1] : null;
44066
+ const upcoming = label !== null && UPCOMING_RE.test(label);
44067
+ const region = { kind: "item", depth: indent, label, upcoming, played: [], childBlocks: 0, ownLatest: null };
44068
+ this.open.push(region);
44069
+ this.block = { heading: this.heading, text: line.trim() };
44070
+ this.blockRegion = this.upcomingRegion();
44071
+ this.blockIsLabel = this.blockRegion === region;
44072
+ this.collectDates(labelMatch && upcoming ? body.slice(labelMatch[0].length) : body);
44073
+ this.lastBlank = false;
44074
+ }
44075
+ onText(line) {
44076
+ const indent = indentOf(line);
44077
+ if (this.lastBlank) {
44078
+ this.closeWhere((r) => r.kind === "item" && r.depth >= indent);
44079
+ }
44080
+ if (this.block === null) {
44081
+ this.block = { heading: this.heading, text: line.trim() };
44082
+ this.blockRegion = this.upcomingRegion();
44083
+ this.blockIsLabel = false;
44084
+ } else {
44085
+ this.block.text += `
44086
+ ${line.trim()}`;
44087
+ }
44088
+ this.collectDates(line);
44089
+ this.lastBlank = false;
44090
+ }
44091
+ flushBlock() {
44092
+ if (this.block === null)
44093
+ return;
44094
+ const chars = [...this.block.text].length;
44095
+ if (chars > CONTEXT_BLOCK_BAR_CHARS) {
44096
+ this.walls.push({
44097
+ heading: this.block.heading === null ? null : safeLabel(this.block.heading),
44098
+ opening: openingOf(this.block.text),
44099
+ chars
44100
+ });
44101
+ }
44102
+ this.judgeEntry(this.blockRegion, this.blockDates, this.blockIsLabel);
44103
+ this.block = null;
44104
+ this.blockDates = [];
44105
+ this.blockRegion = null;
44106
+ this.blockIsLabel = false;
44107
+ }
44108
+ judgeEntry(region, dates, isLabel) {
44109
+ if (region === null)
44110
+ return;
44111
+ const latest = latestOf(dates);
44112
+ const played = latest !== null && utcMs(latest) < this.todayMs;
44113
+ if (isLabel) {
44114
+ region.ownLatest = played ? latest : null;
44115
+ return;
44116
+ }
44117
+ region.childBlocks += 1;
44118
+ if (played)
44119
+ region.played.push(latest);
44120
+ }
44121
+ closeRegion(r) {
44122
+ if (!r.upcoming)
44123
+ return;
44124
+ const entries = r.childBlocks === 0 && r.ownLatest !== null ? [r.ownLatest] : r.played;
44125
+ const latest = latestOf(entries);
44126
+ if (latest === null)
44127
+ return;
44128
+ this.played.push({ label: safeLabel(r.label ?? ""), playedEntries: entries.length, latest });
44129
+ }
44130
+ closeWhere(reject) {
44131
+ for (let i = this.open.length - 1;i >= 0; i -= 1) {
44132
+ const r = this.open[i];
44133
+ if (reject(r)) {
44134
+ this.closeRegion(r);
44135
+ this.open.splice(i, 1);
44136
+ }
44137
+ }
44138
+ }
44139
+ upcomingRegion() {
44140
+ for (let i = this.open.length - 1;i >= 0; i -= 1) {
44141
+ if (this.open[i].upcoming)
44142
+ return this.open[i];
44143
+ }
44144
+ return null;
44145
+ }
44146
+ collectDates(segment) {
44147
+ if (this.blockRegion === null)
44148
+ return;
44149
+ for (const mention of readDayMentions(segment, this.today))
44150
+ this.blockDates.push(mention.parts);
44151
+ }
44152
+ }
44153
+ function assessContextWalls(workspaceSlug, deps = {}) {
44154
+ const readAnchor = deps.readArtistAnchor ?? readArtistAnchorFile;
44155
+ const readContext = deps.readContext ?? readContextFile;
44156
+ const rosterOf = deps.readRosterFn ?? readRoster;
44157
+ const today = localDayOf((deps.now ?? (() => new Date))());
44158
+ const walls = [];
44159
+ const played = [];
44160
+ const asOf = [];
44161
+ let unreadable = 0;
44162
+ const take = (path5, result) => {
44163
+ for (const w of result.walls)
44164
+ walls.push({ path: path5, ...w });
44165
+ for (const p of result.played)
44166
+ played.push({ path: path5, ...p });
44167
+ for (const a of result.asOf)
44168
+ asOf.push({ path: path5, ...a });
44169
+ };
44170
+ let roster;
44171
+ try {
44172
+ roster = rosterOf(workspaceSlug);
44173
+ } catch {
44174
+ roster = [];
44175
+ }
44176
+ for (const artist of roster) {
44177
+ let raw = null;
44178
+ try {
44179
+ raw = readAnchor(workspaceSlug, artist.slug);
44180
+ } catch {
44181
+ unreadable += 1;
44182
+ continue;
44183
+ }
44184
+ if (raw === null)
44185
+ continue;
44186
+ take(`artists/${artist.slug}/${ARTIST_ANCHOR_FILENAME}`, scanContextText(raw, today));
44187
+ }
44188
+ let root = null;
44189
+ try {
44190
+ root = readContext(workspaceSlug, CLAUDE_MD);
44191
+ } catch {
44192
+ root = null;
44193
+ }
44194
+ if (root !== null) {
44195
+ const block = extractUserBlock(root);
44196
+ if (block !== null) {
44197
+ take(CLAUDE_MD, scanContextText(block, today, { initialHeading: "the customisable block" }));
44198
+ }
44199
+ }
44200
+ walls.sort((a, b) => b.chars - a.chars || a.path.localeCompare(b.path));
44201
+ played.sort((a, b) => b.playedEntries - a.playedEntries || a.path.localeCompare(b.path));
44202
+ asOf.sort((a, b) => {
44203
+ if (a.daysOld === null !== (b.daysOld === null))
44204
+ return a.daysOld === null ? -1 : 1;
44205
+ return (b.daysOld ?? 0) - (a.daysOld ?? 0) || a.path.localeCompare(b.path);
44206
+ });
44207
+ return { walls, played, asOf, unreadable };
44208
+ }
44209
+
42462
44210
  // src/lib/build-stamp.ts
42463
44211
  import { execFileSync as execFileSync4 } from "node:child_process";
42464
44212
  import { existsSync as existsSync9, mkdirSync as mkdirSync11, readFileSync as readFileSync26, writeFileSync as writeFileSync8 } from "node:fs";
@@ -42547,8 +44295,8 @@ function checkBuildStaleness(deps = {}) {
42547
44295
 
42548
44296
  // src/lib/memory-links.ts
42549
44297
  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;
44298
+ import { relative as relative14, sep as sep6 } from "node:path";
44299
+ var MEMORY_FILE_RE = /^(workspace-memory\.md|user-memory\.md|artists\/[^/]+\/memory\.md|memory\/[^/]+\.md|memory\/private\/[^/]+\.md)$/i;
42552
44300
  function scanMemoryLinks(deps) {
42553
44301
  const listFiles = deps.listFiles ?? ((r) => walkSubtreeFiles(r, isVcsInternal));
42554
44302
  const readFile = deps.readFile ?? ((p) => readFileSync27(p, "utf8"));
@@ -42560,7 +44308,7 @@ function scanMemoryLinks(deps) {
42560
44308
  } catch {
42561
44309
  return { scanned: 0, unreadable: [], dead: [] };
42562
44310
  }
42563
- const relPaths = absPaths.map((p) => relative13(root, p).split(sep5).join("/"));
44311
+ const relPaths = absPaths.map((p) => relative14(root, p).split(sep6).join("/"));
42564
44312
  const artists = buildArtistIndex(relPaths, root, (abs) => readFile(abs));
42565
44313
  const dead = [];
42566
44314
  const unreadable = [];
@@ -42596,7 +44344,7 @@ init_output();
42596
44344
 
42597
44345
  // src/lib/pending-writes.ts
42598
44346
  import { readFileSync as readFileSync28, statSync as statSync18 } from "node:fs";
42599
- import { relative as relative14 } from "node:path";
44347
+ import { relative as relative15 } from "node:path";
42600
44348
  var import_gray_matter14 = __toESM(require_gray_matter(), 1);
42601
44349
  var EMPTY2 = { writes: [], count: 0 };
42602
44350
  function reportedElsewhere(state) {
@@ -42625,7 +44373,7 @@ function scanPendingWrites(args) {
42625
44373
  const writes = [];
42626
44374
  const discovered = [];
42627
44375
  for (const absolutePath of walkSubtreeFiles(mirrorRootPath, ignored)) {
42628
- const relPath = relative14(mirrorRootPath, absolutePath);
44376
+ const relPath = relative15(mirrorRootPath, absolutePath);
42629
44377
  const fileMatch = classifyFilesPath({ workspaceSlug, absolutePath });
42630
44378
  if (fileMatch) {
42631
44379
  if (frozenFiles.has(fileMatch.relPath))
@@ -43102,8 +44850,11 @@ async function runStatus(args, deps = {}) {
43102
44850
  if (stashes.length === 0) {
43103
44851
  out.info(row("Conflicts", "No conflict stashes."));
43104
44852
  } else {
43105
- const cloudSide = stashes.filter((s) => s.side === "cloud");
43106
- const localSide = stashes.filter((s) => s.side !== "cloud");
44853
+ const generatedDeps = generatedMemoryStashDepsFrom(localState);
44854
+ const generatedSide = stashes.filter((s) => isGeneratedMemoryStash(s.label, generatedDeps) !== null);
44855
+ const generatedLabels = new Set(generatedSide.map((s) => s.label));
44856
+ const cloudSide = stashes.filter((s) => s.side === "cloud" && !generatedLabels.has(s.label));
44857
+ const localSide = stashes.filter((s) => s.side !== "cloud" && !generatedLabels.has(s.label));
43107
44858
  out.info(row("Conflicts", `${stashes.length} stashed cop${stashes.length === 1 ? "y" : "ies"} in ${conflictsRoot(slug)}`));
43108
44859
  const newest = stashes[0];
43109
44860
  const when = newest.stashedAt ? `${formatAbsolute(newest.stashedAt)} (${formatRelative(now.getTime() - newest.stashedAt.getTime())})` : "time unknown";
@@ -43112,6 +44863,9 @@ async function runStatus(args, deps = {}) {
43112
44863
  if (localSide.length > 0) {
43113
44864
  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
44865
  }
44866
+ if (generatedSide.length > 0) {
44867
+ 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.`));
44868
+ }
43115
44869
  if (cloudSide.length > 0) {
43116
44870
  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
44871
  }
@@ -43359,6 +45113,16 @@ async function runStatus(args, deps = {}) {
43359
45113
  }
43360
45114
  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
45115
  }
45116
+ let context = { walls: [], played: [], asOf: [], unreadable: 0 };
45117
+ try {
45118
+ context = assessContextWalls(slug, { now: () => now });
45119
+ } catch {
45120
+ context = { walls: [], played: [], asOf: [], unreadable: 0 };
45121
+ }
45122
+ renderWalls(out, context);
45123
+ renderPlayed(out, context);
45124
+ renderAsOf(out, context);
45125
+ renderUnchecked(out, context);
43362
45126
  const anchor = localState.size_anchor;
43363
45127
  if (anchor !== undefined) {
43364
45128
  let now2 = {
@@ -43399,6 +45163,53 @@ async function runStatus(args, deps = {}) {
43399
45163
  out.info("");
43400
45164
  return { exitCode: 0 };
43401
45165
  }
45166
+ var num = (v) => v.toLocaleString("en-GB");
45167
+ function renderWalls(out, context) {
45168
+ const total = context.walls.length;
45169
+ if (total === 0)
45170
+ return;
45171
+ const named = context.walls.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
45172
+ out.info(row("Walls", `${total} block${total === 1 ? "" : "s"} of master context ${total === 1 ? "has" : "have"} ` + `grown past ${num(CONTEXT_BLOCK_BAR_CHARS)} characters.`));
45173
+ for (const w of named) {
45174
+ const where = w.heading === null ? "before its first heading" : `under "${w.heading}"`;
45175
+ out.info(cont(`${w.path}, ${where}: ${num(w.chars)} characters, opening "${w.opening}"`));
45176
+ }
45177
+ if (total > named.length)
45178
+ out.info(cont(`And ${total - named.length} more, not named here.`));
45179
+ 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."));
45180
+ }
45181
+ function renderPlayed(out, context) {
45182
+ const total = context.played.length;
45183
+ if (total === 0)
45184
+ return;
45185
+ const named = context.played.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
45186
+ out.info(row("Played", `${total} label${total === 1 ? "" : "s"} that ${total === 1 ? "says" : "say"} upcoming still ` + `${total === 1 ? "holds" : "hold"} entries that have played.`));
45187
+ for (const p of named) {
45188
+ 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)}.`));
45189
+ }
45190
+ if (total > named.length)
45191
+ out.info(cont(`And ${total - named.length} more, not named here.`));
45192
+ 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."));
45193
+ }
45194
+ function renderAsOf(out, context) {
45195
+ const total = context.asOf.length;
45196
+ if (total === 0)
45197
+ return;
45198
+ const named = context.asOf.slice(0, MAX_NAMED_CONTEXT_FINDINGS);
45199
+ out.info(row("As of", `${total} current-state section${total === 1 ? "" : "s"} cannot be checked against a date.`));
45200
+ for (const a of named) {
45201
+ 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.`));
45202
+ }
45203
+ if (total > named.length)
45204
+ out.info(cont(`And ${total - named.length} more, not named here.`));
45205
+ 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."));
45206
+ }
45207
+ function renderUnchecked(out, context) {
45208
+ const n = context.unreadable;
45209
+ if (n === 0)
45210
+ return;
45211
+ 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"}.`));
45212
+ }
43402
45213
  var statusCommand = defineCommand({
43403
45214
  meta: {
43404
45215
  name: "status",
@@ -43737,7 +45548,7 @@ function denyUngated(detail) {
43737
45548
  // src/lib/voice-gate/payload.ts
43738
45549
  import { readFileSync as readFileSync30, statSync as statSync20 } from "node:fs";
43739
45550
  import { homedir as homedir2 } from "node:os";
43740
- import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
45551
+ import { isAbsolute as isAbsolute4, resolve as resolve4 } from "node:path";
43741
45552
  var MAX_BODY_BYTES = 256 * 1024;
43742
45553
  var HELPER_BASENAME = "push_gmail_draft.py";
43743
45554
  var DRAFT_FLAGS = ["--to", "--body-file", "--body-stdin"];
@@ -43927,7 +45738,7 @@ function applyCd(tokens, state) {
43927
45738
  return { dir: state.dir, known: false };
43928
45739
  }
43929
45740
  const expanded = expandHome(target);
43930
- if (isAbsolute3(expanded))
45741
+ if (isAbsolute4(expanded))
43931
45742
  return { dir: expanded, known: true };
43932
45743
  if (!state.known)
43933
45744
  return state;
@@ -43958,12 +45769,12 @@ function classifyHelperSegment(tokens, dirState, readBodyFile) {
43958
45769
  };
43959
45770
  }
43960
45771
  const expanded = expandHome(bodyFile);
43961
- if (!isAbsolute3(expanded) && !dirState.known) {
45772
+ if (!isAbsolute4(expanded) && !dirState.known) {
43962
45773
  return {
43963
45774
  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
45775
  };
43965
45776
  }
43966
- const resolved = isAbsolute3(expanded) ? expanded : resolve4(dirState.dir, expanded);
45777
+ const resolved = isAbsolute4(expanded) ? expanded : resolve4(dirState.dir, expanded);
43967
45778
  let body;
43968
45779
  try {
43969
45780
  body = readBodyFile(resolved);