@plinth-music/cli 0.5.0 → 0.7.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/CHANGELOG.md +101 -0
- package/README.md +47 -4
- package/dist/build-stamp.json +2 -2
- package/dist/cli.js +2906 -463
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7714,6 +7714,7 @@ var init_ora = __esm(() => {
|
|
|
7714
7714
|
var exports_output = {};
|
|
7715
7715
|
__export(exports_output, {
|
|
7716
7716
|
spinnerEnabled: () => spinnerEnabled,
|
|
7717
|
+
noColorFromArgs: () => noColorFromArgs,
|
|
7717
7718
|
createSpinner: () => createSpinner,
|
|
7718
7719
|
Output: () => Output
|
|
7719
7720
|
});
|
|
@@ -7743,6 +7744,9 @@ function createSpinner(opts = {}) {
|
|
|
7743
7744
|
}
|
|
7744
7745
|
};
|
|
7745
7746
|
}
|
|
7747
|
+
function noColorFromArgs(args) {
|
|
7748
|
+
return args.color === false || args["no-color"] === true;
|
|
7749
|
+
}
|
|
7746
7750
|
function selectChalk(noColor) {
|
|
7747
7751
|
if (noColor)
|
|
7748
7752
|
return new Chalk({ level: 0 });
|
|
@@ -9189,7 +9193,7 @@ async function runMain(cmd, opts = {}) {
|
|
|
9189
9193
|
// package.json
|
|
9190
9194
|
var package_default = {
|
|
9191
9195
|
name: "@plinth-music/cli",
|
|
9192
|
-
version: "0.
|
|
9196
|
+
version: "0.7.0",
|
|
9193
9197
|
description: "Plinth capstone sync daemon and CLI",
|
|
9194
9198
|
type: "module",
|
|
9195
9199
|
private: false,
|
|
@@ -24832,7 +24836,9 @@ var ThreadLiveSchema = exports_external.object({
|
|
|
24832
24836
|
scope: exports_external.enum(["internal", "artist"]),
|
|
24833
24837
|
frontmatter: exports_external.record(exports_external.string(), exports_external.unknown()),
|
|
24834
24838
|
last_edited_at: exports_external.string().datetime({ offset: true }).nullable(),
|
|
24835
|
-
last_edited_by: exports_external.string().uuid().nullable()
|
|
24839
|
+
last_edited_by: exports_external.string().uuid().nullable(),
|
|
24840
|
+
message_count: exports_external.number().int().nonnegative(),
|
|
24841
|
+
last_message_at: exports_external.string().datetime({ offset: true }).nullable()
|
|
24836
24842
|
});
|
|
24837
24843
|
var ThreadsPageResponseSchema = makePageResponseSchema("threads", ThreadLiveSchema);
|
|
24838
24844
|
var SERVER_AUTHORITATIVE_KEYS6 = new Set([
|
|
@@ -24842,7 +24848,9 @@ var SERVER_AUTHORITATIVE_KEYS6 = new Set([
|
|
|
24842
24848
|
"last_edited_by",
|
|
24843
24849
|
"scope",
|
|
24844
24850
|
"name",
|
|
24845
|
-
"artist_id"
|
|
24851
|
+
"artist_id",
|
|
24852
|
+
"message_count",
|
|
24853
|
+
"last_message_at"
|
|
24846
24854
|
]);
|
|
24847
24855
|
var WIRE_EDITABLE_TOP_LEVEL_KEYS6 = new Set([
|
|
24848
24856
|
"name",
|
|
@@ -24854,7 +24862,9 @@ var PROJECTION_KEYS6 = [
|
|
|
24854
24862
|
"artist_id",
|
|
24855
24863
|
"scope",
|
|
24856
24864
|
"revision",
|
|
24857
|
-
"last_edited_at"
|
|
24865
|
+
"last_edited_at",
|
|
24866
|
+
"message_count",
|
|
24867
|
+
"last_message_at"
|
|
24858
24868
|
];
|
|
24859
24869
|
function buildCreatePayload6(args) {
|
|
24860
24870
|
const fm = args.parsed.data ?? {};
|
|
@@ -25174,6 +25184,132 @@ function deleteFilesFile(workspaceSlug, relPath) {
|
|
|
25174
25184
|
throw err;
|
|
25175
25185
|
}
|
|
25176
25186
|
}
|
|
25187
|
+
var VIEWS_SUBTREE = "views";
|
|
25188
|
+
var EXTRACTED_VIEW_DIR = "extracted";
|
|
25189
|
+
function extractedTextRelPath(relPath) {
|
|
25190
|
+
return `${VIEWS_SUBTREE}/${EXTRACTED_VIEW_DIR}/${relPath}.md`;
|
|
25191
|
+
}
|
|
25192
|
+
function extractedTextPath(workspaceSlug, relPath) {
|
|
25193
|
+
if (validateFilesRelPath(relPath) === null) {
|
|
25194
|
+
throw new MirrorPathError(`Invalid files sub-path for extracted text: ${JSON.stringify(relPath)}`);
|
|
25195
|
+
}
|
|
25196
|
+
const ws = workspaceMirrorPath(workspaceSlug);
|
|
25197
|
+
const posix = extractedTextRelPath(relPath);
|
|
25198
|
+
const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
|
|
25199
|
+
const target = resolve(join2(ws, native));
|
|
25200
|
+
const prefix = join2(ws, VIEWS_SUBTREE, EXTRACTED_VIEW_DIR) + sep2;
|
|
25201
|
+
if (!target.startsWith(prefix)) {
|
|
25202
|
+
throw new MirrorPathError(`Resolved extracted-text path escapes the views/extracted subtree`);
|
|
25203
|
+
}
|
|
25204
|
+
return target;
|
|
25205
|
+
}
|
|
25206
|
+
function writeExtractedText(workspaceSlug, relPath, content) {
|
|
25207
|
+
const target = extractedTextPath(workspaceSlug, relPath);
|
|
25208
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
25209
|
+
mkdirSync(dirname(target), { recursive: true, mode: 448 });
|
|
25210
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
25211
|
+
try {
|
|
25212
|
+
renameSync(tmp, target);
|
|
25213
|
+
} catch (err) {
|
|
25214
|
+
try {
|
|
25215
|
+
unlinkSync(tmp);
|
|
25216
|
+
} catch {}
|
|
25217
|
+
throw err;
|
|
25218
|
+
}
|
|
25219
|
+
}
|
|
25220
|
+
function readExtractedText(workspaceSlug, relPath) {
|
|
25221
|
+
try {
|
|
25222
|
+
return readFileSync(extractedTextPath(workspaceSlug, relPath), "utf8");
|
|
25223
|
+
} catch (err) {
|
|
25224
|
+
if (err?.code === "ENOENT")
|
|
25225
|
+
return null;
|
|
25226
|
+
throw err;
|
|
25227
|
+
}
|
|
25228
|
+
}
|
|
25229
|
+
function deleteExtractedText(workspaceSlug, relPath) {
|
|
25230
|
+
const target = extractedTextPath(workspaceSlug, relPath);
|
|
25231
|
+
try {
|
|
25232
|
+
unlinkSync(target);
|
|
25233
|
+
return true;
|
|
25234
|
+
} catch (err) {
|
|
25235
|
+
if (err?.code === "ENOENT")
|
|
25236
|
+
return false;
|
|
25237
|
+
throw err;
|
|
25238
|
+
}
|
|
25239
|
+
}
|
|
25240
|
+
var CALENDAR_VIEW_DIR = "calendar";
|
|
25241
|
+
var CALENDAR_UPCOMING_NAME = "upcoming";
|
|
25242
|
+
var CALENDAR_INTERNAL_NAME = "_internal";
|
|
25243
|
+
var CALENDAR_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
25244
|
+
function calendarViewRelPath(name) {
|
|
25245
|
+
return `${VIEWS_SUBTREE}/${CALENDAR_VIEW_DIR}/${name}.md`;
|
|
25246
|
+
}
|
|
25247
|
+
function calendarViewPath(workspaceSlug, name) {
|
|
25248
|
+
if (name !== CALENDAR_INTERNAL_NAME && !CALENDAR_NAME_RE.test(name)) {
|
|
25249
|
+
throw new MirrorPathError(`Invalid calendar view name: ${JSON.stringify(name)}`);
|
|
25250
|
+
}
|
|
25251
|
+
if (name.length > 200) {
|
|
25252
|
+
throw new MirrorPathError(`Calendar view name too long: ${name.length}`);
|
|
25253
|
+
}
|
|
25254
|
+
const ws = workspaceMirrorPath(workspaceSlug);
|
|
25255
|
+
const posix = calendarViewRelPath(name);
|
|
25256
|
+
const native = sep2 === "/" ? posix : posix.split("/").join(sep2);
|
|
25257
|
+
const target = resolve(join2(ws, native));
|
|
25258
|
+
const prefix = join2(ws, VIEWS_SUBTREE, CALENDAR_VIEW_DIR) + sep2;
|
|
25259
|
+
if (!target.startsWith(prefix)) {
|
|
25260
|
+
throw new MirrorPathError(`Resolved calendar-view path escapes the views/calendar subtree`);
|
|
25261
|
+
}
|
|
25262
|
+
return target;
|
|
25263
|
+
}
|
|
25264
|
+
function writeCalendarView(workspaceSlug, name, content) {
|
|
25265
|
+
const target = calendarViewPath(workspaceSlug, name);
|
|
25266
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
25267
|
+
mkdirSync(dirname(target), { recursive: true, mode: 448 });
|
|
25268
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
25269
|
+
try {
|
|
25270
|
+
renameSync(tmp, target);
|
|
25271
|
+
} catch (err) {
|
|
25272
|
+
try {
|
|
25273
|
+
unlinkSync(tmp);
|
|
25274
|
+
} catch {}
|
|
25275
|
+
throw err;
|
|
25276
|
+
}
|
|
25277
|
+
}
|
|
25278
|
+
function readCalendarView(workspaceSlug, name) {
|
|
25279
|
+
try {
|
|
25280
|
+
return readFileSync(calendarViewPath(workspaceSlug, name), "utf8");
|
|
25281
|
+
} catch (err) {
|
|
25282
|
+
if (err?.code === "ENOENT")
|
|
25283
|
+
return null;
|
|
25284
|
+
throw err;
|
|
25285
|
+
}
|
|
25286
|
+
}
|
|
25287
|
+
function deleteCalendarView(workspaceSlug, name) {
|
|
25288
|
+
const target = calendarViewPath(workspaceSlug, name);
|
|
25289
|
+
try {
|
|
25290
|
+
unlinkSync(target);
|
|
25291
|
+
return true;
|
|
25292
|
+
} catch (err) {
|
|
25293
|
+
if (err?.code === "ENOENT")
|
|
25294
|
+
return false;
|
|
25295
|
+
throw err;
|
|
25296
|
+
}
|
|
25297
|
+
}
|
|
25298
|
+
function listCalendarViews(workspaceSlug) {
|
|
25299
|
+
let dir;
|
|
25300
|
+
try {
|
|
25301
|
+
dir = join2(workspaceMirrorPath(workspaceSlug), VIEWS_SUBTREE, CALENDAR_VIEW_DIR);
|
|
25302
|
+
} catch {
|
|
25303
|
+
return [];
|
|
25304
|
+
}
|
|
25305
|
+
let entries;
|
|
25306
|
+
try {
|
|
25307
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
25308
|
+
} catch {
|
|
25309
|
+
return [];
|
|
25310
|
+
}
|
|
25311
|
+
return entries.filter((e2) => e2.isFile() && e2.name.endsWith(".md")).map((e2) => e2.name.slice(0, -3)).sort();
|
|
25312
|
+
}
|
|
25177
25313
|
function contextFilePath(workspaceSlug, relativePath) {
|
|
25178
25314
|
const ws = workspaceMirrorPath(workspaceSlug);
|
|
25179
25315
|
const resolved = resolve(join2(ws, relativePath));
|
|
@@ -26772,9 +26908,15 @@ function buildGroundingBlock(today, workspaceRules = null, workspaceMemory = nul
|
|
|
26772
26908
|
``,
|
|
26773
26909
|
`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. (Native dry_run support in the Plinth MCP tools ` + `ships separately; until then this is a behavioural rule.)`,
|
|
26774
26910
|
``,
|
|
26775
|
-
`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 one
|
|
26911
|
+
`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.`,
|
|
26776
26912
|
``,
|
|
26777
|
-
`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. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface).
|
|
26913
|
+
`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. Never create ` + `or update a key date: do not call \`create_key_date\` or \`update_key_date\` (they ` + `write to the retired table and are being removed from the tool surface). Reading ` + `dates is fine, 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\`, \`list_key_dates\`). 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.`,
|
|
26914
|
+
``,
|
|
26915
|
+
`Read the calendar, and hand a date over rather than writing it yourself. An ` + `artist's dates live on their mapped Google Calendar and reading them is yours: ` + `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 it is not yours. When a date needs ` + `adding or correcting (a show, a hold, a release, a deadline), say plainly what ` + `the change is, say that it goes in through Plinth's calendar view, and say that ` + `a person makes that change rather than you. Say the reason too, because a date ` + `left looking handled is worse than one openly outstanding: a calendar write ` + `goes out to everyone invited the moment it lands, and it cannot be recalled. ` + `This is not a puzzle to route around. Do not reach for a shell command line to ` + `do it, the Google Workspace one included, and do not go looking for another ` + `tool that writes a calendar. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, the rule is the ` + `same and you are the only thing holding it.`,
|
|
26916
|
+
``,
|
|
26917
|
+
`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 and forwarding are not yours, and neither is any shell command line ` + `that does the same thing. Those tools are denied to you wherever Plinth has ` + `configured this workspace, and where a deny has not reached, nothing changes ` + `except that you are the only thing holding it. This is not a comment on your ` + `writing. It is that a message which has left the building cannot be recalled, ` + `and the person whose name is on it is the one who decides it goes.`,
|
|
26918
|
+
``,
|
|
26919
|
+
`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 the Voice section of your own \`user-memory.md\` holds the personal ` + `part, 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.`
|
|
26778
26920
|
];
|
|
26779
26921
|
if (workspaceRules !== null) {
|
|
26780
26922
|
block.push(``, `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:
|
|
@@ -26851,7 +26993,8 @@ var FileStateEntrySchema = exports_external.object({
|
|
|
26851
26993
|
size_bytes: exports_external.number().int().nonnegative(),
|
|
26852
26994
|
mime_type: exports_external.string().min(1),
|
|
26853
26995
|
updated_at: exports_external.string().datetime({ offset: true }),
|
|
26854
|
-
inode: exports_external.number().int().nonnegative().optional()
|
|
26996
|
+
inode: exports_external.number().int().nonnegative().optional(),
|
|
26997
|
+
text_hash: exports_external.string().min(1).optional()
|
|
26855
26998
|
});
|
|
26856
26999
|
var FileEntriesSchema = exports_external.record(exports_external.string(), FileStateEntrySchema);
|
|
26857
27000
|
var WorkspaceRulesStateSchema = exports_external.object({
|
|
@@ -26895,7 +27038,8 @@ var WorkspaceStateV2Schema = exports_external.object({
|
|
|
26895
27038
|
rejected: exports_external.array(RejectedWriteSchema).optional(),
|
|
26896
27039
|
workspace_rules: WorkspaceRulesStateSchema.optional(),
|
|
26897
27040
|
workspace_memory: WorkspaceMemoryStateSchema.optional(),
|
|
26898
|
-
user_memory: UserMemoryStateSchema.optional()
|
|
27041
|
+
user_memory: UserMemoryStateSchema.optional(),
|
|
27042
|
+
derived_text_version: exports_external.number().int().nonnegative().optional()
|
|
26899
27043
|
});
|
|
26900
27044
|
var WorkspaceStateV1Schema = exports_external.object({
|
|
26901
27045
|
version: exports_external.literal(1),
|
|
@@ -27745,106 +27889,744 @@ var loginCommand = defineCommand({
|
|
|
27745
27889
|
}
|
|
27746
27890
|
});
|
|
27747
27891
|
|
|
27748
|
-
// src/lib/
|
|
27749
|
-
import {
|
|
27892
|
+
// src/lib/memory-gate/log.ts
|
|
27893
|
+
import {
|
|
27894
|
+
appendFileSync,
|
|
27895
|
+
closeSync,
|
|
27896
|
+
mkdirSync as mkdirSync7,
|
|
27897
|
+
openSync,
|
|
27898
|
+
readFileSync as readFileSync8,
|
|
27899
|
+
readSync,
|
|
27900
|
+
statSync as statSync2
|
|
27901
|
+
} from "node:fs";
|
|
27902
|
+
import { join as join7 } from "node:path";
|
|
27750
27903
|
|
|
27751
|
-
// src/lib/
|
|
27752
|
-
|
|
27753
|
-
|
|
27754
|
-
|
|
27755
|
-
|
|
27756
|
-
|
|
27757
|
-
|
|
27758
|
-
|
|
27759
|
-
|
|
27760
|
-
|
|
27761
|
-
|
|
27762
|
-
|
|
27763
|
-
{
|
|
27764
|
-
label: "threads + thread_messages",
|
|
27765
|
-
blurb: "threaded discussions, one folder per thread anchored at " + "`threads/<slug>/_thread.md`; messages live in Plinth, not the mirror, and " + "the anchor's own body is a local scratchpad that never pushes",
|
|
27766
|
-
file: true
|
|
27767
|
-
},
|
|
27768
|
-
{ label: "key_dates", blurb: "calendar milestones and release dates", file: false },
|
|
27769
|
-
{ label: "comments", blurb: "comments anchored to any entity", file: false },
|
|
27770
|
-
{ label: "tracks", blurb: "track and release metadata", file: false },
|
|
27771
|
-
{ label: "social_posts", blurb: "planned and published social content", file: false },
|
|
27772
|
-
{ label: "useful_links", blurb: "bookmarked links per artist or workspace", file: false },
|
|
27773
|
-
{ label: "financial_entries", blurb: "income and cost ledger rows", file: false },
|
|
27774
|
-
{ label: "commissions", blurb: "agent, sync, streaming, publishing & other statements", file: false }
|
|
27775
|
-
];
|
|
27776
|
-
var MCP_INTROSPECTION_POINTER = "ask the `mcp__plinth__*` tools for exact fields & enum values (the " + "platform is the schema source of truth)";
|
|
27777
|
-
var MCP_AVAILABILITY_POINTER = "Other MCP servers may be connected on this machine: `claude mcp list` " + "names them and their connection status. Plinth neither configures nor " + "ships them.";
|
|
27778
|
-
var SKILL_LOCATIONS = [
|
|
27779
|
-
{
|
|
27780
|
-
label: "workspace",
|
|
27781
|
-
detail: "`.claude/commands/` in this folder; this machine only, the daemon does not sync it"
|
|
27782
|
-
},
|
|
27783
|
-
{ label: "personal", detail: "`~/.claude/commands/`; your own, available everywhere" }
|
|
27784
|
-
];
|
|
27785
|
-
var SECURITY_NOTE = "Scoped to a single Plinth workspace: do not read or write across " + "workspace folders; each has its own token and isolation boundary.";
|
|
27904
|
+
// src/lib/sessions/registry.ts
|
|
27905
|
+
import {
|
|
27906
|
+
mkdirSync as mkdirSync6,
|
|
27907
|
+
readdirSync as readdirSync3,
|
|
27908
|
+
readFileSync as readFileSync7,
|
|
27909
|
+
renameSync as renameSync6,
|
|
27910
|
+
statSync,
|
|
27911
|
+
unlinkSync as unlinkSync6,
|
|
27912
|
+
writeFileSync as writeFileSync6
|
|
27913
|
+
} from "node:fs";
|
|
27914
|
+
import { execFileSync } from "node:child_process";
|
|
27915
|
+
import { basename, dirname as dirname5, join as join6 } from "node:path";
|
|
27786
27916
|
|
|
27787
|
-
// src/lib/
|
|
27788
|
-
|
|
27789
|
-
|
|
27790
|
-
var
|
|
27791
|
-
|
|
27792
|
-
|
|
27793
|
-
|
|
27794
|
-
|
|
27795
|
-
|
|
27796
|
-
|
|
27797
|
-
|
|
27917
|
+
// src/lib/daemon-pid.ts
|
|
27918
|
+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync6, renameSync as renameSync5, unlinkSync as unlinkSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
27919
|
+
import { dirname as dirname4, join as join5 } from "node:path";
|
|
27920
|
+
var DaemonPidSchema = exports_external.object({
|
|
27921
|
+
version: exports_external.literal(1),
|
|
27922
|
+
pid: exports_external.number().int().positive(),
|
|
27923
|
+
workspace_slug: exports_external.string().min(1),
|
|
27924
|
+
started_at: exports_external.string().min(1)
|
|
27925
|
+
});
|
|
27926
|
+
function daemonPidPath(workspaceSlug) {
|
|
27927
|
+
return join5(plinthHome(), `daemon-${workspaceSlug}.pid`);
|
|
27928
|
+
}
|
|
27929
|
+
function daemonPidTmpPath(workspaceSlug) {
|
|
27930
|
+
return join5(plinthHome(), `daemon-${workspaceSlug}.pid.tmp`);
|
|
27931
|
+
}
|
|
27932
|
+
function writeDaemonPid(workspaceSlug, pid, startedAt) {
|
|
27933
|
+
const file2 = {
|
|
27934
|
+
version: 1,
|
|
27935
|
+
pid,
|
|
27936
|
+
workspace_slug: workspaceSlug,
|
|
27937
|
+
started_at: startedAt.toISOString()
|
|
27938
|
+
};
|
|
27939
|
+
const target = daemonPidPath(workspaceSlug);
|
|
27940
|
+
const tmp = daemonPidTmpPath(workspaceSlug);
|
|
27941
|
+
mkdirSync5(dirname4(target), { recursive: true, mode: 448 });
|
|
27942
|
+
writeFileSync5(tmp, JSON.stringify(file2, null, 2) + `
|
|
27943
|
+
`, { mode: 384 });
|
|
27944
|
+
try {
|
|
27945
|
+
renameSync5(tmp, target);
|
|
27946
|
+
} catch (err) {
|
|
27947
|
+
try {
|
|
27948
|
+
unlinkSync5(tmp);
|
|
27949
|
+
} catch {}
|
|
27950
|
+
throw err;
|
|
27951
|
+
}
|
|
27952
|
+
}
|
|
27953
|
+
function readDaemonPid(workspaceSlug) {
|
|
27954
|
+
let raw;
|
|
27955
|
+
try {
|
|
27956
|
+
raw = readFileSync6(daemonPidPath(workspaceSlug), "utf8");
|
|
27957
|
+
} catch (err) {
|
|
27958
|
+
if (err?.code === "ENOENT")
|
|
27959
|
+
return null;
|
|
27960
|
+
throw err;
|
|
27961
|
+
}
|
|
27962
|
+
let json2;
|
|
27963
|
+
try {
|
|
27964
|
+
json2 = JSON.parse(raw);
|
|
27965
|
+
} catch {
|
|
27798
27966
|
return null;
|
|
27799
|
-
|
|
27967
|
+
}
|
|
27968
|
+
const parsed = DaemonPidSchema.safeParse(json2);
|
|
27969
|
+
return parsed.success ? parsed.data : null;
|
|
27800
27970
|
}
|
|
27801
|
-
function
|
|
27802
|
-
|
|
27803
|
-
|
|
27804
|
-
|
|
27971
|
+
function clearDaemonPid(workspaceSlug) {
|
|
27972
|
+
try {
|
|
27973
|
+
unlinkSync5(daemonPidPath(workspaceSlug));
|
|
27974
|
+
} catch (err) {
|
|
27975
|
+
if (err?.code === "ENOENT")
|
|
27976
|
+
return;
|
|
27977
|
+
throw err;
|
|
27805
27978
|
}
|
|
27806
|
-
|
|
27807
|
-
|
|
27808
|
-
|
|
27809
|
-
|
|
27979
|
+
}
|
|
27980
|
+
function isPidAlive(pid) {
|
|
27981
|
+
try {
|
|
27982
|
+
process.kill(pid, 0);
|
|
27983
|
+
return true;
|
|
27984
|
+
} catch (err) {
|
|
27985
|
+
return err?.code === "EPERM";
|
|
27810
27986
|
}
|
|
27811
|
-
|
|
27987
|
+
}
|
|
27988
|
+
function readLiveDaemon(workspaceSlug, aliveFn = isPidAlive) {
|
|
27989
|
+
const pidFile = readDaemonPid(workspaceSlug);
|
|
27990
|
+
if (!pidFile)
|
|
27991
|
+
return null;
|
|
27992
|
+
if (aliveFn(pidFile.pid))
|
|
27993
|
+
return pidFile;
|
|
27994
|
+
clearDaemonPid(workspaceSlug);
|
|
27995
|
+
return null;
|
|
27812
27996
|
}
|
|
27813
27997
|
|
|
27814
|
-
// src/lib/
|
|
27815
|
-
var
|
|
27816
|
-
|
|
27817
|
-
|
|
27818
|
-
|
|
27819
|
-
|
|
27820
|
-
|
|
27821
|
-
|
|
27822
|
-
|
|
27823
|
-
|
|
27824
|
-
|
|
27825
|
-
|
|
27826
|
-
|
|
27827
|
-
|
|
27998
|
+
// src/lib/sessions/registry.ts
|
|
27999
|
+
var SessionRecordSchema = exports_external.object({
|
|
28000
|
+
version: exports_external.literal(1),
|
|
28001
|
+
session_id: exports_external.string().min(1),
|
|
28002
|
+
pid: exports_external.number().int().positive(),
|
|
28003
|
+
pid_start_time: exports_external.string().min(1),
|
|
28004
|
+
cwd: exports_external.string().min(1),
|
|
28005
|
+
workspace_slug: exports_external.string().min(1),
|
|
28006
|
+
started_at: exports_external.string().min(1),
|
|
28007
|
+
transcript_path: exports_external.string().min(1).nullable(),
|
|
28008
|
+
tab_id: exports_external.string().min(1).nullable().default(null),
|
|
28009
|
+
ended_at: exports_external.string().min(1).nullable().default(null)
|
|
28010
|
+
});
|
|
28011
|
+
var SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
28012
|
+
function isSafeSegment(value) {
|
|
28013
|
+
return SAFE_SEGMENT.test(value) && !value.includes("..");
|
|
27828
28014
|
}
|
|
27829
|
-
function
|
|
27830
|
-
|
|
27831
|
-
|
|
27832
|
-
lines.push(`<!-- Auto-generated by Plinth. Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`);
|
|
27833
|
-
lines.push("");
|
|
27834
|
-
lines.push(`# ${name} · Plinth artist`);
|
|
27835
|
-
lines.push("");
|
|
27836
|
-
const statusPart = status ? `, status ${status}` : "";
|
|
27837
|
-
const bioPart = profile.bio ? ` ${profile.bio}` : "";
|
|
27838
|
-
lines.push(`${name} (artist \`${slug}\`${statusPart}).${bioPart}`);
|
|
27839
|
-
lines.push(`Working dir for ${name}-scoped AI sessions. ${SECURITY_NOTE}`);
|
|
27840
|
-
lines.push("");
|
|
27841
|
-
const links = buildLinks(profile);
|
|
27842
|
-
if (links) {
|
|
27843
|
-
lines.push(`## Links`);
|
|
27844
|
-
lines.push(links);
|
|
27845
|
-
lines.push("");
|
|
28015
|
+
function sessionsDir(workspaceSlug) {
|
|
28016
|
+
if (!isSafeSegment(workspaceSlug)) {
|
|
28017
|
+
throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
|
|
27846
28018
|
}
|
|
27847
|
-
|
|
28019
|
+
return join6(plinthHome(), workspaceSlug, "sessions");
|
|
28020
|
+
}
|
|
28021
|
+
function sessionRecordPath(workspaceSlug, sessionId) {
|
|
28022
|
+
if (!isSafeSegment(sessionId)) {
|
|
28023
|
+
throw new Error(`Refusing unsafe session id: ${sessionId}`);
|
|
28024
|
+
}
|
|
28025
|
+
return join6(sessionsDir(workspaceSlug), `${sessionId}.json`);
|
|
28026
|
+
}
|
|
28027
|
+
function readPidStartTime(pid) {
|
|
28028
|
+
try {
|
|
28029
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
28030
|
+
encoding: "utf8",
|
|
28031
|
+
timeout: 5000,
|
|
28032
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
28033
|
+
env: { ...process.env, TZ: "UTC" }
|
|
28034
|
+
});
|
|
28035
|
+
const collapsed = out.trim().replace(/\s+/g, " ");
|
|
28036
|
+
return collapsed.length > 0 ? collapsed : null;
|
|
28037
|
+
} catch {
|
|
28038
|
+
return null;
|
|
28039
|
+
}
|
|
28040
|
+
}
|
|
28041
|
+
function registerSession(input) {
|
|
28042
|
+
const startTime = (input.startTimeFn ?? readPidStartTime)(input.pid);
|
|
28043
|
+
if (startTime === null)
|
|
28044
|
+
return null;
|
|
28045
|
+
const record2 = {
|
|
28046
|
+
version: 1,
|
|
28047
|
+
session_id: input.sessionId,
|
|
28048
|
+
pid: input.pid,
|
|
28049
|
+
pid_start_time: startTime,
|
|
28050
|
+
cwd: input.cwd,
|
|
28051
|
+
workspace_slug: input.workspaceSlug,
|
|
28052
|
+
started_at: (input.now ?? new Date).toISOString(),
|
|
28053
|
+
transcript_path: input.transcriptPath,
|
|
28054
|
+
tab_id: input.tabId ?? null,
|
|
28055
|
+
ended_at: null
|
|
28056
|
+
};
|
|
28057
|
+
writeRecordAtomic(sessionRecordPath(input.workspaceSlug, input.sessionId), record2);
|
|
28058
|
+
return record2;
|
|
28059
|
+
}
|
|
28060
|
+
var tmpCounter = 0;
|
|
28061
|
+
function writeRecordAtomic(target, record2) {
|
|
28062
|
+
const dir = dirname5(target);
|
|
28063
|
+
mkdirSync6(dir, { recursive: true, mode: 448 });
|
|
28064
|
+
const tmp = `${target}.${process.pid}.${tmpCounter++}.tmp`;
|
|
28065
|
+
sweepDeadTmpFiles(dir, target);
|
|
28066
|
+
writeFileSync6(tmp, JSON.stringify(record2, null, 2) + `
|
|
28067
|
+
`, { mode: 384 });
|
|
28068
|
+
try {
|
|
28069
|
+
renameSync6(tmp, target);
|
|
28070
|
+
} catch (err) {
|
|
28071
|
+
try {
|
|
28072
|
+
unlinkSync6(tmp);
|
|
28073
|
+
} catch {}
|
|
28074
|
+
throw err;
|
|
28075
|
+
}
|
|
28076
|
+
}
|
|
28077
|
+
function sweepDeadTmpFiles(dir, target) {
|
|
28078
|
+
const prefix = `${basename(target)}.`;
|
|
28079
|
+
let names;
|
|
28080
|
+
try {
|
|
28081
|
+
names = readdirSync3(dir);
|
|
28082
|
+
} catch {
|
|
28083
|
+
return;
|
|
28084
|
+
}
|
|
28085
|
+
for (const name of names) {
|
|
28086
|
+
if (!name.startsWith(prefix) || !name.endsWith(".tmp"))
|
|
28087
|
+
continue;
|
|
28088
|
+
const parts = name.slice(0, -".tmp".length).split(".");
|
|
28089
|
+
const pid = Number.parseInt(parts[parts.length - 2] ?? "", 10);
|
|
28090
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
28091
|
+
continue;
|
|
28092
|
+
if (pid === process.pid || isPidAlive(pid))
|
|
28093
|
+
continue;
|
|
28094
|
+
try {
|
|
28095
|
+
unlinkSync6(join6(dir, name));
|
|
28096
|
+
} catch {}
|
|
28097
|
+
}
|
|
28098
|
+
}
|
|
28099
|
+
function endSession(workspaceSlug, sessionId, now = new Date, deps = {}) {
|
|
28100
|
+
const read = deps.readFn ?? readSessionRecord;
|
|
28101
|
+
const path3 = sessionRecordPath(workspaceSlug, sessionId);
|
|
28102
|
+
const current = read(path3);
|
|
28103
|
+
if (current === null)
|
|
28104
|
+
return null;
|
|
28105
|
+
const latest = read(path3);
|
|
28106
|
+
if (latest === null || latest.pid !== current.pid || latest.pid_start_time !== current.pid_start_time) {
|
|
28107
|
+
return null;
|
|
28108
|
+
}
|
|
28109
|
+
const record2 = { ...latest, ended_at: now.toISOString() };
|
|
28110
|
+
writeRecordAtomic(path3, record2);
|
|
28111
|
+
return record2;
|
|
28112
|
+
}
|
|
28113
|
+
function readSessionRecord(path3) {
|
|
28114
|
+
let raw;
|
|
28115
|
+
try {
|
|
28116
|
+
raw = readFileSync7(path3, "utf8");
|
|
28117
|
+
} catch {
|
|
28118
|
+
return null;
|
|
28119
|
+
}
|
|
28120
|
+
let json2;
|
|
28121
|
+
try {
|
|
28122
|
+
json2 = JSON.parse(raw);
|
|
28123
|
+
} catch {
|
|
28124
|
+
return null;
|
|
28125
|
+
}
|
|
28126
|
+
const parsed = SessionRecordSchema.safeParse(json2);
|
|
28127
|
+
return parsed.success ? parsed.data : null;
|
|
28128
|
+
}
|
|
28129
|
+
function sessionLiveness(record2, deps = {}) {
|
|
28130
|
+
if (!(deps.aliveFn ?? isPidAlive)(record2.pid))
|
|
28131
|
+
return "dead";
|
|
28132
|
+
const startTime = (deps.startTimeFn ?? readPidStartTime)(record2.pid);
|
|
28133
|
+
if (startTime === null)
|
|
28134
|
+
return "unknown";
|
|
28135
|
+
return startTime === record2.pid_start_time ? "live" : "dead";
|
|
28136
|
+
}
|
|
28137
|
+
function transcriptMtime(path3) {
|
|
28138
|
+
try {
|
|
28139
|
+
return statSync(path3).mtimeMs;
|
|
28140
|
+
} catch {
|
|
28141
|
+
return null;
|
|
28142
|
+
}
|
|
28143
|
+
}
|
|
28144
|
+
function listSessions(workspaceSlug, deps = {}) {
|
|
28145
|
+
let names;
|
|
28146
|
+
try {
|
|
28147
|
+
names = readdirSync3(sessionsDir(workspaceSlug));
|
|
28148
|
+
} catch {
|
|
28149
|
+
return { live: [], reaped: 0, unknown: 0, ended: 0 };
|
|
28150
|
+
}
|
|
28151
|
+
const live = [];
|
|
28152
|
+
let reaped = 0;
|
|
28153
|
+
let unknown2 = 0;
|
|
28154
|
+
let ended = 0;
|
|
28155
|
+
for (const name of names) {
|
|
28156
|
+
if (!name.endsWith(".json"))
|
|
28157
|
+
continue;
|
|
28158
|
+
const path3 = join6(sessionsDir(workspaceSlug), name);
|
|
28159
|
+
const record2 = readSessionRecord(path3);
|
|
28160
|
+
if (!record2) {
|
|
28161
|
+
continue;
|
|
28162
|
+
}
|
|
28163
|
+
const verdict = sessionLiveness(record2, deps);
|
|
28164
|
+
if (verdict === "unknown") {
|
|
28165
|
+
unknown2 += 1;
|
|
28166
|
+
continue;
|
|
28167
|
+
}
|
|
28168
|
+
if (verdict === "dead") {
|
|
28169
|
+
const current = readSessionRecord(path3);
|
|
28170
|
+
const sameRecord = current !== null && current.pid === record2.pid && current.pid_start_time === record2.pid_start_time;
|
|
28171
|
+
if (sameRecord) {
|
|
28172
|
+
try {
|
|
28173
|
+
unlinkSync6(path3);
|
|
28174
|
+
reaped += 1;
|
|
28175
|
+
} catch {}
|
|
28176
|
+
}
|
|
28177
|
+
continue;
|
|
28178
|
+
}
|
|
28179
|
+
if (record2.ended_at !== null) {
|
|
28180
|
+
ended += 1;
|
|
28181
|
+
continue;
|
|
28182
|
+
}
|
|
28183
|
+
const mtime = record2.transcript_path ? (deps.mtimeFn ?? transcriptMtime)(record2.transcript_path) : null;
|
|
28184
|
+
live.push({ record: record2, lastActivityMs: mtime });
|
|
28185
|
+
}
|
|
28186
|
+
live.sort((a2, b2) => (b2.lastActivityMs ?? -1) - (a2.lastActivityMs ?? -1));
|
|
28187
|
+
return { live, reaped, unknown: unknown2, ended };
|
|
28188
|
+
}
|
|
28189
|
+
|
|
28190
|
+
// src/lib/memory-gate/log.ts
|
|
28191
|
+
var MEMORY_GATE_DECISIONS = ["accept", "reject", "edit"];
|
|
28192
|
+
var Common = {
|
|
28193
|
+
version: exports_external.literal(1),
|
|
28194
|
+
at: exports_external.string().min(1),
|
|
28195
|
+
session_id: exports_external.string().min(1).nullable(),
|
|
28196
|
+
member: exports_external.string().min(1).nullable()
|
|
28197
|
+
};
|
|
28198
|
+
var DecisionRecordSchema = exports_external.object({
|
|
28199
|
+
...Common,
|
|
28200
|
+
kind: exports_external.literal("decision"),
|
|
28201
|
+
proposed: exports_external.string().min(1),
|
|
28202
|
+
decision: exports_external.enum(MEMORY_GATE_DECISIONS),
|
|
28203
|
+
final: exports_external.string().min(1).nullable()
|
|
28204
|
+
});
|
|
28205
|
+
var NothingProposedRecordSchema = exports_external.object({
|
|
28206
|
+
...Common,
|
|
28207
|
+
kind: exports_external.literal("nothing-proposed")
|
|
28208
|
+
});
|
|
28209
|
+
var MemoryGateRecordSchema = exports_external.discriminatedUnion("kind", [
|
|
28210
|
+
DecisionRecordSchema,
|
|
28211
|
+
NothingProposedRecordSchema
|
|
28212
|
+
]);
|
|
28213
|
+
function memoryGateDir(workspaceSlug) {
|
|
28214
|
+
if (!isSafeSegment(workspaceSlug)) {
|
|
28215
|
+
throw new Error(`Refusing unsafe workspace slug: ${workspaceSlug}`);
|
|
28216
|
+
}
|
|
28217
|
+
return join7(plinthHome(), "_memory_gate", workspaceSlug);
|
|
28218
|
+
}
|
|
28219
|
+
function memoryGateLogPath(workspaceSlug) {
|
|
28220
|
+
return join7(memoryGateDir(workspaceSlug), "log.jsonl");
|
|
28221
|
+
}
|
|
28222
|
+
function buildDecisionRecord(input) {
|
|
28223
|
+
const final = input.decision === "accept" ? input.proposed : input.decision === "reject" ? null : input.final ?? null;
|
|
28224
|
+
return {
|
|
28225
|
+
version: 1,
|
|
28226
|
+
kind: "decision",
|
|
28227
|
+
at: (input.now ?? new Date).toISOString(),
|
|
28228
|
+
session_id: input.sessionId,
|
|
28229
|
+
member: input.member,
|
|
28230
|
+
proposed: input.proposed,
|
|
28231
|
+
decision: input.decision,
|
|
28232
|
+
final
|
|
28233
|
+
};
|
|
28234
|
+
}
|
|
28235
|
+
function buildNothingProposedRecord(input) {
|
|
28236
|
+
return {
|
|
28237
|
+
version: 1,
|
|
28238
|
+
kind: "nothing-proposed",
|
|
28239
|
+
at: (input.now ?? new Date).toISOString(),
|
|
28240
|
+
session_id: input.sessionId,
|
|
28241
|
+
member: input.member
|
|
28242
|
+
};
|
|
28243
|
+
}
|
|
28244
|
+
function endsMidLine(path3) {
|
|
28245
|
+
let size;
|
|
28246
|
+
try {
|
|
28247
|
+
size = statSync2(path3).size;
|
|
28248
|
+
} catch {
|
|
28249
|
+
return false;
|
|
28250
|
+
}
|
|
28251
|
+
if (size === 0)
|
|
28252
|
+
return false;
|
|
28253
|
+
const fd = openSync(path3, "r");
|
|
28254
|
+
try {
|
|
28255
|
+
const last = Buffer.alloc(1);
|
|
28256
|
+
readSync(fd, last, 0, 1, size - 1);
|
|
28257
|
+
return last[0] !== 10;
|
|
28258
|
+
} finally {
|
|
28259
|
+
closeSync(fd);
|
|
28260
|
+
}
|
|
28261
|
+
}
|
|
28262
|
+
function appendMemoryGateRecord(workspaceSlug, record2) {
|
|
28263
|
+
const checked = MemoryGateRecordSchema.parse(record2);
|
|
28264
|
+
const path3 = memoryGateLogPath(workspaceSlug);
|
|
28265
|
+
mkdirSync7(memoryGateDir(workspaceSlug), { recursive: true, mode: 448 });
|
|
28266
|
+
const prefix = endsMidLine(path3) ? `
|
|
28267
|
+
` : "";
|
|
28268
|
+
appendFileSync(path3, `${prefix}${JSON.stringify(checked)}
|
|
28269
|
+
`, { mode: 384 });
|
|
28270
|
+
}
|
|
28271
|
+
function readMemoryGateLog(workspaceSlug) {
|
|
28272
|
+
let raw;
|
|
28273
|
+
try {
|
|
28274
|
+
raw = readFileSync8(memoryGateLogPath(workspaceSlug), "utf8");
|
|
28275
|
+
} catch {
|
|
28276
|
+
return { records: [], skipped: 0 };
|
|
28277
|
+
}
|
|
28278
|
+
const records = [];
|
|
28279
|
+
let skipped = 0;
|
|
28280
|
+
for (const line of raw.split(`
|
|
28281
|
+
`)) {
|
|
28282
|
+
if (line.trim().length === 0)
|
|
28283
|
+
continue;
|
|
28284
|
+
let json2;
|
|
28285
|
+
try {
|
|
28286
|
+
json2 = JSON.parse(line);
|
|
28287
|
+
} catch {
|
|
28288
|
+
skipped += 1;
|
|
28289
|
+
continue;
|
|
28290
|
+
}
|
|
28291
|
+
const parsed = MemoryGateRecordSchema.safeParse(json2);
|
|
28292
|
+
if (parsed.success)
|
|
28293
|
+
records.push(parsed.data);
|
|
28294
|
+
else
|
|
28295
|
+
skipped += 1;
|
|
28296
|
+
}
|
|
28297
|
+
return { records, skipped };
|
|
28298
|
+
}
|
|
28299
|
+
|
|
28300
|
+
// src/commands/memory-gate.ts
|
|
28301
|
+
init_output();
|
|
28302
|
+
function resolveTarget2(explicitSlug, explicitMember, loadConfigFn) {
|
|
28303
|
+
let config2;
|
|
28304
|
+
try {
|
|
28305
|
+
config2 = loadConfigFn();
|
|
28306
|
+
} catch {
|
|
28307
|
+
return { ok: false, message: "Config at ~/.plinth/config.json could not be read. Run `plinth login --workspace <slug>` first." };
|
|
28308
|
+
}
|
|
28309
|
+
if (!config2) {
|
|
28310
|
+
return { ok: false, message: "No workspace configured. Run `plinth login --workspace <slug>` first." };
|
|
28311
|
+
}
|
|
28312
|
+
const slug = explicitSlug ?? config2.active_workspace;
|
|
28313
|
+
const entry2 = config2.workspaces[slug];
|
|
28314
|
+
if (!entry2) {
|
|
28315
|
+
return {
|
|
28316
|
+
ok: false,
|
|
28317
|
+
message: explicitSlug === undefined ? "No workspace configured. Run `plinth login --workspace <slug>` first." : `Workspace "${slug}" not found. Run \`plinth login --workspace ${slug}\` first.`
|
|
28318
|
+
};
|
|
28319
|
+
}
|
|
28320
|
+
return { ok: true, slug, member: explicitMember ?? entry2.user_id ?? null };
|
|
28321
|
+
}
|
|
28322
|
+
function isDecision(value) {
|
|
28323
|
+
return MEMORY_GATE_DECISIONS.includes(value);
|
|
28324
|
+
}
|
|
28325
|
+
async function runMemoryGateLog(args, deps = {}) {
|
|
28326
|
+
const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
|
|
28327
|
+
const env3 = deps.env ?? process.env;
|
|
28328
|
+
const now = (deps.now ?? (() => new Date))();
|
|
28329
|
+
const nothing = args.nothingProposed === true;
|
|
28330
|
+
const hasDecisionArgs = args.proposed !== undefined || args.decision !== undefined;
|
|
28331
|
+
if (nothing && hasDecisionArgs) {
|
|
28332
|
+
out.error("--nothing-proposed records a close that proposed nothing; it cannot be combined with --proposed/--decision.");
|
|
28333
|
+
return { exitCode: 1 };
|
|
28334
|
+
}
|
|
28335
|
+
if (!nothing && !hasDecisionArgs) {
|
|
28336
|
+
out.error("Nothing to log. Pass --proposed and --decision for one decision, or --nothing-proposed for a close that had nothing to bank.");
|
|
28337
|
+
return { exitCode: 1 };
|
|
28338
|
+
}
|
|
28339
|
+
const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
|
|
28340
|
+
if (!target.ok) {
|
|
28341
|
+
out.error(target.message);
|
|
28342
|
+
return { exitCode: 1 };
|
|
28343
|
+
}
|
|
28344
|
+
const sessionId = typeof env3.CLAUDE_CODE_SESSION_ID === "string" && env3.CLAUDE_CODE_SESSION_ID.length > 0 ? env3.CLAUDE_CODE_SESSION_ID : null;
|
|
28345
|
+
const append = deps.appendFn ?? appendMemoryGateRecord;
|
|
28346
|
+
if (nothing) {
|
|
28347
|
+
try {
|
|
28348
|
+
append(target.slug, buildNothingProposedRecord({ sessionId, member: target.member, now }));
|
|
28349
|
+
} catch (err) {
|
|
28350
|
+
out.error(`Could not write the memory-gate log: ${err.message}`);
|
|
28351
|
+
return { exitCode: 1 };
|
|
28352
|
+
}
|
|
28353
|
+
out.info("Logged: this close proposed nothing for shared memory.");
|
|
28354
|
+
return { exitCode: 0 };
|
|
28355
|
+
}
|
|
28356
|
+
const proposed = args.proposed?.trim() ?? "";
|
|
28357
|
+
if (proposed.length === 0) {
|
|
28358
|
+
out.error("--proposed must carry the line as it was put to the user.");
|
|
28359
|
+
return { exitCode: 1 };
|
|
28360
|
+
}
|
|
28361
|
+
const decisionRaw = args.decision?.trim() ?? "";
|
|
28362
|
+
if (!isDecision(decisionRaw)) {
|
|
28363
|
+
out.error(`--decision must be one of ${MEMORY_GATE_DECISIONS.join(", ")} (got ${decisionRaw === "" ? "nothing" : `"${decisionRaw}"`}).`);
|
|
28364
|
+
return { exitCode: 1 };
|
|
28365
|
+
}
|
|
28366
|
+
const finalArg = args.final?.trim() ?? "";
|
|
28367
|
+
if (decisionRaw === "edit" && finalArg.length === 0) {
|
|
28368
|
+
out.error("--decision edit needs --final: the user's rewritten line is the record.");
|
|
28369
|
+
return { exitCode: 1 };
|
|
28370
|
+
}
|
|
28371
|
+
if (decisionRaw !== "edit" && finalArg.length > 0) {
|
|
28372
|
+
out.error(`--final applies to --decision edit only (accept records the proposed line, reject records none).`);
|
|
28373
|
+
return { exitCode: 1 };
|
|
28374
|
+
}
|
|
28375
|
+
try {
|
|
28376
|
+
append(target.slug, buildDecisionRecord({
|
|
28377
|
+
proposed,
|
|
28378
|
+
decision: decisionRaw,
|
|
28379
|
+
final: finalArg.length > 0 ? finalArg : null,
|
|
28380
|
+
sessionId,
|
|
28381
|
+
member: target.member,
|
|
28382
|
+
now
|
|
28383
|
+
}));
|
|
28384
|
+
} catch (err) {
|
|
28385
|
+
out.error(`Could not write the memory-gate log: ${err.message}`);
|
|
28386
|
+
return { exitCode: 1 };
|
|
28387
|
+
}
|
|
28388
|
+
out.info(`Logged ${decisionRaw} for shared memory (${target.slug}).`);
|
|
28389
|
+
return { exitCode: 0 };
|
|
28390
|
+
}
|
|
28391
|
+
async function runMemoryGateShow(args, deps = {}) {
|
|
28392
|
+
const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
|
|
28393
|
+
const target = resolveTarget2(args.workspace, args.member, deps.loadConfigFn ?? loadConfig);
|
|
28394
|
+
if (!target.ok) {
|
|
28395
|
+
out.error(target.message);
|
|
28396
|
+
return { exitCode: 1 };
|
|
28397
|
+
}
|
|
28398
|
+
const { records, skipped } = (deps.readFn ?? readMemoryGateLog)(target.slug);
|
|
28399
|
+
const limit = args.limit !== undefined && args.limit > 0 ? args.limit : undefined;
|
|
28400
|
+
const shown = limit === undefined ? records : records.slice(-limit);
|
|
28401
|
+
if (args.json) {
|
|
28402
|
+
out.info(JSON.stringify({
|
|
28403
|
+
workspace_slug: target.slug,
|
|
28404
|
+
path: memoryGateLogPath(target.slug),
|
|
28405
|
+
records: shown,
|
|
28406
|
+
withheld: records.length - shown.length,
|
|
28407
|
+
unreadable: skipped
|
|
28408
|
+
}, null, 2));
|
|
28409
|
+
return { exitCode: 0 };
|
|
28410
|
+
}
|
|
28411
|
+
if (shown.length === 0) {
|
|
28412
|
+
out.info(`No memory-gate records for ${target.slug}.`);
|
|
28413
|
+
}
|
|
28414
|
+
for (const record2 of shown) {
|
|
28415
|
+
if (record2.kind === "nothing-proposed") {
|
|
28416
|
+
out.info(`${record2.at} · nothing proposed`);
|
|
28417
|
+
continue;
|
|
28418
|
+
}
|
|
28419
|
+
const tail = record2.final === null ? "" : ` → ${record2.final}`;
|
|
28420
|
+
out.info(`${record2.at} · ${record2.decision} · ${record2.proposed}${tail}`);
|
|
28421
|
+
}
|
|
28422
|
+
if (records.length - shown.length > 0) {
|
|
28423
|
+
out.info(`${records.length - shown.length} earlier record(s) not shown (--limit).`);
|
|
28424
|
+
}
|
|
28425
|
+
if (skipped > 0) {
|
|
28426
|
+
out.info(`${skipped} line(s) could not be read and are not counted above.`);
|
|
28427
|
+
}
|
|
28428
|
+
return { exitCode: 0 };
|
|
28429
|
+
}
|
|
28430
|
+
var memoryGateCommand = defineCommand({
|
|
28431
|
+
meta: {
|
|
28432
|
+
name: "memory-gate",
|
|
28433
|
+
description: "Record what a /close ritual proposed for shared memory, and what the user decided"
|
|
28434
|
+
},
|
|
28435
|
+
subCommands: {
|
|
28436
|
+
log: defineCommand({
|
|
28437
|
+
meta: {
|
|
28438
|
+
name: "log",
|
|
28439
|
+
description: "Append one decision (or a nothing-proposed close) to the local memory-gate log. Machine-local: nothing under ~/.plinth syncs."
|
|
28440
|
+
},
|
|
28441
|
+
args: {
|
|
28442
|
+
workspace: { type: "string", description: "Workspace slug" },
|
|
28443
|
+
proposed: { type: "string", description: "The line as it was put to the user" },
|
|
28444
|
+
decision: { type: "string", description: `One of ${MEMORY_GATE_DECISIONS.join(", ")}` },
|
|
28445
|
+
final: { type: "string", description: "The user's rewritten line (--decision edit only)" },
|
|
28446
|
+
"nothing-proposed": {
|
|
28447
|
+
type: "boolean",
|
|
28448
|
+
description: "Record a close that proposed nothing for shared memory"
|
|
28449
|
+
},
|
|
28450
|
+
member: { type: "string", description: "Override the member id (defaults to this login's user)" },
|
|
28451
|
+
"no-color": { type: "boolean", description: "Disable colour" }
|
|
28452
|
+
},
|
|
28453
|
+
async run({ args }) {
|
|
28454
|
+
const result = await runMemoryGateLog({
|
|
28455
|
+
workspace: args.workspace,
|
|
28456
|
+
proposed: args.proposed,
|
|
28457
|
+
decision: args.decision,
|
|
28458
|
+
final: args.final,
|
|
28459
|
+
nothingProposed: args["nothing-proposed"],
|
|
28460
|
+
member: args.member
|
|
28461
|
+
}, { noColor: noColorFromArgs(args) });
|
|
28462
|
+
process.exitCode = result.exitCode;
|
|
28463
|
+
}
|
|
28464
|
+
}),
|
|
28465
|
+
show: defineCommand({
|
|
28466
|
+
meta: {
|
|
28467
|
+
name: "show",
|
|
28468
|
+
description: "Read the local memory-gate log back. Machine-local: nothing under ~/.plinth syncs."
|
|
28469
|
+
},
|
|
28470
|
+
args: {
|
|
28471
|
+
workspace: { type: "string", description: "Workspace slug" },
|
|
28472
|
+
json: { type: "boolean", description: "Machine-readable output" },
|
|
28473
|
+
limit: { type: "string", description: "Show only the most recent N records" },
|
|
28474
|
+
"no-color": { type: "boolean", description: "Disable colour" }
|
|
28475
|
+
},
|
|
28476
|
+
async run({ args }) {
|
|
28477
|
+
const rawLimit = args.limit;
|
|
28478
|
+
const parsed = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
|
|
28479
|
+
const result = await runMemoryGateShow({
|
|
28480
|
+
workspace: args.workspace,
|
|
28481
|
+
json: args.json,
|
|
28482
|
+
limit: parsed !== undefined && Number.isInteger(parsed) ? parsed : undefined
|
|
28483
|
+
}, { noColor: noColorFromArgs(args) });
|
|
28484
|
+
process.exitCode = result.exitCode;
|
|
28485
|
+
}
|
|
28486
|
+
})
|
|
28487
|
+
}
|
|
28488
|
+
});
|
|
28489
|
+
|
|
28490
|
+
// src/lib/context/artist-context.ts
|
|
28491
|
+
import { existsSync } from "node:fs";
|
|
28492
|
+
|
|
28493
|
+
// src/lib/context/command-paths.ts
|
|
28494
|
+
var WORKSPACE_COMMANDS_DIR = ".claude/commands/plinth";
|
|
28495
|
+
var WORKSPACE_COMMANDS_SUBFOLDER = WORKSPACE_COMMANDS_DIR.split("/").pop() ?? "";
|
|
28496
|
+
var START_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/start.md`;
|
|
28497
|
+
var CLOSE_COMMAND_REL = `${WORKSPACE_COMMANDS_DIR}/close.md`;
|
|
28498
|
+
function invocationFor(relPath) {
|
|
28499
|
+
const parts = relPath.split("/");
|
|
28500
|
+
const name = (parts.pop() ?? "").replace(/\.md$/, "");
|
|
28501
|
+
const namespace = parts.pop() ?? "";
|
|
28502
|
+
return `/${namespace}:${name}`;
|
|
28503
|
+
}
|
|
28504
|
+
var START_COMMAND_INVOCATION = invocationFor(START_COMMAND_REL);
|
|
28505
|
+
var CLOSE_COMMAND_INVOCATION = invocationFor(CLOSE_COMMAND_REL);
|
|
28506
|
+
|
|
28507
|
+
// src/lib/context/entity-catalog.ts
|
|
28508
|
+
var ENTITY_CATALOG = [
|
|
28509
|
+
{ label: "artists", blurb: "roster acts; one folder per artist under `artists/<slug>/`", file: true },
|
|
28510
|
+
{ label: "projects", blurb: "campaigns, releases, and workstreams", file: true },
|
|
28511
|
+
{ label: "tasks", blurb: "actionable to-dos, optionally tied to an artist or project", file: true },
|
|
28512
|
+
{ label: "meetings + meeting_notes", blurb: "meeting records and their attached notes", file: true },
|
|
28513
|
+
{ label: "documents", blurb: "notes, briefs, bios, transcripts, context docs (markdown bodies, not attachments)", file: true },
|
|
28514
|
+
{
|
|
28515
|
+
label: "workspace_files",
|
|
28516
|
+
blurb: "the free-form document store: PDFs, executed agreements, spreadsheets, " + "anything that is a FILE rather than an entity. Mirrored byte-for-byte " + "both ways under `files/`, folders and all. Look here for contracts",
|
|
28517
|
+
file: true
|
|
28518
|
+
},
|
|
28519
|
+
{
|
|
28520
|
+
label: "threads + thread_messages",
|
|
28521
|
+
blurb: "threaded discussions, one folder per thread anchored at " + "`threads/<slug>/_thread.md`; messages live in Plinth, not the mirror, and " + "the anchor's own body is a local scratchpad that never pushes",
|
|
28522
|
+
file: true
|
|
28523
|
+
},
|
|
28524
|
+
{ label: "key_dates", blurb: "calendar milestones and release dates", file: false },
|
|
28525
|
+
{ label: "comments", blurb: "comments anchored to any entity", file: false },
|
|
28526
|
+
{ label: "tracks", blurb: "track and release metadata", file: false },
|
|
28527
|
+
{ label: "social_posts", blurb: "planned and published social content", file: false },
|
|
28528
|
+
{ label: "useful_links", blurb: "bookmarked links per artist or workspace", file: false },
|
|
28529
|
+
{ label: "financial_entries", blurb: "income and cost ledger rows", file: false },
|
|
28530
|
+
{ label: "commissions", blurb: "agent, sync, streaming, publishing & other statements", file: false }
|
|
28531
|
+
];
|
|
28532
|
+
var MCP_INTROSPECTION_POINTER = "ask the `mcp__plinth__*` tools for exact fields & enum values (the " + "platform is the schema source of truth)";
|
|
28533
|
+
var MCP_AVAILABILITY_POINTER = "Other MCP servers may be connected on this machine: `claude mcp list` " + "names them and their connection status, though that command is blocked " + "inside the Plinth cockpit (it denies `Bash(claude:*)`), so in the cockpit " + "ask the user instead of concluding there are none. Plinth neither " + "configures nor ships them.";
|
|
28534
|
+
var SKILL_LOCATIONS = [
|
|
28535
|
+
{
|
|
28536
|
+
label: "workspace",
|
|
28537
|
+
detail: "`.claude/commands/` in this folder; this machine only, the daemon does not sync it. " + `Plinth generates two into \`${WORKSPACE_COMMANDS_SUBFOLDER}/\` on every sync: ` + `**${START_COMMAND_INVOCATION}** to open a session and **${CLOSE_COMMAND_INVOCATION}** to ` + "bank what it learned before the context is gone. Anything else in the folder is yours and is never touched"
|
|
28538
|
+
},
|
|
28539
|
+
{ label: "personal", detail: "`~/.claude/commands/`; your own, available everywhere" }
|
|
28540
|
+
];
|
|
28541
|
+
var SECURITY_NOTE = "Scoped to a single Plinth workspace: do not read or write across " + "workspace folders; each has its own token and isolation boundary.";
|
|
28542
|
+
|
|
28543
|
+
// src/lib/context/generated-at.ts
|
|
28544
|
+
var GENERATED_AT_PLACEHOLDER = "{{plinth-generated-at}}";
|
|
28545
|
+
var GENERATED_AT_PREFIX = "Content built ";
|
|
28546
|
+
var STAMP_RE = /\d{4}-\d{2}-\d{2} \d{2}:\d{2} [+-]\d{2}:\d{2}/;
|
|
28547
|
+
function escapeRegExp(literal2) {
|
|
28548
|
+
return literal2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
28549
|
+
}
|
|
28550
|
+
var STAMP_SLOT_RE = new RegExp(escapeRegExp(GENERATED_AT_PREFIX) + "(?:" + STAMP_RE.source + "|" + escapeRegExp(GENERATED_AT_PLACEHOLDER) + ")");
|
|
28551
|
+
function pad2(n2) {
|
|
28552
|
+
return String(n2).padStart(2, "0");
|
|
28553
|
+
}
|
|
28554
|
+
function formatGeneratedAt(d2) {
|
|
28555
|
+
const date5 = `${d2.getFullYear()}-${pad2(d2.getMonth() + 1)}-${pad2(d2.getDate())}`;
|
|
28556
|
+
const time3 = `${pad2(d2.getHours())}:${pad2(d2.getMinutes())}`;
|
|
28557
|
+
const offsetMinutes = -d2.getTimezoneOffset();
|
|
28558
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
28559
|
+
const abs = Math.abs(offsetMinutes);
|
|
28560
|
+
return `${date5} ${time3} ${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
|
|
28561
|
+
}
|
|
28562
|
+
function renderGeneratedAt(content, at) {
|
|
28563
|
+
return content.replace(GENERATED_AT_PLACEHOLDER, formatGeneratedAt(at));
|
|
28564
|
+
}
|
|
28565
|
+
function normaliseGeneratedAt(content) {
|
|
28566
|
+
return content.replace(STAMP_SLOT_RE, GENERATED_AT_PREFIX + GENERATED_AT_PLACEHOLDER);
|
|
28567
|
+
}
|
|
28568
|
+
|
|
28569
|
+
// src/lib/context/markers.ts
|
|
28570
|
+
var USER_BLOCK_BEGIN = "<!-- BEGIN: user-customisable -->";
|
|
28571
|
+
var USER_BLOCK_END = "<!-- END -->";
|
|
28572
|
+
var USER_BLOCK_PLACEHOLDER = "_Add workspace-specific guidance here. This block is the only part of " + "the file Plinth preserves across regenerations._";
|
|
28573
|
+
function extractUserBlock(content) {
|
|
28574
|
+
const beginIdx = content.indexOf(USER_BLOCK_BEGIN);
|
|
28575
|
+
if (beginIdx === -1)
|
|
28576
|
+
return null;
|
|
28577
|
+
const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
|
|
28578
|
+
const endIdx = content.indexOf(USER_BLOCK_END, afterBegin);
|
|
28579
|
+
if (endIdx === -1)
|
|
28580
|
+
return null;
|
|
28581
|
+
return content.slice(afterBegin, endIdx);
|
|
28582
|
+
}
|
|
28583
|
+
function injectUserBlock(generated, preserved) {
|
|
28584
|
+
const beginIdx = generated.indexOf(USER_BLOCK_BEGIN);
|
|
28585
|
+
if (beginIdx === -1) {
|
|
28586
|
+
throw new Error("injectUserBlock: generated content has no BEGIN marker");
|
|
28587
|
+
}
|
|
28588
|
+
const afterBegin = beginIdx + USER_BLOCK_BEGIN.length;
|
|
28589
|
+
const endIdx = generated.indexOf(USER_BLOCK_END, afterBegin);
|
|
28590
|
+
if (endIdx === -1) {
|
|
28591
|
+
throw new Error("injectUserBlock: generated content has no END marker");
|
|
28592
|
+
}
|
|
28593
|
+
return generated.slice(0, afterBegin) + preserved + generated.slice(endIdx);
|
|
28594
|
+
}
|
|
28595
|
+
|
|
28596
|
+
// src/lib/context/artist-claude-md.ts
|
|
28597
|
+
var CONTEXT_VERSION_HEADER = "plinth-context-version";
|
|
28598
|
+
var TASK_DISPLAY_CAP = 10;
|
|
28599
|
+
var PRIORITY_RANK = {
|
|
28600
|
+
urgent: 0,
|
|
28601
|
+
high: 1,
|
|
28602
|
+
medium: 2,
|
|
28603
|
+
low: 3
|
|
28604
|
+
};
|
|
28605
|
+
function buildArtistClaudeMd(input) {
|
|
28606
|
+
const body = buildBody(input);
|
|
28607
|
+
const version2 = stableHash(body).slice(0, 12);
|
|
28608
|
+
return `<!-- ${CONTEXT_VERSION_HEADER}: ${version2} -->
|
|
28609
|
+
${body}`;
|
|
28610
|
+
}
|
|
28611
|
+
function buildBody(input) {
|
|
28612
|
+
const { slug, name, status, uuid: uuid3, profile, projects, tasks } = input;
|
|
28613
|
+
const lines = [];
|
|
28614
|
+
lines.push(`<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`);
|
|
28615
|
+
lines.push("");
|
|
28616
|
+
lines.push(`# ${name} · Plinth artist`);
|
|
28617
|
+
lines.push("");
|
|
28618
|
+
const statusPart = status ? `, status ${status}` : "";
|
|
28619
|
+
const bioPart = profile.bio ? ` ${profile.bio}` : "";
|
|
28620
|
+
lines.push(`${name} (artist \`${slug}\`${statusPart}).${bioPart}`);
|
|
28621
|
+
lines.push(`Working dir for ${name}-scoped AI sessions. ${SECURITY_NOTE}`);
|
|
28622
|
+
lines.push("");
|
|
28623
|
+
const links = buildLinks(profile);
|
|
28624
|
+
if (links) {
|
|
28625
|
+
lines.push(`## Links`);
|
|
28626
|
+
lines.push(links);
|
|
28627
|
+
lines.push("");
|
|
28628
|
+
}
|
|
28629
|
+
const sortedProjects = sortProjects(projects);
|
|
27848
28630
|
const names = sortedProjects.map((p) => p.name).join(", ");
|
|
27849
28631
|
lines.push(`## Active projects (${sortedProjects.length})${names ? `: ${names}` : ""}`);
|
|
27850
28632
|
if (sortedProjects.length === 0) {
|
|
@@ -27877,7 +28659,8 @@ function buildBody(input) {
|
|
|
27877
28659
|
lines.push(`- Master context (stable profile + current state) → \`./_artist.md\`; edit its body and the daemon syncs both ways. Blocks labelled "rendered from Plinth" are the exception: they are overwritten on the next sync`);
|
|
27878
28660
|
lines.push(`- Closed/archived, meetings & full lists: \`mcp__plinth__list_projects\` / \`mcp__plinth__list_tasks\`${artistIdHint(uuid3)}`);
|
|
27879
28661
|
lines.push(`- Schema: ${MCP_INTROSPECTION_POINTER}`);
|
|
27880
|
-
lines.push(`- Long-term facts → \`./memory.md
|
|
28662
|
+
lines.push(`- Long-term facts → \`./memory.md\`, which syncs to this artist's Plinth record. Most artists have none yet: the file appears once the record carries memory, and creating it here is how you start one. One line per fact with its source and an absolute date; when a fact goes wrong, edit that line rather than adding a second`);
|
|
28663
|
+
lines.push(`- Rolling day-to-day activity → \`./daily-log.md\`, local to this machine and never synced. Durable facts do not belong here; they belong in \`./memory.md\` above`);
|
|
27881
28664
|
lines.push("");
|
|
27882
28665
|
lines.push(USER_BLOCK_BEGIN);
|
|
27883
28666
|
lines.push(USER_BLOCK_PLACEHOLDER);
|
|
@@ -27950,7 +28733,7 @@ function datePart(value) {
|
|
|
27950
28733
|
|
|
27951
28734
|
// src/lib/context/artist-data.ts
|
|
27952
28735
|
var import_gray_matter5 = __toESM(require_gray_matter(), 1);
|
|
27953
|
-
import { readFileSync as
|
|
28736
|
+
import { readFileSync as readFileSync9 } from "node:fs";
|
|
27954
28737
|
function artistUuidBySlug(state) {
|
|
27955
28738
|
const map2 = new Map;
|
|
27956
28739
|
for (const entry2 of Object.values(state.entries.artists ?? {})) {
|
|
@@ -28062,7 +28845,7 @@ function readEntityFrontmatter(workspaceSlug, entityType, slug) {
|
|
|
28062
28845
|
}
|
|
28063
28846
|
let raw;
|
|
28064
28847
|
try {
|
|
28065
|
-
raw =
|
|
28848
|
+
raw = readFileSync9(path3, "utf8");
|
|
28066
28849
|
} catch {
|
|
28067
28850
|
return null;
|
|
28068
28851
|
}
|
|
@@ -28090,6 +28873,7 @@ function readSchemaVersion(content) {
|
|
|
28090
28873
|
// src/lib/context/regen.ts
|
|
28091
28874
|
function decideRegen(input) {
|
|
28092
28875
|
const { existing, currentVersion, generated, force } = input;
|
|
28876
|
+
const normalise = input.normalise ?? ((c3) => c3);
|
|
28093
28877
|
if (existing === null || existing.trim() === "") {
|
|
28094
28878
|
return { action: "create", content: generated };
|
|
28095
28879
|
}
|
|
@@ -28103,7 +28887,7 @@ function decideRegen(input) {
|
|
|
28103
28887
|
return { action: "backup-regen", content: generated, backup: existing };
|
|
28104
28888
|
}
|
|
28105
28889
|
const merged = injectUserBlock(generated, preserved);
|
|
28106
|
-
if (merged === existing) {
|
|
28890
|
+
if (normalise(merged) === normalise(existing)) {
|
|
28107
28891
|
return { action: "skip", content: null };
|
|
28108
28892
|
}
|
|
28109
28893
|
return { action: "regen", content: merged };
|
|
@@ -28111,18 +28895,18 @@ function decideRegen(input) {
|
|
|
28111
28895
|
|
|
28112
28896
|
// src/lib/context/roster.ts
|
|
28113
28897
|
var import_gray_matter6 = __toESM(require_gray_matter(), 1);
|
|
28114
|
-
import { readdirSync as
|
|
28115
|
-
import { join as
|
|
28898
|
+
import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
|
|
28899
|
+
import { join as join8 } from "node:path";
|
|
28116
28900
|
function readRoster(workspaceSlug) {
|
|
28117
28901
|
let artistsDir;
|
|
28118
28902
|
try {
|
|
28119
|
-
artistsDir =
|
|
28903
|
+
artistsDir = join8(workspaceMirrorPath(workspaceSlug), "artists");
|
|
28120
28904
|
} catch {
|
|
28121
28905
|
return [];
|
|
28122
28906
|
}
|
|
28123
28907
|
let slugs;
|
|
28124
28908
|
try {
|
|
28125
|
-
slugs =
|
|
28909
|
+
slugs = readdirSync4(artistsDir, { withFileTypes: true }).filter((d2) => d2.isDirectory()).map((d2) => d2.name).sort();
|
|
28126
28910
|
} catch {
|
|
28127
28911
|
return [];
|
|
28128
28912
|
}
|
|
@@ -28130,7 +28914,7 @@ function readRoster(workspaceSlug) {
|
|
|
28130
28914
|
for (const slug of slugs) {
|
|
28131
28915
|
let raw;
|
|
28132
28916
|
try {
|
|
28133
|
-
raw =
|
|
28917
|
+
raw = readFileSync10(join8(artistsDir, slug, "_artist.md"), "utf8");
|
|
28134
28918
|
} catch {
|
|
28135
28919
|
continue;
|
|
28136
28920
|
}
|
|
@@ -28147,9 +28931,9 @@ function readRoster(workspaceSlug) {
|
|
|
28147
28931
|
}
|
|
28148
28932
|
|
|
28149
28933
|
// src/lib/context/session-hook.ts
|
|
28150
|
-
import { execFileSync } from "node:child_process";
|
|
28151
|
-
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
28152
|
-
import { basename } from "node:path";
|
|
28934
|
+
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
28935
|
+
import { accessSync, constants, realpathSync, statSync as statSync3 } from "node:fs";
|
|
28936
|
+
import { basename as basename2 } from "node:path";
|
|
28153
28937
|
var SETTINGS_REL = ".claude/settings.json";
|
|
28154
28938
|
var MANAGED_KEY = "_plinth";
|
|
28155
28939
|
var COMMENT_SENTINEL = 'Managed by plinth-cli. Entries tagged "_plinth" are REGENERATED on every sync:';
|
|
@@ -28157,8 +28941,32 @@ var MANAGED_COMMENT = [
|
|
|
28157
28941
|
COMMENT_SENTINEL,
|
|
28158
28942
|
"edits to them are overwritten without warning. Add your own hooks as separate",
|
|
28159
28943
|
"entries in this file (they are preserved verbatim, including their matchers)",
|
|
28160
|
-
"or in .claude/settings.local.json."
|
|
28944
|
+
"or in .claude/settings.local.json.",
|
|
28945
|
+
"The permissions.deny entries below are managed the same way. They stop an agent",
|
|
28946
|
+
"in this workspace sending, replying to or forwarding mail and writing a calendar;",
|
|
28947
|
+
"drafting and reading stay allowed. Your own allow list is untouched, and your own",
|
|
28948
|
+
"deny rules are kept and listed first. One exception, stated because it is",
|
|
28949
|
+
"visible: if you write a deny rule whose text is identical to one of ours, it is",
|
|
28950
|
+
"kept once, in our section, rather than twice. A deny always beats an allow,",
|
|
28951
|
+
"including your own."
|
|
28161
28952
|
];
|
|
28953
|
+
var MANAGED_DENY = [
|
|
28954
|
+
"mcp__claude_ai_Gmail__send_message",
|
|
28955
|
+
"mcp__claude_ai_Gmail__reply",
|
|
28956
|
+
"mcp__claude_ai_Gmail__forward",
|
|
28957
|
+
"mcp__claude_ai_Google_Calendar__create_event",
|
|
28958
|
+
"mcp__claude_ai_Google_Calendar__update_event",
|
|
28959
|
+
"mcp__claude_ai_Google_Calendar__delete_event",
|
|
28960
|
+
"mcp__claude_ai_Google_Calendar__respond_to_event",
|
|
28961
|
+
"mcp__*__send_message",
|
|
28962
|
+
"mcp__*__reply",
|
|
28963
|
+
"mcp__*__forward",
|
|
28964
|
+
"mcp__*__create_event",
|
|
28965
|
+
"mcp__*__update_event",
|
|
28966
|
+
"mcp__*__delete_event",
|
|
28967
|
+
"mcp__*__respond_to_event"
|
|
28968
|
+
];
|
|
28969
|
+
var RETIRED_DENY = [];
|
|
28162
28970
|
var BIN_SENTINEL = "__PLINTH_BIN__";
|
|
28163
28971
|
var ALL_SENTINEL = "__PLINTH_ALL__";
|
|
28164
28972
|
var RESOLVE_SCRIPT = `printf '%s
|
|
@@ -28189,7 +28997,7 @@ function resolveOverride(override, deps) {
|
|
|
28189
28997
|
}
|
|
28190
28998
|
function resolveUncached(deps) {
|
|
28191
28999
|
const shell = deps.shell ?? process.env.SHELL ?? "/bin/zsh";
|
|
28192
|
-
const exec = deps.exec ?? ((sh, args) =>
|
|
29000
|
+
const exec = deps.exec ?? ((sh, args) => execFileSync2(sh, args, {
|
|
28193
29001
|
encoding: "utf8",
|
|
28194
29002
|
timeout: 5000,
|
|
28195
29003
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -28252,13 +29060,13 @@ function probe(run, usable) {
|
|
|
28252
29060
|
function isPlinthBinary(candidate, usable) {
|
|
28253
29061
|
if (!candidate.startsWith("/"))
|
|
28254
29062
|
return false;
|
|
28255
|
-
if (
|
|
29063
|
+
if (basename2(candidate) !== "plinth")
|
|
28256
29064
|
return false;
|
|
28257
29065
|
return usable(candidate);
|
|
28258
29066
|
}
|
|
28259
29067
|
function defaultIsExecutableFile(path3) {
|
|
28260
29068
|
try {
|
|
28261
|
-
if (!
|
|
29069
|
+
if (!statSync3(path3).isFile())
|
|
28262
29070
|
return false;
|
|
28263
29071
|
accessSync(path3, constants.X_OK);
|
|
28264
29072
|
return true;
|
|
@@ -28269,10 +29077,6 @@ function defaultIsExecutableFile(path3) {
|
|
|
28269
29077
|
function shellQuote(value) {
|
|
28270
29078
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
28271
29079
|
}
|
|
28272
|
-
var VOICE_GATE_MATCHERS = [
|
|
28273
|
-
"Bash",
|
|
28274
|
-
"mcp__.*[Gg]mail.*__(create|update)_draft"
|
|
28275
|
-
];
|
|
28276
29080
|
function managedEntry(id, invocation, subcommand, workspaceSlug) {
|
|
28277
29081
|
return {
|
|
28278
29082
|
type: "command",
|
|
@@ -28284,24 +29088,41 @@ function buildManagedBlocks(invocation, workspaceSlug) {
|
|
|
28284
29088
|
return {
|
|
28285
29089
|
SessionStart: [
|
|
28286
29090
|
{
|
|
28287
|
-
hooks: [
|
|
29091
|
+
hooks: [
|
|
29092
|
+
managedEntry("grounding", invocation, "grounding", workspaceSlug),
|
|
29093
|
+
managedEntry("sessions", invocation, "session register", workspaceSlug)
|
|
29094
|
+
]
|
|
28288
29095
|
}
|
|
28289
|
-
]
|
|
28290
|
-
PreToolUse: VOICE_GATE_MATCHERS.map((matcher) => ({
|
|
28291
|
-
matcher,
|
|
28292
|
-
hooks: [
|
|
28293
|
-
managedEntry("voice-gate", invocation, "voice-gate --hook", workspaceSlug)
|
|
28294
|
-
]
|
|
28295
|
-
}))
|
|
29096
|
+
]
|
|
28296
29097
|
};
|
|
28297
29098
|
}
|
|
29099
|
+
function mergePermissions(existing) {
|
|
29100
|
+
if (existing !== undefined && existing !== null && !isEntry(existing)) {
|
|
29101
|
+
throw new Error("`permissions` is not a JSON object");
|
|
29102
|
+
}
|
|
29103
|
+
const base2 = existing ? { ...existing } : {};
|
|
29104
|
+
const currentDeny = base2.deny;
|
|
29105
|
+
if (currentDeny !== undefined && currentDeny !== null && !Array.isArray(currentDeny)) {
|
|
29106
|
+
throw new Error("`permissions.deny` is not an array");
|
|
29107
|
+
}
|
|
29108
|
+
const ours = new Set([...MANAGED_DENY, ...RETIRED_DENY]);
|
|
29109
|
+
const kept = (Array.isArray(currentDeny) ? currentDeny : []).filter((rule) => typeof rule !== "string" || !ours.has(rule));
|
|
29110
|
+
base2.deny = [...kept, ...MANAGED_DENY];
|
|
29111
|
+
return base2;
|
|
29112
|
+
}
|
|
28298
29113
|
var LEGACY_GROUNDING = /^'[^']*'\s+'[^']*'\s+grounding(\s|$)/;
|
|
29114
|
+
var OWNED_EVENTS = {
|
|
29115
|
+
grounding: "SessionStart",
|
|
29116
|
+
sessions: "SessionStart",
|
|
29117
|
+
"voice-gate": "PreToolUse"
|
|
29118
|
+
};
|
|
28299
29119
|
function isManaged(entry2, event) {
|
|
28300
29120
|
if (!isEntry(entry2))
|
|
28301
29121
|
return false;
|
|
28302
29122
|
const marker = entry2[MANAGED_KEY];
|
|
28303
|
-
if (marker === "
|
|
29123
|
+
if (typeof marker === "string" && OWNED_EVENTS[marker] === event) {
|
|
28304
29124
|
return true;
|
|
29125
|
+
}
|
|
28305
29126
|
if (event === "SessionStart" && typeof entry2.command === "string") {
|
|
28306
29127
|
return LEGACY_GROUNDING.test(entry2.command);
|
|
28307
29128
|
}
|
|
@@ -28317,17 +29138,18 @@ function mergeSettings(existing, managed) {
|
|
|
28317
29138
|
const base2 = existing ? { ...existing } : {};
|
|
28318
29139
|
const existingHooks = typeof base2.hooks === "object" && base2.hooks !== null && !Array.isArray(base2.hooks) ? base2.hooks : {};
|
|
28319
29140
|
const hooks = {};
|
|
28320
|
-
|
|
28321
|
-
|
|
28322
|
-
|
|
28323
|
-
for (const [event, managedBlocks] of Object.entries(managed)) {
|
|
28324
|
-
const kept = asArray(hooks[event]).filter(isEntry).map((block) => ({
|
|
29141
|
+
const events = new Set([...Object.keys(existingHooks), ...Object.keys(managed)]);
|
|
29142
|
+
for (const event of events) {
|
|
29143
|
+
const kept = asArray(existingHooks[event]).filter(isEntry).map((block) => ({
|
|
28325
29144
|
...block,
|
|
28326
29145
|
hooks: asArray(block.hooks).filter((entry2) => isEntry(entry2) && !isManaged(entry2, event))
|
|
28327
29146
|
})).filter((block) => block.hooks.length > 0);
|
|
28328
|
-
|
|
29147
|
+
const merged = [...kept, ...managed[event] ?? []];
|
|
29148
|
+
if (merged.length > 0)
|
|
29149
|
+
hooks[event] = merged;
|
|
28329
29150
|
}
|
|
28330
29151
|
base2.hooks = hooks;
|
|
29152
|
+
base2.permissions = mergePermissions(base2.permissions);
|
|
28331
29153
|
const current = base2.$comment;
|
|
28332
29154
|
const isOurs = Array.isArray(current) && current[0] === COMMENT_SENTINEL;
|
|
28333
29155
|
if (current === undefined || isOurs) {
|
|
@@ -28355,7 +29177,7 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
|
|
|
28355
29177
|
parsed = raw;
|
|
28356
29178
|
} catch (err) {
|
|
28357
29179
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
28358
|
-
out.error(`${rel} could not be parsed (${msg}), so the Plinth session-grounding ` + `
|
|
29180
|
+
out.error(`${rel} could not be parsed (${msg}), so the Plinth session-grounding ` + `hook, the session-visibility hook and the permissions.deny list were ` + `NOT installed there. Fix or remove the file and re-run — until then ` + `this workspace is ungrounded and an agent here can SEND mail and ` + `write calendars.`);
|
|
28359
29181
|
return "skipped";
|
|
28360
29182
|
}
|
|
28361
29183
|
}
|
|
@@ -28364,16 +29186,16 @@ function ensureSessionHook(workspaceSlug, baseRelDir, out, resolved) {
|
|
|
28364
29186
|
generated = buildSettingsJson(resolved.invocation, workspaceSlug, parsed);
|
|
28365
29187
|
} catch (err) {
|
|
28366
29188
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
28367
|
-
out.error(`${rel} has a structure the generator could not merge into (${msg}), so ` + `the Plinth session-grounding
|
|
29189
|
+
out.error(`${rel} has a structure the generator could not merge into (${msg}), so ` + `the Plinth session-grounding hook, the session-visibility hook and the ` + `permissions.deny list were NOT installed there. This workspace is ` + `ungrounded and an agent here can SEND mail and write calendars. Fix or ` + `remove the file and re-run.`);
|
|
28368
29190
|
return "skipped";
|
|
28369
29191
|
}
|
|
28370
29192
|
if (existing === generated)
|
|
28371
29193
|
return "unchanged";
|
|
28372
29194
|
writeContextFile(workspaceSlug, rel, generated);
|
|
28373
29195
|
if (existing === null) {
|
|
28374
|
-
out.info(`Generated Plinth session
|
|
29196
|
+
out.info(`Generated Plinth session settings (${rel}).`);
|
|
28375
29197
|
} else {
|
|
28376
|
-
out.info(`Refreshed the Plinth session
|
|
29198
|
+
out.info(`Refreshed the Plinth session settings in ${rel} (your entries kept).`);
|
|
28377
29199
|
}
|
|
28378
29200
|
return "written";
|
|
28379
29201
|
}
|
|
@@ -28390,7 +29212,7 @@ function reportResolution(out, resolved) {
|
|
|
28390
29212
|
}
|
|
28391
29213
|
}
|
|
28392
29214
|
function warnUnresolvedBinary(out) {
|
|
28393
|
-
out.error("Could not locate an installed `plinth` binary, so no Claude Code
|
|
29215
|
+
out.error("Could not locate an installed `plinth` binary, so no Claude Code settings " + "were written for this workspace — sessions will be UNGROUNDED and an " + "agent here can SEND mail and write calendars. Install the CLI globally " + "(`npm i -g @plinth-music/cli`) so `command -v plinth` resolves, then " + "re-run `plinth refresh-context`.");
|
|
28394
29216
|
}
|
|
28395
29217
|
|
|
28396
29218
|
// src/lib/context/artist-context.ts
|
|
@@ -28441,35 +29263,249 @@ function ensurePerArtistContext(deps) {
|
|
|
28441
29263
|
function regenerateClaudeMd(workspaceSlug, artistSlug, generated, force, out, now) {
|
|
28442
29264
|
const claudeRel = `artists/${artistSlug}/${ARTIST_CLAUDE_MD}`;
|
|
28443
29265
|
const existing = readContextFile(workspaceSlug, claudeRel);
|
|
28444
|
-
const decision = decideRegen({
|
|
29266
|
+
const decision = decideRegen({
|
|
29267
|
+
existing,
|
|
29268
|
+
currentVersion: "",
|
|
29269
|
+
generated,
|
|
29270
|
+
force,
|
|
29271
|
+
normalise: normaliseGeneratedAt
|
|
29272
|
+
});
|
|
28445
29273
|
if (decision.action === "backup-regen" && decision.backup !== undefined) {
|
|
28446
29274
|
const bakRel = `${claudeRel}.bak-${backupStamp(now())}`;
|
|
28447
29275
|
writeContextFile(workspaceSlug, bakRel, decision.backup);
|
|
28448
29276
|
out.warn(`artists/${artistSlug}/${ARTIST_CLAUDE_MD} had no recognisable BEGIN/END ` + `markers; backed your copy up to ${bakRel} before regenerating.`);
|
|
28449
29277
|
}
|
|
28450
|
-
if (decision.content !== null) {
|
|
28451
|
-
writeContextFile(workspaceSlug, claudeRel, decision.content);
|
|
29278
|
+
if (decision.content !== null) {
|
|
29279
|
+
writeContextFile(workspaceSlug, claudeRel, renderGeneratedAt(decision.content, now()));
|
|
29280
|
+
}
|
|
29281
|
+
return decision.action === "create";
|
|
29282
|
+
}
|
|
29283
|
+
function ensureStub(workspaceSlug, relativePath, content) {
|
|
29284
|
+
if (existsSync(contextFilePath(workspaceSlug, relativePath)))
|
|
29285
|
+
return;
|
|
29286
|
+
writeContextFile(workspaceSlug, relativePath, content);
|
|
29287
|
+
}
|
|
29288
|
+
function dailyLogStub(name) {
|
|
29289
|
+
return `# ${name} - Daily log
|
|
29290
|
+
|
|
29291
|
+
` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
|
|
29292
|
+
` + `<!-- Durable facts do not belong here - they go in ./memory.md, which syncs to this artist's Plinth record. -->
|
|
29293
|
+
`;
|
|
29294
|
+
}
|
|
29295
|
+
function backupStamp(d2) {
|
|
29296
|
+
return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
|
|
29297
|
+
}
|
|
29298
|
+
|
|
29299
|
+
// src/lib/context/generate.ts
|
|
29300
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
29301
|
+
|
|
29302
|
+
// src/lib/context/skill-commands.ts
|
|
29303
|
+
var COMMAND_USER_BLOCK_PLACEHOLDER = "_This workspace's own steps go here: anything specific to your team, your " + "tools or your data. Plinth preserves this block verbatim on every " + "regeneration; everything outside it is overwritten._";
|
|
29304
|
+
var OWNERSHIP_NOTE = "<!-- Auto-generated by Plinth into every mirror on every sync. Edit only " + "between the BEGIN/END markers at the foot; everything else is overwritten. " + "Your own commands in `.claude/commands/` are never read or touched by " + "Plinth, only this `plinth/` folder is generated. -->";
|
|
29305
|
+
function withUserBlock(lines) {
|
|
29306
|
+
return [...lines, "", USER_BLOCK_BEGIN, COMMAND_USER_BLOCK_PLACEHOLDER, USER_BLOCK_END].join(`
|
|
29307
|
+
`) + `
|
|
29308
|
+
`;
|
|
29309
|
+
}
|
|
29310
|
+
function buildStartCommandMd() {
|
|
29311
|
+
return withUserBlock([
|
|
29312
|
+
"---",
|
|
29313
|
+
"description: Open a Plinth session in this workspace. Reads both memory files, pulls what is unread, reads the projected calendar, gives a three-line brief, then asks what you want to work on.",
|
|
29314
|
+
"---",
|
|
29315
|
+
"",
|
|
29316
|
+
OWNERSHIP_NOTE,
|
|
29317
|
+
"",
|
|
29318
|
+
"# Start",
|
|
29319
|
+
"",
|
|
29320
|
+
"Get oriented in this Plinth workspace in a few seconds, then load context for whatever the user names.",
|
|
29321
|
+
"",
|
|
29322
|
+
"**Do not re-run `date`, and do not restate the grounding rules.** The Plinth SessionStart hook has already injected today's date, the entity-resolution rule and the write-confirmation rule, and the workspace `CLAUDE.md` (roster and folder map) is already in context. Repeating them spends the budget this command exists to protect.",
|
|
29323
|
+
"",
|
|
29324
|
+
"## Step 1: use the two memory indexes you were already given",
|
|
29325
|
+
"",
|
|
29326
|
+
"The SessionStart hook injects **both memory indexes in full**, above: the workspace index (durable facts and conventions that hold for anyone here) and your personal index (facts true of **this member**: their email address, how they write, how they work). **Do not re-read `workspace-memory.md` or `user-memory.md`.** They are already in this context window, and re-reading them is the single most expensive thing this command could do for nothing.",
|
|
29327
|
+
"",
|
|
29328
|
+
"**Use the second one. It is the one that gets skipped**, and it is the one carrying the member's own identity, including the email address that every task assignment needs. An agent that ignores it asks the user for something the workspace already told it.",
|
|
29329
|
+
"",
|
|
29330
|
+
"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.",
|
|
29331
|
+
"",
|
|
29332
|
+
"## Step 2: what has landed",
|
|
29333
|
+
"",
|
|
29334
|
+
"Pull **unread notifications** from the Plinth MCP (`list_notifications`). If the Plinth MCP is not connected, say so in one line and carry on from the injected indexes rather than failing.",
|
|
29335
|
+
"",
|
|
29336
|
+
"**That one call plus the single file read in Step 3 are the whole budget, and the list below is exhaustive rather than indicative.** Do not sweep tasks, do not list projects, do not open a calendar, do not read an artist folder, and do not re-read the memory files. If a fact is not in the two injected indexes, in the notifications, or in that one file, it does not belong in this brief.",
|
|
29337
|
+
"",
|
|
29338
|
+
"## Step 3: what is coming up",
|
|
29339
|
+
"",
|
|
29340
|
+
"Read **one file**: `views/calendar/upcoming.md` at the mirror root. It is Plinth's projection of every artist's calendar, written into the mirror by the daemon.",
|
|
29341
|
+
"",
|
|
29342
|
+
"**This is a FILE READ, not a calendar call, and the rule in Step 2 stands unchanged: you still never open a calendar tool.** The file is here so that you do not have to.",
|
|
29343
|
+
"",
|
|
29344
|
+
'**Brief it as of its own `generated_at` stamp, and say the stamp.** A projection is a cache. It is exactly as current as the header says it is and no more, so "nothing on this week" is only ever true as of that moment.',
|
|
29345
|
+
"",
|
|
29346
|
+
"**Read its calendar-status list before you read the dates, and escalate anything that was not read.** A calendar Plinth could not reach is UNKNOWN, not empty, and the file says which. Briefing an unread calendar as a clear diary is the one failure this file exists to prevent - if a line says NOT READ, say so out loud in the brief.",
|
|
29347
|
+
"",
|
|
29348
|
+
"If the file carries `fetch_state: stale`, the last refresh failed: say how old the dates are rather than reporting them as current. If the file is absent altogether, say the projection has not run yet and carry on without it.",
|
|
29349
|
+
"",
|
|
29350
|
+
"**Dates are read here and changed by a person, in Plinth's calendar view.** If something needs moving, say what the change is and hand it over.",
|
|
29351
|
+
"",
|
|
29352
|
+
"## Step 4: brief, in three lines",
|
|
29353
|
+
"",
|
|
29354
|
+
"Then stop reading and report. **Three lines, and not a summary of what you read:**",
|
|
29355
|
+
"",
|
|
29356
|
+
"1. Today, and anything with a clock on it that the injected indexes, the notifications or the calendar file **have already named** - with the calendar's own stamp when you use it. Do not go looking for more.",
|
|
29357
|
+
"2. The unread count, and the one or two that actually matter.",
|
|
29358
|
+
"3. Anything blocked or waiting on someone.",
|
|
29359
|
+
"",
|
|
29360
|
+
'Then ask: **"What do you want to work on?"** If the user already said so in the same message, skip the question and load that context now.',
|
|
29361
|
+
"",
|
|
29362
|
+
"## Step 5: offer the sweep, do not run it",
|
|
29363
|
+
"",
|
|
29364
|
+
`A full roster sweep, every artist's overdue and upcoming work plus calendars and project status, is useful and it is **not** part of opening a session. **Offer it in one clause ("want the roster sweep?") and stop there.** Reporting a sweep you ran anyway is the failure this step exists to prevent: it is the same cost the user did not ask for, with the offer bolted on afterwards.`,
|
|
29365
|
+
"",
|
|
29366
|
+
"**The budget is the one call in Step 2 and the one file read in Step 3, and it is a ceiling rather than a target.** Every new session pays this command before the user has asked for anything, so a second call needs a reason you would defend out loud. An opener that takes half a minute gets turned off, and an opener nobody runs banks nothing at the other end.",
|
|
29367
|
+
"",
|
|
29368
|
+
"## Loading area context",
|
|
29369
|
+
"",
|
|
29370
|
+
"Once the user names something, resolve it against the workspace before answering: search the Plinth MCP, or read the roster in `CLAUDE.md`. **Never answer from name recognition alone.** Then read the surfaces that carry it. An artist's master context is `artists/<slug>/_artist.md`, their durable facts are `artists/<slug>/memory.md`, their recent activity is `artists/<slug>/daily-log.md`. Brief on what is live, not on what you opened.",
|
|
29371
|
+
"",
|
|
29372
|
+
"If nothing resolves, say so plainly and ask. Do not invent an entity.",
|
|
29373
|
+
"",
|
|
29374
|
+
"## Write discipline",
|
|
29375
|
+
"",
|
|
29376
|
+
"This command is read-only orientation. If the session moves into changes, follow the injected grounding: prose and context through the mirror file, structured fields through the Plinth MCP, the exact write stated before the first mutation, and never the database directly.",
|
|
29377
|
+
"",
|
|
29378
|
+
`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.`
|
|
29379
|
+
]);
|
|
29380
|
+
}
|
|
29381
|
+
function buildCloseCommandMd() {
|
|
29382
|
+
return withUserBlock([
|
|
29383
|
+
"---",
|
|
29384
|
+
"description: Close a Plinth session. Verifies what you wrote landed, routes follow-ups to a durable surface, asks before anything reaches shared memory, and logs what was decided.",
|
|
29385
|
+
"---",
|
|
29386
|
+
"",
|
|
29387
|
+
OWNERSHIP_NOTE,
|
|
29388
|
+
"",
|
|
29389
|
+
"# Close",
|
|
29390
|
+
"",
|
|
29391
|
+
"End a working session cleanly: everything done this session is on a durable surface, nothing is half-written, and the next session can pick up cold.",
|
|
29392
|
+
"",
|
|
29393
|
+
"Trust the date the session hook injected. Do not re-derive it.",
|
|
29394
|
+
"",
|
|
29395
|
+
"## Step 1: reconstruct the session",
|
|
29396
|
+
"",
|
|
29397
|
+
"From the **transcript**, not from your memory of it. Which entities were touched, which writes were made, what was decided, what was learned. If the session was pure reading with no changes, say so and go to Step 4 anyway: a read-only close still logs, and the `--nothing-proposed` rule below is why.",
|
|
29398
|
+
"",
|
|
29399
|
+
"## Step 2: verify each write landed",
|
|
29400
|
+
"",
|
|
29401
|
+
"For every intended write, confirm it actually happened: the tool returned success, or the file on disk reflects it. Re-read the file if you are unsure. **A change that exists only in this conversation is lost the moment it ends.**",
|
|
29402
|
+
"",
|
|
29403
|
+
"Flag anything left in flight: an unsent draft, a quarantined edit awaiting `plinth confirm`, a task created with no owner or due date, something agreed out loud and written nowhere.",
|
|
29404
|
+
"",
|
|
29405
|
+
"## Step 3: route the follow-ups",
|
|
29406
|
+
"",
|
|
29407
|
+
"Each loose end goes to the surface that holds it, and nowhere else:",
|
|
29408
|
+
"",
|
|
29409
|
+
"- **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.",
|
|
29410
|
+
"- **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.",
|
|
29411
|
+
"- **What happened today** goes to `artists/<slug>/daily-log.md`, a dated entry per artist touched. Local-only and rolling, and never the place for a durable fact.",
|
|
29412
|
+
"",
|
|
29413
|
+
"## Step 4: memory, in two piles",
|
|
29414
|
+
"",
|
|
29415
|
+
"The two piles are treated differently on purpose, and the difference is not politeness. A wrong line in **shared** memory reaches every member on every machine and is injected into every future session. A wrong line in a member's **own** memory is theirs to prune.",
|
|
29416
|
+
"",
|
|
29417
|
+
"### Private: `user-memory.md` at the mirror root. Write it directly.",
|
|
29418
|
+
"",
|
|
29419
|
+
"Facts true of this member: how they write, how they sign off, what they prefer, their own identifiers. No gate. **List what you wrote in the closing summary**, so it is visible without opening the file.",
|
|
29420
|
+
"",
|
|
29421
|
+
"### Shared: `workspace-memory.md` at the mirror root. Propose. Never write unasked.",
|
|
29422
|
+
"",
|
|
29423
|
+
"Draft the candidate lines first: durable facts about this workspace that would change how a future session behaves, not this session's detail.",
|
|
29424
|
+
"",
|
|
29425
|
+
"**Each line has a required shape, and it is the file's own, not this command's.** `workspace-memory.md` is an INDEX, and the grounding block tells every future session that each line is a one-line hook pointing via a `[[wikilink]]` to the warm store where the full detail lives. So a line carries: the hook, a `[[wikilink]]` to where the detail actually is, an **absolute** date (turn \"last week\" into a real one), and what taught you it. **A line with no warm store behind it makes the grounding block lie to every session that loads it.** Read the file's header comment and follow the conventions there; where it and this command disagree, the file wins.",
|
|
29426
|
+
"",
|
|
29427
|
+
"Then put the candidates to the user with **`AskUserQuestion`**:",
|
|
29428
|
+
"",
|
|
29429
|
+
"- **One multi-select question, one option per proposed line.** Selected means bank it. Unselected means rejected.",
|
|
29430
|
+
'- **The free-text "Other" choice is how the user rewrites a line in their own words.** Their wording wins over yours, always.',
|
|
29431
|
+
"- The tool takes **four options per question and four questions**. If you have more than four candidate lines, you are banking session detail rather than durable facts: **say so and cut the list. Do not paginate.**",
|
|
29432
|
+
"",
|
|
29433
|
+
"**An accepted line that contradicts one already in the file supersedes it in place.** Edit the wrong line. Never leave two lines disagreeing.",
|
|
29434
|
+
"",
|
|
29435
|
+
"## Step 5: log every decision, then write the file",
|
|
29436
|
+
"",
|
|
29437
|
+
"**In that order.** The log is the record of what was decided; the file is the consequence.",
|
|
29438
|
+
"",
|
|
29439
|
+
"```sh",
|
|
29440
|
+
'plinth memory-gate log --proposed "<the line as you put it>" --decision accept',
|
|
29441
|
+
'plinth memory-gate log --proposed "<the line as you put it>" --decision reject',
|
|
29442
|
+
'plinth memory-gate log --proposed "<the line as you put it>" --decision edit --final "<their rewrite>"',
|
|
29443
|
+
"```",
|
|
29444
|
+
"",
|
|
29445
|
+
"A close that proposed nothing still logs, because *gated and proposed nothing* has to stay distinguishable from *never gated*:",
|
|
29446
|
+
"",
|
|
29447
|
+
"```sh",
|
|
29448
|
+
"plinth memory-gate log --nothing-proposed",
|
|
29449
|
+
"```",
|
|
29450
|
+
"",
|
|
29451
|
+
"`plinth memory-gate show [--json]` reads the log back.",
|
|
29452
|
+
"",
|
|
29453
|
+
"**Four rules. Break one and a decision is lost:**",
|
|
29454
|
+
"",
|
|
29455
|
+
"1. `--decision edit` **requires** `--final`. The rewrite is the record: it is the user's own words for their own standard, and it is the most valuable row in the log.",
|
|
29456
|
+
"2. `--final` is **refused** on `accept` and `reject`. An accept records the proposed line; a reject records none.",
|
|
29457
|
+
"3. `--nothing-proposed` **cannot** be combined with `--proposed` or `--decision`.",
|
|
29458
|
+
'4. **`memory-gate` fails LOUD**: exit 1, with a message on stderr. **Surface it. Never swallow it.** A silently dropped decision produces exactly the "never gated" state the log exists to make visible, while looking like a clean close. If it fails, tell the user, and do not report the line as banked.',
|
|
29459
|
+
"",
|
|
29460
|
+
"**Never write this log by hand.** The CLI owns the format so it can still be read back in a year.",
|
|
29461
|
+
"",
|
|
29462
|
+
"Only once the decisions are logged, write the accepted lines into `workspace-memory.md`.",
|
|
29463
|
+
"",
|
|
29464
|
+
"## Step 6: summarise, then end the session",
|
|
29465
|
+
"",
|
|
29466
|
+
"**Four to six lines**, and no file-by-file dump: what now lives on a durable surface, what is still in flight and on whom, what was banked to memory (private and shared), and the obvious next move.",
|
|
29467
|
+
"",
|
|
29468
|
+
"Then, as the **very last step**:",
|
|
29469
|
+
"",
|
|
29470
|
+
"```sh",
|
|
29471
|
+
'plinth session end --summary "<one line>"',
|
|
29472
|
+
"```",
|
|
29473
|
+
"",
|
|
29474
|
+
"This is the opposite of `memory-gate`: it **always exits 0 and always prints nothing**, whatever happens. It marks this session ended, and when it is running inside the Plinth desktop dock it tells the dock the session is finished so the tab can close itself. **Outside the dock it does nothing visible, and that is correct.** Nothing in this command depends on the dock; it runs the same in a bare terminal."
|
|
29475
|
+
]);
|
|
29476
|
+
}
|
|
29477
|
+
function ensureWorkspaceCommands(deps) {
|
|
29478
|
+
const { workspaceSlug, force, out, now } = deps;
|
|
29479
|
+
const created = [];
|
|
29480
|
+
const refreshed = [];
|
|
29481
|
+
const targets = [
|
|
29482
|
+
[START_COMMAND_REL, buildStartCommandMd()],
|
|
29483
|
+
[CLOSE_COMMAND_REL, buildCloseCommandMd()]
|
|
29484
|
+
];
|
|
29485
|
+
for (const [rel, generated] of targets) {
|
|
29486
|
+
const existing = readContextFile(workspaceSlug, rel);
|
|
29487
|
+
const decision = decideRegen({ existing, currentVersion: "", generated, force });
|
|
29488
|
+
if (decision.action === "backup-regen" && decision.backup !== undefined) {
|
|
29489
|
+
const bakRel = `${rel}.bak-${backupStamp2(now())}`;
|
|
29490
|
+
writeContextFile(workspaceSlug, bakRel, decision.backup);
|
|
29491
|
+
out.warn(`${rel} had no recognisable BEGIN/END markers; backed your copy up to ` + `${bakRel} before regenerating.`);
|
|
29492
|
+
}
|
|
29493
|
+
if (decision.content !== null) {
|
|
29494
|
+
writeContextFile(workspaceSlug, rel, decision.content);
|
|
29495
|
+
(decision.action === "create" ? created : refreshed).push(invocationFor(rel));
|
|
29496
|
+
}
|
|
29497
|
+
}
|
|
29498
|
+
if (created.length > 0) {
|
|
29499
|
+
out.info(`Generated ${created.join(" and ")}.`);
|
|
29500
|
+
}
|
|
29501
|
+
if (refreshed.length > 0) {
|
|
29502
|
+
out.info(`Refreshed ${refreshed.join(" and ")} (your edits preserved).`);
|
|
28452
29503
|
}
|
|
28453
|
-
return decision.action === "create";
|
|
28454
|
-
}
|
|
28455
|
-
function ensureStub(workspaceSlug, relativePath, content) {
|
|
28456
|
-
if (existsSync(contextFilePath(workspaceSlug, relativePath)))
|
|
28457
|
-
return;
|
|
28458
|
-
writeContextFile(workspaceSlug, relativePath, content);
|
|
28459
|
-
}
|
|
28460
|
-
function dailyLogStub(name) {
|
|
28461
|
-
return `# ${name} - Daily log
|
|
28462
|
-
|
|
28463
|
-
` + `<!-- Rolling activity, newest first; edit freely, Plinth never overwrites this file. -->
|
|
28464
|
-
`;
|
|
28465
29504
|
}
|
|
28466
|
-
function
|
|
29505
|
+
function backupStamp2(d2) {
|
|
28467
29506
|
return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
|
|
28468
29507
|
}
|
|
28469
29508
|
|
|
28470
|
-
// src/lib/context/generate.ts
|
|
28471
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
28472
|
-
|
|
28473
29509
|
// src/lib/context/workspace-claude-md.ts
|
|
28474
29510
|
function rosterBlock(roster, activeProjectCountBySlug) {
|
|
28475
29511
|
if (roster.length === 0)
|
|
@@ -28495,7 +29531,7 @@ function buildWorkspaceClaudeMd(input) {
|
|
|
28495
29531
|
const skillCount = SKILL_LOCATIONS.length;
|
|
28496
29532
|
const lines = [
|
|
28497
29533
|
`<!-- plinth-schema-version: ${schemaVersion} -->`,
|
|
28498
|
-
`<!-- Auto-generated by Plinth. Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
|
|
29534
|
+
`<!-- Auto-generated by Plinth. ${GENERATED_AT_PREFIX}${GENERATED_AT_PLACEHOLDER}; ` + `that stamp moves only when this file's content changes, so an unchanged file keeps an older one ` + `and is still re-checked against Plinth on every sync. Run \`plinth status\` for when the mirror last synced. ` + `Edit only between the BEGIN/END markers below; everything else is overwritten on sync. -->`,
|
|
28499
29535
|
``,
|
|
28500
29536
|
`# ${workspaceName} · Plinth workspace`,
|
|
28501
29537
|
``,
|
|
@@ -28503,17 +29539,20 @@ function buildWorkspaceClaudeMd(input) {
|
|
|
28503
29539
|
``,
|
|
28504
29540
|
`> **Session grounding**: the current date, an entity-resolution rule, and a write-confirmation rule are injected at every session start by the Plinth hook in \`.claude/settings.json\` (and each \`artists/<slug>/.claude/settings.json\`).`,
|
|
28505
29541
|
``,
|
|
29542
|
+
`> **Filing a file the user hands you - BOTH halves, and the first one is unconditional**: put a copy in \`files/\`, which is how Plinth gets the text (folder map below) - do this even if everything else here fails. Then put the **original** in the user's own file storage with whatever tool you have for that, and write down where it went somewhere durable: \`artists/<slug>/memory.md\` if it is that artist's, otherwise \`workspace-memory.md\`. A pointer that exists only in this conversation is lost when it ends. If you have no tool that reaches their file storage, SAY SO - do not leave them believing the original was filed.`,
|
|
29543
|
+
``,
|
|
28506
29544
|
`## Folder map`,
|
|
28507
29545
|
`- \`artists/<slug>/\` one folder per artist, the working dir for artist-scoped sessions · \`threads/<slug>/_thread.md\` one folder per thread`,
|
|
28508
29546
|
`- \`projects/\` \`tasks/\` \`documents/\` \`meetings/\` workspace-root flat entity files (one \`.md\` per entity, NOT nested under \`artists/\`)`,
|
|
28509
29547
|
`- \`files/\` free-form document store: PDFs, executed agreements, spreadsheets, anything that is a FILE rather than an entity. Mirrored byte-for-byte both ways, folders and all`,
|
|
28510
|
-
`-
|
|
29548
|
+
`- \`${extractedTextRelPath("<path>")}\` where \`<path>\` is the file's path under \`files/\`: the text Plinth extracted from that file, so a PDF's contents can be searched. Not every file has one - text files are already their own text, and extraction can fail. Where it produced junk the file exists and says so instead of carrying it, so read what is there before trusting a silence. Derived: edits here are overwritten and never reach Plinth`,
|
|
29549
|
+
`- \`.claude/commands/\` local skills, incl. generated \`${START_COMMAND_INVOCATION}\` + \`${CLOSE_COMMAND_INVOCATION}\` · at the mirror root: \`workspace-memory.md\` \`workspace-rules.md\` (workspace-wide) + \`user-memory.md\` (yours), synced to Plinth · \`CLAUDE.local.md\` private, gitignored`,
|
|
28511
29550
|
``,
|
|
28512
29551
|
`## Where key context lives: look here before hunting`,
|
|
28513
29552
|
`- Each artist's **master context** (stable profile + current state) → \`artists/<slug>/_artist.md\`; edit its body and the daemon syncs both ways. Blocks labelled "rendered from Plinth" are the exception: they are overwritten on the next sync`,
|
|
28514
29553
|
`- **Contracts, executed agreements & any PDF** → \`files/\` (search here first; \`documents/\` holds markdown bodies, not attachments)`,
|
|
28515
29554
|
`- Workspace-wide **notes, briefs, bios & context docs** → \`documents/\` (one markdown file per doc, shared across artists)`,
|
|
28516
|
-
`- Per-artist long-term facts → \`artists/<slug>/memory.md
|
|
29555
|
+
`- Per-artist long-term facts → \`artists/<slug>/memory.md\`, which syncs to that artist's Plinth record; most have none yet, and creating the file is how you start one · rolling activity → \`artists/<slug>/daily-log.md\`, local-only and never the place for a durable fact`,
|
|
28517
29556
|
`- ${MCP_AVAILABILITY_POINTER}`,
|
|
28518
29557
|
``,
|
|
28519
29558
|
`## Entity model: ${MCP_INTROSPECTION_POINTER}`,
|
|
@@ -28571,19 +29610,21 @@ function ensureWorkspaceContext(deps) {
|
|
|
28571
29610
|
existing,
|
|
28572
29611
|
currentVersion: "",
|
|
28573
29612
|
generated,
|
|
28574
|
-
force
|
|
29613
|
+
force,
|
|
29614
|
+
normalise: normaliseGeneratedAt
|
|
28575
29615
|
});
|
|
28576
29616
|
if (decision.action === "backup-regen" && decision.backup !== undefined) {
|
|
28577
|
-
const bakRel = `${CLAUDE_MD}.bak-${
|
|
29617
|
+
const bakRel = `${CLAUDE_MD}.bak-${backupStamp3(now())}`;
|
|
28578
29618
|
writeContextFile(workspaceSlug, bakRel, decision.backup);
|
|
28579
29619
|
out.warn(`${CLAUDE_MD} had no recognisable BEGIN/END markers; backed your copy ` + `up to ${bakRel} before regenerating.`);
|
|
28580
29620
|
}
|
|
28581
29621
|
if (decision.content !== null) {
|
|
28582
|
-
writeContextFile(workspaceSlug, CLAUDE_MD, decision.content);
|
|
29622
|
+
writeContextFile(workspaceSlug, CLAUDE_MD, renderGeneratedAt(decision.content, now()));
|
|
28583
29623
|
}
|
|
28584
29624
|
if (!existsSync2(contextFilePath(workspaceSlug, CLAUDE_LOCAL_MD))) {
|
|
28585
29625
|
writeContextFile(workspaceSlug, CLAUDE_LOCAL_MD, CLAUDE_LOCAL_STUB);
|
|
28586
29626
|
}
|
|
29627
|
+
ensureWorkspaceCommands({ workspaceSlug, force, out, now });
|
|
28587
29628
|
ensureGitignore(workspaceSlug);
|
|
28588
29629
|
const resolved = resolvePlinthBinary();
|
|
28589
29630
|
reportResolution(out, resolved);
|
|
@@ -28615,7 +29656,7 @@ function ensureGitignore(workspaceSlug) {
|
|
|
28615
29656
|
`;
|
|
28616
29657
|
writeContextFile(workspaceSlug, GITIGNORE, `${existing}${lead}${block}`);
|
|
28617
29658
|
}
|
|
28618
|
-
function
|
|
29659
|
+
function backupStamp3(d2) {
|
|
28619
29660
|
return d2.toISOString().replace(/[-:.]/g, "").replace("Z", "");
|
|
28620
29661
|
}
|
|
28621
29662
|
|
|
@@ -28824,12 +29865,12 @@ function classify(facts) {
|
|
|
28824
29865
|
}
|
|
28825
29866
|
|
|
28826
29867
|
// src/lib/review-tier/collect.ts
|
|
28827
|
-
import { execFileSync as
|
|
29868
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
28828
29869
|
import fs2 from "node:fs";
|
|
28829
29870
|
import path3 from "node:path";
|
|
28830
29871
|
function execGit(args, cwd) {
|
|
28831
29872
|
try {
|
|
28832
|
-
const stdout2 =
|
|
29873
|
+
const stdout2 = execFileSync3("git", args, {
|
|
28833
29874
|
cwd,
|
|
28834
29875
|
encoding: "utf8",
|
|
28835
29876
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -29047,16 +30088,280 @@ var reviewTierCommand = defineCommand({
|
|
|
29047
30088
|
}
|
|
29048
30089
|
});
|
|
29049
30090
|
|
|
30091
|
+
// src/commands/session.ts
|
|
30092
|
+
init_output();
|
|
30093
|
+
async function defaultReadStdin() {
|
|
30094
|
+
if (process.stdin.isTTY)
|
|
30095
|
+
return "";
|
|
30096
|
+
const chunks = [];
|
|
30097
|
+
for await (const chunk of process.stdin) {
|
|
30098
|
+
chunks.push(Buffer.from(chunk));
|
|
30099
|
+
}
|
|
30100
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
30101
|
+
}
|
|
30102
|
+
function asString(value) {
|
|
30103
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
30104
|
+
}
|
|
30105
|
+
function resolveSlug(explicit) {
|
|
30106
|
+
let config2;
|
|
30107
|
+
try {
|
|
30108
|
+
config2 = loadConfig();
|
|
30109
|
+
} catch {
|
|
30110
|
+
return { ok: false, reason: "unconfigured" };
|
|
30111
|
+
}
|
|
30112
|
+
if (!config2)
|
|
30113
|
+
return { ok: false, reason: "unconfigured" };
|
|
30114
|
+
const slug = explicit ?? config2.active_workspace;
|
|
30115
|
+
if (!config2.workspaces[slug]) {
|
|
30116
|
+
return explicit === undefined ? { ok: false, reason: "unconfigured" } : { ok: false, reason: "unknown-workspace", slug };
|
|
30117
|
+
}
|
|
30118
|
+
return { ok: true, slug };
|
|
30119
|
+
}
|
|
30120
|
+
async function runSessionRegister(args, deps = {}) {
|
|
30121
|
+
const env3 = deps.env ?? process.env;
|
|
30122
|
+
const raw = await (deps.readStdin ?? defaultReadStdin)().catch(() => "");
|
|
30123
|
+
let payload = {};
|
|
30124
|
+
if (raw.trim().length > 0) {
|
|
30125
|
+
try {
|
|
30126
|
+
const parsed = JSON.parse(raw);
|
|
30127
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
30128
|
+
payload = parsed;
|
|
30129
|
+
}
|
|
30130
|
+
} catch {}
|
|
30131
|
+
}
|
|
30132
|
+
const sessionId = asString(payload.session_id) ?? asString(env3.CLAUDE_CODE_SESSION_ID);
|
|
30133
|
+
const cwd = asString(payload.cwd) ?? (deps.cwd ?? process.cwd)();
|
|
30134
|
+
const transcriptPath = asString(payload.transcript_path);
|
|
30135
|
+
const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
|
|
30136
|
+
const pidRaw = asString(env3.CLAUDE_PID);
|
|
30137
|
+
const pid = pidRaw !== null ? Number.parseInt(pidRaw, 10) : Number.NaN;
|
|
30138
|
+
if (sessionId === null || !isSafeSegment(sessionId))
|
|
30139
|
+
return { exitCode: 0 };
|
|
30140
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
30141
|
+
return { exitCode: 0 };
|
|
30142
|
+
const resolved = resolveSlug(args.workspace);
|
|
30143
|
+
if (!resolved.ok)
|
|
30144
|
+
return { exitCode: 0 };
|
|
30145
|
+
try {
|
|
30146
|
+
registerSession({
|
|
30147
|
+
sessionId,
|
|
30148
|
+
pid,
|
|
30149
|
+
cwd,
|
|
30150
|
+
workspaceSlug: resolved.slug,
|
|
30151
|
+
transcriptPath,
|
|
30152
|
+
tabId,
|
|
30153
|
+
now: (deps.now ?? (() => new Date))(),
|
|
30154
|
+
startTimeFn: deps.startTimeFn
|
|
30155
|
+
});
|
|
30156
|
+
} catch {}
|
|
30157
|
+
return { exitCode: 0 };
|
|
30158
|
+
}
|
|
30159
|
+
function buildSessionListJson(workspaceSlug, live, reaped, unchecked, selfPid, selfSessionId, ended = 0) {
|
|
30160
|
+
return {
|
|
30161
|
+
workspace_slug: workspaceSlug,
|
|
30162
|
+
self_pid: selfPid,
|
|
30163
|
+
self_session_id: selfSessionId,
|
|
30164
|
+
sessions: live.map((s2) => ({
|
|
30165
|
+
session_id: s2.record.session_id,
|
|
30166
|
+
pid: s2.record.pid,
|
|
30167
|
+
cwd: s2.record.cwd,
|
|
30168
|
+
started_at: s2.record.started_at,
|
|
30169
|
+
last_activity: s2.lastActivityMs === null ? null : new Date(s2.lastActivityMs).toISOString(),
|
|
30170
|
+
is_self: s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid,
|
|
30171
|
+
tab_id: s2.record.tab_id
|
|
30172
|
+
})),
|
|
30173
|
+
reaped,
|
|
30174
|
+
unchecked,
|
|
30175
|
+
ended,
|
|
30176
|
+
scope: "machine-local"
|
|
30177
|
+
};
|
|
30178
|
+
}
|
|
30179
|
+
async function runSessionList(args, deps = {}) {
|
|
30180
|
+
const out = new Output({ sink: deps.sink, noColor: deps.noColor ?? args.noColor });
|
|
30181
|
+
const env3 = deps.env ?? process.env;
|
|
30182
|
+
const resolved = resolveSlug(args.workspace);
|
|
30183
|
+
if (!resolved.ok) {
|
|
30184
|
+
out.error(resolved.reason === "unknown-workspace" ? `Workspace "${resolved.slug}" not found. Run \`plinth login --workspace ${resolved.slug}\` first.` : "No workspace configured. Run `plinth login --workspace <slug>` first.");
|
|
30185
|
+
return { exitCode: 1 };
|
|
30186
|
+
}
|
|
30187
|
+
const slug = resolved.slug;
|
|
30188
|
+
const { live, reaped, unknown: unknown2, ended } = listSessions(slug, deps);
|
|
30189
|
+
const selfPidRaw = asString(env3.CLAUDE_PID);
|
|
30190
|
+
const selfPid = selfPidRaw !== null ? Number.parseInt(selfPidRaw, 10) : null;
|
|
30191
|
+
const selfSessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
|
|
30192
|
+
if (args.json) {
|
|
30193
|
+
out.info(JSON.stringify(buildSessionListJson(slug, live, reaped, unknown2, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, ended), null, 2));
|
|
30194
|
+
return { exitCode: 0 };
|
|
30195
|
+
}
|
|
30196
|
+
const now = (deps.now ?? (() => new Date))();
|
|
30197
|
+
for (const line of renderSessionLines(live, now, selfPid, selfSessionId)) {
|
|
30198
|
+
out.info(line);
|
|
30199
|
+
}
|
|
30200
|
+
for (const line of sessionCaveats(live.length, unknown2, ended))
|
|
30201
|
+
out.info(line);
|
|
30202
|
+
return { exitCode: 0 };
|
|
30203
|
+
}
|
|
30204
|
+
var DOCK_CLOSED_ROUTE = "/closed";
|
|
30205
|
+
var DOCK_SIGNAL_TIMEOUT_MS = 1000;
|
|
30206
|
+
var SUMMARY_MAX_CHARS = 2000;
|
|
30207
|
+
function readDockSignalEnv(env3) {
|
|
30208
|
+
const port = asString(env3.PLINTH_DOCK_SIGNAL_PORT);
|
|
30209
|
+
const token = asString(env3.PLINTH_DOCK_SIGNAL_TOKEN);
|
|
30210
|
+
const tabId = asString(env3.PLINTH_DOCK_TAB_ID);
|
|
30211
|
+
if (port === null || token === null || tabId === null)
|
|
30212
|
+
return null;
|
|
30213
|
+
if (!/^[0-9]{1,5}$/.test(port))
|
|
30214
|
+
return null;
|
|
30215
|
+
return { port, token, tabId };
|
|
30216
|
+
}
|
|
30217
|
+
function normaliseSummary(raw) {
|
|
30218
|
+
if (raw === undefined)
|
|
30219
|
+
return null;
|
|
30220
|
+
const folded = raw.replace(/\s+/g, " ").trim();
|
|
30221
|
+
if (folded.length === 0)
|
|
30222
|
+
return null;
|
|
30223
|
+
return folded.length > SUMMARY_MAX_CHARS ? folded.slice(0, SUMMARY_MAX_CHARS) : folded;
|
|
30224
|
+
}
|
|
30225
|
+
var defaultFetch = (url2, init2) => fetch(url2, init2);
|
|
30226
|
+
async function runSessionEnd(args, deps = {}) {
|
|
30227
|
+
const env3 = deps.env ?? process.env;
|
|
30228
|
+
const now = (deps.now ?? (() => new Date))();
|
|
30229
|
+
const sessionId = asString(env3.CLAUDE_CODE_SESSION_ID);
|
|
30230
|
+
if (sessionId !== null && isSafeSegment(sessionId)) {
|
|
30231
|
+
const resolved = resolveSlug(args.workspace);
|
|
30232
|
+
if (resolved.ok) {
|
|
30233
|
+
try {
|
|
30234
|
+
endSession(resolved.slug, sessionId, now);
|
|
30235
|
+
} catch {}
|
|
30236
|
+
}
|
|
30237
|
+
}
|
|
30238
|
+
const dock = readDockSignalEnv(env3);
|
|
30239
|
+
if (dock === null)
|
|
30240
|
+
return { exitCode: 0 };
|
|
30241
|
+
const body = JSON.stringify({ summary: normaliseSummary(args.summary) });
|
|
30242
|
+
try {
|
|
30243
|
+
await (deps.fetchFn ?? defaultFetch)(`http://127.0.0.1:${dock.port}${DOCK_CLOSED_ROUTE}`, {
|
|
30244
|
+
method: "POST",
|
|
30245
|
+
headers: {
|
|
30246
|
+
"content-type": "application/json",
|
|
30247
|
+
"x-plinth-token": dock.token,
|
|
30248
|
+
"x-plinth-tab": dock.tabId
|
|
30249
|
+
},
|
|
30250
|
+
body,
|
|
30251
|
+
signal: AbortSignal.timeout(DOCK_SIGNAL_TIMEOUT_MS)
|
|
30252
|
+
});
|
|
30253
|
+
} catch {}
|
|
30254
|
+
return { exitCode: 0 };
|
|
30255
|
+
}
|
|
30256
|
+
function renderSessionLines(live, now, selfPid, selfSessionId, formatRelative = defaultRelative) {
|
|
30257
|
+
if (live.length === 0)
|
|
30258
|
+
return ["No Claude sessions registered on this machine."];
|
|
30259
|
+
return live.map((s2) => {
|
|
30260
|
+
const isSelf = s2.record.session_id === selfSessionId || selfPid !== null && s2.record.pid === selfPid;
|
|
30261
|
+
const activity = s2.lastActivityMs === null ? "activity unknown" : `active ${formatRelative(now.getTime() - s2.lastActivityMs)}`;
|
|
30262
|
+
const tag = isSelf ? " (this session)" : "";
|
|
30263
|
+
const tab = s2.record.tab_id === null ? "" : ` · tab ${s2.record.tab_id}`;
|
|
30264
|
+
return `${s2.record.session_id.slice(0, 8)} · pid ${s2.record.pid}${tab} · ${activity} · ${s2.record.cwd}${tag}`;
|
|
30265
|
+
});
|
|
30266
|
+
}
|
|
30267
|
+
function sessionCaveats(liveCount, unknown2, ended = 0) {
|
|
30268
|
+
const lines = [];
|
|
30269
|
+
if (liveCount > 1) {
|
|
30270
|
+
lines.push("Visibility only - nothing here prevents a conflicting write.");
|
|
30271
|
+
}
|
|
30272
|
+
if (ended > 0) {
|
|
30273
|
+
lines.push(`${ended} session${ended === 1 ? " has" : "s have"} run \`plinth session end\` but ${ended === 1 ? "its process is" : "their processes are"} still alive - not listed above.`);
|
|
30274
|
+
}
|
|
30275
|
+
if (unknown2 > 0) {
|
|
30276
|
+
lines.push(`${unknown2} record${unknown2 === 1 ? "" : "s"} could not be checked and ${unknown2 === 1 ? "is" : "are"} not listed.`);
|
|
30277
|
+
}
|
|
30278
|
+
lines.push("Machine-local - a session on another machine is not counted.");
|
|
30279
|
+
return lines;
|
|
30280
|
+
}
|
|
30281
|
+
function defaultRelative(deltaMs) {
|
|
30282
|
+
const s2 = Math.floor(Math.max(0, deltaMs) / 1000);
|
|
30283
|
+
if (s2 < 60)
|
|
30284
|
+
return `${s2}s ago`;
|
|
30285
|
+
const m2 = Math.floor(s2 / 60);
|
|
30286
|
+
if (m2 < 60)
|
|
30287
|
+
return `${m2}m ago`;
|
|
30288
|
+
const h2 = Math.floor(m2 / 60);
|
|
30289
|
+
if (h2 < 24)
|
|
30290
|
+
return `${h2}h ago`;
|
|
30291
|
+
return `${Math.floor(h2 / 24)}d ago`;
|
|
30292
|
+
}
|
|
30293
|
+
var sessionCommand = defineCommand({
|
|
30294
|
+
meta: {
|
|
30295
|
+
name: "session",
|
|
30296
|
+
description: "Concurrent Claude session visibility for this workspace"
|
|
30297
|
+
},
|
|
30298
|
+
subCommands: {
|
|
30299
|
+
register: defineCommand({
|
|
30300
|
+
meta: {
|
|
30301
|
+
name: "register",
|
|
30302
|
+
description: "Record this Claude session (SessionStart hook; fail-quiet)"
|
|
30303
|
+
},
|
|
30304
|
+
args: {
|
|
30305
|
+
workspace: { type: "string", description: "Workspace slug" }
|
|
30306
|
+
},
|
|
30307
|
+
async run({ args }) {
|
|
30308
|
+
const result = await runSessionRegister({
|
|
30309
|
+
workspace: args.workspace
|
|
30310
|
+
});
|
|
30311
|
+
process.exitCode = result.exitCode;
|
|
30312
|
+
}
|
|
30313
|
+
}),
|
|
30314
|
+
list: defineCommand({
|
|
30315
|
+
meta: {
|
|
30316
|
+
name: "list",
|
|
30317
|
+
description: "List live Claude sessions writing this workspace"
|
|
30318
|
+
},
|
|
30319
|
+
args: {
|
|
30320
|
+
workspace: { type: "string", description: "Workspace slug" },
|
|
30321
|
+
json: { type: "boolean", description: "Machine-readable output" },
|
|
30322
|
+
"no-color": { type: "boolean", description: "Disable colour" }
|
|
30323
|
+
},
|
|
30324
|
+
async run({ args }) {
|
|
30325
|
+
const result = await runSessionList({
|
|
30326
|
+
workspace: args.workspace,
|
|
30327
|
+
json: args.json
|
|
30328
|
+
}, { noColor: noColorFromArgs(args) });
|
|
30329
|
+
process.exitCode = result.exitCode;
|
|
30330
|
+
}
|
|
30331
|
+
}),
|
|
30332
|
+
end: defineCommand({
|
|
30333
|
+
meta: {
|
|
30334
|
+
name: "end",
|
|
30335
|
+
description: "Mark this Claude session ended and, inside the desktop dock, tell the dock (last step of /close; fail-quiet)"
|
|
30336
|
+
},
|
|
30337
|
+
args: {
|
|
30338
|
+
workspace: { type: "string", description: "Workspace slug" },
|
|
30339
|
+
summary: {
|
|
30340
|
+
type: "string",
|
|
30341
|
+
description: "One-line summary of the session, forwarded to the dock"
|
|
30342
|
+
}
|
|
30343
|
+
},
|
|
30344
|
+
async run({ args }) {
|
|
30345
|
+
const result = await runSessionEnd({
|
|
30346
|
+
workspace: args.workspace,
|
|
30347
|
+
summary: args.summary
|
|
30348
|
+
});
|
|
30349
|
+
process.exitCode = result.exitCode;
|
|
30350
|
+
}
|
|
30351
|
+
})
|
|
30352
|
+
}
|
|
30353
|
+
});
|
|
30354
|
+
|
|
29050
30355
|
// src/commands/start.ts
|
|
29051
30356
|
var import_gray_matter10 = __toESM(require_gray_matter(), 1);
|
|
29052
|
-
import { readFileSync as
|
|
30357
|
+
import { readFileSync as readFileSync20, statSync as statSync12 } from "node:fs";
|
|
29053
30358
|
|
|
29054
30359
|
// src/lib/daemon-log.ts
|
|
29055
|
-
import { appendFileSync, mkdirSync as
|
|
29056
|
-
import { dirname as
|
|
30360
|
+
import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync8, renameSync as renameSync7, statSync as statSync4 } from "node:fs";
|
|
30361
|
+
import { dirname as dirname6, join as join9 } from "node:path";
|
|
29057
30362
|
var DEFAULT_DAEMON_LOG_MAX_BYTES = 5000000;
|
|
29058
30363
|
function daemonLogPath() {
|
|
29059
|
-
return
|
|
30364
|
+
return join9(plinthHome(), "daemon.log");
|
|
29060
30365
|
}
|
|
29061
30366
|
function daemonLogMaxBytes() {
|
|
29062
30367
|
const raw = process.env.PLINTH_DAEMON_LOG_MAX_BYTES;
|
|
@@ -29078,10 +30383,10 @@ class DaemonLog {
|
|
|
29078
30383
|
this.now = opts.now ?? (() => new Date);
|
|
29079
30384
|
this.maxBytes = opts.maxBytes ?? daemonLogMaxBytes();
|
|
29080
30385
|
try {
|
|
29081
|
-
|
|
30386
|
+
mkdirSync8(dirname6(this.path), { recursive: true });
|
|
29082
30387
|
} catch {}
|
|
29083
30388
|
try {
|
|
29084
|
-
this.bytesWritten =
|
|
30389
|
+
this.bytesWritten = statSync4(this.path).size;
|
|
29085
30390
|
} catch {
|
|
29086
30391
|
this.bytesWritten = 0;
|
|
29087
30392
|
}
|
|
@@ -29094,7 +30399,7 @@ class DaemonLog {
|
|
|
29094
30399
|
const bytes = Buffer.byteLength(line);
|
|
29095
30400
|
try {
|
|
29096
30401
|
this.maybeRotate(bytes);
|
|
29097
|
-
|
|
30402
|
+
appendFileSync2(this.path, line);
|
|
29098
30403
|
this.bytesWritten += bytes;
|
|
29099
30404
|
} catch {
|
|
29100
30405
|
try {
|
|
@@ -29108,97 +30413,16 @@ class DaemonLog {
|
|
|
29108
30413
|
}
|
|
29109
30414
|
rotate() {
|
|
29110
30415
|
try {
|
|
29111
|
-
|
|
30416
|
+
renameSync7(this.path, `${this.path}.1`);
|
|
29112
30417
|
} catch {}
|
|
29113
30418
|
this.bytesWritten = 0;
|
|
29114
30419
|
}
|
|
29115
30420
|
}
|
|
29116
30421
|
|
|
29117
|
-
// src/lib/daemon-pid.ts
|
|
29118
|
-
import { mkdirSync as mkdirSync6, readFileSync as readFileSync8, renameSync as renameSync6, unlinkSync as unlinkSync5, writeFileSync as writeFileSync5 } from "node:fs";
|
|
29119
|
-
import { dirname as dirname5, join as join7 } from "node:path";
|
|
29120
|
-
var DaemonPidSchema = exports_external.object({
|
|
29121
|
-
version: exports_external.literal(1),
|
|
29122
|
-
pid: exports_external.number().int().positive(),
|
|
29123
|
-
workspace_slug: exports_external.string().min(1),
|
|
29124
|
-
started_at: exports_external.string().min(1)
|
|
29125
|
-
});
|
|
29126
|
-
function daemonPidPath(workspaceSlug) {
|
|
29127
|
-
return join7(plinthHome(), `daemon-${workspaceSlug}.pid`);
|
|
29128
|
-
}
|
|
29129
|
-
function daemonPidTmpPath(workspaceSlug) {
|
|
29130
|
-
return join7(plinthHome(), `daemon-${workspaceSlug}.pid.tmp`);
|
|
29131
|
-
}
|
|
29132
|
-
function writeDaemonPid(workspaceSlug, pid, startedAt) {
|
|
29133
|
-
const file2 = {
|
|
29134
|
-
version: 1,
|
|
29135
|
-
pid,
|
|
29136
|
-
workspace_slug: workspaceSlug,
|
|
29137
|
-
started_at: startedAt.toISOString()
|
|
29138
|
-
};
|
|
29139
|
-
const target = daemonPidPath(workspaceSlug);
|
|
29140
|
-
const tmp = daemonPidTmpPath(workspaceSlug);
|
|
29141
|
-
mkdirSync6(dirname5(target), { recursive: true, mode: 448 });
|
|
29142
|
-
writeFileSync5(tmp, JSON.stringify(file2, null, 2) + `
|
|
29143
|
-
`, { mode: 384 });
|
|
29144
|
-
try {
|
|
29145
|
-
renameSync6(tmp, target);
|
|
29146
|
-
} catch (err) {
|
|
29147
|
-
try {
|
|
29148
|
-
unlinkSync5(tmp);
|
|
29149
|
-
} catch {}
|
|
29150
|
-
throw err;
|
|
29151
|
-
}
|
|
29152
|
-
}
|
|
29153
|
-
function readDaemonPid(workspaceSlug) {
|
|
29154
|
-
let raw;
|
|
29155
|
-
try {
|
|
29156
|
-
raw = readFileSync8(daemonPidPath(workspaceSlug), "utf8");
|
|
29157
|
-
} catch (err) {
|
|
29158
|
-
if (err?.code === "ENOENT")
|
|
29159
|
-
return null;
|
|
29160
|
-
throw err;
|
|
29161
|
-
}
|
|
29162
|
-
let json2;
|
|
29163
|
-
try {
|
|
29164
|
-
json2 = JSON.parse(raw);
|
|
29165
|
-
} catch {
|
|
29166
|
-
return null;
|
|
29167
|
-
}
|
|
29168
|
-
const parsed = DaemonPidSchema.safeParse(json2);
|
|
29169
|
-
return parsed.success ? parsed.data : null;
|
|
29170
|
-
}
|
|
29171
|
-
function clearDaemonPid(workspaceSlug) {
|
|
29172
|
-
try {
|
|
29173
|
-
unlinkSync5(daemonPidPath(workspaceSlug));
|
|
29174
|
-
} catch (err) {
|
|
29175
|
-
if (err?.code === "ENOENT")
|
|
29176
|
-
return;
|
|
29177
|
-
throw err;
|
|
29178
|
-
}
|
|
29179
|
-
}
|
|
29180
|
-
function isPidAlive(pid) {
|
|
29181
|
-
try {
|
|
29182
|
-
process.kill(pid, 0);
|
|
29183
|
-
return true;
|
|
29184
|
-
} catch (err) {
|
|
29185
|
-
return err?.code === "EPERM";
|
|
29186
|
-
}
|
|
29187
|
-
}
|
|
29188
|
-
function readLiveDaemon(workspaceSlug, aliveFn = isPidAlive) {
|
|
29189
|
-
const pidFile = readDaemonPid(workspaceSlug);
|
|
29190
|
-
if (!pidFile)
|
|
29191
|
-
return null;
|
|
29192
|
-
if (aliveFn(pidFile.pid))
|
|
29193
|
-
return pidFile;
|
|
29194
|
-
clearDaemonPid(workspaceSlug);
|
|
29195
|
-
return null;
|
|
29196
|
-
}
|
|
29197
|
-
|
|
29198
30422
|
// src/lib/destructive-gate.ts
|
|
29199
30423
|
var import_gray_matter7 = __toESM(require_gray_matter(), 1);
|
|
29200
|
-
import { readFileSync as
|
|
29201
|
-
import { basename as
|
|
30424
|
+
import { readFileSync as readFileSync11, statSync as statSync5 } from "node:fs";
|
|
30425
|
+
import { basename as basename3 } from "node:path";
|
|
29202
30426
|
var GUTTING_RATIO_DEFAULT = 0.5;
|
|
29203
30427
|
var GUTTING_MIN_PRIOR_BYTES = 200;
|
|
29204
30428
|
function envFloat(name, def) {
|
|
@@ -29241,10 +30465,10 @@ function classifyDestructive(event, entityType, state, deps = {}) {
|
|
|
29241
30465
|
absolutePath: event.absolutePath
|
|
29242
30466
|
};
|
|
29243
30467
|
}
|
|
29244
|
-
const readFile = deps.readFile ?? ((p) =>
|
|
30468
|
+
const readFile = deps.readFile ?? ((p) => readFileSync11(p, "utf8"));
|
|
29245
30469
|
const statInode = deps.statInode ?? ((p) => {
|
|
29246
30470
|
try {
|
|
29247
|
-
return
|
|
30471
|
+
return statSync5(p).ino;
|
|
29248
30472
|
} catch {
|
|
29249
30473
|
return;
|
|
29250
30474
|
}
|
|
@@ -29263,7 +30487,7 @@ function classifyDestructive(event, entityType, state, deps = {}) {
|
|
|
29263
30487
|
return null;
|
|
29264
30488
|
}
|
|
29265
30489
|
const liveInode = statInode(event.absolutePath);
|
|
29266
|
-
const slug =
|
|
30490
|
+
const slug = basename3(event.absolutePath, ".md");
|
|
29267
30491
|
const entry2 = (liveInode !== undefined ? getEntryByInode(state, entityType, liveInode) : undefined) ?? getEntryBySlug(state, entityType, slug);
|
|
29268
30492
|
if (!entry2)
|
|
29269
30493
|
return null;
|
|
@@ -29368,40 +30592,40 @@ class DestructiveGate {
|
|
|
29368
30592
|
}
|
|
29369
30593
|
|
|
29370
30594
|
// src/lib/resolve-pending.ts
|
|
29371
|
-
import { readFileSync as
|
|
30595
|
+
import { readFileSync as readFileSync15 } from "node:fs";
|
|
29372
30596
|
|
|
29373
30597
|
// src/lib/conflicts.ts
|
|
29374
|
-
import { mkdirSync as
|
|
29375
|
-
import { dirname as
|
|
30598
|
+
import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync7 } from "node:fs";
|
|
30599
|
+
import { dirname as dirname7, join as join10 } from "node:path";
|
|
29376
30600
|
function conflictsRoot(workspaceSlug) {
|
|
29377
|
-
return
|
|
30601
|
+
return join10(plinthHome(), "_conflicts", workspaceSlug);
|
|
29378
30602
|
}
|
|
29379
30603
|
function conflictFilePath(args) {
|
|
29380
30604
|
const { workspaceSlug, entityType, entitySlug, timestamp } = args;
|
|
29381
30605
|
const iso = timestamp.toISOString().replace(/[:.]/g, "-");
|
|
29382
|
-
return
|
|
30606
|
+
return join10(conflictsRoot(workspaceSlug), `${entityType}.${entitySlug}.${iso}.md`);
|
|
29383
30607
|
}
|
|
29384
30608
|
function stashConflict(args) {
|
|
29385
30609
|
const target = conflictFilePath(args);
|
|
29386
|
-
|
|
29387
|
-
|
|
30610
|
+
mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
|
|
30611
|
+
writeFileSync7(target, args.contents, { mode: 384 });
|
|
29388
30612
|
return target;
|
|
29389
30613
|
}
|
|
29390
30614
|
function fileConflictPath(args) {
|
|
29391
30615
|
const { workspaceSlug, relPath, timestamp } = args;
|
|
29392
30616
|
const iso = timestamp.toISOString().replace(/[:.]/g, "-");
|
|
29393
30617
|
const safe = relPath.replace(/[/\\]/g, "__");
|
|
29394
|
-
return
|
|
30618
|
+
return join10(conflictsRoot(workspaceSlug), `files.${safe}.${iso}`);
|
|
29395
30619
|
}
|
|
29396
30620
|
function stashFileConflict(args) {
|
|
29397
30621
|
const target = fileConflictPath(args);
|
|
29398
|
-
|
|
29399
|
-
|
|
30622
|
+
mkdirSync9(dirname7(target), { recursive: true, mode: 448 });
|
|
30623
|
+
writeFileSync7(target, args.bytes, { mode: 384 });
|
|
29400
30624
|
return target;
|
|
29401
30625
|
}
|
|
29402
30626
|
|
|
29403
30627
|
// src/lib/files/push.ts
|
|
29404
|
-
import { readFileSync as
|
|
30628
|
+
import { readFileSync as readFileSync12, statSync as statSync6 } from "node:fs";
|
|
29405
30629
|
|
|
29406
30630
|
// src/lib/log-tokens.ts
|
|
29407
30631
|
function categorizeError(err) {
|
|
@@ -29472,6 +30696,17 @@ function pullNoticeLine(kind) {
|
|
|
29472
30696
|
const token = kind === "auth" ? "PULL_PAUSED" : "PULL_DEGRADED";
|
|
29473
30697
|
return `[${token} reason=${kind}]`;
|
|
29474
30698
|
}
|
|
30699
|
+
function calendarProjectedLine(f3) {
|
|
30700
|
+
return `[CALENDAR_PROJECTED written=${f3.written} pruned=${f3.pruned} ` + `calendars=${f3.calendars} not_read=${f3.notRead}]`;
|
|
30701
|
+
}
|
|
30702
|
+
function calendarFailedLine(f3) {
|
|
30703
|
+
const serverPart = f3.serverMessage ? ` server_msg="${f3.serverMessage}"` : "";
|
|
30704
|
+
return `[CALENDAR_FAILED status=${f3.status} reason=${f3.reason} staled=${f3.staled}${serverPart}]`;
|
|
30705
|
+
}
|
|
30706
|
+
function calendarNoticeLine(kind) {
|
|
30707
|
+
const token = kind === "auth" ? "CALENDAR_PAUSED" : kind === "unsupported" ? "CALENDAR_UNSUPPORTED" : "CALENDAR_DEGRADED";
|
|
30708
|
+
return `[${token} reason=${kind}]`;
|
|
30709
|
+
}
|
|
29475
30710
|
|
|
29476
30711
|
// src/lib/rejected.ts
|
|
29477
30712
|
import { relative as relative3 } from "node:path";
|
|
@@ -29561,6 +30796,7 @@ function clearFileRejected(state, relPath) {
|
|
|
29561
30796
|
// src/lib/files/api.ts
|
|
29562
30797
|
var MAX_WORKSPACE_FILE_BYTES = 4000000;
|
|
29563
30798
|
var HASH_RE = /^[0-9a-f]{64}$/;
|
|
30799
|
+
var TextQualitySchema = exports_external.enum(["ok", "partial", "garbled"]);
|
|
29564
30800
|
var FileLiveSchema = exports_external.object({
|
|
29565
30801
|
id: exports_external.string().uuid(),
|
|
29566
30802
|
path: exports_external.string().min(1),
|
|
@@ -29571,8 +30807,25 @@ var FileLiveSchema = exports_external.object({
|
|
|
29571
30807
|
project_id: exports_external.string().uuid().nullable(),
|
|
29572
30808
|
updated_at: exports_external.string().datetime({ offset: true }),
|
|
29573
30809
|
deleted_at: exports_external.null(),
|
|
29574
|
-
last_edited_by: exports_external.string().uuid().nullable()
|
|
29575
|
-
|
|
30810
|
+
last_edited_by: exports_external.string().uuid().nullable(),
|
|
30811
|
+
content_quality: TextQualitySchema.nullable().optional().default(null).catch(null),
|
|
30812
|
+
has_extracted_text: exports_external.boolean().optional().catch(undefined),
|
|
30813
|
+
has_stored_bytes: exports_external.boolean().optional().catch(undefined)
|
|
30814
|
+
});
|
|
30815
|
+
function storedBytesSignal(row) {
|
|
30816
|
+
if (row.has_stored_bytes === true)
|
|
30817
|
+
return "present";
|
|
30818
|
+
if (row.has_stored_bytes === false)
|
|
30819
|
+
return "absent";
|
|
30820
|
+
return "unknown";
|
|
30821
|
+
}
|
|
30822
|
+
function extractedTextSignal(row) {
|
|
30823
|
+
if (row.has_extracted_text === true)
|
|
30824
|
+
return "present";
|
|
30825
|
+
if (row.has_extracted_text === false)
|
|
30826
|
+
return "absent";
|
|
30827
|
+
return "unknown";
|
|
30828
|
+
}
|
|
29576
30829
|
var FileTombstoneSchema = exports_external.object({
|
|
29577
30830
|
id: exports_external.string().uuid(),
|
|
29578
30831
|
path: exports_external.string().min(1),
|
|
@@ -29585,7 +30838,18 @@ var FilesPageSchema = exports_external.object({
|
|
|
29585
30838
|
next_cursor: exports_external.string().nullable()
|
|
29586
30839
|
});
|
|
29587
30840
|
var FileWithDownloadSchema = FileLiveSchema.extend({
|
|
29588
|
-
download_url: exports_external.string().url()
|
|
30841
|
+
download_url: exports_external.string().url().nullable()
|
|
30842
|
+
});
|
|
30843
|
+
var FileTextSchema = exports_external.object({
|
|
30844
|
+
id: exports_external.string().uuid(),
|
|
30845
|
+
path: exports_external.string().min(1),
|
|
30846
|
+
mime_type: exports_external.string(),
|
|
30847
|
+
updated_at: exports_external.string().datetime({ offset: true }),
|
|
30848
|
+
content_quality: TextQualitySchema.nullable(),
|
|
30849
|
+
source_uri: exports_external.string().nullable(),
|
|
30850
|
+
text: exports_external.string().nullable(),
|
|
30851
|
+
chars: exports_external.number().int().nonnegative(),
|
|
30852
|
+
withheld: exports_external.enum(["bytes_are_text", "no_text"]).nullable()
|
|
29589
30853
|
});
|
|
29590
30854
|
var PreconditionSchema = exports_external.object({
|
|
29591
30855
|
code: exports_external.literal("precondition_failed"),
|
|
@@ -29624,11 +30888,39 @@ async function syncFilesPage(args) {
|
|
|
29624
30888
|
if (!parsed.success) {
|
|
29625
30889
|
throw new ApiError(res.status, `sync files response did not match the expected shape: ${parsed.error.message}`);
|
|
29626
30890
|
}
|
|
29627
|
-
return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
|
|
30891
|
+
return { rows: parsed.data.files, next_cursor: parsed.data.next_cursor };
|
|
30892
|
+
}
|
|
30893
|
+
async function getFile(args) {
|
|
30894
|
+
const { baseUrl, pat, id, fetchImpl = fetch } = args;
|
|
30895
|
+
const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
|
|
30896
|
+
let res;
|
|
30897
|
+
try {
|
|
30898
|
+
res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
|
|
30899
|
+
} catch (err) {
|
|
30900
|
+
const msg = err instanceof Error ? err.message : "network error";
|
|
30901
|
+
throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
|
|
30902
|
+
}
|
|
30903
|
+
if (res.status === 404)
|
|
30904
|
+
return { ok: false, code: 404 };
|
|
30905
|
+
if (res.status === 401) {
|
|
30906
|
+
throw new ApiError(401, "Token was rejected by the server.");
|
|
30907
|
+
}
|
|
30908
|
+
if (res.status === 429) {
|
|
30909
|
+
throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
|
|
30910
|
+
}
|
|
30911
|
+
if (!res.ok) {
|
|
30912
|
+
throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
|
|
30913
|
+
}
|
|
30914
|
+
const body = await res.json();
|
|
30915
|
+
const parsed = FileWithDownloadSchema.safeParse(body);
|
|
30916
|
+
if (!parsed.success) {
|
|
30917
|
+
throw new ApiError(res.status, `get file response did not match the expected shape`);
|
|
30918
|
+
}
|
|
30919
|
+
return { ok: true, row: parsed.data };
|
|
29628
30920
|
}
|
|
29629
|
-
async function
|
|
30921
|
+
async function getFileText(args) {
|
|
29630
30922
|
const { baseUrl, pat, id, fetchImpl = fetch } = args;
|
|
29631
|
-
const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}`;
|
|
30923
|
+
const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/files/${id}/text`;
|
|
29632
30924
|
let res;
|
|
29633
30925
|
try {
|
|
29634
30926
|
res = await fetchImpl(url2, { method: "GET", headers: baseHeaders(pat) });
|
|
@@ -29639,18 +30931,18 @@ async function getFile(args) {
|
|
|
29639
30931
|
if (res.status === 404)
|
|
29640
30932
|
return { ok: false, code: 404 };
|
|
29641
30933
|
if (res.status === 401) {
|
|
29642
|
-
throw new ApiError(401, "Token was rejected by the server.");
|
|
30934
|
+
throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
|
|
29643
30935
|
}
|
|
29644
30936
|
if (res.status === 429) {
|
|
29645
30937
|
throw new ApiError(429, "Rate limited by the server.", parseRetryAfterMs(res.headers.get("retry-after")));
|
|
29646
30938
|
}
|
|
29647
30939
|
if (!res.ok) {
|
|
29648
|
-
throw new ApiError(res.status, `get file failed with HTTP ${res.status}`);
|
|
30940
|
+
throw new ApiError(res.status, `get file text failed with HTTP ${res.status}`);
|
|
29649
30941
|
}
|
|
29650
30942
|
const body = await res.json();
|
|
29651
|
-
const parsed =
|
|
30943
|
+
const parsed = FileTextSchema.safeParse(body);
|
|
29652
30944
|
if (!parsed.success) {
|
|
29653
|
-
throw new ApiError(res.status, `get file response did not match the expected shape`);
|
|
30945
|
+
throw new ApiError(res.status, `get file text response did not match the expected shape: ${parsed.error.message}`);
|
|
29654
30946
|
}
|
|
29655
30947
|
return { ok: true, row: parsed.data };
|
|
29656
30948
|
}
|
|
@@ -29845,13 +31137,13 @@ function dropFiles(state, relPath) {
|
|
|
29845
31137
|
}
|
|
29846
31138
|
function safeInode(absolutePath) {
|
|
29847
31139
|
try {
|
|
29848
|
-
return
|
|
31140
|
+
return statSync6(absolutePath).ino;
|
|
29849
31141
|
} catch {
|
|
29850
31142
|
return;
|
|
29851
31143
|
}
|
|
29852
31144
|
}
|
|
29853
31145
|
async function restoreFileFromCloud(args) {
|
|
29854
|
-
const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor } = args;
|
|
31146
|
+
const { workspaceSlug, baseUrl, pat, id, relPath, state, fetchImpl, suppressor, output } = args;
|
|
29855
31147
|
const got = await getFile({ baseUrl, pat, id, fetchImpl });
|
|
29856
31148
|
const abs = filesSubtreePath(workspaceSlug, relPath);
|
|
29857
31149
|
if (!got.ok) {
|
|
@@ -29859,12 +31151,17 @@ async function restoreFileFromCloud(args) {
|
|
|
29859
31151
|
deleteFilesFile(workspaceSlug, relPath);
|
|
29860
31152
|
return dropFiles(state, relPath);
|
|
29861
31153
|
}
|
|
31154
|
+
if (got.row.download_url === null) {
|
|
31155
|
+
output?.warn(`files/${relPath}: Plinth holds no bytes for this file, so there is nothing to restore from cloud — the local copy and its state were left exactly as they are.`);
|
|
31156
|
+
return state;
|
|
31157
|
+
}
|
|
29862
31158
|
const bytes = await downloadBytes(got.row.download_url, fetchImpl);
|
|
29863
31159
|
suppressor?.registerWrite(abs, {
|
|
29864
31160
|
bodyHash: got.row.content_hash,
|
|
29865
31161
|
frontmatterHash: ""
|
|
29866
31162
|
});
|
|
29867
31163
|
writeFilesBytes(workspaceSlug, relPath, bytes);
|
|
31164
|
+
deleteExtractedText(workspaceSlug, relPath);
|
|
29868
31165
|
return persistFiles(state, {
|
|
29869
31166
|
id: got.row.id,
|
|
29870
31167
|
path: relPath,
|
|
@@ -29893,7 +31190,7 @@ async function pushFilesUpsert(args) {
|
|
|
29893
31190
|
} else {
|
|
29894
31191
|
let st;
|
|
29895
31192
|
try {
|
|
29896
|
-
st =
|
|
31193
|
+
st = statSync6(absolutePath);
|
|
29897
31194
|
} catch (err) {
|
|
29898
31195
|
if (err?.code === "ENOENT") {
|
|
29899
31196
|
return pushFilesDelete({ workspaceSlug, baseUrl, pat, relPath, state, deps });
|
|
@@ -29928,7 +31225,7 @@ async function pushFilesUpsert(args) {
|
|
|
29928
31225
|
state: next
|
|
29929
31226
|
};
|
|
29930
31227
|
}
|
|
29931
|
-
bytes =
|
|
31228
|
+
bytes = readFileSync12(absolutePath);
|
|
29932
31229
|
hash2 = sha256Bytes(bytes);
|
|
29933
31230
|
inode = st.ino;
|
|
29934
31231
|
}
|
|
@@ -29999,7 +31296,8 @@ async function pushFilesUpsert(args) {
|
|
|
29999
31296
|
relPath,
|
|
30000
31297
|
state,
|
|
30001
31298
|
fetchImpl,
|
|
30002
|
-
suppressor
|
|
31299
|
+
suppressor,
|
|
31300
|
+
output
|
|
30003
31301
|
});
|
|
30004
31302
|
}
|
|
30005
31303
|
return { outcome: { kind: "conflict", relPath, stashPath }, state: nextState };
|
|
@@ -30036,7 +31334,8 @@ async function pushFilesDelete(args) {
|
|
|
30036
31334
|
relPath,
|
|
30037
31335
|
state,
|
|
30038
31336
|
fetchImpl,
|
|
30039
|
-
suppressor
|
|
31337
|
+
suppressor,
|
|
31338
|
+
output
|
|
30040
31339
|
});
|
|
30041
31340
|
return { outcome: { kind: "conflict", relPath, stashPath: "" }, state: nextState };
|
|
30042
31341
|
}
|
|
@@ -30063,12 +31362,12 @@ class PerIdMutex {
|
|
|
30063
31362
|
|
|
30064
31363
|
// src/lib/push.ts
|
|
30065
31364
|
var import_gray_matter9 = __toESM(require_gray_matter(), 1);
|
|
30066
|
-
import { readFileSync as
|
|
30067
|
-
import { basename as
|
|
31365
|
+
import { readFileSync as readFileSync14, statSync as statSync7 } from "node:fs";
|
|
31366
|
+
import { basename as basename4, relative as relative4 } from "node:path";
|
|
30068
31367
|
|
|
30069
31368
|
// src/lib/pull-write.ts
|
|
30070
31369
|
var import_gray_matter8 = __toESM(require_gray_matter(), 1);
|
|
30071
|
-
import { readFileSync as
|
|
31370
|
+
import { readFileSync as readFileSync13 } from "node:fs";
|
|
30072
31371
|
function computeMirrorHashes(strategy, content) {
|
|
30073
31372
|
const parsed = import_gray_matter8.default(content);
|
|
30074
31373
|
const fm = parsed.data ?? {};
|
|
@@ -30090,7 +31389,7 @@ function writeMirrorWithBodyPreserve(workspaceSlug, strategy, doc2, suppressor)
|
|
|
30090
31389
|
const target = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
|
|
30091
31390
|
let existing = null;
|
|
30092
31391
|
try {
|
|
30093
|
-
existing =
|
|
31392
|
+
existing = readFileSync13(target, "utf8");
|
|
30094
31393
|
} catch (err) {
|
|
30095
31394
|
if (err?.code !== "ENOENT")
|
|
30096
31395
|
throw err;
|
|
@@ -30161,7 +31460,7 @@ function omitKeys(obj, keys) {
|
|
|
30161
31460
|
}
|
|
30162
31461
|
function safeInode2(absolutePath) {
|
|
30163
31462
|
try {
|
|
30164
|
-
return
|
|
31463
|
+
return statSync7(absolutePath).ino;
|
|
30165
31464
|
} catch {
|
|
30166
31465
|
return;
|
|
30167
31466
|
}
|
|
@@ -30181,7 +31480,7 @@ function dropEntry(state, entityType, id) {
|
|
|
30181
31480
|
function stampStateFromMirror(args) {
|
|
30182
31481
|
const { strategy, workspaceSlug, doc: doc2, state, priorMemoryHash } = args;
|
|
30183
31482
|
const mirrorPath = entityFilePath(workspaceSlug, strategy.type, doc2.slug);
|
|
30184
|
-
const mirrorRaw =
|
|
31483
|
+
const mirrorRaw = readFileSync14(mirrorPath, "utf8");
|
|
30185
31484
|
const mirrorParsed = import_gray_matter9.default(mirrorRaw);
|
|
30186
31485
|
const mirrorFm = mirrorParsed.data ?? {};
|
|
30187
31486
|
const mirrorLifted = splitUserFrontmatter(strategy, mirrorFm);
|
|
@@ -30260,7 +31559,7 @@ async function pushFile(args) {
|
|
|
30260
31559
|
}
|
|
30261
31560
|
let raw;
|
|
30262
31561
|
try {
|
|
30263
|
-
raw =
|
|
31562
|
+
raw = readFileSync14(absolutePath, "utf8");
|
|
30264
31563
|
} catch (err) {
|
|
30265
31564
|
if (err?.code === "ENOENT") {
|
|
30266
31565
|
return runPushDelete({
|
|
@@ -30294,7 +31593,7 @@ async function pushFile(args) {
|
|
|
30294
31593
|
const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
|
|
30295
31594
|
parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
|
|
30296
31595
|
} catch {}
|
|
30297
|
-
const slugFromPath = parsedSlug ??
|
|
31596
|
+
const slugFromPath = parsedSlug ?? basename4(absolutePath, ".md");
|
|
30298
31597
|
const slug = slugFromFm ?? slugFromPath;
|
|
30299
31598
|
if (!slug) {
|
|
30300
31599
|
return {
|
|
@@ -30377,9 +31676,9 @@ async function reconcileContextDocConflict(args) {
|
|
|
30377
31676
|
let fileSlug;
|
|
30378
31677
|
try {
|
|
30379
31678
|
const rel = relative4(workspaceMirrorPath(workspaceSlug), absolutePath);
|
|
30380
|
-
fileSlug = strategy.parseMirrorPath(rel)?.slug ??
|
|
31679
|
+
fileSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(absolutePath, ".md");
|
|
30381
31680
|
} catch {
|
|
30382
|
-
fileSlug =
|
|
31681
|
+
fileSlug = basename4(absolutePath, ".md");
|
|
30383
31682
|
}
|
|
30384
31683
|
if (fileSlug !== existingSlug) {
|
|
30385
31684
|
const stashPath = stashConflict({
|
|
@@ -31006,7 +32305,7 @@ async function runPushDelete(args) {
|
|
|
31006
32305
|
const rel = relative4(workspaceMirrorPath(args.workspaceSlug), args.absolutePath);
|
|
31007
32306
|
parsedSlug = strategy.parseMirrorPath(rel)?.slug ?? null;
|
|
31008
32307
|
} catch {}
|
|
31009
|
-
const lookupSlug = parsedSlug ??
|
|
32308
|
+
const lookupSlug = parsedSlug ?? basename4(args.absolutePath, ".md");
|
|
31010
32309
|
const entry2 = getEntryBySlug(args.state, strategy.type, lookupSlug);
|
|
31011
32310
|
if (entry2) {
|
|
31012
32311
|
id = entry2.id;
|
|
@@ -31101,9 +32400,9 @@ async function pushRename(args) {
|
|
|
31101
32400
|
let toSlug;
|
|
31102
32401
|
try {
|
|
31103
32402
|
const rel = relative4(workspaceMirrorPath(workspaceSlug), toPath);
|
|
31104
|
-
toSlug = strategy.parseMirrorPath(rel)?.slug ??
|
|
32403
|
+
toSlug = strategy.parseMirrorPath(rel)?.slug ?? basename4(toPath, ".md");
|
|
31105
32404
|
} catch {
|
|
31106
|
-
toSlug =
|
|
32405
|
+
toSlug = basename4(toPath, ".md");
|
|
31107
32406
|
}
|
|
31108
32407
|
return mutex.run(id, async () => {
|
|
31109
32408
|
const live = getEntry(state, entityType, id);
|
|
@@ -31133,7 +32432,7 @@ async function pushRename(args) {
|
|
|
31133
32432
|
};
|
|
31134
32433
|
if (!updated.body_hash || !updated.frontmatter_hash) {
|
|
31135
32434
|
try {
|
|
31136
|
-
const mirrorRaw =
|
|
32435
|
+
const mirrorRaw = readFileSync14(toPath, "utf8");
|
|
31137
32436
|
const mirrorParsed = import_gray_matter9.default(mirrorRaw);
|
|
31138
32437
|
const renameFm = mirrorParsed.data ?? {};
|
|
31139
32438
|
const renameLifted = splitUserFrontmatter(strategy, renameFm);
|
|
@@ -31168,7 +32467,7 @@ async function pushRename(args) {
|
|
|
31168
32467
|
if (patchResult.reason === "slug_conflict") {
|
|
31169
32468
|
let contents2 = "";
|
|
31170
32469
|
try {
|
|
31171
|
-
contents2 =
|
|
32470
|
+
contents2 = readFileSync14(toPath, "utf8");
|
|
31172
32471
|
} catch {}
|
|
31173
32472
|
const stashPath2 = stashConflict({
|
|
31174
32473
|
workspaceSlug,
|
|
@@ -31208,7 +32507,7 @@ async function pushRename(args) {
|
|
|
31208
32507
|
}
|
|
31209
32508
|
let contents = "";
|
|
31210
32509
|
try {
|
|
31211
|
-
contents =
|
|
32510
|
+
contents = readFileSync14(toPath, "utf8");
|
|
31212
32511
|
} catch {}
|
|
31213
32512
|
const stashPath = stashConflict({
|
|
31214
32513
|
workspaceSlug,
|
|
@@ -31329,7 +32628,7 @@ async function discardBatchOps(batch, deps) {
|
|
|
31329
32628
|
const path5 = opPath(deps.workspaceSlug, op);
|
|
31330
32629
|
let local = null;
|
|
31331
32630
|
try {
|
|
31332
|
-
local =
|
|
32631
|
+
local = readFileSync15(path5, "utf8");
|
|
31333
32632
|
} catch {
|
|
31334
32633
|
local = null;
|
|
31335
32634
|
}
|
|
@@ -31363,7 +32662,7 @@ async function discardFileOp(op, deps) {
|
|
|
31363
32662
|
const abs = filesSubtreePath(deps.workspaceSlug, op.path);
|
|
31364
32663
|
let local = null;
|
|
31365
32664
|
try {
|
|
31366
|
-
local =
|
|
32665
|
+
local = readFileSync15(abs);
|
|
31367
32666
|
} catch {
|
|
31368
32667
|
local = null;
|
|
31369
32668
|
}
|
|
@@ -31382,7 +32681,8 @@ async function discardFileOp(op, deps) {
|
|
|
31382
32681
|
id: op.id,
|
|
31383
32682
|
relPath: op.path,
|
|
31384
32683
|
state: deps.getState(),
|
|
31385
|
-
fetchImpl: deps.fetchImpl
|
|
32684
|
+
fetchImpl: deps.fetchImpl,
|
|
32685
|
+
output: deps.output
|
|
31386
32686
|
});
|
|
31387
32687
|
deps.setState(next);
|
|
31388
32688
|
}
|
|
@@ -31544,10 +32844,10 @@ class FileDestructiveGate {
|
|
|
31544
32844
|
}
|
|
31545
32845
|
|
|
31546
32846
|
// src/lib/files/reconcile.ts
|
|
31547
|
-
import { readFileSync as
|
|
32847
|
+
import { readFileSync as readFileSync16, statSync as statSync9 } from "node:fs";
|
|
31548
32848
|
|
|
31549
32849
|
// src/lib/unsynced.ts
|
|
31550
|
-
import { join as
|
|
32850
|
+
import { join as join11, relative as relative6 } from "node:path";
|
|
31551
32851
|
var RECORDABLE_REASONS = new Set([
|
|
31552
32852
|
"rate_limit",
|
|
31553
32853
|
"server",
|
|
@@ -31610,7 +32910,7 @@ async function drainUnsyncedOps(args) {
|
|
|
31610
32910
|
attempted += 1;
|
|
31611
32911
|
try {
|
|
31612
32912
|
if (op.target === "entity") {
|
|
31613
|
-
const absolutePath =
|
|
32913
|
+
const absolutePath = join11(workspaceMirrorPath(workspaceSlug), op.path);
|
|
31614
32914
|
const classified = classifyMirrorPath({ workspaceSlug, absolutePath });
|
|
31615
32915
|
if (!classified) {
|
|
31616
32916
|
clear("entity", op.path);
|
|
@@ -31856,10 +33156,10 @@ class ReaddirpStream extends Readable {
|
|
|
31856
33156
|
}
|
|
31857
33157
|
async _formatEntry(dirent, path5) {
|
|
31858
33158
|
let entry2;
|
|
31859
|
-
const
|
|
33159
|
+
const basename5 = this._isDirent ? dirent.name : dirent;
|
|
31860
33160
|
try {
|
|
31861
|
-
const fullPath = presolve(pjoin(path5,
|
|
31862
|
-
entry2 = { path: prelative(this._root, fullPath), fullPath, basename:
|
|
33161
|
+
const fullPath = presolve(pjoin(path5, basename5));
|
|
33162
|
+
entry2 = { path: prelative(this._root, fullPath), fullPath, basename: basename5 };
|
|
31863
33163
|
entry2[this._statsProp] = this._isDirent ? dirent : await this._stat(fullPath);
|
|
31864
33164
|
} catch (err) {
|
|
31865
33165
|
this._onError(err);
|
|
@@ -32386,9 +33686,9 @@ class NodeFsHandler {
|
|
|
32386
33686
|
_watchWithNodeFs(path5, listener) {
|
|
32387
33687
|
const opts = this.fsw.options;
|
|
32388
33688
|
const directory = sp.dirname(path5);
|
|
32389
|
-
const
|
|
33689
|
+
const basename6 = sp.basename(path5);
|
|
32390
33690
|
const parent = this.fsw._getWatchedDir(directory);
|
|
32391
|
-
parent.add(
|
|
33691
|
+
parent.add(basename6);
|
|
32392
33692
|
const absolutePath = sp.resolve(path5);
|
|
32393
33693
|
const options2 = {
|
|
32394
33694
|
persistent: opts.persistent
|
|
@@ -32398,7 +33698,7 @@ class NodeFsHandler {
|
|
|
32398
33698
|
let closer;
|
|
32399
33699
|
if (opts.usePolling) {
|
|
32400
33700
|
const enableBin = opts.interval !== opts.binaryInterval;
|
|
32401
|
-
options2.interval = enableBin && isBinaryPath(
|
|
33701
|
+
options2.interval = enableBin && isBinaryPath(basename6) ? opts.binaryInterval : opts.interval;
|
|
32402
33702
|
closer = setFsWatchFileListener(path5, absolutePath, options2, {
|
|
32403
33703
|
listener,
|
|
32404
33704
|
rawEmitter: this.fsw._emitRaw
|
|
@@ -32416,11 +33716,11 @@ class NodeFsHandler {
|
|
|
32416
33716
|
if (this.fsw.closed) {
|
|
32417
33717
|
return;
|
|
32418
33718
|
}
|
|
32419
|
-
const
|
|
32420
|
-
const
|
|
32421
|
-
const parent = this.fsw._getWatchedDir(
|
|
33719
|
+
const dirname9 = sp.dirname(file2);
|
|
33720
|
+
const basename6 = sp.basename(file2);
|
|
33721
|
+
const parent = this.fsw._getWatchedDir(dirname9);
|
|
32422
33722
|
let prevStats = stats;
|
|
32423
|
-
if (parent.has(
|
|
33723
|
+
if (parent.has(basename6))
|
|
32424
33724
|
return;
|
|
32425
33725
|
const listener = async (path5, newStats) => {
|
|
32426
33726
|
if (!this.fsw._throttle(THROTTLE_MODE_WATCH, file2, 5))
|
|
@@ -32445,9 +33745,9 @@ class NodeFsHandler {
|
|
|
32445
33745
|
prevStats = newStats2;
|
|
32446
33746
|
}
|
|
32447
33747
|
} catch (error52) {
|
|
32448
|
-
this.fsw._remove(
|
|
33748
|
+
this.fsw._remove(dirname9, basename6);
|
|
32449
33749
|
}
|
|
32450
|
-
} else if (parent.has(
|
|
33750
|
+
} else if (parent.has(basename6)) {
|
|
32451
33751
|
const at = newStats.atimeMs;
|
|
32452
33752
|
const mt = newStats.mtimeMs;
|
|
32453
33753
|
if (!at || at <= mt || mt !== prevStats.mtimeMs) {
|
|
@@ -33301,7 +34601,7 @@ function watch(paths, options2 = {}) {
|
|
|
33301
34601
|
var chokidar_default = { watch, FSWatcher };
|
|
33302
34602
|
|
|
33303
34603
|
// src/lib/watcher.ts
|
|
33304
|
-
import { statSync as
|
|
34604
|
+
import { statSync as statSync8 } from "node:fs";
|
|
33305
34605
|
import { sep as sep5 } from "node:path";
|
|
33306
34606
|
|
|
33307
34607
|
// src/lib/rename-classifier.ts
|
|
@@ -33517,7 +34817,7 @@ function startWatcher(opts, handler) {
|
|
|
33517
34817
|
pairingWindowMs: opts.renamePairingWindowMs ?? DEFAULT_DEBOUNCE_MS,
|
|
33518
34818
|
classifyPath: opts.classifyPath,
|
|
33519
34819
|
getStateEntry: opts.getStateEntry,
|
|
33520
|
-
statSync: opts.statSync ?? ((p) => ({ ino:
|
|
34820
|
+
statSync: opts.statSync ?? ((p) => ({ ino: statSync8(p).ino }))
|
|
33521
34821
|
});
|
|
33522
34822
|
const factory = opts.chokidarFactory ?? ((paths, watcherOpts) => chokidar_default.watch(paths, watcherOpts));
|
|
33523
34823
|
const watcher = factory(workspaceRoot, {
|
|
@@ -33552,7 +34852,7 @@ function readBlobForPush(args) {
|
|
|
33552
34852
|
const { absolutePath, relPath, output } = args;
|
|
33553
34853
|
let st;
|
|
33554
34854
|
try {
|
|
33555
|
-
st =
|
|
34855
|
+
st = statSync9(absolutePath);
|
|
33556
34856
|
} catch (err) {
|
|
33557
34857
|
if (err?.code === "ENOENT")
|
|
33558
34858
|
return { kind: "gone" };
|
|
@@ -33571,7 +34871,7 @@ function readBlobForPush(args) {
|
|
|
33571
34871
|
}
|
|
33572
34872
|
let bytes;
|
|
33573
34873
|
try {
|
|
33574
|
-
bytes =
|
|
34874
|
+
bytes = readFileSync16(absolutePath);
|
|
33575
34875
|
} catch (err) {
|
|
33576
34876
|
if (err?.code === "ENOENT")
|
|
33577
34877
|
return { kind: "gone" };
|
|
@@ -34099,66 +35399,813 @@ class PullLoop {
|
|
|
34099
35399
|
this.timer = null;
|
|
34100
35400
|
}
|
|
34101
35401
|
}
|
|
34102
|
-
schedule(ms) {
|
|
34103
|
-
if (this.stopped)
|
|
34104
|
-
return;
|
|
34105
|
-
this.timer = this.setTimer(() => {
|
|
34106
|
-
this.tick();
|
|
34107
|
-
}, ms);
|
|
35402
|
+
schedule(ms) {
|
|
35403
|
+
if (this.stopped)
|
|
35404
|
+
return;
|
|
35405
|
+
this.timer = this.setTimer(() => {
|
|
35406
|
+
this.tick();
|
|
35407
|
+
}, ms);
|
|
35408
|
+
}
|
|
35409
|
+
async tick() {
|
|
35410
|
+
if (this.stopped || this.inFlight)
|
|
35411
|
+
return;
|
|
35412
|
+
this.inFlight = true;
|
|
35413
|
+
let nextDelay;
|
|
35414
|
+
try {
|
|
35415
|
+
const result = await this.runOnce();
|
|
35416
|
+
nextDelay = result.exitCode === 0 ? this.onSuccess(result) : this.onFailure(result.failure);
|
|
35417
|
+
} catch {
|
|
35418
|
+
nextDelay = this.onFailure({ kind: "transient", status: 0 });
|
|
35419
|
+
} finally {
|
|
35420
|
+
this.inFlight = false;
|
|
35421
|
+
}
|
|
35422
|
+
this.schedule(nextDelay);
|
|
35423
|
+
}
|
|
35424
|
+
onSuccess(result) {
|
|
35425
|
+
this.failures = 0;
|
|
35426
|
+
this.capStreak = 0;
|
|
35427
|
+
this.degradedAnnounced = false;
|
|
35428
|
+
this.authAnnounced = false;
|
|
35429
|
+
if (result.applied !== undefined && result.applied > 0) {
|
|
35430
|
+
this.onApplied(result.applied);
|
|
35431
|
+
}
|
|
35432
|
+
this.afterPoll();
|
|
35433
|
+
return this.intervalMs;
|
|
35434
|
+
}
|
|
35435
|
+
onFailure(failure) {
|
|
35436
|
+
const kind = failure?.kind ?? "transient";
|
|
35437
|
+
if (kind === "auth") {
|
|
35438
|
+
if (!this.authAnnounced) {
|
|
35439
|
+
this.authAnnounced = true;
|
|
35440
|
+
this.notice("auth", `Pull sync paused — the server rejected the token. Run \`${this.loginHint}\`; ` + `it resumes automatically on the next retry once the token is valid, or on daemon restart.`);
|
|
35441
|
+
}
|
|
35442
|
+
return this.authRetryMs;
|
|
35443
|
+
}
|
|
35444
|
+
this.failures += 1;
|
|
35445
|
+
let delay;
|
|
35446
|
+
if (failure?.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
|
|
35447
|
+
delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
|
|
35448
|
+
} else {
|
|
35449
|
+
delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
|
|
35450
|
+
}
|
|
35451
|
+
this.trackCap(delay);
|
|
35452
|
+
return delay;
|
|
35453
|
+
}
|
|
35454
|
+
trackCap(delay) {
|
|
35455
|
+
this.capStreak = delay >= this.capMs ? this.capStreak + 1 : 0;
|
|
35456
|
+
if (this.capStreak >= DEGRADED_CAP_STREAK && !this.degradedAnnounced) {
|
|
35457
|
+
this.degradedAnnounced = true;
|
|
35458
|
+
const mins = Math.round(this.capMs / 60000);
|
|
35459
|
+
this.notice("degraded", `Pull sync degraded — repeated failures, retrying about every ${mins}m. ` + `Local edits still push; the mirror may be stale until the cloud is reachable again.`);
|
|
35460
|
+
}
|
|
35461
|
+
}
|
|
35462
|
+
}
|
|
35463
|
+
|
|
35464
|
+
// src/lib/calendar-loop.ts
|
|
35465
|
+
var DEFAULT_CALENDAR_INTERVAL_MS = 900000;
|
|
35466
|
+
var CALENDAR_BACKOFF_CAP_MS = 3600000;
|
|
35467
|
+
var CALENDAR_AUTH_RETRY_MS = 300000;
|
|
35468
|
+
function calendarIntervalMs() {
|
|
35469
|
+
const raw = process.env.PLINTH_CALENDAR_INTERVAL_MS;
|
|
35470
|
+
if (raw) {
|
|
35471
|
+
const n2 = Number(raw);
|
|
35472
|
+
if (Number.isFinite(n2) && n2 > 0)
|
|
35473
|
+
return n2;
|
|
35474
|
+
}
|
|
35475
|
+
return DEFAULT_CALENDAR_INTERVAL_MS;
|
|
35476
|
+
}
|
|
35477
|
+
|
|
35478
|
+
class CalendarLoop {
|
|
35479
|
+
runOnce;
|
|
35480
|
+
onProjected;
|
|
35481
|
+
notice;
|
|
35482
|
+
loginHint;
|
|
35483
|
+
intervalMs;
|
|
35484
|
+
capMs;
|
|
35485
|
+
authRetryMs;
|
|
35486
|
+
setTimer;
|
|
35487
|
+
clearTimer;
|
|
35488
|
+
timer = null;
|
|
35489
|
+
inFlight = false;
|
|
35490
|
+
stopped = false;
|
|
35491
|
+
failures = 0;
|
|
35492
|
+
authAnnounced = false;
|
|
35493
|
+
degradedAnnounced = false;
|
|
35494
|
+
constructor(deps) {
|
|
35495
|
+
this.runOnce = deps.runOnce;
|
|
35496
|
+
this.onProjected = deps.onProjected;
|
|
35497
|
+
this.notice = deps.notice;
|
|
35498
|
+
this.loginHint = deps.loginHint;
|
|
35499
|
+
this.intervalMs = deps.intervalMs ?? calendarIntervalMs();
|
|
35500
|
+
this.capMs = deps.capMs ?? CALENDAR_BACKOFF_CAP_MS;
|
|
35501
|
+
this.authRetryMs = deps.authRetryMs ?? CALENDAR_AUTH_RETRY_MS;
|
|
35502
|
+
this.setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
35503
|
+
this.clearTimer = deps.clearTimer ?? ((t2) => clearTimeout(t2));
|
|
35504
|
+
}
|
|
35505
|
+
startNow() {
|
|
35506
|
+
this.tick();
|
|
35507
|
+
}
|
|
35508
|
+
start() {
|
|
35509
|
+
this.schedule(this.intervalMs);
|
|
35510
|
+
}
|
|
35511
|
+
stop() {
|
|
35512
|
+
this.stopped = true;
|
|
35513
|
+
if (this.timer !== null) {
|
|
35514
|
+
this.clearTimer(this.timer);
|
|
35515
|
+
this.timer = null;
|
|
35516
|
+
}
|
|
35517
|
+
}
|
|
35518
|
+
schedule(ms) {
|
|
35519
|
+
if (this.stopped)
|
|
35520
|
+
return;
|
|
35521
|
+
this.timer = this.setTimer(() => {
|
|
35522
|
+
this.tick();
|
|
35523
|
+
}, ms);
|
|
35524
|
+
}
|
|
35525
|
+
async tick() {
|
|
35526
|
+
if (this.stopped || this.inFlight)
|
|
35527
|
+
return;
|
|
35528
|
+
this.inFlight = true;
|
|
35529
|
+
let nextDelay;
|
|
35530
|
+
try {
|
|
35531
|
+
const result = await this.runOnce();
|
|
35532
|
+
nextDelay = result.ok ? this.onSuccess(result) : this.onFailure(result.failure);
|
|
35533
|
+
} catch (err) {
|
|
35534
|
+
const reason = err instanceof Error ? err.message : "unknown error";
|
|
35535
|
+
nextDelay = this.onFailure({ kind: "transient", reason });
|
|
35536
|
+
} finally {
|
|
35537
|
+
this.inFlight = false;
|
|
35538
|
+
}
|
|
35539
|
+
if (nextDelay !== null)
|
|
35540
|
+
this.schedule(nextDelay);
|
|
35541
|
+
}
|
|
35542
|
+
onSuccess(result) {
|
|
35543
|
+
this.failures = 0;
|
|
35544
|
+
this.authAnnounced = false;
|
|
35545
|
+
this.degradedAnnounced = false;
|
|
35546
|
+
if (result.written > 0 || result.pruned > 0) {
|
|
35547
|
+
this.onProjected(result.written, result.pruned);
|
|
35548
|
+
}
|
|
35549
|
+
return this.intervalMs;
|
|
35550
|
+
}
|
|
35551
|
+
onFailure(failure) {
|
|
35552
|
+
if (failure.kind === "unsupported") {
|
|
35553
|
+
this.stop();
|
|
35554
|
+
this.notice("unsupported", `Calendar projection is off: this Plinth server has no calendar route. ` + `Nothing under views/calendar/ will be written or updated. Restart the ` + `daemon once the server is up to date.`);
|
|
35555
|
+
return null;
|
|
35556
|
+
}
|
|
35557
|
+
if (failure.kind === "auth") {
|
|
35558
|
+
if (!this.authAnnounced) {
|
|
35559
|
+
this.authAnnounced = true;
|
|
35560
|
+
this.notice("auth", `Calendar refresh paused - the server rejected the token. Run ` + `\`${this.loginHint}\`, then RESTART the daemon: the token is read once ` + `at startup, so a re-login is not picked up until it does. Files ` + `under views/calendar/ keep their last good read and say they are stale.`);
|
|
35561
|
+
}
|
|
35562
|
+
return this.authRetryMs;
|
|
35563
|
+
}
|
|
35564
|
+
this.failures += 1;
|
|
35565
|
+
let delay;
|
|
35566
|
+
if (failure.kind === "rate_limit" && failure.retryAfterMs !== undefined) {
|
|
35567
|
+
delay = Math.min(Math.max(failure.retryAfterMs, 1000), this.capMs);
|
|
35568
|
+
} else {
|
|
35569
|
+
delay = Math.min(this.intervalMs * 2 ** (this.failures - 1), this.capMs);
|
|
35570
|
+
}
|
|
35571
|
+
if (delay >= this.capMs && !this.degradedAnnounced) {
|
|
35572
|
+
this.degradedAnnounced = true;
|
|
35573
|
+
const mins = Math.round(this.capMs / 60000);
|
|
35574
|
+
this.notice("degraded", `Calendar refresh degraded - repeated failures, retrying about every ` + `${mins}m. Files under views/calendar/ still show the last good read ` + `and say that they are stale.`);
|
|
35575
|
+
}
|
|
35576
|
+
return delay;
|
|
35577
|
+
}
|
|
35578
|
+
}
|
|
35579
|
+
function classifyCalendarFailure(err) {
|
|
35580
|
+
if (err instanceof ApiError) {
|
|
35581
|
+
if (err.status === 404)
|
|
35582
|
+
return { kind: "unsupported", reason: err.message };
|
|
35583
|
+
if (err.status === 401 || err.status === 403) {
|
|
35584
|
+
return { kind: "auth", reason: err.message };
|
|
35585
|
+
}
|
|
35586
|
+
if (err.status === 429) {
|
|
35587
|
+
return {
|
|
35588
|
+
kind: "rate_limit",
|
|
35589
|
+
reason: err.message,
|
|
35590
|
+
retryAfterMs: err.retryAfterMs
|
|
35591
|
+
};
|
|
35592
|
+
}
|
|
35593
|
+
return { kind: "transient", reason: err.message };
|
|
35594
|
+
}
|
|
35595
|
+
return {
|
|
35596
|
+
kind: "transient",
|
|
35597
|
+
reason: err instanceof Error ? err.message : "unknown error"
|
|
35598
|
+
};
|
|
35599
|
+
}
|
|
35600
|
+
|
|
35601
|
+
// src/lib/calendar/api.ts
|
|
35602
|
+
class CalendarContractError extends ApiError {
|
|
35603
|
+
constructor(status, message) {
|
|
35604
|
+
super(status, message);
|
|
35605
|
+
this.name = "CalendarContractError";
|
|
35606
|
+
}
|
|
35607
|
+
}
|
|
35608
|
+
var CalendarEventSchema = exports_external.object({
|
|
35609
|
+
id: exports_external.string(),
|
|
35610
|
+
title: exports_external.string(),
|
|
35611
|
+
start: exports_external.string().min(1),
|
|
35612
|
+
end: exports_external.string().min(1),
|
|
35613
|
+
all_day: exports_external.boolean(),
|
|
35614
|
+
location: exports_external.string().nullish().transform((v2) => v2 ?? null),
|
|
35615
|
+
booking_status: exports_external.enum(["confirmed", "hold"]),
|
|
35616
|
+
url: exports_external.string()
|
|
35617
|
+
});
|
|
35618
|
+
var CALENDAR_STATUSES = [
|
|
35619
|
+
"fetched",
|
|
35620
|
+
"no-token",
|
|
35621
|
+
"token-expired",
|
|
35622
|
+
"google-error"
|
|
35623
|
+
];
|
|
35624
|
+
var CalendarRowSchema = exports_external.object({
|
|
35625
|
+
artist_id: exports_external.string().nullable(),
|
|
35626
|
+
artist_slug: exports_external.string().nullable(),
|
|
35627
|
+
artist_name: exports_external.string().nullable(),
|
|
35628
|
+
calendar_id: exports_external.string(),
|
|
35629
|
+
calendar_name: exports_external.string(),
|
|
35630
|
+
status: exports_external.enum(CALENDAR_STATUSES),
|
|
35631
|
+
events: exports_external.array(CalendarEventSchema).nullable(),
|
|
35632
|
+
error: exports_external.object({ upstream_status: exports_external.number(), message: exports_external.string() }).nullish().transform((v2) => v2 ?? null),
|
|
35633
|
+
truncated: exports_external.boolean().optional().default(false)
|
|
35634
|
+
});
|
|
35635
|
+
var CalendarResponseSchema = exports_external.object({
|
|
35636
|
+
generated_at: exports_external.string().min(1),
|
|
35637
|
+
window: exports_external.object({
|
|
35638
|
+
time_min: exports_external.string().min(1),
|
|
35639
|
+
time_max: exports_external.string().min(1)
|
|
35640
|
+
}),
|
|
35641
|
+
artist_count: exports_external.number().int().nonnegative(),
|
|
35642
|
+
mapped_artist_count: exports_external.number().int().nonnegative(),
|
|
35643
|
+
calendars: exports_external.array(CalendarRowSchema)
|
|
35644
|
+
}).superRefine((body, ctx) => {
|
|
35645
|
+
for (const [i2, row] of body.calendars.entries()) {
|
|
35646
|
+
const isFetched = row.status === "fetched";
|
|
35647
|
+
if (isFetched && row.events === null) {
|
|
35648
|
+
ctx.addIssue({
|
|
35649
|
+
code: exports_external.ZodIssueCode.custom,
|
|
35650
|
+
path: ["calendars", i2, "events"],
|
|
35651
|
+
message: `status "fetched" must carry an events array, not null`
|
|
35652
|
+
});
|
|
35653
|
+
}
|
|
35654
|
+
if (!isFetched && row.events !== null) {
|
|
35655
|
+
ctx.addIssue({
|
|
35656
|
+
code: exports_external.ZodIssueCode.custom,
|
|
35657
|
+
path: ["calendars", i2, "events"],
|
|
35658
|
+
message: `status "${row.status}" must carry events: null, not an array`
|
|
35659
|
+
});
|
|
35660
|
+
}
|
|
35661
|
+
}
|
|
35662
|
+
});
|
|
35663
|
+
async function fetchCalendar(args) {
|
|
35664
|
+
const { baseUrl, pat, fetchImpl = fetch } = args;
|
|
35665
|
+
const url2 = `${baseUrl.replace(/\/$/, "")}/api/sync/calendar`;
|
|
35666
|
+
let res;
|
|
35667
|
+
try {
|
|
35668
|
+
res = await fetchImpl(url2, {
|
|
35669
|
+
method: "GET",
|
|
35670
|
+
headers: {
|
|
35671
|
+
Authorization: `Bearer ${pat}`,
|
|
35672
|
+
Accept: "application/json"
|
|
35673
|
+
}
|
|
35674
|
+
});
|
|
35675
|
+
} catch (err) {
|
|
35676
|
+
const msg = err instanceof Error ? err.message : "network error";
|
|
35677
|
+
throw new ApiError(0, `Could not reach ${url2}: ${msg}`);
|
|
35678
|
+
}
|
|
35679
|
+
if (res.status === 401) {
|
|
35680
|
+
throw new ApiError(401, "Token was rejected by the server. Run `plinth login --workspace <slug>` to refresh.");
|
|
35681
|
+
}
|
|
35682
|
+
if (res.status === 404) {
|
|
35683
|
+
throw new ApiError(404, "This Plinth server has no /api/sync/calendar route.");
|
|
35684
|
+
}
|
|
35685
|
+
if (res.status === 429) {
|
|
35686
|
+
throw new ApiError(429, "Rate limited by the server. Wait a few seconds and retry.", parseRetryAfterMs(res.headers.get("retry-after")));
|
|
35687
|
+
}
|
|
35688
|
+
if (!res.ok) {
|
|
35689
|
+
throw new ApiError(res.status, `sync calendar failed with HTTP ${res.status}`);
|
|
35690
|
+
}
|
|
35691
|
+
let body;
|
|
35692
|
+
try {
|
|
35693
|
+
body = await res.json();
|
|
35694
|
+
} catch {
|
|
35695
|
+
throw new CalendarContractError(res.status, "sync calendar response was not JSON");
|
|
35696
|
+
}
|
|
35697
|
+
const parsed = CalendarResponseSchema.safeParse(body);
|
|
35698
|
+
if (!parsed.success) {
|
|
35699
|
+
const detail = parsed.error.issues.slice(0, 3).map((i2) => `${i2.path.join(".") || "(root)"}: ${i2.message}`).join("; ");
|
|
35700
|
+
throw new CalendarContractError(res.status, `sync calendar response did not match the contract: ${detail}`);
|
|
35701
|
+
}
|
|
35702
|
+
return parsed.data;
|
|
35703
|
+
}
|
|
35704
|
+
|
|
35705
|
+
// src/lib/calendar/render.ts
|
|
35706
|
+
var CALENDAR_BODY_MARKER = "<!-- PLINTH:CALENDAR-BODY -->";
|
|
35707
|
+
var OWNERSHIP_NOTE2 = [
|
|
35708
|
+
"<!-- Generated by Plinth from the calendars mapped in this workspace. This file",
|
|
35709
|
+
" is DERIVED: one-way, overwritten on every refresh, and never uploaded.",
|
|
35710
|
+
" Your edits here are lost. Dates are changed in Plinth's calendar view, by a",
|
|
35711
|
+
" person - not from this file and not by an agent. -->"
|
|
35712
|
+
].join(`
|
|
35713
|
+
`);
|
|
35714
|
+
var DEFAULT_USER_LABEL = "the Plinth account this mirror syncs with";
|
|
35715
|
+
var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
35716
|
+
var MONTHS = [
|
|
35717
|
+
"Jan",
|
|
35718
|
+
"Feb",
|
|
35719
|
+
"Mar",
|
|
35720
|
+
"Apr",
|
|
35721
|
+
"May",
|
|
35722
|
+
"Jun",
|
|
35723
|
+
"Jul",
|
|
35724
|
+
"Aug",
|
|
35725
|
+
"Sep",
|
|
35726
|
+
"Oct",
|
|
35727
|
+
"Nov",
|
|
35728
|
+
"Dec"
|
|
35729
|
+
];
|
|
35730
|
+
var DAY_MS = 86400000;
|
|
35731
|
+
var FULL_BAND_DAYS = 14;
|
|
35732
|
+
function yamlString(value) {
|
|
35733
|
+
return JSON.stringify(value);
|
|
35734
|
+
}
|
|
35735
|
+
function oneLine(value, maxLength = 200) {
|
|
35736
|
+
const flattened = value.replace(/[\u0000-\u001F\u007F]+/g, " ").replace(/<!--/g, "<! --").replace(/\s+/g, " ").trim();
|
|
35737
|
+
if (flattened.length === 0)
|
|
35738
|
+
return "(untitled)";
|
|
35739
|
+
if (flattened.length <= maxLength)
|
|
35740
|
+
return flattened;
|
|
35741
|
+
return `${flattened.slice(0, maxLength - 3)}...`;
|
|
35742
|
+
}
|
|
35743
|
+
function dateParts(value) {
|
|
35744
|
+
const m2 = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
35745
|
+
if (!m2)
|
|
35746
|
+
return null;
|
|
35747
|
+
return { y: Number(m2[1]), m: Number(m2[2]), d: Number(m2[3]) };
|
|
35748
|
+
}
|
|
35749
|
+
function timeParts(value) {
|
|
35750
|
+
const m2 = /T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:\d{2})?$/.exec(value);
|
|
35751
|
+
if (!m2)
|
|
35752
|
+
return null;
|
|
35753
|
+
const rawOffset = m2[3];
|
|
35754
|
+
const offset = rawOffset === undefined ? "" : rawOffset === "Z" ? "UTC" : rawOffset;
|
|
35755
|
+
return { hh: m2[1], mi: m2[2], offset };
|
|
35756
|
+
}
|
|
35757
|
+
function utcMs(parts) {
|
|
35758
|
+
return Date.UTC(parts.y, parts.m - 1, parts.d);
|
|
35759
|
+
}
|
|
35760
|
+
function shiftDays(parts, days) {
|
|
35761
|
+
const d2 = new Date(utcMs(parts) + days * DAY_MS);
|
|
35762
|
+
return { y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() };
|
|
35763
|
+
}
|
|
35764
|
+
function formatDay(parts, referenceYear) {
|
|
35765
|
+
const weekday = WEEKDAYS[new Date(utcMs(parts)).getUTCDay()] ?? "";
|
|
35766
|
+
const month = MONTHS[parts.m - 1] ?? "";
|
|
35767
|
+
const year = parts.y === referenceYear ? "" : ` ${parts.y}`;
|
|
35768
|
+
return `${weekday} ${parts.d} ${month}${year}`;
|
|
35769
|
+
}
|
|
35770
|
+
function sortInstant(event) {
|
|
35771
|
+
if (!event.all_day) {
|
|
35772
|
+
const ms = Date.parse(event.start);
|
|
35773
|
+
if (!Number.isNaN(ms))
|
|
35774
|
+
return ms;
|
|
35775
|
+
}
|
|
35776
|
+
const parts = dateParts(event.start);
|
|
35777
|
+
if (parts)
|
|
35778
|
+
return utcMs(parts);
|
|
35779
|
+
return Number.MAX_SAFE_INTEGER;
|
|
35780
|
+
}
|
|
35781
|
+
function describeWhen(event, referenceYear) {
|
|
35782
|
+
const start = dateParts(event.start);
|
|
35783
|
+
if (!start)
|
|
35784
|
+
return oneLine(event.start, 60);
|
|
35785
|
+
if (event.all_day) {
|
|
35786
|
+
const end2 = dateParts(event.end);
|
|
35787
|
+
const last = end2 ? shiftDays(end2, -1) : start;
|
|
35788
|
+
const single = !end2 || utcMs(last) <= utcMs(start);
|
|
35789
|
+
return single ? `${formatDay(start, referenceYear)} (all day)` : `${formatDay(start, referenceYear)} to ${formatDay(last, referenceYear)} (all day)`;
|
|
35790
|
+
}
|
|
35791
|
+
const startTime = timeParts(event.start);
|
|
35792
|
+
const end = dateParts(event.end);
|
|
35793
|
+
const endTime = timeParts(event.end);
|
|
35794
|
+
if (!startTime)
|
|
35795
|
+
return formatDay(start, referenceYear);
|
|
35796
|
+
const suffix = startTime.offset ? ` ${startTime.offset}` : "";
|
|
35797
|
+
if (!end || !endTime) {
|
|
35798
|
+
return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}${suffix}`;
|
|
35799
|
+
}
|
|
35800
|
+
if (utcMs(end) === utcMs(start)) {
|
|
35801
|
+
return `${formatDay(start, referenceYear)}, ${startTime.hh}:${startTime.mi}-${endTime.hh}:${endTime.mi}${suffix}`;
|
|
35802
|
+
}
|
|
35803
|
+
const endSuffix = endTime.offset ? ` ${endTime.offset}` : "";
|
|
35804
|
+
return `${formatDay(start, referenceYear)} ${startTime.hh}:${startTime.mi}${suffix}` + ` to ${formatDay(end, referenceYear)} ${endTime.hh}:${endTime.mi}${endSuffix}`;
|
|
35805
|
+
}
|
|
35806
|
+
function fullLine(event, referenceYear, artistPrefix, includeUrl) {
|
|
35807
|
+
const parts = [
|
|
35808
|
+
`**${describeWhen(event, referenceYear)}**`,
|
|
35809
|
+
artistPrefix,
|
|
35810
|
+
`${oneLine(event.title)} (${event.booking_status})`
|
|
35811
|
+
].filter((p) => p.length > 0);
|
|
35812
|
+
if (event.location)
|
|
35813
|
+
parts.push(oneLine(event.location, 80));
|
|
35814
|
+
if (includeUrl && event.url)
|
|
35815
|
+
parts.push(oneLine(event.url, 300));
|
|
35816
|
+
return `- ${parts.join(" - ")}`;
|
|
35817
|
+
}
|
|
35818
|
+
function compactLine(event, referenceYear, artistPrefix) {
|
|
35819
|
+
const parts = [
|
|
35820
|
+
describeWhen(event, referenceYear),
|
|
35821
|
+
artistPrefix,
|
|
35822
|
+
`${oneLine(event.title)} (${event.booking_status})`
|
|
35823
|
+
].filter((p) => p.length > 0);
|
|
35824
|
+
return `- ${parts.join(" - ")}`;
|
|
35825
|
+
}
|
|
35826
|
+
function humanStamp(iso) {
|
|
35827
|
+
const ms = Date.parse(iso);
|
|
35828
|
+
if (Number.isNaN(ms))
|
|
35829
|
+
return iso;
|
|
35830
|
+
const d2 = new Date(ms);
|
|
35831
|
+
const day = formatDay({ y: d2.getUTCFullYear(), m: d2.getUTCMonth() + 1, d: d2.getUTCDate() }, -1);
|
|
35832
|
+
const hh = String(d2.getUTCHours()).padStart(2, "0");
|
|
35833
|
+
const mi = String(d2.getUTCMinutes()).padStart(2, "0");
|
|
35834
|
+
return `${day}, ${hh}:${mi} UTC`;
|
|
35835
|
+
}
|
|
35836
|
+
function windowLine(timeMin, timeMax) {
|
|
35837
|
+
const from = dateParts(timeMin);
|
|
35838
|
+
const to = dateParts(timeMax);
|
|
35839
|
+
if (!from || !to)
|
|
35840
|
+
return `${timeMin} to ${timeMax}`;
|
|
35841
|
+
return `${formatDay(from, -1)} to ${formatDay(to, -1)}`;
|
|
35842
|
+
}
|
|
35843
|
+
function statusLine(args) {
|
|
35844
|
+
const { status, eventCount, error: error52, truncated, stamp, userLabel } = args;
|
|
35845
|
+
switch (status) {
|
|
35846
|
+
case "fetched": {
|
|
35847
|
+
if (eventCount === 0) {
|
|
35848
|
+
return `**Read fine as of ${stamp}. Nothing in this window.** The diary really ` + `is clear across the dates above. This is a successful read of an empty ` + `calendar, not a failed read - if it had failed, this line would say so.`;
|
|
35849
|
+
}
|
|
35850
|
+
const truncatedNote = truncated ? " **This list is INCOMPLETE**: Google had more entries than one page and the rest were not collected." : "";
|
|
35851
|
+
return `**Read fine as of ${stamp}.** ${eventCount} ` + `${eventCount === 1 ? "entry" : "entries"} in the window.${truncatedNote}`;
|
|
35852
|
+
}
|
|
35853
|
+
case "no-token":
|
|
35854
|
+
return `**NOT READ - Google is not connected for ${userLabel}.** Nothing is listed ` + `below because Plinth could not look, NOT because the diary is clear. ` + `Connect Google in Plinth to fill this in, and until then treat this ` + `calendar as unknown rather than empty.`;
|
|
35855
|
+
case "token-expired":
|
|
35856
|
+
return `**NOT READ - the Google connection for ${userLabel} has expired.** Plinth ` + `still holds a connection but could not refresh it, so nothing below is ` + `current. Reconnect Google in Plinth. Treat this calendar as unknown ` + `rather than empty.`;
|
|
35857
|
+
case "google-error":
|
|
35858
|
+
return `**NOT READ - Google refused the request` + `${error52 ? ` (HTTP ${error52.upstream_status})` : ""}.** ` + `${error52 ? oneLine(error52.message, 300) : "No detail was returned."} ` + `A calendar that was unshared or deleted in Google reads exactly like this. ` + `Nothing below is current; treat this calendar as unknown rather than empty.`;
|
|
35859
|
+
}
|
|
35860
|
+
}
|
|
35861
|
+
function shortStatus(row) {
|
|
35862
|
+
switch (row.status) {
|
|
35863
|
+
case "fetched": {
|
|
35864
|
+
const n2 = row.events?.length ?? 0;
|
|
35865
|
+
if (n2 === 0)
|
|
35866
|
+
return "read fine, nothing in this window";
|
|
35867
|
+
return `read fine, ${n2} ${n2 === 1 ? "entry" : "entries"}${row.truncated ? " (INCOMPLETE - more than one page)" : ""}`;
|
|
35868
|
+
}
|
|
35869
|
+
case "no-token":
|
|
35870
|
+
return "NOT READ - Google is not connected; unknown, not empty";
|
|
35871
|
+
case "token-expired":
|
|
35872
|
+
return "NOT READ - the Google connection has expired; unknown, not empty";
|
|
35873
|
+
case "google-error":
|
|
35874
|
+
return `NOT READ - Google refused` + `${row.error ? ` (HTTP ${row.error.upstream_status}): ${oneLine(row.error.message, 120)}` : ""}` + `; unknown, not empty`;
|
|
35875
|
+
}
|
|
35876
|
+
}
|
|
35877
|
+
function rowFileName(row, internalName) {
|
|
35878
|
+
return row.artist_slug ?? internalName;
|
|
35879
|
+
}
|
|
35880
|
+
function rowDisplayName(row) {
|
|
35881
|
+
if (row.artist_name)
|
|
35882
|
+
return oneLine(row.artist_name, 80);
|
|
35883
|
+
return `${oneLine(row.calendar_name, 80)} (internal)`;
|
|
35884
|
+
}
|
|
35885
|
+
function splitBands(events, generatedAt) {
|
|
35886
|
+
const base2 = Date.parse(generatedAt);
|
|
35887
|
+
const boundary = Number.isNaN(base2) ? Number.MAX_SAFE_INTEGER : base2 + FULL_BAND_DAYS * DAY_MS;
|
|
35888
|
+
const full = [];
|
|
35889
|
+
const rest = [];
|
|
35890
|
+
for (const e2 of events) {
|
|
35891
|
+
(sortInstant(e2) < boundary ? full : rest).push(e2);
|
|
35892
|
+
}
|
|
35893
|
+
return { full, rest };
|
|
35894
|
+
}
|
|
35895
|
+
function renderArtistCalendar(args) {
|
|
35896
|
+
const { row, generatedAt, window: window2 } = args;
|
|
35897
|
+
const userLabel = args.userLabel ?? DEFAULT_USER_LABEL;
|
|
35898
|
+
const events = row.events ?? [];
|
|
35899
|
+
const stamp = humanStamp(generatedAt);
|
|
35900
|
+
const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
|
|
35901
|
+
const title = rowDisplayName(row);
|
|
35902
|
+
const header = [
|
|
35903
|
+
"---",
|
|
35904
|
+
"plinth_generated: true",
|
|
35905
|
+
"view: calendar",
|
|
35906
|
+
`artist_slug: ${row.artist_slug === null ? "null" : yamlString(row.artist_slug)}`,
|
|
35907
|
+
`artist_name: ${row.artist_name === null ? "null" : yamlString(row.artist_name)}`,
|
|
35908
|
+
`calendar_name: ${yamlString(row.calendar_name)}`,
|
|
35909
|
+
`generated_at: ${yamlString(generatedAt)}`,
|
|
35910
|
+
`window_start: ${yamlString(window2.time_min)}`,
|
|
35911
|
+
`window_end: ${yamlString(window2.time_max)}`,
|
|
35912
|
+
`status: ${row.status}`,
|
|
35913
|
+
"fetch_state: fresh",
|
|
35914
|
+
`entry_count: ${events.length}`,
|
|
35915
|
+
`truncated: ${row.truncated}`,
|
|
35916
|
+
"---",
|
|
35917
|
+
"",
|
|
35918
|
+
OWNERSHIP_NOTE2,
|
|
35919
|
+
"",
|
|
35920
|
+
`# ${title} - calendar`,
|
|
35921
|
+
""
|
|
35922
|
+
].join(`
|
|
35923
|
+
`);
|
|
35924
|
+
const body = [
|
|
35925
|
+
"",
|
|
35926
|
+
statusLine({
|
|
35927
|
+
status: row.status,
|
|
35928
|
+
eventCount: events.length,
|
|
35929
|
+
error: row.error,
|
|
35930
|
+
truncated: row.truncated,
|
|
35931
|
+
stamp,
|
|
35932
|
+
userLabel
|
|
35933
|
+
}),
|
|
35934
|
+
"",
|
|
35935
|
+
`**Window:** ${windowLine(window2.time_min, window2.time_max)}.`,
|
|
35936
|
+
"",
|
|
35937
|
+
"**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
|
|
35938
|
+
"",
|
|
35939
|
+
renderBands(events, generatedAt, referenceYear, "")
|
|
35940
|
+
].join(`
|
|
35941
|
+
`);
|
|
35942
|
+
return `${header}${CALENDAR_BODY_MARKER}
|
|
35943
|
+
${body}`;
|
|
35944
|
+
}
|
|
35945
|
+
function renderBands(events, generatedAt, referenceYear, artistPrefix) {
|
|
35946
|
+
if (events.length === 0)
|
|
35947
|
+
return "";
|
|
35948
|
+
const { full, rest } = splitBands(events, generatedAt);
|
|
35949
|
+
const out = [""];
|
|
35950
|
+
out.push(`## Next ${FULL_BAND_DAYS} days`, "");
|
|
35951
|
+
if (full.length === 0) {
|
|
35952
|
+
out.push(`_Nothing in the next ${FULL_BAND_DAYS} days._`);
|
|
35953
|
+
} else {
|
|
35954
|
+
for (const e2 of full)
|
|
35955
|
+
out.push(fullLine(e2, referenceYear, artistPrefix, true));
|
|
35956
|
+
}
|
|
35957
|
+
out.push("", "## Rest of the window", "");
|
|
35958
|
+
if (rest.length === 0) {
|
|
35959
|
+
out.push("_Nothing further in the window._");
|
|
35960
|
+
} else {
|
|
35961
|
+
for (const e2 of rest)
|
|
35962
|
+
out.push(compactLine(e2, referenceYear, artistPrefix));
|
|
35963
|
+
}
|
|
35964
|
+
out.push("");
|
|
35965
|
+
return out.join(`
|
|
35966
|
+
`);
|
|
35967
|
+
}
|
|
35968
|
+
function renderUpcoming(args) {
|
|
35969
|
+
const {
|
|
35970
|
+
rows,
|
|
35971
|
+
unmappedArtists,
|
|
35972
|
+
generatedAt,
|
|
35973
|
+
window: window2,
|
|
35974
|
+
artistCount,
|
|
35975
|
+
mappedArtistCount,
|
|
35976
|
+
internalName
|
|
35977
|
+
} = args;
|
|
35978
|
+
const stamp = humanStamp(generatedAt);
|
|
35979
|
+
const referenceYear = new Date(Date.parse(generatedAt) || 0).getUTCFullYear();
|
|
35980
|
+
const fetched = rows.filter((r3) => r3.status === "fetched");
|
|
35981
|
+
const failed = rows.filter((r3) => r3.status !== "fetched");
|
|
35982
|
+
const truncatedAnywhere = rows.some((r3) => r3.truncated);
|
|
35983
|
+
const merged = fetched.flatMap((row) => (row.events ?? []).map((event) => ({ event, row }))).sort((a2, b2) => sortInstant(a2.event) - sortInstant(b2.event));
|
|
35984
|
+
const { full, rest } = splitBands(merged.map((m2) => m2.event), generatedAt);
|
|
35985
|
+
const fullSet = new Set(full);
|
|
35986
|
+
const restSet = new Set(rest);
|
|
35987
|
+
const header = [
|
|
35988
|
+
"---",
|
|
35989
|
+
"plinth_generated: true",
|
|
35990
|
+
"view: calendar",
|
|
35991
|
+
"scope: workspace",
|
|
35992
|
+
`generated_at: ${yamlString(generatedAt)}`,
|
|
35993
|
+
`window_start: ${yamlString(window2.time_min)}`,
|
|
35994
|
+
`window_end: ${yamlString(window2.time_max)}`,
|
|
35995
|
+
`artist_count: ${artistCount}`,
|
|
35996
|
+
`mapped_artist_count: ${mappedArtistCount}`,
|
|
35997
|
+
`calendars_read: ${fetched.length}`,
|
|
35998
|
+
`calendars_not_read: ${failed.length}`,
|
|
35999
|
+
"fetch_state: fresh",
|
|
36000
|
+
`truncated: ${truncatedAnywhere}`,
|
|
36001
|
+
"---",
|
|
36002
|
+
"",
|
|
36003
|
+
OWNERSHIP_NOTE2,
|
|
36004
|
+
"",
|
|
36005
|
+
"# Upcoming - every calendar in this workspace",
|
|
36006
|
+
""
|
|
36007
|
+
].join(`
|
|
36008
|
+
`);
|
|
36009
|
+
const preamble = [
|
|
36010
|
+
"",
|
|
36011
|
+
`**As of ${stamp}.** Window ${windowLine(window2.time_min, window2.time_max)}.`,
|
|
36012
|
+
"",
|
|
36013
|
+
failed.length === 0 ? `**All ${fetched.length} mapped ${fetched.length === 1 ? "calendar was" : "calendars were"} read.**` : `**${fetched.length} of ${rows.length} mapped ${rows.length === 1 ? "calendar" : "calendars"} read; ${failed.length} could NOT be read.**` + ` Anything on an unread calendar is missing from the lists below - unknown, not absent.`,
|
|
36014
|
+
"",
|
|
36015
|
+
`${artistCount} ${artistCount === 1 ? "artist" : "artists"} in the roster; ` + `${mappedArtistCount} ${mappedArtistCount === 1 ? "has" : "have"} a calendar mapped in Plinth.`,
|
|
36016
|
+
"",
|
|
36017
|
+
"**Dates are read here and changed in Plinth's calendar view, by a person.** If something below needs moving, say what the change is and hand it over.",
|
|
36018
|
+
"",
|
|
36019
|
+
"## Calendar status",
|
|
36020
|
+
"",
|
|
36021
|
+
...rows.map((row) => `- **${rowDisplayName(row)}**` + `${row.artist_slug ? ` (\`${oneLine(row.artist_slug, 80)}\`)` : ""}` + ` - ${shortStatus(row)} - \`views/calendar/${rowFileName(row, internalName)}.md\``),
|
|
36022
|
+
...unmappedArtists.map((a2) => `- **${oneLine(a2.name, 80)}** (\`${oneLine(a2.slug, 80)}\`) - NO CALENDAR MAPPED in Plinth, so there is nothing to read and no file for them. Map one in Plinth if they should have dates.`),
|
|
36023
|
+
""
|
|
36024
|
+
].join(`
|
|
36025
|
+
`);
|
|
36026
|
+
const body = [preamble];
|
|
36027
|
+
body.push(`## Next ${FULL_BAND_DAYS} days`, "", "_Links are in the per-artist file named beside each calendar above._", "");
|
|
36028
|
+
const fullMerged = merged.filter((m2) => fullSet.has(m2.event));
|
|
36029
|
+
if (fullMerged.length === 0) {
|
|
36030
|
+
body.push(fetched.length === 0 ? `_No calendar could be read, so nothing can be listed for the next ${FULL_BAND_DAYS} days._` : `_Nothing in the next ${FULL_BAND_DAYS} days on any calendar that was read._`);
|
|
36031
|
+
} else {
|
|
36032
|
+
for (const { event, row } of fullMerged) {
|
|
36033
|
+
body.push(fullLine(event, referenceYear, `**${rowDisplayName(row)}**`, false));
|
|
36034
|
+
}
|
|
36035
|
+
}
|
|
36036
|
+
body.push("", "## Rest of the window", "");
|
|
36037
|
+
const restMerged = merged.filter((m2) => restSet.has(m2.event));
|
|
36038
|
+
if (restMerged.length === 0) {
|
|
36039
|
+
body.push(fetched.length === 0 ? "_No calendar could be read, so nothing can be listed._" : "_Nothing further in the window on any calendar that was read._");
|
|
36040
|
+
} else {
|
|
36041
|
+
for (const { event, row } of restMerged) {
|
|
36042
|
+
body.push(compactLine(event, referenceYear, `**${rowDisplayName(row)}**`));
|
|
36043
|
+
}
|
|
34108
36044
|
}
|
|
34109
|
-
|
|
34110
|
-
|
|
34111
|
-
|
|
34112
|
-
|
|
34113
|
-
|
|
36045
|
+
body.push("");
|
|
36046
|
+
return `${header}${CALENDAR_BODY_MARKER}
|
|
36047
|
+
${body.join(`
|
|
36048
|
+
`)}`;
|
|
36049
|
+
}
|
|
36050
|
+
function splitCalendarFile(content) {
|
|
36051
|
+
const at = content.indexOf(CALENDAR_BODY_MARKER);
|
|
36052
|
+
if (at === -1)
|
|
36053
|
+
return null;
|
|
36054
|
+
return {
|
|
36055
|
+
header: content.slice(0, at),
|
|
36056
|
+
body: content.slice(at + CALENDAR_BODY_MARKER.length)
|
|
36057
|
+
};
|
|
36058
|
+
}
|
|
36059
|
+
function headerField(header, key) {
|
|
36060
|
+
const re = new RegExp(`^${key}: (.*)$`, "m");
|
|
36061
|
+
const m2 = re.exec(header);
|
|
36062
|
+
if (!m2)
|
|
36063
|
+
return null;
|
|
36064
|
+
const raw = m2[1].trim();
|
|
36065
|
+
if (raw === "null")
|
|
36066
|
+
return null;
|
|
36067
|
+
if (raw.startsWith('"')) {
|
|
34114
36068
|
try {
|
|
34115
|
-
|
|
34116
|
-
nextDelay = result.exitCode === 0 ? this.onSuccess(result) : this.onFailure(result.failure);
|
|
36069
|
+
return JSON.parse(raw);
|
|
34117
36070
|
} catch {
|
|
34118
|
-
|
|
34119
|
-
} finally {
|
|
34120
|
-
this.inFlight = false;
|
|
36071
|
+
return raw;
|
|
34121
36072
|
}
|
|
34122
|
-
this.schedule(nextDelay);
|
|
34123
36073
|
}
|
|
34124
|
-
|
|
34125
|
-
|
|
34126
|
-
|
|
34127
|
-
|
|
34128
|
-
|
|
34129
|
-
|
|
34130
|
-
|
|
36074
|
+
return raw;
|
|
36075
|
+
}
|
|
36076
|
+
function renderStaleFile(existing, args) {
|
|
36077
|
+
const split = splitCalendarFile(existing);
|
|
36078
|
+
if (!split)
|
|
36079
|
+
return null;
|
|
36080
|
+
const previousGeneratedAt = headerField(split.header, "generated_at");
|
|
36081
|
+
const title = /^# (.+)$/m.exec(split.header)?.[1]?.trim() ?? "Calendar";
|
|
36082
|
+
const fmMatch = /^---\n([\s\S]*?)\n---\n/.exec(split.header);
|
|
36083
|
+
const carried = (fmMatch?.[1] ?? "").split(`
|
|
36084
|
+
`).filter((line) => !/^(fetch_state|last_refresh_attempt|last_refresh_error):/.test(line));
|
|
36085
|
+
const stamp = previousGeneratedAt ? humanStamp(previousGeneratedAt) : "an earlier refresh";
|
|
36086
|
+
const header = [
|
|
36087
|
+
"---",
|
|
36088
|
+
...carried,
|
|
36089
|
+
"fetch_state: stale",
|
|
36090
|
+
`last_refresh_attempt: ${yamlString(args.attemptedAt)}`,
|
|
36091
|
+
`last_refresh_error: ${yamlString(oneLine(args.reason, 300))}`,
|
|
36092
|
+
"---",
|
|
36093
|
+
"",
|
|
36094
|
+
OWNERSHIP_NOTE2,
|
|
36095
|
+
"",
|
|
36096
|
+
`# ${title}`,
|
|
36097
|
+
"",
|
|
36098
|
+
`**STALE - the last refresh FAILED and nothing below was updated.** Attempted ` + `${humanStamp(args.attemptedAt)} and it did not succeed: ` + `${oneLine(args.reason, 300)}`,
|
|
36099
|
+
"",
|
|
36100
|
+
`**What is below was last read at ${stamp}** and has not moved since. Anything ` + `changed in Google after that is not here, and something cancelled after that ` + `is still listed. Say the age out loud rather than briefing it as current.`,
|
|
36101
|
+
""
|
|
36102
|
+
].join(`
|
|
36103
|
+
`);
|
|
36104
|
+
return `${header}${CALENDAR_BODY_MARKER}${split.body}`;
|
|
36105
|
+
}
|
|
36106
|
+
|
|
36107
|
+
// src/lib/calendar/project.ts
|
|
36108
|
+
function writeIfChanged(workspaceSlug, name, content) {
|
|
36109
|
+
if (readCalendarView(workspaceSlug, name) === content)
|
|
36110
|
+
return false;
|
|
36111
|
+
writeCalendarView(workspaceSlug, name, content);
|
|
36112
|
+
return true;
|
|
36113
|
+
}
|
|
36114
|
+
function projectCalendar(args) {
|
|
36115
|
+
const { workspaceSlug, response, roster, userLabel } = args;
|
|
36116
|
+
const { generated_at: generatedAt, window: window2 } = response;
|
|
36117
|
+
let written = 0;
|
|
36118
|
+
let unchanged = 0;
|
|
36119
|
+
let collided = 0;
|
|
36120
|
+
const seen = new Set;
|
|
36121
|
+
const rows = [];
|
|
36122
|
+
for (const row of response.calendars) {
|
|
36123
|
+
const name = rowFileName(row, CALENDAR_INTERNAL_NAME);
|
|
36124
|
+
if (seen.has(name) || name === CALENDAR_UPCOMING_NAME) {
|
|
36125
|
+
collided += 1;
|
|
36126
|
+
continue;
|
|
34131
36127
|
}
|
|
34132
|
-
|
|
34133
|
-
|
|
36128
|
+
seen.add(name);
|
|
36129
|
+
rows.push(row);
|
|
36130
|
+
const content = renderArtistCalendar({
|
|
36131
|
+
row,
|
|
36132
|
+
generatedAt,
|
|
36133
|
+
window: window2,
|
|
36134
|
+
userLabel
|
|
36135
|
+
});
|
|
36136
|
+
if (writeIfChanged(workspaceSlug, name, content))
|
|
36137
|
+
written += 1;
|
|
36138
|
+
else
|
|
36139
|
+
unchanged += 1;
|
|
36140
|
+
}
|
|
36141
|
+
const mapped = new Set(response.calendars.map((r3) => r3.artist_slug).filter((s2) => s2 !== null));
|
|
36142
|
+
const unmappedArtists = roster.filter((a2) => !mapped.has(a2.slug) && a2.status !== "archived").map((a2) => ({ slug: a2.slug, name: a2.name }));
|
|
36143
|
+
const upcoming = renderUpcoming({
|
|
36144
|
+
rows,
|
|
36145
|
+
unmappedArtists,
|
|
36146
|
+
generatedAt,
|
|
36147
|
+
window: window2,
|
|
36148
|
+
artistCount: response.artist_count,
|
|
36149
|
+
mappedArtistCount: response.mapped_artist_count,
|
|
36150
|
+
internalName: CALENDAR_INTERNAL_NAME,
|
|
36151
|
+
userLabel
|
|
36152
|
+
});
|
|
36153
|
+
if (writeIfChanged(workspaceSlug, CALENDAR_UPCOMING_NAME, upcoming))
|
|
36154
|
+
written += 1;
|
|
36155
|
+
else
|
|
36156
|
+
unchanged += 1;
|
|
36157
|
+
const expected = new Set([...seen, CALENDAR_UPCOMING_NAME]);
|
|
36158
|
+
let pruned = 0;
|
|
36159
|
+
for (const name of listCalendarViews(workspaceSlug)) {
|
|
36160
|
+
if (expected.has(name))
|
|
36161
|
+
continue;
|
|
36162
|
+
if (!ownsFile(workspaceSlug, name))
|
|
36163
|
+
continue;
|
|
36164
|
+
try {
|
|
36165
|
+
if (deleteCalendarView(workspaceSlug, name))
|
|
36166
|
+
pruned += 1;
|
|
36167
|
+
} catch {}
|
|
34134
36168
|
}
|
|
34135
|
-
|
|
34136
|
-
|
|
34137
|
-
|
|
34138
|
-
|
|
34139
|
-
|
|
34140
|
-
|
|
34141
|
-
|
|
34142
|
-
|
|
36169
|
+
return { written, unchanged, pruned, collided };
|
|
36170
|
+
}
|
|
36171
|
+
function ownsFile(workspaceSlug, name) {
|
|
36172
|
+
try {
|
|
36173
|
+
const existing = readCalendarView(workspaceSlug, name);
|
|
36174
|
+
return existing !== null && existing.includes(CALENDAR_BODY_MARKER);
|
|
36175
|
+
} catch {
|
|
36176
|
+
return false;
|
|
36177
|
+
}
|
|
36178
|
+
}
|
|
36179
|
+
function markCalendarStale(args) {
|
|
36180
|
+
const { workspaceSlug, attemptedAt, reason } = args;
|
|
36181
|
+
let rewritten = 0;
|
|
36182
|
+
let unchanged = 0;
|
|
36183
|
+
let skipped = 0;
|
|
36184
|
+
for (const name of listCalendarViews(workspaceSlug)) {
|
|
36185
|
+
let existing;
|
|
36186
|
+
try {
|
|
36187
|
+
existing = readCalendarView(workspaceSlug, name);
|
|
36188
|
+
} catch {
|
|
36189
|
+
skipped += 1;
|
|
36190
|
+
continue;
|
|
34143
36191
|
}
|
|
34144
|
-
|
|
34145
|
-
|
|
34146
|
-
|
|
34147
|
-
|
|
34148
|
-
|
|
34149
|
-
|
|
36192
|
+
if (existing === null)
|
|
36193
|
+
continue;
|
|
36194
|
+
const staled = renderStaleFile(existing, { attemptedAt, reason });
|
|
36195
|
+
if (staled === null) {
|
|
36196
|
+
skipped += 1;
|
|
36197
|
+
continue;
|
|
34150
36198
|
}
|
|
34151
|
-
|
|
34152
|
-
|
|
34153
|
-
|
|
34154
|
-
|
|
34155
|
-
|
|
34156
|
-
|
|
34157
|
-
|
|
34158
|
-
const mins = Math.round(this.capMs / 60000);
|
|
34159
|
-
this.notice("degraded", `Pull sync degraded — repeated failures, retrying about every ${mins}m. ` + `Local edits still push; the mirror may be stale until the cloud is reachable again.`);
|
|
36199
|
+
try {
|
|
36200
|
+
if (writeIfChanged(workspaceSlug, name, staled))
|
|
36201
|
+
rewritten += 1;
|
|
36202
|
+
else
|
|
36203
|
+
unchanged += 1;
|
|
36204
|
+
} catch {
|
|
36205
|
+
skipped += 1;
|
|
34160
36206
|
}
|
|
34161
36207
|
}
|
|
36208
|
+
return { rewritten, unchanged, skipped };
|
|
34162
36209
|
}
|
|
34163
36210
|
|
|
34164
36211
|
// src/lib/pull-suppression.ts
|
|
@@ -34230,7 +36277,7 @@ class PullSuppressor {
|
|
|
34230
36277
|
}
|
|
34231
36278
|
|
|
34232
36279
|
// src/lib/reconcile.ts
|
|
34233
|
-
import { readFileSync as
|
|
36280
|
+
import { readFileSync as readFileSync17 } from "node:fs";
|
|
34234
36281
|
async function reconcileDivergedRows(args) {
|
|
34235
36282
|
const { workspaceSlug, baseUrl, pat, fetchImpl, output, isFrozen } = args;
|
|
34236
36283
|
const now = args.now ?? (() => new Date);
|
|
@@ -34316,7 +36363,7 @@ async function reconcileDivergedRows(args) {
|
|
|
34316
36363
|
}
|
|
34317
36364
|
let content;
|
|
34318
36365
|
try {
|
|
34319
|
-
content =
|
|
36366
|
+
content = readFileSync17(absolutePath, "utf8");
|
|
34320
36367
|
} catch (err) {
|
|
34321
36368
|
if (err?.code === "ENOENT")
|
|
34322
36369
|
continue;
|
|
@@ -35259,25 +37306,111 @@ function createThrottledFetch(baseFetch, opts = {}) {
|
|
|
35259
37306
|
// src/commands/sync.ts
|
|
35260
37307
|
import {
|
|
35261
37308
|
existsSync as existsSync6,
|
|
35262
|
-
readdirSync as
|
|
35263
|
-
readFileSync as
|
|
35264
|
-
statSync as
|
|
35265
|
-
unlinkSync as
|
|
37309
|
+
readdirSync as readdirSync5,
|
|
37310
|
+
readFileSync as readFileSync19,
|
|
37311
|
+
statSync as statSync11,
|
|
37312
|
+
unlinkSync as unlinkSync7
|
|
35266
37313
|
} from "node:fs";
|
|
35267
|
-
import { join as
|
|
37314
|
+
import { join as join14 } from "node:path";
|
|
37315
|
+
|
|
37316
|
+
// src/lib/files/pull.ts
|
|
37317
|
+
import { existsSync as existsSync5, readFileSync as readFileSync18, statSync as statSync10 } from "node:fs";
|
|
37318
|
+
|
|
37319
|
+
// src/lib/files/extracted-text.ts
|
|
37320
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
37321
|
+
var DERIVED_TEXT_VERSION = 2;
|
|
37322
|
+
function yamlString2(value) {
|
|
37323
|
+
return JSON.stringify(value);
|
|
37324
|
+
}
|
|
37325
|
+
var PARTIAL_WARNING = [
|
|
37326
|
+
"> ⚠️ **PARTIAL EXTRACTION.** Some spans of this document came out as text and",
|
|
37327
|
+
"> some did not. Which spans is NOT recorded, so this could be a clean document",
|
|
37328
|
+
"> with mangled party names, or clean prose that degrades part-way through.",
|
|
37329
|
+
"> Verify anything load-bearing against the source file."
|
|
37330
|
+
].join(`
|
|
37331
|
+
`);
|
|
37332
|
+
var NO_LOCAL_COPY_NOTE = [
|
|
37333
|
+
"> ℹ️ **NO LOCAL COPY OF THIS FILE.** Plinth does not hold this file's bytes, so",
|
|
37334
|
+
"> nothing was written under `files/` for it and the text below is the only copy",
|
|
37335
|
+
"> in this mirror. The original lives wherever `source_uri` above points."
|
|
37336
|
+
].join(`
|
|
37337
|
+
`);
|
|
37338
|
+
function garbledBody(relPath, bytesInMirror) {
|
|
37339
|
+
return [
|
|
37340
|
+
"> \uD83D\uDD34 **NOT TEXT — body withheld.**",
|
|
37341
|
+
">",
|
|
37342
|
+
"> Extraction produced characters that are not words. The usual cause is a PDF",
|
|
37343
|
+
"> whose embedded fonts carry no ToUnicode map, so the glyph indices come out",
|
|
37344
|
+
"> where the letters should be: a page reading *“Dated this 10th day of",
|
|
37345
|
+
"> September”* extracts as `):-,:019`.",
|
|
37346
|
+
">",
|
|
37347
|
+
"> The characters are NOT reproduced here on purpose. They read as text to a",
|
|
37348
|
+
"> reader and to a search index, and they say nothing — writing them into the",
|
|
37349
|
+
"> mirror would put a document into your corpus that appears readable and is",
|
|
37350
|
+
"> not. They are excluded from Plinth's own search index for the same reason.",
|
|
37351
|
+
">",
|
|
37352
|
+
bytesInMirror ? `> **Read the original instead:** \`files/${relPath}\`` : "> **There is no local original to read** — Plinth holds no bytes for this file." + " Follow `source_uri` above to the copy that has them."
|
|
37353
|
+
].join(`
|
|
37354
|
+
`);
|
|
37355
|
+
}
|
|
37356
|
+
function renderExtractedText(doc2) {
|
|
37357
|
+
const withheld = doc2.quality === "garbled";
|
|
37358
|
+
const frontmatter = [
|
|
37359
|
+
"---",
|
|
37360
|
+
"plinth_generated: true",
|
|
37361
|
+
`source_file: ${doc2.bytesInMirror ? yamlString2(`files/${doc2.relPath}`) : "null"}`,
|
|
37362
|
+
`source_uri: ${doc2.sourceUri === undefined ? "unknown" : doc2.sourceUri === null ? "null" : yamlString2(doc2.sourceUri)}`,
|
|
37363
|
+
`content_quality: ${doc2.quality ?? "unchecked"}`,
|
|
37364
|
+
`body_withheld: ${withheld}`,
|
|
37365
|
+
`extracted_chars: ${withheld ? 0 : doc2.text.length}`,
|
|
37366
|
+
"---",
|
|
37367
|
+
"",
|
|
37368
|
+
"<!-- Generated by Plinth from the source file above. This file is DERIVED:",
|
|
37369
|
+
" your edits here are overwritten on the next sync and are never uploaded.",
|
|
37370
|
+
" Edit the source file instead. -->",
|
|
37371
|
+
""
|
|
37372
|
+
].join(`
|
|
37373
|
+
`);
|
|
37374
|
+
if (withheld) {
|
|
37375
|
+
return `${frontmatter}${garbledBody(doc2.relPath, doc2.bytesInMirror)}
|
|
37376
|
+
`;
|
|
37377
|
+
}
|
|
37378
|
+
const notices = [
|
|
37379
|
+
...doc2.quality === "partial" ? [PARTIAL_WARNING] : [],
|
|
37380
|
+
...doc2.bytesInMirror ? [] : [NO_LOCAL_COPY_NOTE]
|
|
37381
|
+
];
|
|
37382
|
+
if (notices.length > 0) {
|
|
37383
|
+
return `${frontmatter}${notices.join(`
|
|
37384
|
+
|
|
37385
|
+
`)}
|
|
37386
|
+
|
|
37387
|
+
${doc2.text}
|
|
37388
|
+
`;
|
|
37389
|
+
}
|
|
37390
|
+
return `${frontmatter}${doc2.text}
|
|
37391
|
+
`;
|
|
37392
|
+
}
|
|
37393
|
+
function extractedTextHash(rendered) {
|
|
37394
|
+
return createHash3("sha256").update(rendered, "utf8").digest("hex");
|
|
37395
|
+
}
|
|
35268
37396
|
|
|
35269
37397
|
// src/lib/files/pull.ts
|
|
35270
|
-
import { existsSync as existsSync5, readFileSync as readFileSync16, statSync as statSync8 } from "node:fs";
|
|
35271
37398
|
async function pullFiles(args) {
|
|
35272
37399
|
const { workspaceSlug, baseUrl, pat, fetchImpl } = args;
|
|
35273
37400
|
let state = args.state;
|
|
35274
37401
|
let fetched = 0;
|
|
35275
37402
|
let written = 0;
|
|
35276
37403
|
let deleted = 0;
|
|
37404
|
+
let textWritten = 0;
|
|
37405
|
+
let textDeleted = 0;
|
|
37406
|
+
let textUnknown = 0;
|
|
37407
|
+
let pointerRows = 0;
|
|
35277
37408
|
let cursor = args.cursor;
|
|
35278
37409
|
let lastCursor = args.cursor;
|
|
37410
|
+
let cursorBlocked = false;
|
|
35279
37411
|
while (true) {
|
|
35280
37412
|
const page = await syncFilesPage({ baseUrl, pat, cursor, fetchImpl });
|
|
37413
|
+
let pageTextFailed = false;
|
|
35281
37414
|
for (const row of page.rows) {
|
|
35282
37415
|
fetched++;
|
|
35283
37416
|
const outcome = await applyFileRow({
|
|
@@ -35290,20 +37423,101 @@ async function pullFiles(args) {
|
|
|
35290
37423
|
now: args.now,
|
|
35291
37424
|
output: args.output,
|
|
35292
37425
|
suppressor: args.suppressor,
|
|
35293
|
-
isFileFrozen: args.isFileFrozen
|
|
37426
|
+
isFileFrozen: args.isFileFrozen,
|
|
37427
|
+
forceTextRewalk: args.forceTextRewalk
|
|
35294
37428
|
});
|
|
35295
37429
|
state = outcome.state;
|
|
35296
37430
|
if (outcome.action === "written")
|
|
35297
37431
|
written++;
|
|
35298
37432
|
else if (outcome.action === "deleted")
|
|
35299
37433
|
deleted++;
|
|
35300
|
-
|
|
37434
|
+
textWritten += outcome.textWritten ?? 0;
|
|
37435
|
+
textDeleted += outcome.textDeleted ?? 0;
|
|
37436
|
+
if (outcome.textUnknown)
|
|
37437
|
+
textUnknown++;
|
|
37438
|
+
if (outcome.pointer)
|
|
37439
|
+
pointerRows++;
|
|
37440
|
+
if (outcome.textFailed)
|
|
37441
|
+
pageTextFailed = true;
|
|
37442
|
+
}
|
|
37443
|
+
if (pageTextFailed)
|
|
37444
|
+
cursorBlocked = true;
|
|
35301
37445
|
if (page.next_cursor === null)
|
|
35302
37446
|
break;
|
|
35303
|
-
|
|
37447
|
+
if (!cursorBlocked)
|
|
37448
|
+
lastCursor = page.next_cursor;
|
|
35304
37449
|
cursor = page.next_cursor;
|
|
35305
37450
|
}
|
|
35306
|
-
|
|
37451
|
+
if (textUnknown > 0) {
|
|
37452
|
+
args.output?.warn(`${textUnknown} file ${textUnknown === 1 ? "row" : "rows"} arrived without a readable \`has_extracted_text\` — this server predates the extracted-text contract, or the field was dropped from the payload. Derived text under views/extracted/ was left untouched rather than deleted, and is not being updated either.`);
|
|
37453
|
+
}
|
|
37454
|
+
if (pointerRows > 0) {
|
|
37455
|
+
args.output?.info(`${pointerRows} file ${pointerRows === 1 ? "row holds" : "rows hold"} no bytes in Plinth — nothing was written under files/ for ${pointerRows === 1 ? "it" : "them"}; where there is extracted text, views/extracted/ holds the only local copy.`);
|
|
37456
|
+
}
|
|
37457
|
+
return {
|
|
37458
|
+
state,
|
|
37459
|
+
fetched,
|
|
37460
|
+
written,
|
|
37461
|
+
deleted,
|
|
37462
|
+
textWritten,
|
|
37463
|
+
textDeleted,
|
|
37464
|
+
textUnknown,
|
|
37465
|
+
pointerRows,
|
|
37466
|
+
lastCursor
|
|
37467
|
+
};
|
|
37468
|
+
}
|
|
37469
|
+
async function applyExtractedText(args) {
|
|
37470
|
+
try {
|
|
37471
|
+
return await materialiseExtractedText(args);
|
|
37472
|
+
} catch (err) {
|
|
37473
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37474
|
+
args.output?.warn(`files/${args.relPath}: could not materialise extracted text (${msg}) — mirror text left unwritten, will retry.`);
|
|
37475
|
+
return { textHash: undefined, written: 0, deleted: 0, failed: true };
|
|
37476
|
+
}
|
|
37477
|
+
}
|
|
37478
|
+
async function materialiseExtractedText(args) {
|
|
37479
|
+
const { workspaceSlug, baseUrl, pat, row, relPath, bytesInMirror, fetchImpl, output } = args;
|
|
37480
|
+
const signal = extractedTextSignal(row);
|
|
37481
|
+
if (signal === "absent") {
|
|
37482
|
+
const removed = deleteExtractedText(workspaceSlug, relPath);
|
|
37483
|
+
return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
|
|
37484
|
+
}
|
|
37485
|
+
if (signal === "unknown") {
|
|
37486
|
+
return { textHash: undefined, written: 0, deleted: 0 };
|
|
37487
|
+
}
|
|
37488
|
+
let rendered;
|
|
37489
|
+
if (row.content_quality === "garbled" && bytesInMirror) {
|
|
37490
|
+
rendered = renderExtractedText({
|
|
37491
|
+
relPath,
|
|
37492
|
+
quality: "garbled",
|
|
37493
|
+
bytesInMirror,
|
|
37494
|
+
sourceUri: undefined,
|
|
37495
|
+
text: ""
|
|
37496
|
+
});
|
|
37497
|
+
} else {
|
|
37498
|
+
const got = await getFileText({ baseUrl, pat, id: row.id, fetchImpl });
|
|
37499
|
+
if (!got.ok)
|
|
37500
|
+
return { textHash: undefined, written: 0, deleted: 0 };
|
|
37501
|
+
if (got.row.text === null) {
|
|
37502
|
+
output?.warn(`files/${relPath}: feed reported extracted text, text route withheld it (${got.row.withheld ?? "no reason given"}) — no derived file written.`);
|
|
37503
|
+
const removed = deleteExtractedText(workspaceSlug, relPath);
|
|
37504
|
+
return { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
|
|
37505
|
+
}
|
|
37506
|
+
rendered = renderExtractedText({
|
|
37507
|
+
relPath,
|
|
37508
|
+
quality: got.row.content_quality,
|
|
37509
|
+
sourceUri: got.row.source_uri,
|
|
37510
|
+
text: got.row.text,
|
|
37511
|
+
bytesInMirror
|
|
37512
|
+
});
|
|
37513
|
+
}
|
|
37514
|
+
const hash2 = extractedTextHash(rendered);
|
|
37515
|
+
const onDisk = readExtractedText(workspaceSlug, relPath);
|
|
37516
|
+
if (onDisk !== null && extractedTextHash(onDisk) === hash2) {
|
|
37517
|
+
return { textHash: hash2, written: 0, deleted: 0 };
|
|
37518
|
+
}
|
|
37519
|
+
writeExtractedText(workspaceSlug, relPath, rendered);
|
|
37520
|
+
return { textHash: hash2, written: 1, deleted: 0 };
|
|
35307
37521
|
}
|
|
35308
37522
|
async function applyFileRow(args) {
|
|
35309
37523
|
const { workspaceSlug, baseUrl, pat, row, state, fetchImpl, output, suppressor } = args;
|
|
@@ -35321,28 +37535,88 @@ async function applyFileRow(args) {
|
|
|
35321
37535
|
if (row.deleted_at !== null) {
|
|
35322
37536
|
suppressor?.registerDelete(abs);
|
|
35323
37537
|
const didUnlink = deleteFilesFile(workspaceSlug, relPath);
|
|
37538
|
+
const removedText = deleteExtractedText(workspaceSlug, relPath);
|
|
37539
|
+
const textDeleted = removedText ? 1 : 0;
|
|
35324
37540
|
if (existing) {
|
|
35325
37541
|
const { state: next2 } = removeFileEntry(state, relPath);
|
|
35326
|
-
return { state: next2, action: "deleted" };
|
|
37542
|
+
return { state: next2, action: "deleted", textDeleted };
|
|
35327
37543
|
}
|
|
35328
|
-
return { state, action: didUnlink ? "deleted" : "skipped" };
|
|
37544
|
+
return { state, action: didUnlink ? "deleted" : "skipped", textDeleted };
|
|
37545
|
+
}
|
|
37546
|
+
const textSignal = extractedTextSignal(row);
|
|
37547
|
+
const textUnknown = textSignal === "unknown";
|
|
37548
|
+
if (storedBytesSignal(row) === "absent") {
|
|
37549
|
+
return applyPointerRow({
|
|
37550
|
+
workspaceSlug,
|
|
37551
|
+
baseUrl,
|
|
37552
|
+
pat,
|
|
37553
|
+
row,
|
|
37554
|
+
relPath,
|
|
37555
|
+
state,
|
|
37556
|
+
existing,
|
|
37557
|
+
textSignal,
|
|
37558
|
+
fetchImpl,
|
|
37559
|
+
output
|
|
37560
|
+
});
|
|
35329
37561
|
}
|
|
35330
37562
|
if (existing && existing.content_hash === row.content_hash && existsSync5(abs)) {
|
|
35331
|
-
|
|
37563
|
+
if (!args.forceTextRewalk && textIsCurrent(existing, row, workspaceSlug, relPath)) {
|
|
37564
|
+
return { state, action: "noop", textUnknown };
|
|
37565
|
+
}
|
|
37566
|
+
const text2 = await applyExtractedText({
|
|
37567
|
+
workspaceSlug,
|
|
37568
|
+
baseUrl,
|
|
37569
|
+
pat,
|
|
37570
|
+
row,
|
|
37571
|
+
relPath,
|
|
37572
|
+
bytesInMirror: true,
|
|
37573
|
+
fetchImpl,
|
|
37574
|
+
output
|
|
37575
|
+
});
|
|
37576
|
+
const { state: next2 } = upsertFileEntry(state, {
|
|
37577
|
+
...existing,
|
|
37578
|
+
updated_at: row.updated_at,
|
|
37579
|
+
text_hash: textUnknown ? existing.text_hash : text2.textHash
|
|
37580
|
+
});
|
|
37581
|
+
return {
|
|
37582
|
+
state: next2,
|
|
37583
|
+
action: "noop",
|
|
37584
|
+
textWritten: text2.written,
|
|
37585
|
+
textDeleted: text2.deleted,
|
|
37586
|
+
textFailed: text2.failed,
|
|
37587
|
+
textUnknown
|
|
37588
|
+
};
|
|
37589
|
+
}
|
|
37590
|
+
const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
|
|
37591
|
+
if (!got.ok) {
|
|
37592
|
+
return { state, action: "skipped" };
|
|
37593
|
+
}
|
|
37594
|
+
if (got.row.download_url === null) {
|
|
37595
|
+
if (storedBytesSignal(row) === "present") {
|
|
37596
|
+
output?.warn(`files/${relPath}: the file index says Plinth holds this file's bytes and the file route says it does not — treating it as having none, and leaving any local copy alone.`);
|
|
37597
|
+
}
|
|
37598
|
+
return applyPointerRow({
|
|
37599
|
+
workspaceSlug,
|
|
37600
|
+
baseUrl,
|
|
37601
|
+
pat,
|
|
37602
|
+
row,
|
|
37603
|
+
relPath,
|
|
37604
|
+
state,
|
|
37605
|
+
existing,
|
|
37606
|
+
textSignal,
|
|
37607
|
+
fetchImpl,
|
|
37608
|
+
output
|
|
37609
|
+
});
|
|
35332
37610
|
}
|
|
35333
37611
|
if (!suppressor && existing && existing.content_hash !== row.content_hash && existsSync5(abs)) {
|
|
35334
37612
|
try {
|
|
35335
|
-
const localBytes =
|
|
37613
|
+
const localBytes = readFileSync18(abs);
|
|
35336
37614
|
if (sha256Bytes(localBytes) !== existing.content_hash) {
|
|
35337
37615
|
const stashPath = stashFileConflict({ workspaceSlug, relPath, timestamp: now(), bytes: localBytes });
|
|
35338
37616
|
output?.warn(`Conflict on files/${relPath} → stashed local edit to ${stashPath} (cloud-wins on boot).`);
|
|
35339
37617
|
}
|
|
35340
37618
|
} catch {}
|
|
35341
37619
|
}
|
|
35342
|
-
const got = await getFile({ baseUrl, pat, id: row.id, fetchImpl });
|
|
35343
|
-
if (!got.ok) {
|
|
35344
|
-
return { state, action: "skipped" };
|
|
35345
|
-
}
|
|
35346
37620
|
const bytes = await downloadBytes(got.row.download_url, fetchImpl);
|
|
35347
37621
|
if (sha256Bytes(bytes) !== row.content_hash) {
|
|
35348
37622
|
output?.warn(`Integrity check failed for files/${relPath} (hash mismatch) — skipped this pull.`);
|
|
@@ -35350,9 +37624,28 @@ async function applyFileRow(args) {
|
|
|
35350
37624
|
}
|
|
35351
37625
|
suppressor?.registerWrite(abs, { bodyHash: row.content_hash, frontmatterHash: "" });
|
|
35352
37626
|
writeFilesBytes(workspaceSlug, relPath, bytes);
|
|
37627
|
+
let text;
|
|
37628
|
+
if (textUnknown) {
|
|
37629
|
+
const removed = deleteExtractedText(workspaceSlug, relPath);
|
|
37630
|
+
if (removed) {
|
|
37631
|
+
output?.warn(`files/${relPath}: bytes changed while the server said nothing about extracted text — removed the derived text, which described the previous version of this file.`);
|
|
37632
|
+
}
|
|
37633
|
+
text = { textHash: undefined, written: 0, deleted: removed ? 1 : 0 };
|
|
37634
|
+
} else {
|
|
37635
|
+
text = await applyExtractedText({
|
|
37636
|
+
workspaceSlug,
|
|
37637
|
+
baseUrl,
|
|
37638
|
+
pat,
|
|
37639
|
+
row,
|
|
37640
|
+
relPath,
|
|
37641
|
+
bytesInMirror: true,
|
|
37642
|
+
fetchImpl,
|
|
37643
|
+
output
|
|
37644
|
+
});
|
|
37645
|
+
}
|
|
35353
37646
|
let inode;
|
|
35354
37647
|
try {
|
|
35355
|
-
inode =
|
|
37648
|
+
inode = statSync10(abs).ino;
|
|
35356
37649
|
} catch {
|
|
35357
37650
|
inode = undefined;
|
|
35358
37651
|
}
|
|
@@ -35363,9 +37656,60 @@ async function applyFileRow(args) {
|
|
|
35363
37656
|
size_bytes: row.size_bytes,
|
|
35364
37657
|
mime_type: row.mime_type,
|
|
35365
37658
|
updated_at: row.updated_at,
|
|
35366
|
-
inode
|
|
37659
|
+
inode,
|
|
37660
|
+
text_hash: text.textHash
|
|
37661
|
+
});
|
|
37662
|
+
return {
|
|
37663
|
+
state: next,
|
|
37664
|
+
action: "written",
|
|
37665
|
+
textWritten: text.written,
|
|
37666
|
+
textDeleted: text.deleted,
|
|
37667
|
+
textFailed: text.failed,
|
|
37668
|
+
textUnknown
|
|
37669
|
+
};
|
|
37670
|
+
}
|
|
37671
|
+
async function applyPointerRow(args) {
|
|
37672
|
+
const { workspaceSlug, baseUrl, pat, row, relPath, state, textSignal } = args;
|
|
37673
|
+
if (textSignal === "absent") {
|
|
37674
|
+
args.output?.warn(`files/${relPath}: Plinth holds neither bytes nor text for this file — it has no local representation in the mirror.`);
|
|
37675
|
+
}
|
|
37676
|
+
const bytesInMirror = existsSync5(filesSubtreePath(workspaceSlug, relPath));
|
|
37677
|
+
if (bytesInMirror) {
|
|
37678
|
+
args.output?.warn(`files/${relPath}: Plinth holds no bytes for this file, but a local copy is present at that path — left exactly as it is. Plinth will not update it.`);
|
|
37679
|
+
}
|
|
37680
|
+
const text = await applyExtractedText({
|
|
37681
|
+
workspaceSlug,
|
|
37682
|
+
baseUrl,
|
|
37683
|
+
pat,
|
|
37684
|
+
row,
|
|
37685
|
+
relPath,
|
|
37686
|
+
bytesInMirror,
|
|
37687
|
+
fetchImpl: args.fetchImpl,
|
|
37688
|
+
output: args.output
|
|
35367
37689
|
});
|
|
35368
|
-
return {
|
|
37690
|
+
return {
|
|
37691
|
+
state,
|
|
37692
|
+
action: "noop",
|
|
37693
|
+
textWritten: text.written,
|
|
37694
|
+
textDeleted: text.deleted,
|
|
37695
|
+
textFailed: text.failed,
|
|
37696
|
+
textUnknown: textSignal === "unknown",
|
|
37697
|
+
pointer: true
|
|
37698
|
+
};
|
|
37699
|
+
}
|
|
37700
|
+
function textIsCurrent(existing, row, workspaceSlug, relPath) {
|
|
37701
|
+
const signal = extractedTextSignal(row);
|
|
37702
|
+
if (signal === "unknown")
|
|
37703
|
+
return true;
|
|
37704
|
+
if (signal === "absent") {
|
|
37705
|
+
return existing.text_hash === undefined && !existsSync5(extractedTextPath(workspaceSlug, relPath));
|
|
37706
|
+
}
|
|
37707
|
+
if (existing.text_hash === undefined)
|
|
37708
|
+
return false;
|
|
37709
|
+
const onDisk = readExtractedText(workspaceSlug, relPath);
|
|
37710
|
+
if (onDisk === null)
|
|
37711
|
+
return false;
|
|
37712
|
+
return extractedTextHash(onDisk) === existing.text_hash;
|
|
35369
37713
|
}
|
|
35370
37714
|
|
|
35371
37715
|
// src/commands/sync.ts
|
|
@@ -35383,11 +37727,11 @@ function hasExistingContent(workspacePath) {
|
|
|
35383
37727
|
if (!existsSync6(workspacePath))
|
|
35384
37728
|
return false;
|
|
35385
37729
|
try {
|
|
35386
|
-
return
|
|
37730
|
+
return readdirSync5(workspacePath, { withFileTypes: true }).some((e2) => {
|
|
35387
37731
|
if (!e2.isDirectory())
|
|
35388
37732
|
return true;
|
|
35389
37733
|
try {
|
|
35390
|
-
return
|
|
37734
|
+
return readdirSync5(join14(workspacePath, e2.name)).length > 0;
|
|
35391
37735
|
} catch {
|
|
35392
37736
|
return true;
|
|
35393
37737
|
}
|
|
@@ -35407,7 +37751,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
|
|
|
35407
37751
|
}
|
|
35408
37752
|
let liveInode;
|
|
35409
37753
|
try {
|
|
35410
|
-
liveInode =
|
|
37754
|
+
liveInode = statSync11(target).ino;
|
|
35411
37755
|
} catch {
|
|
35412
37756
|
return { didUnlink: false, skippedReason: "absent" };
|
|
35413
37757
|
}
|
|
@@ -35416,7 +37760,7 @@ function unlinkIfInodeMatches(workspaceSlug, entityType, entrySlug, expectedInod
|
|
|
35416
37760
|
}
|
|
35417
37761
|
try {
|
|
35418
37762
|
suppressor?.registerDelete(target);
|
|
35419
|
-
|
|
37763
|
+
unlinkSync7(target);
|
|
35420
37764
|
return { didUnlink: true };
|
|
35421
37765
|
} catch (err) {
|
|
35422
37766
|
if (err?.code === "ENOENT") {
|
|
@@ -35503,7 +37847,11 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
35503
37847
|
updateSyncCursor(slug, entityType, lastAdvancedCursor);
|
|
35504
37848
|
}
|
|
35505
37849
|
}
|
|
35506
|
-
const
|
|
37850
|
+
const needsDerivedTextRewalk = state.derived_text_version !== DERIVED_TEXT_VERSION;
|
|
37851
|
+
const filesStartCursor = needsDerivedTextRewalk ? null : entry2.sync_cursors?.["files"] ?? null;
|
|
37852
|
+
if (needsDerivedTextRewalk) {
|
|
37853
|
+
out.info("Re-reading the file index once to refresh extracted text (template changed).");
|
|
37854
|
+
}
|
|
35507
37855
|
const filesResult = await pullFiles({
|
|
35508
37856
|
workspaceSlug: slug,
|
|
35509
37857
|
baseUrl: entry2.api_base_url,
|
|
@@ -35514,16 +37862,21 @@ async function runSync(workspaceSlug, deps = {}) {
|
|
|
35514
37862
|
now,
|
|
35515
37863
|
output: out,
|
|
35516
37864
|
suppressor: deps.suppressor,
|
|
35517
|
-
isFileFrozen: deps.isFileFrozen
|
|
37865
|
+
isFileFrozen: deps.isFileFrozen,
|
|
37866
|
+
forceTextRewalk: needsDerivedTextRewalk
|
|
35518
37867
|
});
|
|
35519
37868
|
if (filesResult.state !== state) {
|
|
35520
37869
|
state = filesResult.state;
|
|
35521
37870
|
stateDirty = true;
|
|
35522
37871
|
}
|
|
37872
|
+
if (needsDerivedTextRewalk && filesResult.textUnknown === 0) {
|
|
37873
|
+
state = { ...state, derived_text_version: DERIVED_TEXT_VERSION };
|
|
37874
|
+
stateDirty = true;
|
|
37875
|
+
}
|
|
35523
37876
|
counters["files"] = {
|
|
35524
37877
|
fetched: filesResult.fetched,
|
|
35525
|
-
written: filesResult.written,
|
|
35526
|
-
deleted: filesResult.deleted
|
|
37878
|
+
written: filesResult.written + filesResult.textWritten,
|
|
37879
|
+
deleted: filesResult.deleted + filesResult.textDeleted
|
|
35527
37880
|
};
|
|
35528
37881
|
if (filesResult.lastCursor !== filesStartCursor) {
|
|
35529
37882
|
updateSyncCursor(slug, "files", filesResult.lastCursor);
|
|
@@ -35705,7 +38058,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
35705
38058
|
if (!suppressor && existingEntry && existingEntry.updated_at !== live.updated_at) {
|
|
35706
38059
|
let localRaw = null;
|
|
35707
38060
|
try {
|
|
35708
|
-
localRaw =
|
|
38061
|
+
localRaw = readFileSync19(entityFilePath(workspaceSlug, strategy.type, live.slug), "utf8");
|
|
35709
38062
|
} catch {
|
|
35710
38063
|
localRaw = null;
|
|
35711
38064
|
}
|
|
@@ -35728,7 +38081,7 @@ function applyRow(workspaceSlug, strategy, row, state, out, suppressor, now = ()
|
|
|
35728
38081
|
const hashes = computeMirrorHashes(strategy, content);
|
|
35729
38082
|
let inode;
|
|
35730
38083
|
try {
|
|
35731
|
-
inode =
|
|
38084
|
+
inode = statSync11(entityFilePath(workspaceSlug, strategy.type, live.slug)).ino;
|
|
35732
38085
|
} catch {
|
|
35733
38086
|
inode = undefined;
|
|
35734
38087
|
}
|
|
@@ -35785,7 +38138,7 @@ var syncCommand = defineCommand({
|
|
|
35785
38138
|
// src/commands/start.ts
|
|
35786
38139
|
var SHUTDOWN_DRAIN_TIMEOUT_MS = 5000;
|
|
35787
38140
|
var PULL_HEARTBEAT_MS = 900000;
|
|
35788
|
-
function backfillStateInodes(workspaceSlug, state, statFn = (p) =>
|
|
38141
|
+
function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync12(p)) {
|
|
35789
38142
|
let changed = false;
|
|
35790
38143
|
const nextEntries = {};
|
|
35791
38144
|
for (const [entityType, bucket] of Object.entries(state.entries)) {
|
|
@@ -35818,7 +38171,7 @@ function backfillStateInodes(workspaceSlug, state, statFn = (p) => statSync10(p)
|
|
|
35818
38171
|
return { state, changed: false };
|
|
35819
38172
|
return { state: { ...state, entries: nextEntries }, changed: true };
|
|
35820
38173
|
}
|
|
35821
|
-
function backfillBodyLengths(workspaceSlug, state, readFn = (p) =>
|
|
38174
|
+
function backfillBodyLengths(workspaceSlug, state, readFn = (p) => readFileSync20(p, "utf8")) {
|
|
35822
38175
|
let changed = false;
|
|
35823
38176
|
const nextEntries = {};
|
|
35824
38177
|
for (const [entityType, bucket] of Object.entries(state.entries)) {
|
|
@@ -35915,7 +38268,7 @@ function makeWatchHandler(deps) {
|
|
|
35915
38268
|
}
|
|
35916
38269
|
let st;
|
|
35917
38270
|
try {
|
|
35918
|
-
st =
|
|
38271
|
+
st = statSync12(absolutePath);
|
|
35919
38272
|
} catch (err) {
|
|
35920
38273
|
if (err?.code === "ENOENT")
|
|
35921
38274
|
return;
|
|
@@ -35946,7 +38299,7 @@ function makeWatchHandler(deps) {
|
|
|
35946
38299
|
spinner.update(`Watching ${workspaceSlug} · ${watchingSummary(getState())}`);
|
|
35947
38300
|
return;
|
|
35948
38301
|
}
|
|
35949
|
-
const bytes =
|
|
38302
|
+
const bytes = readFileSync20(absolutePath);
|
|
35950
38303
|
const hash2 = sha256Bytes(bytes);
|
|
35951
38304
|
if (suppressor?.shouldSuppressUpsert(absolutePath, {
|
|
35952
38305
|
bodyHash: hash2,
|
|
@@ -36004,7 +38357,7 @@ function makeWatchHandler(deps) {
|
|
|
36004
38357
|
if (suppressor) {
|
|
36005
38358
|
let raw = null;
|
|
36006
38359
|
try {
|
|
36007
|
-
raw =
|
|
38360
|
+
raw = readFileSync20(absolutePath, "utf8");
|
|
36008
38361
|
} catch {
|
|
36009
38362
|
raw = null;
|
|
36010
38363
|
}
|
|
@@ -36042,7 +38395,7 @@ function makeWatchHandler(deps) {
|
|
|
36042
38395
|
if (suppressor) {
|
|
36043
38396
|
let raw = null;
|
|
36044
38397
|
try {
|
|
36045
|
-
raw =
|
|
38398
|
+
raw = readFileSync20(absolutePath, "utf8");
|
|
36046
38399
|
} catch {
|
|
36047
38400
|
raw = null;
|
|
36048
38401
|
}
|
|
@@ -36080,7 +38433,7 @@ function makeWatchHandler(deps) {
|
|
|
36080
38433
|
if (suppressor) {
|
|
36081
38434
|
let raw = null;
|
|
36082
38435
|
try {
|
|
36083
|
-
raw =
|
|
38436
|
+
raw = readFileSync20(absolutePath, "utf8");
|
|
36084
38437
|
} catch {
|
|
36085
38438
|
raw = null;
|
|
36086
38439
|
}
|
|
@@ -36118,7 +38471,7 @@ function makeWatchHandler(deps) {
|
|
|
36118
38471
|
if (suppressor) {
|
|
36119
38472
|
let raw = null;
|
|
36120
38473
|
try {
|
|
36121
|
-
raw =
|
|
38474
|
+
raw = readFileSync20(absolutePath, "utf8");
|
|
36122
38475
|
} catch {
|
|
36123
38476
|
raw = null;
|
|
36124
38477
|
}
|
|
@@ -36211,7 +38564,7 @@ function makeWatchHandler(deps) {
|
|
|
36211
38564
|
if (strategy) {
|
|
36212
38565
|
let raw = null;
|
|
36213
38566
|
try {
|
|
36214
|
-
raw =
|
|
38567
|
+
raw = readFileSync20(absolutePath, "utf8");
|
|
36215
38568
|
} catch {
|
|
36216
38569
|
raw = null;
|
|
36217
38570
|
}
|
|
@@ -36819,9 +39172,76 @@ async function runStart(workspaceSlug, deps = {}) {
|
|
|
36819
39172
|
loginHint: `plinth login --workspace ${slug}`
|
|
36820
39173
|
});
|
|
36821
39174
|
pullLoop.start();
|
|
39175
|
+
let contractMismatchAnnounced = false;
|
|
39176
|
+
let collisionAnnounced = false;
|
|
39177
|
+
const calendarLoop = new CalendarLoop({
|
|
39178
|
+
runOnce: async () => {
|
|
39179
|
+
const attemptedAt = (deps.now?.() ?? new Date).toISOString();
|
|
39180
|
+
try {
|
|
39181
|
+
const response = await fetchCalendar({
|
|
39182
|
+
baseUrl: entry2.api_base_url,
|
|
39183
|
+
pat,
|
|
39184
|
+
fetchImpl
|
|
39185
|
+
});
|
|
39186
|
+
const result = projectCalendar({
|
|
39187
|
+
workspaceSlug: slug,
|
|
39188
|
+
response,
|
|
39189
|
+
roster: readRoster(slug)
|
|
39190
|
+
});
|
|
39191
|
+
const notRead = response.calendars.filter((c3) => c3.status !== "fetched").length;
|
|
39192
|
+
daemonLog.log(calendarProjectedLine({
|
|
39193
|
+
written: result.written,
|
|
39194
|
+
pruned: result.pruned,
|
|
39195
|
+
calendars: response.calendars.length,
|
|
39196
|
+
notRead
|
|
39197
|
+
}));
|
|
39198
|
+
if (result.collided > 0 && !collisionAnnounced) {
|
|
39199
|
+
collisionAnnounced = true;
|
|
39200
|
+
spinner.warn(`${result.collided} calendar row${result.collided === 1 ? "" : "s"} ` + `shared a file name with another and ${result.collided === 1 ? "was" : "were"} skipped.`);
|
|
39201
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39202
|
+
}
|
|
39203
|
+
contractMismatchAnnounced = false;
|
|
39204
|
+
return { ok: true, written: result.written, pruned: result.pruned };
|
|
39205
|
+
} catch (err) {
|
|
39206
|
+
const failure = classifyCalendarFailure(err);
|
|
39207
|
+
let staled = 0;
|
|
39208
|
+
try {
|
|
39209
|
+
staled = markCalendarStale({
|
|
39210
|
+
workspaceSlug: slug,
|
|
39211
|
+
attemptedAt,
|
|
39212
|
+
reason: failure.reason
|
|
39213
|
+
}).rewritten;
|
|
39214
|
+
} catch (staleErr) {
|
|
39215
|
+
out.log(`[CALENDAR_STALE_FAILED ${categorizeError(staleErr).reason}]`);
|
|
39216
|
+
}
|
|
39217
|
+
daemonLog.log(calendarFailedLine({ ...categorizeError(err), staled }));
|
|
39218
|
+
if (err instanceof CalendarContractError && !contractMismatchAnnounced) {
|
|
39219
|
+
contractMismatchAnnounced = true;
|
|
39220
|
+
spinner.warn(`Calendar refresh: the server's response did not match the contract ` + `this daemon was built against. ${err.message}`);
|
|
39221
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39222
|
+
}
|
|
39223
|
+
return { ok: false, failure };
|
|
39224
|
+
}
|
|
39225
|
+
},
|
|
39226
|
+
onProjected: (written, pruned) => {
|
|
39227
|
+
const parts = [`${written} calendar file${written === 1 ? "" : "s"}`];
|
|
39228
|
+
if (pruned > 0)
|
|
39229
|
+
parts.push(`${pruned} pruned`);
|
|
39230
|
+
spinner.success(`↻ ${parts.join(", ")}`);
|
|
39231
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39232
|
+
},
|
|
39233
|
+
notice: (kind, message) => {
|
|
39234
|
+
daemonLog.log(calendarNoticeLine(kind));
|
|
39235
|
+
spinner.warn(message);
|
|
39236
|
+
spinner.update(`Watching ${slug} · ${watchingSummary(state)}`);
|
|
39237
|
+
},
|
|
39238
|
+
loginHint: `plinth login --workspace ${slug}`
|
|
39239
|
+
});
|
|
39240
|
+
calendarLoop.startNow();
|
|
36822
39241
|
const shutdown = (signal) => {
|
|
36823
39242
|
(async () => {
|
|
36824
39243
|
pullLoop.stop();
|
|
39244
|
+
calendarLoop.stop();
|
|
36825
39245
|
spinner.warn(`Received ${signal}, draining pending pushes…`);
|
|
36826
39246
|
try {
|
|
36827
39247
|
await Promise.race([
|
|
@@ -37032,27 +39452,27 @@ var startCommand = defineCommand({
|
|
|
37032
39452
|
});
|
|
37033
39453
|
|
|
37034
39454
|
// src/commands/status.ts
|
|
37035
|
-
import { statSync as
|
|
39455
|
+
import { statSync as statSync13 } from "node:fs";
|
|
37036
39456
|
|
|
37037
39457
|
// src/lib/build-stamp.ts
|
|
37038
|
-
import { execFileSync as
|
|
37039
|
-
import { existsSync as existsSync7, mkdirSync as
|
|
37040
|
-
import { dirname as
|
|
39458
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
39459
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync10, readFileSync as readFileSync21, writeFileSync as writeFileSync8 } from "node:fs";
|
|
39460
|
+
import { dirname as dirname10, join as join15 } from "node:path";
|
|
37041
39461
|
import { fileURLToPath } from "node:url";
|
|
37042
39462
|
var BUILD_STAMP_FILENAME = "build-stamp.json";
|
|
37043
39463
|
function stampPath(distDir) {
|
|
37044
|
-
return
|
|
39464
|
+
return join15(distDir, BUILD_STAMP_FILENAME);
|
|
37045
39465
|
}
|
|
37046
39466
|
function readHead(repoDir) {
|
|
37047
39467
|
try {
|
|
37048
|
-
const sha =
|
|
39468
|
+
const sha = execFileSync4("git", ["rev-parse", "HEAD"], {
|
|
37049
39469
|
cwd: repoDir,
|
|
37050
39470
|
encoding: "utf8",
|
|
37051
39471
|
stdio: ["ignore", "pipe", "ignore"]
|
|
37052
39472
|
}).trim();
|
|
37053
39473
|
if (!sha)
|
|
37054
39474
|
return null;
|
|
37055
|
-
const porcelain =
|
|
39475
|
+
const porcelain = execFileSync4("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
37056
39476
|
return { sha, dirty: porcelain.trim().length > 0 };
|
|
37057
39477
|
} catch {
|
|
37058
39478
|
return null;
|
|
@@ -37060,7 +39480,7 @@ function readHead(repoDir) {
|
|
|
37060
39480
|
}
|
|
37061
39481
|
function readBuildStamp(distDir) {
|
|
37062
39482
|
try {
|
|
37063
|
-
const parsed = JSON.parse(
|
|
39483
|
+
const parsed = JSON.parse(readFileSync21(stampPath(distDir), "utf8"));
|
|
37064
39484
|
if (parsed && typeof parsed.sha === "string" && parsed.sha.length > 0 && typeof parsed.dirty === "boolean" && typeof parsed.built_at === "string") {
|
|
37065
39485
|
return { sha: parsed.sha, dirty: parsed.dirty, built_at: parsed.built_at };
|
|
37066
39486
|
}
|
|
@@ -37072,9 +39492,9 @@ function readBuildStamp(distDir) {
|
|
|
37072
39492
|
function findRepoRoot(fromDir) {
|
|
37073
39493
|
let dir = fromDir;
|
|
37074
39494
|
for (;; ) {
|
|
37075
|
-
if (existsSync7(
|
|
39495
|
+
if (existsSync7(join15(dir, ".git")))
|
|
37076
39496
|
return dir;
|
|
37077
|
-
const parent =
|
|
39497
|
+
const parent = dirname10(dir);
|
|
37078
39498
|
if (parent === dir)
|
|
37079
39499
|
return null;
|
|
37080
39500
|
dir = parent;
|
|
@@ -37082,7 +39502,7 @@ function findRepoRoot(fromDir) {
|
|
|
37082
39502
|
}
|
|
37083
39503
|
function probeRepo(repoDir, stampSha) {
|
|
37084
39504
|
try {
|
|
37085
|
-
|
|
39505
|
+
execFileSync4("git", ["cat-file", "-e", `${stampSha}^{commit}`], {
|
|
37086
39506
|
cwd: repoDir,
|
|
37087
39507
|
stdio: "ignore"
|
|
37088
39508
|
});
|
|
@@ -37098,7 +39518,7 @@ function checkBuildStaleness(deps = {}) {
|
|
|
37098
39518
|
const metaUrl = deps.metaUrl ?? import.meta.url;
|
|
37099
39519
|
let distDir;
|
|
37100
39520
|
try {
|
|
37101
|
-
distDir =
|
|
39521
|
+
distDir = dirname10(fileURLToPath(metaUrl));
|
|
37102
39522
|
} catch {
|
|
37103
39523
|
return { kind: "unknown" };
|
|
37104
39524
|
}
|
|
@@ -37144,8 +39564,8 @@ function formatRelative(deltaMs) {
|
|
|
37144
39564
|
return `${Math.floor(h2 / 24)}d ago`;
|
|
37145
39565
|
}
|
|
37146
39566
|
function formatAbsolute(date5) {
|
|
37147
|
-
const
|
|
37148
|
-
return `${date5.getFullYear()}-${
|
|
39567
|
+
const pad3 = (n2) => String(n2).padStart(2, "0");
|
|
39568
|
+
return `${date5.getFullYear()}-${pad3(date5.getMonth() + 1)}-${pad3(date5.getDate())} ` + `${pad3(date5.getHours())}:${pad3(date5.getMinutes())}:${pad3(date5.getSeconds())}`;
|
|
37149
39569
|
}
|
|
37150
39570
|
function short(sha) {
|
|
37151
39571
|
return sha.slice(0, 7);
|
|
@@ -37168,7 +39588,7 @@ function renderBuild(out, staleness) {
|
|
|
37168
39588
|
}
|
|
37169
39589
|
function lastActivity(now) {
|
|
37170
39590
|
try {
|
|
37171
|
-
const mtimeMs =
|
|
39591
|
+
const mtimeMs = statSync13(daemonLogPath()).mtimeMs;
|
|
37172
39592
|
return formatRelative(now.getTime() - mtimeMs);
|
|
37173
39593
|
} catch {
|
|
37174
39594
|
return null;
|
|
@@ -37203,6 +39623,27 @@ async function runStatus(args, deps = {}) {
|
|
|
37203
39623
|
if (activity)
|
|
37204
39624
|
out.info(cont(`last activity ${activity}`));
|
|
37205
39625
|
out.info("");
|
|
39626
|
+
const sessions = listSessions(slug, {
|
|
39627
|
+
aliveFn: deps.aliveFn,
|
|
39628
|
+
startTimeFn: deps.sessionStartTimeFn,
|
|
39629
|
+
mtimeFn: deps.sessionMtimeFn
|
|
39630
|
+
});
|
|
39631
|
+
const selfPidRaw = process.env.CLAUDE_PID;
|
|
39632
|
+
const selfPid = selfPidRaw ? Number.parseInt(selfPidRaw, 10) : null;
|
|
39633
|
+
const selfSessionId = process.env.CLAUDE_CODE_SESSION_ID ?? null;
|
|
39634
|
+
const sessionLines = renderSessionLines(sessions.live, now, Number.isInteger(selfPid) ? selfPid : null, selfSessionId, formatRelative);
|
|
39635
|
+
const count = sessions.live.length;
|
|
39636
|
+
const reapNote = sessions.reaped > 0 ? ` (cleared ${sessions.reaped} ended session${sessions.reaped === 1 ? "" : "s"})` : "";
|
|
39637
|
+
out.info(row("Sessions", count === 0 ? `None registered on this machine.${reapNote}` : `${count} live on this machine${reapNote}`));
|
|
39638
|
+
if (count > 0 || sessions.unknown > 0 || sessions.ended > 0) {
|
|
39639
|
+
if (count > 0)
|
|
39640
|
+
for (const line of sessionLines)
|
|
39641
|
+
out.info(cont(line));
|
|
39642
|
+
for (const line of sessionCaveats(count, sessions.unknown, sessions.ended)) {
|
|
39643
|
+
out.info(cont(line));
|
|
39644
|
+
}
|
|
39645
|
+
}
|
|
39646
|
+
out.info("");
|
|
37206
39647
|
let localState;
|
|
37207
39648
|
try {
|
|
37208
39649
|
localState = loadState(slug);
|
|
@@ -37291,7 +39732,7 @@ var versionCommand = defineCommand({
|
|
|
37291
39732
|
});
|
|
37292
39733
|
|
|
37293
39734
|
// src/commands/voice-gate.ts
|
|
37294
|
-
import { readFileSync as
|
|
39735
|
+
import { readFileSync as readFileSync24 } from "node:fs";
|
|
37295
39736
|
|
|
37296
39737
|
// src/lib/voice-gate/api.ts
|
|
37297
39738
|
var VoiceGateSurfaces = [
|
|
@@ -37393,15 +39834,15 @@ async function callVoiceGate(req) {
|
|
|
37393
39834
|
}
|
|
37394
39835
|
|
|
37395
39836
|
// src/lib/voice-gate/attempts.ts
|
|
37396
|
-
import { createHash as
|
|
39837
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
37397
39838
|
import {
|
|
37398
|
-
mkdirSync as
|
|
37399
|
-
readFileSync as
|
|
37400
|
-
renameSync as
|
|
37401
|
-
unlinkSync as
|
|
37402
|
-
writeFileSync as
|
|
39839
|
+
mkdirSync as mkdirSync11,
|
|
39840
|
+
readFileSync as readFileSync22,
|
|
39841
|
+
renameSync as renameSync8,
|
|
39842
|
+
unlinkSync as unlinkSync8,
|
|
39843
|
+
writeFileSync as writeFileSync9
|
|
37403
39844
|
} from "node:fs";
|
|
37404
|
-
import { join as
|
|
39845
|
+
import { join as join16 } from "node:path";
|
|
37405
39846
|
var MAX_REGENS = 2;
|
|
37406
39847
|
var ENTRY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
37407
39848
|
var AttemptEntrySchema = exports_external.object({
|
|
@@ -37414,20 +39855,20 @@ var AttemptsFileSchema = exports_external.object({
|
|
|
37414
39855
|
entries: exports_external.record(exports_external.string(), AttemptEntrySchema)
|
|
37415
39856
|
});
|
|
37416
39857
|
function attemptsDir(workspaceSlug) {
|
|
37417
|
-
return
|
|
39858
|
+
return join16(plinthHome(), "_voice_gate", workspaceSlug);
|
|
37418
39859
|
}
|
|
37419
39860
|
function attemptsPath(workspaceSlug) {
|
|
37420
|
-
return
|
|
39861
|
+
return join16(attemptsDir(workspaceSlug), "attempts.json");
|
|
37421
39862
|
}
|
|
37422
39863
|
function attemptKey(identity) {
|
|
37423
|
-
return
|
|
39864
|
+
return createHash4("sha256").update(identity, "utf8").digest("hex").slice(0, 32);
|
|
37424
39865
|
}
|
|
37425
39866
|
function emptyFile() {
|
|
37426
39867
|
return { version: 1, entries: {} };
|
|
37427
39868
|
}
|
|
37428
39869
|
function load2(workspaceSlug) {
|
|
37429
39870
|
try {
|
|
37430
|
-
const raw =
|
|
39871
|
+
const raw = readFileSync22(attemptsPath(workspaceSlug), "utf8");
|
|
37431
39872
|
const parsed = AttemptsFileSchema.safeParse(JSON.parse(raw));
|
|
37432
39873
|
return parsed.success ? parsed.data : emptyFile();
|
|
37433
39874
|
} catch {
|
|
@@ -37442,19 +39883,19 @@ function prune(file2, now) {
|
|
|
37442
39883
|
}));
|
|
37443
39884
|
return { version: 1, entries };
|
|
37444
39885
|
}
|
|
37445
|
-
var
|
|
39886
|
+
var tmpCounter2 = 0;
|
|
37446
39887
|
function save(workspaceSlug, file2) {
|
|
37447
39888
|
const dir = attemptsDir(workspaceSlug);
|
|
37448
39889
|
const target = attemptsPath(workspaceSlug);
|
|
37449
|
-
const tmp = `${target}.${process.pid}.${
|
|
37450
|
-
|
|
37451
|
-
|
|
39890
|
+
const tmp = `${target}.${process.pid}.${tmpCounter2++}.tmp`;
|
|
39891
|
+
mkdirSync11(dir, { recursive: true, mode: 448 });
|
|
39892
|
+
writeFileSync9(tmp, `${JSON.stringify(file2, null, 2)}
|
|
37452
39893
|
`, { mode: 384 });
|
|
37453
39894
|
try {
|
|
37454
|
-
|
|
39895
|
+
renameSync8(tmp, target);
|
|
37455
39896
|
} catch (err) {
|
|
37456
39897
|
try {
|
|
37457
|
-
|
|
39898
|
+
unlinkSync8(tmp);
|
|
37458
39899
|
} catch {}
|
|
37459
39900
|
throw err;
|
|
37460
39901
|
}
|
|
@@ -37590,7 +40031,7 @@ function denyUngated(detail) {
|
|
|
37590
40031
|
}
|
|
37591
40032
|
|
|
37592
40033
|
// src/lib/voice-gate/payload.ts
|
|
37593
|
-
import { readFileSync as
|
|
40034
|
+
import { readFileSync as readFileSync23, statSync as statSync14 } from "node:fs";
|
|
37594
40035
|
import { homedir as homedir2 } from "node:os";
|
|
37595
40036
|
import { isAbsolute as isAbsolute3, resolve as resolve4 } from "node:path";
|
|
37596
40037
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
@@ -37600,7 +40041,7 @@ function isRawGwsDraftCreate(tokens) {
|
|
|
37600
40041
|
return tokens[0] === "gws" && tokens.includes("drafts") && tokens.includes("create");
|
|
37601
40042
|
}
|
|
37602
40043
|
var MCP_GMAIL_DRAFT_PATTERN = /^mcp__.*gmail.*__(create|update)_draft$/i;
|
|
37603
|
-
function
|
|
40044
|
+
function asString2(value) {
|
|
37604
40045
|
return typeof value === "string" ? value : null;
|
|
37605
40046
|
}
|
|
37606
40047
|
function asNonEmptyString(value) {
|
|
@@ -37747,11 +40188,11 @@ function isHelperInvocation(tokens) {
|
|
|
37747
40188
|
return helperIndex === start + 1 && /(^|\/)python[0-9.]*$/.test(prev);
|
|
37748
40189
|
}
|
|
37749
40190
|
function defaultReadBodyFile(path5) {
|
|
37750
|
-
const stat4 =
|
|
40191
|
+
const stat4 = statSync14(path5);
|
|
37751
40192
|
if (stat4.size > MAX_BODY_BYTES) {
|
|
37752
40193
|
throw new Error(`draft body is ${stat4.size} bytes, over the ${MAX_BODY_BYTES}-byte gate limit`);
|
|
37753
40194
|
}
|
|
37754
|
-
return
|
|
40195
|
+
return readFileSync23(path5, "utf8");
|
|
37755
40196
|
}
|
|
37756
40197
|
function parseRecipients(...raw) {
|
|
37757
40198
|
const all = raw.filter((v2) => typeof v2 === "string").flatMap((v2) => v2.split(",")).map((v2) => v2.trim().toLowerCase()).filter((v2) => v2.length > 0);
|
|
@@ -37839,10 +40280,10 @@ function classifyHelperSegment(tokens, dirState, readBodyFile) {
|
|
|
37839
40280
|
}
|
|
37840
40281
|
function classifyBash(payload, readBodyFile) {
|
|
37841
40282
|
const input = asRecord(payload.tool_input);
|
|
37842
|
-
const command =
|
|
40283
|
+
const command = asString2(input.command);
|
|
37843
40284
|
if (!command)
|
|
37844
40285
|
return { gated: false };
|
|
37845
|
-
const cwd =
|
|
40286
|
+
const cwd = asString2(payload.cwd);
|
|
37846
40287
|
let dirState = cwd ? { dir: cwd, known: true } : { dir: process.cwd(), known: true };
|
|
37847
40288
|
const items = [];
|
|
37848
40289
|
for (const segment of splitCommandSegments(command)) {
|
|
@@ -37883,7 +40324,7 @@ function classifyMcp(payload) {
|
|
|
37883
40324
|
]
|
|
37884
40325
|
};
|
|
37885
40326
|
}
|
|
37886
|
-
const subject =
|
|
40327
|
+
const subject = asString2(input.subject) ?? "";
|
|
37887
40328
|
return {
|
|
37888
40329
|
gated: true,
|
|
37889
40330
|
items: [
|
|
@@ -37891,7 +40332,7 @@ function classifyMcp(payload) {
|
|
|
37891
40332
|
draft: {
|
|
37892
40333
|
site: "mcp-gmail",
|
|
37893
40334
|
recipients: parseRecipients(asStringArray(input.to).join(","), asStringArray(input.cc).join(","), asStringArray(input.bcc).join(",")),
|
|
37894
|
-
threadId:
|
|
40335
|
+
threadId: asString2(input.replyToMessageId) || asString2(input.draftId) || null,
|
|
37895
40336
|
subject,
|
|
37896
40337
|
text: composeDraftText(subject, body)
|
|
37897
40338
|
}
|
|
@@ -37902,7 +40343,7 @@ function classifyMcp(payload) {
|
|
|
37902
40343
|
function classifyPayload(payload, deps = {}) {
|
|
37903
40344
|
const readBodyFile = deps.readBodyFile ?? defaultReadBodyFile;
|
|
37904
40345
|
try {
|
|
37905
|
-
const toolName =
|
|
40346
|
+
const toolName = asString2(payload.tool_name);
|
|
37906
40347
|
if (!toolName)
|
|
37907
40348
|
return { gated: false };
|
|
37908
40349
|
if (toolName === "Bash")
|
|
@@ -37928,7 +40369,7 @@ var diskAttempts = {
|
|
|
37928
40369
|
recordFailure: (slug, key, reason) => recordFailure(slug, key, reason),
|
|
37929
40370
|
clear: clearAttempt
|
|
37930
40371
|
};
|
|
37931
|
-
async function
|
|
40372
|
+
async function defaultReadStdin2() {
|
|
37932
40373
|
if (process.stdin.isTTY)
|
|
37933
40374
|
return "";
|
|
37934
40375
|
const chunks = [];
|
|
@@ -38037,7 +40478,7 @@ async function gateOneDraft(draft, sessionId, auth, deps) {
|
|
|
38037
40478
|
}
|
|
38038
40479
|
async function runVoiceGateHook(args, deps = {}) {
|
|
38039
40480
|
const sink = deps.sink ?? { stdout: process.stdout, stderr: process.stderr };
|
|
38040
|
-
const readStdin = deps.readStdin ??
|
|
40481
|
+
const readStdin = deps.readStdin ?? defaultReadStdin2;
|
|
38041
40482
|
const raw = await readStdin().catch(() => "");
|
|
38042
40483
|
let payload;
|
|
38043
40484
|
try {
|
|
@@ -38102,7 +40543,7 @@ function isSurface(value) {
|
|
|
38102
40543
|
async function runVoiceGateDirect(args, deps = {}) {
|
|
38103
40544
|
const { Output: Output2 } = await Promise.resolve().then(() => (init_output(), exports_output));
|
|
38104
40545
|
const out = new Output2({ sink: deps.sink, noColor: deps.noColor });
|
|
38105
|
-
const readStdin = deps.readStdin ??
|
|
40546
|
+
const readStdin = deps.readStdin ?? defaultReadStdin2;
|
|
38106
40547
|
const surfaceArg = args.surface ?? "email_draft";
|
|
38107
40548
|
if (!isSurface(surfaceArg)) {
|
|
38108
40549
|
out.error(`Unknown surface "${surfaceArg}". Expected one of: ${VoiceGateSurfaces.join(", ")}.`);
|
|
@@ -38111,7 +40552,7 @@ async function runVoiceGateDirect(args, deps = {}) {
|
|
|
38111
40552
|
let text;
|
|
38112
40553
|
if (args.file) {
|
|
38113
40554
|
try {
|
|
38114
|
-
text =
|
|
40555
|
+
text = readFileSync24(args.file, "utf8");
|
|
38115
40556
|
} catch (err) {
|
|
38116
40557
|
const msg = err instanceof Error ? err.message : "unknown error";
|
|
38117
40558
|
out.error(`Could not read ${args.file}: ${msg}`);
|
|
@@ -38256,7 +40697,9 @@ var main = defineCommand({
|
|
|
38256
40697
|
"voice-gate": voiceGateCommand,
|
|
38257
40698
|
backlinks: backlinksCommand,
|
|
38258
40699
|
declare: declareCommand,
|
|
38259
|
-
"review-tier": reviewTierCommand
|
|
40700
|
+
"review-tier": reviewTierCommand,
|
|
40701
|
+
session: sessionCommand,
|
|
40702
|
+
"memory-gate": memoryGateCommand
|
|
38260
40703
|
}
|
|
38261
40704
|
});
|
|
38262
40705
|
runMain(main);
|