@mind-harness/mh-workspace 0.0.0-stage → 0.1.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/LICENSE +57 -0
- package/README.md +4 -2
- package/icon.svg +5 -0
- package/lib/index.js +783 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +191 -0
- package/lib/typert.remote-client.d.ts +31 -0
- package/lib/typert.remote-client.js +116 -0
- package/lib/types/cites.d.ts +11 -0
- package/lib/types/codes.d.ts +28 -0
- package/lib/types/config.d.ts +22 -0
- package/lib/types/cwd-pages.d.ts +13 -0
- package/lib/types/domain.d.ts +18 -0
- package/lib/types/index.d.ts +26 -0
- package/lib/types/json.d.ts +16 -0
- package/lib/types/notice.d.ts +20 -0
- package/lib/types/required.d.ts +11 -0
- package/lib/types/service.d.ts +106 -0
- package/lib/types/views.d.ts +15 -0
- package/lib/types/views.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +67 -3
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 };
|