@mind-harness/mh-workspace 0.0.0-stage → 0.1.1

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/lib/index.js ADDED
@@ -0,0 +1,783 @@
1
+ import { basename, dirname, join, sep } from "node:path";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
4
+ import { mkdir, realpath } from "node:fs/promises";
5
+ import { Remote, RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
+ import { AGENT_VIEW_ACCEPT, readAgentView } from "@mind-harness/mh-browser/agent-view";
7
+ import { z as z$1 } from "zod";
8
+ import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
9
+ //#region lib/types/config.js
10
+ const DEFAULT_HOME = "https://mindharness.com/";
11
+ const workspaceConfig = z.object({
12
+ memory: z.object({ home: z.string().default(DEFAULT_HOME).volatile().description("Memory home page address. A new value is read on the next request, without restarting.") }).default({ home: DEFAULT_HOME }).description("The Memory this Harness lists Projects from."),
13
+ projectsDir: z.string().description("Where each Project gets its workspace directory. Defaults to mh/projects under the dsh home.")
14
+ });
15
+ function projectsDirOf(config) {
16
+ return config.projectsDir ?? join(resolveDshHome(), "mh", "projects");
17
+ }
18
+ //#endregion
19
+ //#region lib/types/codes.js
20
+ function unauthenticated(title, status) {
21
+ return new RemoteError("mh-workspace/unauthenticated", title || "Authentication required", {
22
+ title,
23
+ status
24
+ });
25
+ }
26
+ function unreadable(reason, extra) {
27
+ return new RemoteError("mh-workspace/unreadable", reason, {
28
+ reason,
29
+ ...extra?.status !== void 0 ? { status: extra.status } : {},
30
+ ...extra?.finalUrl !== void 0 ? { finalUrl: extra.finalUrl } : {}
31
+ });
32
+ }
33
+ function unlisted(url) {
34
+ return new RemoteError("mh-workspace/unlisted", "url is not in the project list", { url });
35
+ }
36
+ function cancelled() {
37
+ return new RemoteError("gateway/cancelled", "cancelled", {});
38
+ }
39
+ //#endregion
40
+ //#region lib/types/required.js
41
+ /** What these Agent Views list in `requires`, in order, without repeats. */
42
+ function requiredUrls(views) {
43
+ const urls = [];
44
+ for (const doc of docsOf(views)) for (const url of doc.requires) if (url !== "" && !urls.includes(url)) urls.push(url);
45
+ return urls;
46
+ }
47
+ function docsOf(views) {
48
+ return views.flatMap((view) => {
49
+ const representation = view.representation;
50
+ if (representation?.kind !== "agent-view" || representation.text === void 0) return [];
51
+ const read = readAgentView(representation.text, view.url);
52
+ return read.ok ? [read.doc] : [];
53
+ });
54
+ }
55
+ function sameUrl(left, right) {
56
+ try {
57
+ const a = new URL(left);
58
+ const b = new URL(right);
59
+ a.hash = "";
60
+ b.hash = "";
61
+ return a.href === b.href;
62
+ } catch {
63
+ return left === right;
64
+ }
65
+ }
66
+ //#endregion
67
+ //#region lib/types/cwd-pages.js
68
+ const RULES_PATH = "AGENTS.md";
69
+ /** One root-level page of a Project; undefined when it is not a readable Agent View. */
70
+ function rootLevel(text, url) {
71
+ const read = readAgentView(text, url);
72
+ if (!read.ok) return void 0;
73
+ const { links, items } = read.doc;
74
+ const readme = links.find((link) => link.rel === "describedby")?.href;
75
+ const next = links.find((link) => link.rel === "next")?.href;
76
+ const rules = items.find((item) => item.path === RULES_PATH)?.id;
77
+ return {
78
+ ...readme !== void 0 && readme !== "" ? { readme } : {},
79
+ ...typeof rules === "string" && rules !== "" ? { rules } : {},
80
+ ...next !== void 0 && next !== "" ? { next } : {}
81
+ };
82
+ }
83
+ /** The Project's own cwd pages, broad to specific: root README, then root AGENTS.md. */
84
+ function projectPages(levels) {
85
+ const readme = levels[0]?.readme;
86
+ const rules = levels.find((level) => level.rules !== void 0)?.rules;
87
+ return distinct([readme, rules]);
88
+ }
89
+ /** Placing order: what those pages require first, then the pages themselves. */
90
+ function placingOrder(required, own) {
91
+ return distinct([...required, ...own]);
92
+ }
93
+ function distinct(urls) {
94
+ const out = [];
95
+ for (const url of urls) if (url !== void 0 && !out.some((known) => sameUrl(known, url))) out.push(url);
96
+ return out;
97
+ }
98
+ //#endregion
99
+ //#region lib/types/domain.js
100
+ const projectRecord = z$1.object({
101
+ projectId: z$1.string(),
102
+ url: z$1.string(),
103
+ workspaceId: z$1.string(),
104
+ path: z$1.string(),
105
+ /** Title last written to the Workspace. Absent on records from before it was stored. */
106
+ title: z$1.string().optional(),
107
+ /** The person removed this Workspace. Sync does not register it again. */
108
+ removed: z$1.boolean().optional()
109
+ });
110
+ const mhWorkspaceDomain = defineDomain({
111
+ name: "mh_workspace",
112
+ version: 1,
113
+ tables: { projects: { valueSchema: projectRecord } }
114
+ });
115
+ //#endregion
116
+ //#region lib/types/json.js
117
+ /** Href values for one rel. Array links use `rel`; an object uses the rel as a key. */
118
+ function linkHrefs(page, rel) {
119
+ if (!page || typeof page !== "object") return [];
120
+ const links = page.links;
121
+ if (Array.isArray(links)) {
122
+ const hrefs = [];
123
+ for (const link of links) {
124
+ if (!link || typeof link !== "object") continue;
125
+ const row = link;
126
+ if (row.rel === rel && typeof row.href === "string" && row.href !== "") hrefs.push(row.href);
127
+ }
128
+ return hrefs;
129
+ }
130
+ if (links && typeof links === "object") {
131
+ const value = links[rel];
132
+ if (typeof value === "string" && value !== "") return [value];
133
+ if (value && typeof value === "object") {
134
+ const href = value.href;
135
+ if (typeof href === "string" && href !== "") return [href];
136
+ }
137
+ }
138
+ return [];
139
+ }
140
+ /** Project rows. The address is `items[].id`. `project_id` comes from `fields`. */
141
+ function projectItems(page) {
142
+ if (!page || typeof page !== "object") return [];
143
+ const items = page.items;
144
+ if (!Array.isArray(items)) return [];
145
+ const projects = [];
146
+ for (const item of items) {
147
+ if (!item || typeof item !== "object") continue;
148
+ const row = item;
149
+ if (typeof row.id !== "string" || row.id === "") continue;
150
+ const projectId = (row.fields && typeof row.fields === "object" ? row.fields : void 0)?.project_id;
151
+ if (typeof projectId !== "string" || projectId === "") continue;
152
+ projects.push({
153
+ url: row.id,
154
+ name: typeof row.title === "string" && row.title !== "" ? row.title : projectId,
155
+ projectId
156
+ });
157
+ }
158
+ return projects;
159
+ }
160
+ /** Home viewer is present: the browser is signed in. Memory answers 200 either way. */
161
+ function signedInOf(page) {
162
+ if (!page || typeof page !== "object") return false;
163
+ const viewer = page.viewer;
164
+ return typeof viewer === "string" ? viewer !== "" : viewer != null && viewer !== false;
165
+ }
166
+ /** Home viewer is present and `act` is absent or empty. */
167
+ function selfLoginOf(page) {
168
+ if (!signedInOf(page)) return false;
169
+ const act = page.act;
170
+ return act == null || act === "" || Array.isArray(act) && act.length === 0;
171
+ }
172
+ function titleOf(page) {
173
+ if (!page || typeof page !== "object") return "";
174
+ const title = page.title;
175
+ return typeof title === "string" ? title : "";
176
+ }
177
+ function parseJson(bytes) {
178
+ return JSON.parse(new TextDecoder().decode(bytes));
179
+ }
180
+ //#endregion
181
+ //#region lib/types/notice.js
182
+ function projectContextText(context, home) {
183
+ const lines = [projectSentence(context)];
184
+ if (context.gap !== void 0) lines.push(signInSentence(home, context.gap));
185
+ else if (context.unread === true) lines.push(UNREAD_SENTENCE);
186
+ lines.push(workingCopySentence(context.url));
187
+ return lines.join("\n\n");
188
+ }
189
+ function projectSentence(context) {
190
+ return context.name === void 0 || context.name === "" ? `Current Project: ${context.url}.` : `Current Project: ${JSON.stringify(context.name)} at ${context.url}.`;
191
+ }
192
+ /** What the working directory of a Project's Workspace is. */
193
+ function workingCopySentence(project) {
194
+ return `The working directory is a local working copy for the Project ${project}, not the Project itself: it starts empty and does not mirror the Project. Read the Project by opening its pages and change it through the actions they declare. Keep files on their way in or out here, such as downloads and files to upload; a file edited here is not in the Project until you upload it.`;
195
+ }
196
+ function signInSentence(home, gap) {
197
+ return `Memory at ${home} cannot show this Project to the browser (${gap}). Its README and AGENTS.md were not opened. Tell the person to sign in to Memory in their browser, then continue; Memory pages read before that show only what is public.`;
198
+ }
199
+ const UNREAD_SENTENCE = "The Project page could not be read, so its README and AGENTS.md were not opened; open the Project page to read them.";
200
+ //#endregion
201
+ //#region lib/types/service.js
202
+ var __runInitializers = function(thisArg, initializers, value) {
203
+ var useValue = arguments.length > 2;
204
+ for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
205
+ return useValue ? value : void 0;
206
+ };
207
+ var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
208
+ function accept(f) {
209
+ if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
210
+ return f;
211
+ }
212
+ var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
213
+ var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
214
+ var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
215
+ var _, done = false;
216
+ for (var i = decorators.length - 1; i >= 0; i--) {
217
+ var context = {};
218
+ for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
219
+ for (var p in contextIn.access) context.access[p] = contextIn.access[p];
220
+ context.addInitializer = function(f) {
221
+ if (done) throw new TypeError("Cannot add initializers after decoration has completed");
222
+ extraInitializers.push(accept(f || null));
223
+ };
224
+ var result = (0, decorators[i])(kind === "accessor" ? {
225
+ get: descriptor.get,
226
+ set: descriptor.set
227
+ } : descriptor[key], context);
228
+ if (kind === "accessor") {
229
+ if (result === void 0) continue;
230
+ if (result === null || typeof result !== "object") throw new TypeError("Object expected");
231
+ if (_ = accept(result.get)) descriptor.get = _;
232
+ if (_ = accept(result.set)) descriptor.set = _;
233
+ if (_ = accept(result.init)) initializers.unshift(_);
234
+ } else if (_ = accept(result)) {
235
+ if (kind === "field") initializers.unshift(_);
236
+ else descriptor[key] = _;
237
+ }
238
+ }
239
+ if (target) Object.defineProperty(target, contextIn.name, descriptor);
240
+ done = true;
241
+ };
242
+ /**
243
+ * Host half. Lists Memory projects through the browser request channel,
244
+ * registers one Workspace directory per project, and places the cwd pages
245
+ * on the first step.
246
+ */
247
+ let MhWorkspaceService = (() => {
248
+ let _classSuper = TypertRemoteService;
249
+ let _instanceExtraInitializers = [];
250
+ let _syncProjects_decorators;
251
+ let _ensureWorkspace_decorators;
252
+ let _openPersonWindow_decorators;
253
+ return class MhWorkspaceService extends _classSuper {
254
+ static {
255
+ const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
256
+ _syncProjects_decorators = [Remote];
257
+ _ensureWorkspace_decorators = [Remote];
258
+ _openPersonWindow_decorators = [Remote];
259
+ __esDecorate(this, null, _syncProjects_decorators, {
260
+ kind: "method",
261
+ name: "syncProjects",
262
+ static: false,
263
+ private: false,
264
+ access: {
265
+ has: (obj) => "syncProjects" in obj,
266
+ get: (obj) => obj.syncProjects
267
+ },
268
+ metadata: _metadata
269
+ }, null, _instanceExtraInitializers);
270
+ __esDecorate(this, null, _ensureWorkspace_decorators, {
271
+ kind: "method",
272
+ name: "ensureWorkspace",
273
+ static: false,
274
+ private: false,
275
+ access: {
276
+ has: (obj) => "ensureWorkspace" in obj,
277
+ get: (obj) => obj.ensureWorkspace
278
+ },
279
+ metadata: _metadata
280
+ }, null, _instanceExtraInitializers);
281
+ __esDecorate(this, null, _openPersonWindow_decorators, {
282
+ kind: "method",
283
+ name: "openPersonWindow",
284
+ static: false,
285
+ private: false,
286
+ access: {
287
+ has: (obj) => "openPersonWindow" in obj,
288
+ get: (obj) => obj.openPersonWindow
289
+ },
290
+ metadata: _metadata
291
+ }, null, _instanceExtraInitializers);
292
+ if (_metadata) Object.defineProperty(this, Symbol.metadata, {
293
+ enumerable: true,
294
+ configurable: true,
295
+ writable: true,
296
+ value: _metadata
297
+ });
298
+ }
299
+ static inject = [
300
+ "mhBrowser",
301
+ "workspaceRegistry",
302
+ "storageDomain",
303
+ "sessionProjections",
304
+ "systemPrompt"
305
+ ];
306
+ home = __runInitializers(this, _instanceExtraInitializers);
307
+ projectsDir;
308
+ ready;
309
+ table;
310
+ listed = /* @__PURE__ */ new Map();
311
+ /** One sync at a time. A second call waits on this instead of listing again. */
312
+ syncing;
313
+ contexts = /* @__PURE__ */ new Map();
314
+ constructor(ctx, config) {
315
+ super(ctx, "mhWorkspace");
316
+ this.home = config.memory.home;
317
+ this.projectsDir = projectsDirOf(config);
318
+ this.ready = this.mount();
319
+ Object.getPrototypeOf(ctx).on("loader/volatile-update", () => {
320
+ this.listed.clear();
321
+ });
322
+ const prompt = ctx.systemPrompt;
323
+ prompt.context({
324
+ name: "mh-workspace:project",
325
+ order: prompt.getContextOrder("WORKING_DIRECTORY") + 1,
326
+ required: true,
327
+ interpolate: false,
328
+ text: (context) => {
329
+ const session = context.agent?.session;
330
+ const project = session === void 0 ? void 0 : this.contexts.get(session.id);
331
+ return project === void 0 || project === null ? "" : projectContextText(project, this.home.get());
332
+ }
333
+ });
334
+ const listen = ctx.on;
335
+ listen("agent/pre-step", async (payload, next) => {
336
+ await this.ready;
337
+ await this.prepareStep(payload);
338
+ return next();
339
+ });
340
+ }
341
+ /**
342
+ * List every reachable Project and register a Workspace for each one that
343
+ * the person has not removed. New Workspaces are registered from the end of
344
+ * the list, so the first Project ends up first: the registry prepends.
345
+ */
346
+ async syncProjects(signal) {
347
+ await this.ready;
348
+ if (signal.aborted) throw cancelled();
349
+ const current = this.syncing;
350
+ if (current !== void 0) return current;
351
+ const run = this.doSync(signal).finally(() => {
352
+ if (this.syncing === run) this.syncing = void 0;
353
+ });
354
+ this.syncing = run;
355
+ return run;
356
+ }
357
+ /** Put a listed Project back in the sidebar after the person removed it. */
358
+ async ensureWorkspace(projectUrl, signal) {
359
+ await this.ready;
360
+ if (signal.aborted) throw cancelled();
361
+ const known = this.listed.get(projectUrl);
362
+ if (known === void 0) throw unlisted(projectUrl);
363
+ return { workspaceId: await this.register(known) };
364
+ }
365
+ async openPersonWindow(url, signal) {
366
+ await this.ready;
367
+ if (signal?.aborted === true) throw cancelled();
368
+ const home = this.memoryHome();
369
+ const target = url === void 0 || url === "" ? home : url;
370
+ if (target !== home && !this.listed.has(target)) throw unlisted(target);
371
+ if (!isWebUrl(target)) throw unreadable("address is not http or https");
372
+ await this.browser().open("person", [{ url: target }], signal);
373
+ await this.browser().bringToFront("person");
374
+ }
375
+ /**
376
+ * Keep the Session's Project context current and, on the first step of its
377
+ * first turn, place the cwd pages. Later steps read Memory only while a
378
+ * sign-in gap is open, to notice when it closes.
379
+ */
380
+ async prepareStep(payload) {
381
+ await this.ready;
382
+ const id = payload.agent.session.id;
383
+ const first = this.isFirstStep(payload);
384
+ let context = this.contexts.get(id);
385
+ if (context === void 0) {
386
+ context = await this.contextFor(payload.agent.session, first, payload.signal);
387
+ this.contexts.set(id, context);
388
+ }
389
+ if (context === null) return;
390
+ if (!first && context.gap === void 0) return;
391
+ const gap = await this.signInGap(payload.signal);
392
+ if (gap !== void 0) {
393
+ if (first) this.ctx.logger.warn("mh-workspace: cwd pages not placed: %s", gap);
394
+ this.contexts.set(id, {
395
+ ...context,
396
+ gap
397
+ });
398
+ return;
399
+ }
400
+ const open = withoutGap(context);
401
+ if (!first) {
402
+ this.contexts.set(id, open);
403
+ return;
404
+ }
405
+ const placed = await this.placePages(id, open.url, payload.signal);
406
+ this.contexts.set(id, placed ? open : {
407
+ ...open,
408
+ unread: true
409
+ });
410
+ }
411
+ /** The first step of the Session's first turn. Later turns do not place pages. */
412
+ isFirstStep(payload) {
413
+ if (payload.step !== 1 || payload.messages.length === 0) return false;
414
+ const boundary = this.turns().stateOf(payload.agent.session, "turnBoundary");
415
+ if (boundary === void 0) {
416
+ this.ctx.logger.warn("mh-workspace: turnBoundary projection is not registered; cwd pages not placed");
417
+ return false;
418
+ }
419
+ return boundary.lastTurn === 1;
420
+ }
421
+ /**
422
+ * The Project this Session's cwd is a working copy of. Only the first step
423
+ * may ask Memory, to find a Project by directory name; a resumed or forked
424
+ * Session is matched from storage alone.
425
+ */
426
+ async contextFor(session, first, signal) {
427
+ const cwd = session.header.cwd;
428
+ if (cwd === void 0 || cwd === "") return null;
429
+ const project = first ? await this.projectForCwd(cwd, signal) : await this.storedProject(cwd);
430
+ if (project === void 0) return null;
431
+ return {
432
+ url: project.url,
433
+ ...project.name !== void 0 ? { name: project.name } : {}
434
+ };
435
+ }
436
+ async storedProject(cwd) {
437
+ let canonical;
438
+ try {
439
+ canonical = await realpath(cwd);
440
+ } catch {
441
+ return;
442
+ }
443
+ const record = this.recordForPath(canonical);
444
+ return record === void 0 ? void 0 : named(record.url, record.title);
445
+ }
446
+ /**
447
+ * Place the cwd pages, broad to specific: what the Project page, the root
448
+ * README and AGENTS.md require (the Memory home), the README, then
449
+ * AGENTS.md. The Project page itself is not placed. False when the Project
450
+ * page could not be read.
451
+ */
452
+ async placePages(owner, project, signal) {
453
+ const [view] = await this.openQuietly(owner, [project], signal);
454
+ const first = view === void 0 ? void 0 : levelOf(view);
455
+ if (view === void 0 || first === void 0) {
456
+ this.ctx.logger.warn("mh-workspace: project page has no readable Agent View; cwd pages not placed");
457
+ return false;
458
+ }
459
+ const own = projectPages([first, ...await this.laterLevels(first, signal)]);
460
+ const required = requiredUrls([view, ...own.length === 0 ? [] : await this.openQuietly(owner, own, signal)]);
461
+ const held = this.browser().presenceList(owner);
462
+ const isNew = (url) => !sameUrl(url, project) && !held.some((page) => sameUrl(page.url, url));
463
+ const toOpen = required.filter((url) => isNew(url) && !own.some((page) => sameUrl(page, url)));
464
+ if (toOpen.length > 0) await this.openQuietly(owner, toOpen, signal);
465
+ const urls = placingOrder(required, own).filter(isNew);
466
+ if (urls.length > 0) this.browser().presencePut(owner, urls, "cwd reference");
467
+ return true;
468
+ }
469
+ /** Follow `next` from the first root level until a level names AGENTS.md or none is left. */
470
+ async laterLevels(first, signal) {
471
+ const levels = [];
472
+ const seen = /* @__PURE__ */ new Set();
473
+ let current = first;
474
+ while (current.rules === void 0 && current.next !== void 0 && !seen.has(current.next)) {
475
+ seen.add(current.next);
476
+ const level = await this.readLevel(current.next, signal);
477
+ if (level === void 0) break;
478
+ levels.push(level);
479
+ current = level;
480
+ }
481
+ return levels;
482
+ }
483
+ async readLevel(url, signal) {
484
+ if (signal.aborted) throw cancelled();
485
+ const response = await this.browser().fetch(url, { accept: AGENT_VIEW_ACCEPT });
486
+ if (response.kind === "error" || response.status !== 200) {
487
+ this.ctx.logger.warn("mh-workspace: project level not read: %s", url);
488
+ return;
489
+ }
490
+ return rootLevel(new TextDecoder().decode(response.body), response.finalUrl);
491
+ }
492
+ /** Why the browser cannot read the person's Projects now; undefined when it can. */
493
+ async signInGap(signal) {
494
+ try {
495
+ return signedInOf((await this.readJson(this.memoryHome(), signal)).page) ? void 0 : "not signed in";
496
+ } catch (error) {
497
+ if (signal.aborted) throw error;
498
+ return errorText(error);
499
+ }
500
+ }
501
+ async openQuietly(owner, urls, signal) {
502
+ try {
503
+ return await this.browser().open(owner, urls.map((url) => ({ url })), signal);
504
+ } catch (error) {
505
+ this.ctx.logger.warn("mh-workspace open failed: %s", errorText(error));
506
+ return [];
507
+ }
508
+ }
509
+ turns() {
510
+ return this.ctx.sessionProjections;
511
+ }
512
+ browser() {
513
+ return this.ctx.mhBrowser;
514
+ }
515
+ async mount() {
516
+ const domain = await this.ctx.storageDomain.open(mhWorkspaceDomain);
517
+ this.table = domain.table("projects");
518
+ this.ctx.effect(() => async () => {
519
+ try {
520
+ await domain.close();
521
+ } catch (error) {
522
+ this.ctx.logger.warn("mh-workspace storage close failed: %s", errorText(error));
523
+ }
524
+ });
525
+ }
526
+ projects() {
527
+ if (this.table === void 0) throw new Error("mh-workspace storage is not open");
528
+ return this.table;
529
+ }
530
+ byUrl() {
531
+ return new Map(this.projects().entries());
532
+ }
533
+ async doSync(signal) {
534
+ const { found, signedIn, selfLogin } = await this.readProjects(signal);
535
+ this.listed = new Map(found.map((item) => [item.url, item]));
536
+ const reg = registry(this.ctx);
537
+ const live = new Set(reg.list().map((workspace) => String(workspace.id)));
538
+ for (const item of [...found].reverse()) try {
539
+ await this.reconcile(item, this.byUrl().get(item.url), live, reg);
540
+ } catch (error) {
541
+ this.ctx.logger.warn("mh-workspace register failed: %s", errorText(error));
542
+ }
543
+ const stored = this.byUrl();
544
+ return {
545
+ signedIn,
546
+ selfLogin,
547
+ projects: found.map((item) => projectView(item, stored.get(item.url)))
548
+ };
549
+ }
550
+ /**
551
+ * Bring one listed Project in line with its Workspace. A failure here is
552
+ * the caller's to skip; the rest of the list still registers.
553
+ */
554
+ async reconcile(item, record, live, reg) {
555
+ if (record?.removed === true) return;
556
+ if (record !== void 0 && !live.has(record.workspaceId)) {
557
+ await this.projects().put(item.url, {
558
+ ...record,
559
+ removed: true
560
+ });
561
+ return;
562
+ }
563
+ if (record === void 0) {
564
+ await this.register(item);
565
+ return;
566
+ }
567
+ const workspace = reg.get(record.workspaceId);
568
+ if (workspace === void 0) {
569
+ await this.projects().put(item.url, {
570
+ ...record,
571
+ removed: true
572
+ });
573
+ return;
574
+ }
575
+ if (record.title === void 0) {
576
+ if (workspace.title === item.name) await this.projects().put(item.url, {
577
+ ...record,
578
+ title: item.name
579
+ });
580
+ return;
581
+ }
582
+ if (workspace.title === item.name) {
583
+ if (record.title !== item.name) await this.projects().put(item.url, {
584
+ ...record,
585
+ title: item.name
586
+ });
587
+ return;
588
+ }
589
+ if (workspace.title === record.title) {
590
+ await workspace.setTitle(item.name);
591
+ await this.projects().put(item.url, {
592
+ ...record,
593
+ title: item.name
594
+ });
595
+ }
596
+ }
597
+ /** Create or reuse the Workspace directory and remember it. Clears `removed`. */
598
+ async register(item) {
599
+ const dir = join(this.projectsDir, hostDir(this.memoryHome()), directoryName(item.projectId));
600
+ await mkdir(dir, { recursive: true });
601
+ const workspace = await registry(this.ctx).create(dir, item.name);
602
+ const path = await realpath(dir);
603
+ const record = {
604
+ projectId: item.projectId,
605
+ url: item.url,
606
+ workspaceId: String(workspace.id),
607
+ path,
608
+ title: item.name
609
+ };
610
+ await this.projects().put(item.url, record);
611
+ return record.workspaceId;
612
+ }
613
+ async projectForCwd(cwd, signal) {
614
+ let canonical;
615
+ try {
616
+ canonical = await realpath(cwd);
617
+ } catch {
618
+ return;
619
+ }
620
+ const stored = this.recordForPath(canonical);
621
+ if (stored !== void 0) return named(stored.url, stored.title);
622
+ let root;
623
+ try {
624
+ root = await realpath(join(this.projectsDir, hostDir(this.memoryHome())));
625
+ } catch {
626
+ return;
627
+ }
628
+ if (dirname(canonical) !== root) return void 0;
629
+ try {
630
+ const { found } = await this.readProjects(signal);
631
+ const item = found.find((candidate) => candidate.projectId === basename(canonical));
632
+ return item === void 0 ? void 0 : named(item.url, item.name);
633
+ } catch (error) {
634
+ this.ctx.logger.warn("mh-workspace list failed: %s", errorText(error));
635
+ return;
636
+ }
637
+ }
638
+ async readProjects(signal) {
639
+ if (signal.aborted) throw cancelled();
640
+ const home = await this.readJson(this.memoryHome(), signal);
641
+ const signedIn = signedInOf(home.page);
642
+ const selfLogin = selfLoginOf(home.page);
643
+ const projectsHref = linkHrefs(home.page, "projects")[0];
644
+ const found = [];
645
+ const seen = /* @__PURE__ */ new Set();
646
+ let next = projectsHref;
647
+ while (next !== void 0 && !seen.has(next)) {
648
+ seen.add(next);
649
+ const page = await this.readJson(next, signal);
650
+ found.push(...projectItems(page.page));
651
+ next = linkHrefs(page.page, "next")[0];
652
+ }
653
+ return {
654
+ found,
655
+ signedIn,
656
+ selfLogin
657
+ };
658
+ }
659
+ recordForPath(canonical) {
660
+ let best;
661
+ for (const record of this.byUrl().values()) {
662
+ if (canonical !== record.path && !canonical.startsWith(record.path + sep)) continue;
663
+ if (best === void 0 || record.path.length > best.path.length) best = record;
664
+ }
665
+ return best;
666
+ }
667
+ /** The configured home. Not http(s) fails closed, before any request. */
668
+ memoryHome() {
669
+ const home = this.home.get();
670
+ if (!isWebUrl(home)) throw unreadable("address is not http or https");
671
+ return home;
672
+ }
673
+ async readJson(url, signal) {
674
+ if (signal.aborted) throw cancelled();
675
+ let response;
676
+ try {
677
+ response = await this.browser().fetch(url, { accept: "application/json" });
678
+ } catch (error) {
679
+ throw unreadable(errorText(error));
680
+ }
681
+ if (response.kind === "error") throw unreadable(response.reason);
682
+ const mediaType = response.mediaType?.split(";")[0]?.trim().toLowerCase();
683
+ if (response.status === 401) throw unauthenticated(mediaType === "application/json" ? jsonTitle(response.body) : "", response.status);
684
+ if (mediaType !== "application/json") throw unreadable("response is not JSON", {
685
+ status: response.status,
686
+ finalUrl: response.finalUrl
687
+ });
688
+ try {
689
+ return {
690
+ page: parseJson(response.body),
691
+ status: response.status,
692
+ finalUrl: response.finalUrl
693
+ };
694
+ } catch (error) {
695
+ throw unreadable(errorText(error), {
696
+ status: response.status,
697
+ finalUrl: response.finalUrl
698
+ });
699
+ }
700
+ }
701
+ };
702
+ })();
703
+ function projectView(item, record) {
704
+ return {
705
+ url: item.url,
706
+ name: item.name,
707
+ projectId: item.projectId,
708
+ ...record !== void 0 ? { workspaceId: record.workspaceId } : {},
709
+ ...record?.removed === true ? { removed: true } : {}
710
+ };
711
+ }
712
+ function registry(ctx) {
713
+ return ctx.workspaceRegistry;
714
+ }
715
+ /** Directory segment for one Memory: its host, with `:` written as `_`. */
716
+ function hostDir(home) {
717
+ let host;
718
+ try {
719
+ host = new URL(home).host;
720
+ } catch {
721
+ throw unreadable("address is not http or https");
722
+ }
723
+ const name = host.replaceAll(":", "_");
724
+ if (name === "" || name.includes("/") || name.includes("\\") || name === "." || name === "..") throw unreadable("address is not http or https");
725
+ return name;
726
+ }
727
+ function directoryName(projectId) {
728
+ if (projectId.includes("/") || projectId.includes("\\") || projectId === "." || projectId === "..") throw unreadable("project_id is not a directory name");
729
+ return projectId;
730
+ }
731
+ function isWebUrl(url) {
732
+ try {
733
+ const { protocol } = new URL(url);
734
+ return protocol === "http:" || protocol === "https:";
735
+ } catch {
736
+ return false;
737
+ }
738
+ }
739
+ function jsonTitle(body) {
740
+ try {
741
+ return titleOf(parseJson(body));
742
+ } catch {
743
+ return "";
744
+ }
745
+ }
746
+ function errorText(error) {
747
+ return error instanceof Error ? error.message : String(error);
748
+ }
749
+ function named(url, name) {
750
+ return name === void 0 || name === "" ? { url } : {
751
+ url,
752
+ name
753
+ };
754
+ }
755
+ function withoutGap(context) {
756
+ const open = { ...context };
757
+ delete open.gap;
758
+ return open;
759
+ }
760
+ function levelOf(view) {
761
+ const representation = view.representation;
762
+ if (representation?.kind !== "agent-view" || representation.text === void 0) return void 0;
763
+ return rootLevel(representation.text, view.url);
764
+ }
765
+ //#endregion
766
+ //#region lib/types/index.js
767
+ const name = "mh-workspace";
768
+ const inject = [
769
+ "mhBrowser",
770
+ "workspaceRegistry",
771
+ "storageDomain",
772
+ "sessionProjections",
773
+ "systemPrompt"
774
+ ];
775
+ const Config = workspaceConfig;
776
+ function apply(ctx, config) {
777
+ ctx.plugin(MhWorkspaceService, {
778
+ memory: config.memory,
779
+ projectsDir: projectsDirOf(config)
780
+ });
781
+ }
782
+ //#endregion
783
+ export { Config, DEFAULT_HOME, MhWorkspaceService, apply, inject, name };