@shivaedev/work-board 0.7.2 → 0.9.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 +44 -0
- package/README.md +110 -2
- package/assets/agent.js +37 -0
- package/assets/native.js +54 -25
- package/dist/board.d.ts +1 -0
- package/dist/board.d.ts.map +1 -1
- package/dist/board.js +15 -2
- package/dist/board.js.map +1 -1
- package/dist/browser/client.d.ts +19 -79
- package/dist/browser/client.d.ts.map +1 -1
- package/dist/browser/client.js +12 -1
- package/dist/browser/client.js.map +1 -1
- package/dist/browser/entry.d.ts +3 -0
- package/dist/browser/entry.d.ts.map +1 -0
- package/dist/browser/entry.js +7 -0
- package/dist/browser/entry.js.map +1 -0
- package/dist/browser/responses/Prompt.d.ts +10 -0
- package/dist/browser/responses/Prompt.d.ts.map +1 -0
- package/dist/browser/responses/Prompt.js +2 -0
- package/dist/browser/responses/Prompt.js.map +1 -0
- package/dist/browser/responses/answerBody.d.ts +4 -0
- package/dist/browser/responses/answerBody.d.ts.map +1 -0
- package/dist/browser/responses/answerBody.js +9 -0
- package/dist/browser/responses/answerBody.js.map +1 -0
- package/dist/browser/responses/draftSession.d.ts +8 -0
- package/dist/browser/responses/draftSession.d.ts.map +1 -0
- package/dist/browser/responses/draftSession.js +105 -0
- package/dist/browser/responses/draftSession.js.map +1 -0
- package/dist/browser/responses/drafts.d.ts +21 -0
- package/dist/browser/responses/drafts.d.ts.map +1 -0
- package/dist/browser/responses/drafts.js +68 -0
- package/dist/browser/responses/drafts.js.map +1 -0
- package/dist/browser/responses/preview.d.ts +4 -0
- package/dist/browser/responses/preview.d.ts.map +1 -0
- package/dist/browser/responses/preview.js +5 -0
- package/dist/browser/responses/preview.js.map +1 -0
- package/dist/browser/responses/questionnaireControls.d.ts +10 -0
- package/dist/browser/responses/questionnaireControls.d.ts.map +1 -0
- package/dist/browser/responses/questionnaireControls.js +68 -0
- package/dist/browser/responses/questionnaireControls.js.map +1 -0
- package/dist/browser/responses/schema.d.ts +168 -0
- package/dist/browser/responses/schema.d.ts.map +1 -0
- package/dist/browser/responses/schema.js +57 -0
- package/dist/browser/responses/schema.js.map +1 -0
- package/dist/browser/responses/setup.d.ts +2 -0
- package/dist/browser/responses/setup.d.ts.map +1 -0
- package/dist/browser/responses/setup.js +129 -0
- package/dist/browser/responses/setup.js.map +1 -0
- package/dist/browser/responses/submissionFields.d.ts +15 -0
- package/dist/browser/responses/submissionFields.d.ts.map +1 -0
- package/dist/browser/responses/submissionFields.js +14 -0
- package/dist/browser/responses/submissionFields.js.map +1 -0
- package/dist/browser/responses/submit.d.ts +4 -0
- package/dist/browser/responses/submit.d.ts.map +1 -0
- package/dist/browser/responses/submit.js +30 -0
- package/dist/browser/responses/submit.js.map +1 -0
- package/dist/browser/session.d.ts +1 -79
- package/dist/browser/session.d.ts.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -15
- package/dist/cli.js.map +1 -1
- package/dist/files/changes.d.ts +1 -0
- package/dist/files/changes.d.ts.map +1 -1
- package/dist/files/changes.js +1 -1
- package/dist/files/changes.js.map +1 -1
- package/dist/http/respond.d.ts.map +1 -1
- package/dist/http/respond.js +2 -0
- package/dist/http/respond.js.map +1 -1
- package/dist/metadata/backlinks.d.ts.map +1 -1
- package/dist/metadata/backlinks.js +5 -0
- package/dist/metadata/backlinks.js.map +1 -1
- package/dist/metadata/fields.d.ts +23 -1
- package/dist/metadata/fields.d.ts.map +1 -1
- package/dist/metadata/problems.d.ts.map +1 -1
- package/dist/metadata/problems.js +3 -0
- package/dist/metadata/problems.js.map +1 -1
- package/dist/metadata/schema.d.ts +23 -1
- package/dist/metadata/schema.d.ts.map +1 -1
- package/dist/metadata/schema.js +6 -2
- package/dist/metadata/schema.js.map +1 -1
- package/dist/page/attentionDetails.d.ts.map +1 -1
- package/dist/page/attentionDetails.js +2 -1
- package/dist/page/attentionDetails.js.map +1 -1
- package/dist/page/metadataDetails.d.ts.map +1 -1
- package/dist/page/metadataDetails.js +6 -0
- package/dist/page/metadataDetails.js.map +1 -1
- package/dist/page/navigationScript.js +1 -1
- package/dist/page/shell.d.ts +1 -1
- package/dist/page/shell.d.ts.map +1 -1
- package/dist/page/shell.js.map +1 -1
- package/dist/page/style.d.ts.map +1 -1
- package/dist/page/style.js +3 -1
- package/dist/page/style.js.map +1 -1
- package/dist/page/swap.d.ts +1 -1
- package/dist/page/swap.d.ts.map +1 -1
- package/dist/page/swap.js +3 -0
- package/dist/page/swap.js.map +1 -1
- package/dist/path/Identity.d.ts +3 -0
- package/dist/path/Identity.d.ts.map +1 -0
- package/dist/path/Identity.js +3 -0
- package/dist/path/Identity.js.map +1 -0
- package/dist/render/markdown.d.ts +1 -0
- package/dist/render/markdown.d.ts.map +1 -1
- package/dist/render/markdown.js +4 -2
- package/dist/render/markdown.js.map +1 -1
- package/dist/render/questionnaireDocuments.d.ts +3 -0
- package/dist/render/questionnaireDocuments.d.ts.map +1 -0
- package/dist/render/questionnaireDocuments.js +11 -0
- package/dist/render/questionnaireDocuments.js.map +1 -0
- package/dist/render/visualDocuments.d.ts.map +1 -1
- package/dist/render/visualDocuments.js +3 -0
- package/dist/render/visualDocuments.js.map +1 -1
- package/dist/responses/agent-cli.d.ts +2 -0
- package/dist/responses/agent-cli.d.ts.map +1 -0
- package/dist/responses/agent-cli.js +31 -0
- package/dist/responses/agent-cli.js.map +1 -0
- package/dist/responses/agent.d.ts +80 -0
- package/dist/responses/agent.d.ts.map +1 -0
- package/dist/responses/agent.js +49 -0
- package/dist/responses/agent.js.map +1 -0
- package/dist/responses/await.d.ts +39 -0
- package/dist/responses/await.d.ts.map +1 -0
- package/dist/responses/await.js +21 -0
- package/dist/responses/await.js.map +1 -0
- package/dist/responses/composerHtml.d.ts +27 -0
- package/dist/responses/composerHtml.d.ts.map +1 -0
- package/dist/responses/composerHtml.js +14 -0
- package/dist/responses/composerHtml.js.map +1 -0
- package/dist/responses/flags.d.ts +6 -0
- package/dist/responses/flags.d.ts.map +1 -0
- package/dist/responses/flags.js +23 -0
- package/dist/responses/flags.js.map +1 -0
- package/dist/responses/history.d.ts +20 -0
- package/dist/responses/history.d.ts.map +1 -0
- package/dist/responses/history.js +16 -0
- package/dist/responses/history.js.map +1 -0
- package/dist/responses/locate.d.ts +13 -0
- package/dist/responses/locate.d.ts.map +1 -0
- package/dist/responses/locate.js +55 -0
- package/dist/responses/locate.js.map +1 -0
- package/dist/responses/make.d.ts +3 -0
- package/dist/responses/make.d.ts.map +1 -0
- package/dist/responses/make.js +16 -0
- package/dist/responses/make.js.map +1 -0
- package/dist/responses/outcome.d.ts +71 -0
- package/dist/responses/outcome.d.ts.map +1 -0
- package/dist/responses/outcome.js +14 -0
- package/dist/responses/outcome.js.map +1 -0
- package/dist/responses/page.d.ts +6 -0
- package/dist/responses/page.d.ts.map +1 -0
- package/dist/responses/page.js +22 -0
- package/dist/responses/page.js.map +1 -0
- package/dist/responses/publish.d.ts +4 -0
- package/dist/responses/publish.d.ts.map +1 -0
- package/dist/responses/publish.js +56 -0
- package/dist/responses/publish.js.map +1 -0
- package/dist/responses/publishIn.d.ts +3 -0
- package/dist/responses/publishIn.d.ts.map +1 -0
- package/dist/responses/publishIn.js +69 -0
- package/dist/responses/publishIn.js.map +1 -0
- package/dist/responses/read.d.ts +36 -0
- package/dist/responses/read.d.ts.map +1 -0
- package/dist/responses/read.js +43 -0
- package/dist/responses/read.js.map +1 -0
- package/dist/responses/records.d.ts +12 -0
- package/dist/responses/records.d.ts.map +1 -0
- package/dist/responses/records.js +49 -0
- package/dist/responses/records.js.map +1 -0
- package/dist/responses/retry.d.ts +34 -0
- package/dist/responses/retry.d.ts.map +1 -0
- package/dist/responses/retry.js +14 -0
- package/dist/responses/retry.js.map +1 -0
- package/dist/responses/service.d.ts +142 -0
- package/dist/responses/service.d.ts.map +1 -0
- package/dist/responses/service.js +95 -0
- package/dist/responses/service.js.map +1 -0
- package/dist/responses/template.d.ts +7 -0
- package/dist/responses/template.d.ts.map +1 -0
- package/dist/responses/template.js +58 -0
- package/dist/responses/template.js.map +1 -0
- package/dist/responses/templateHtml.d.ts +7 -0
- package/dist/responses/templateHtml.d.ts.map +1 -0
- package/dist/responses/templateHtml.js +18 -0
- package/dist/responses/templateHtml.js.map +1 -0
- package/dist/responses/templateLinks.d.ts +3 -0
- package/dist/responses/templateLinks.d.ts.map +1 -0
- package/dist/responses/templateLinks.js +45 -0
- package/dist/responses/templateLinks.js.map +1 -0
- package/dist/responses/templateNodes.d.ts +17 -0
- package/dist/responses/templateNodes.d.ts.map +1 -0
- package/dist/responses/templateNodes.js +47 -0
- package/dist/responses/templateNodes.js.map +1 -0
- package/dist/responses/validateSupersedes.d.ts +5 -0
- package/dist/responses/validateSupersedes.d.ts.map +1 -0
- package/dist/responses/validateSupersedes.js +21 -0
- package/dist/responses/validateSupersedes.js.map +1 -0
- package/dist/responses/validatedInput.d.ts +3 -0
- package/dist/responses/validatedInput.d.ts.map +1 -0
- package/dist/responses/validatedInput.js +43 -0
- package/dist/responses/validatedInput.js.map +1 -0
- package/dist/responses/waitFor.d.ts +37 -0
- package/dist/responses/waitFor.d.ts.map +1 -0
- package/dist/responses/waitFor.js +47 -0
- package/dist/responses/waitFor.js.map +1 -0
- package/dist/rpc/contract.d.ts +125 -2
- package/dist/rpc/contract.d.ts.map +1 -1
- package/dist/rpc/contract.js +3 -2
- package/dist/rpc/contract.js.map +1 -1
- package/dist/rpc/responseContract.d.ts +260 -0
- package/dist/rpc/responseContract.d.ts.map +1 -0
- package/dist/rpc/responseContract.js +37 -0
- package/dist/rpc/responseContract.js.map +1 -0
- package/dist/rpc/server.d.ts +3 -1
- package/dist/rpc/server.d.ts.map +1 -1
- package/dist/rpc/server.js +6 -1
- package/dist/rpc/server.js.map +1 -1
- package/dist/serve-cli.d.ts +3 -0
- package/dist/serve-cli.d.ts.map +1 -0
- package/dist/serve-cli.js +20 -0
- package/dist/serve-cli.js.map +1 -0
- package/package.json +3 -3
- package/src/board.ts +16 -2
- package/src/browser/client.ts +30 -4
- package/src/browser/entry.ts +9 -0
- package/src/browser/responses/Prompt.ts +6 -0
- package/src/browser/responses/answerBody.ts +11 -0
- package/src/browser/responses/draftSession.ts +101 -0
- package/src/browser/responses/drafts.ts +82 -0
- package/src/browser/responses/preview.ts +7 -0
- package/src/browser/responses/questionnaireControls.ts +74 -0
- package/src/browser/responses/schema.ts +63 -0
- package/src/browser/responses/setup.ts +132 -0
- package/src/browser/responses/submissionFields.ts +15 -0
- package/src/browser/responses/submit.ts +33 -0
- package/src/cli.ts +14 -22
- package/src/files/changes.ts +2 -1
- package/src/http/respond.ts +2 -0
- package/src/metadata/backlinks.ts +5 -0
- package/src/metadata/problems.ts +3 -0
- package/src/metadata/schema.ts +6 -2
- package/src/page/attentionDetails.ts +2 -1
- package/src/page/metadataDetails.ts +6 -0
- package/src/page/navigationScript.ts +1 -1
- package/src/page/shell.ts +1 -1
- package/src/page/style.ts +3 -1
- package/src/page/swap.ts +3 -0
- package/src/path/Identity.ts +3 -0
- package/src/render/markdown.ts +5 -2
- package/src/render/questionnaireDocuments.ts +12 -0
- package/src/render/visualDocuments.ts +3 -0
- package/src/responses/agent-cli.ts +35 -0
- package/src/responses/agent.ts +59 -0
- package/src/responses/await.ts +21 -0
- package/src/responses/composerHtml.ts +28 -0
- package/src/responses/flags.ts +22 -0
- package/src/responses/history.ts +18 -0
- package/src/responses/locate.ts +60 -0
- package/src/responses/make.ts +17 -0
- package/src/responses/outcome.ts +15 -0
- package/src/responses/page.ts +30 -0
- package/src/responses/publish.ts +53 -0
- package/src/responses/publishIn.ts +64 -0
- package/src/responses/read.ts +45 -0
- package/src/responses/records.ts +55 -0
- package/src/responses/retry.ts +23 -0
- package/src/responses/service.ts +117 -0
- package/src/responses/template.ts +72 -0
- package/src/responses/templateHtml.ts +26 -0
- package/src/responses/templateLinks.ts +43 -0
- package/src/responses/templateNodes.ts +55 -0
- package/src/responses/validateSupersedes.ts +24 -0
- package/src/responses/validatedInput.ts +45 -0
- package/src/responses/waitFor.ts +58 -0
- package/src/rpc/contract.ts +3 -2
- package/src/rpc/responseContract.ts +46 -0
- package/src/rpc/server.ts +8 -1
- package/src/serve-cli.ts +37 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Effect, type FileSystem } from "effect";
|
|
2
|
+
import { attentionRequests } from "#attention/requests.ts";
|
|
3
|
+
import { type QuestionPreview, ResponseFailed } from "#browser/responses/schema.ts";
|
|
4
|
+
import { markdownPath } from "#files/markdownPath.ts";
|
|
5
|
+
import { metadataModel } from "#metadata/model.ts";
|
|
6
|
+
import { metadataParse } from "#metadata/parse.ts";
|
|
7
|
+
import type { Snapshot } from "#search/snapshot.ts";
|
|
8
|
+
import { questionId, revisionOf } from "./records.ts";
|
|
9
|
+
import { questionTemplate } from "./template.ts";
|
|
10
|
+
|
|
11
|
+
function reject(code: ResponseFailed["code"], message: string) {
|
|
12
|
+
return Effect.fail(new ResponseFailed({ code, message }));
|
|
13
|
+
}
|
|
14
|
+
export function locate(root: string, realRoot: string, snapshot: Effect.Effect<Snapshot, ResponseFailed>, fs: FileSystem.FileSystem) {
|
|
15
|
+
return Effect.fn("WorkBoard.locate")(function* (item: string, request: string) {
|
|
16
|
+
const data = yield* snapshot;
|
|
17
|
+
const entry = attentionRequests(data).entries.find((candidate) => candidate.itemId === item && candidate.request.id === request);
|
|
18
|
+
if (!entry) {
|
|
19
|
+
return yield* reject("Missing", "The exact open attention request is missing, ambiguous or unavailable.");
|
|
20
|
+
}
|
|
21
|
+
const real = yield* markdownPath(root, realRoot, entry.document.file);
|
|
22
|
+
if (!real) {
|
|
23
|
+
return yield* reject("Missing", "The reviewed source moved or disappeared.");
|
|
24
|
+
}
|
|
25
|
+
const source = yield* fs.readFileString(real).pipe(Effect.catch(() => reject("Unavailable", "The source could not be read.")));
|
|
26
|
+
if (new TextEncoder().encode(source).length > 256 * 1024) {
|
|
27
|
+
return yield* reject("Unsupported", "Question context is limited to 256 KiB; use a focused source document.");
|
|
28
|
+
}
|
|
29
|
+
const parsed = metadataParse(source);
|
|
30
|
+
if (
|
|
31
|
+
parsed.fields.id !== item
|
|
32
|
+
|| parsed.fields.attention?.filter((candidate) => candidate.id === request && candidate.state === "open").length !== 1
|
|
33
|
+
) {
|
|
34
|
+
return yield* reject("Stale", "The source request changed. Re-read the question before proceeding.");
|
|
35
|
+
}
|
|
36
|
+
const documents = [...data.documents.filter((document) => document.file !== entry.document.file), { file: entry.document.file, parsed }];
|
|
37
|
+
const model = metadataModel(documents, data.unavailable, data.model.rootFile);
|
|
38
|
+
const fresh = attentionRequests({ ...data, documents, model }).entries.find(
|
|
39
|
+
(candidate) => candidate.itemId === item && candidate.request.id === request,
|
|
40
|
+
)?.request;
|
|
41
|
+
if (!fresh) {
|
|
42
|
+
return yield* reject("Stale", "The current request or its references are invalid. Review the source diagnostics before registering it.");
|
|
43
|
+
}
|
|
44
|
+
const revision = revisionOf(source);
|
|
45
|
+
yield* Effect.try({
|
|
46
|
+
catch: (cause) => new ResponseFailed({ code: "Unsupported", message: `Invalid response template: ${String(cause)}` }),
|
|
47
|
+
try: () => questionTemplate(source, request),
|
|
48
|
+
});
|
|
49
|
+
const question: QuestionPreview = {
|
|
50
|
+
context: source,
|
|
51
|
+
id: questionId(item, request, revision, entry.document.file),
|
|
52
|
+
item,
|
|
53
|
+
reason: fresh.reason,
|
|
54
|
+
request,
|
|
55
|
+
reviewedRevision: revision,
|
|
56
|
+
source: entry.document.file,
|
|
57
|
+
};
|
|
58
|
+
return question;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { DraftInput, Question, RecordedResponse } from "#browser/responses/schema.ts";
|
|
2
|
+
|
|
3
|
+
export function makeResponse(input: DraftInput, question: Question, recordedAt: number): RecordedResponse {
|
|
4
|
+
return {
|
|
5
|
+
body: input.body,
|
|
6
|
+
id: input.id,
|
|
7
|
+
response: {
|
|
8
|
+
...(input.answers === undefined ? {} : { answers: input.answers }),
|
|
9
|
+
...(input.supersedes === undefined ? {} : { supersedes: input.supersedes }),
|
|
10
|
+
author: input.author,
|
|
11
|
+
question: input.question,
|
|
12
|
+
recordedAt,
|
|
13
|
+
reviewedRevision: question.question.reviewedRevision,
|
|
14
|
+
type: input.type,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Reading } from "#browser/responses/schema.ts";
|
|
2
|
+
|
|
3
|
+
export function outcome(reading: Reading, after: string | undefined, now: number) {
|
|
4
|
+
const at = after === undefined ? -1 : reading.responses.findIndex((candidate) => candidate.id === after);
|
|
5
|
+
if (after !== undefined && at < 0) {
|
|
6
|
+
return { kind: "missing_cursor" } as const;
|
|
7
|
+
}
|
|
8
|
+
const response = reading.responses[at + 1];
|
|
9
|
+
if (response) {
|
|
10
|
+
return { kind: "response", question: reading.question, response } as const;
|
|
11
|
+
}
|
|
12
|
+
return now >= reading.question.question.deadline
|
|
13
|
+
? ({ kind: "unanswered", question: reading.question } as const)
|
|
14
|
+
: ({ kind: "pending", question: reading.question } as const);
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { Changes } from "#files/changes.ts";
|
|
4
|
+
import { respond } from "#http/respond.ts";
|
|
5
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
6
|
+
import { navHtml } from "#page/nav.ts";
|
|
7
|
+
import { shell } from "#page/shell.ts";
|
|
8
|
+
import { composerHtml } from "./composerHtml.ts";
|
|
9
|
+
import type { responseService } from "./service.ts";
|
|
10
|
+
|
|
11
|
+
export function responsePage(
|
|
12
|
+
service: Effect.Success<ReturnType<typeof responseService>>,
|
|
13
|
+
changes: Changes,
|
|
14
|
+
home: string | undefined,
|
|
15
|
+
enabled: boolean,
|
|
16
|
+
) {
|
|
17
|
+
return Effect.fn("WorkBoard.responsePage")(function* (request: HttpServerRequest.HttpServerRequest) {
|
|
18
|
+
const params = new URL(request.url, "http://127.0.0.1").searchParams;
|
|
19
|
+
const files = yield* changes.files;
|
|
20
|
+
function layout(body: string, status = 200) {
|
|
21
|
+
return respond(shell("Respond", navHtml(files, "", home), body, changes.realRoot, false, undefined, "response"), "text/html", status);
|
|
22
|
+
}
|
|
23
|
+
return yield* Effect.gen(function* () {
|
|
24
|
+
const question = yield* service.locate(params.get("item") ?? "", params.get("request") ?? "");
|
|
25
|
+
yield* service.read(question.id).pipe(Effect.catch((error) => (error.code === "Missing" ? Effect.void : Effect.fail(error))));
|
|
26
|
+
const records = yield* service.history(question);
|
|
27
|
+
return layout(yield* composerHtml(question, records, enabled));
|
|
28
|
+
}).pipe(Effect.catch((error) => Effect.succeed(layout(`<h1>Response unavailable</h1><p>${escapeHtml(String(error))}</p>`, 409))));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { mkdir, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { Effect } from "effect";
|
|
4
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
5
|
+
import { publishIn } from "./publishIn.ts";
|
|
6
|
+
|
|
7
|
+
function failure(cause: unknown): ResponseFailed {
|
|
8
|
+
return cause instanceof ResponseFailed
|
|
9
|
+
? cause
|
|
10
|
+
: new ResponseFailed({
|
|
11
|
+
code: "Unavailable",
|
|
12
|
+
message: "The response could not be recorded. Keep the draft and retry after checking local filesystem access.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function publish(root: string, realRoot: string, id: string, content: string) {
|
|
16
|
+
return Effect.tryPromise({
|
|
17
|
+
catch: failure,
|
|
18
|
+
try: async () => {
|
|
19
|
+
if (/^[a-zA-Z\d][a-zA-Z\d._-]*$/u.exec(id) === null) {
|
|
20
|
+
throw new ResponseFailed({ code: "Conflict", message: "Invalid response identity." });
|
|
21
|
+
}
|
|
22
|
+
if (process.platform !== "linux") {
|
|
23
|
+
throw new ResponseFailed({
|
|
24
|
+
code: "Unsupported",
|
|
25
|
+
message: "Safe response publication currently requires Linux directory descriptors. Reading remains available.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const directory = await open(root, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
29
|
+
try {
|
|
30
|
+
const base = `/proc/self/fd/${directory.fd}`;
|
|
31
|
+
if ((await realpath(base)) !== realRoot || (await realpath(root)) !== realRoot) {
|
|
32
|
+
throw new ResponseFailed({ code: "Conflict", message: "The workspace root changed. Restart against the intended root." });
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await mkdir(`${base}/responses`);
|
|
36
|
+
} catch (cause) {
|
|
37
|
+
if (!(cause instanceof Error && "code" in cause && cause.code === "EEXIST")) {
|
|
38
|
+
throw cause;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
await directory.sync();
|
|
42
|
+
const target = await open(`${base}/responses`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
43
|
+
try {
|
|
44
|
+
await publishIn(target, root, realRoot, id, content);
|
|
45
|
+
} finally {
|
|
46
|
+
await target.close();
|
|
47
|
+
}
|
|
48
|
+
} finally {
|
|
49
|
+
await directory.close();
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { type FileHandle, link, open, readFile, realpath, unlink } from "node:fs/promises";
|
|
4
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
5
|
+
|
|
6
|
+
function uncertain(cause: unknown) {
|
|
7
|
+
return new ResponseFailed({ cause, code: "Uncertain", message: "Publication may have completed. Re-read the response identity before retrying." });
|
|
8
|
+
}
|
|
9
|
+
function exists(cause: unknown) {
|
|
10
|
+
return cause instanceof Error && "code" in cause && cause.code === "EEXIST";
|
|
11
|
+
}
|
|
12
|
+
async function linkOrReconcile(temporary: string, name: string, content: string): Promise<boolean> {
|
|
13
|
+
try {
|
|
14
|
+
await link(temporary, name);
|
|
15
|
+
return true;
|
|
16
|
+
} catch (cause) {
|
|
17
|
+
if (!exists(cause)) {
|
|
18
|
+
throw cause;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const existing = await open(name, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
22
|
+
try {
|
|
23
|
+
if ((await readFile(existing, "utf8")) !== content) {
|
|
24
|
+
throw new ResponseFailed({ code: "Conflict", message: "This identity already records different content; no file was replaced." });
|
|
25
|
+
}
|
|
26
|
+
} finally {
|
|
27
|
+
await existing.close();
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
export async function publishIn(target: FileHandle, root: string, realRoot: string, id: string, content: string) {
|
|
32
|
+
const folder = `/proc/self/fd/${target.fd}`;
|
|
33
|
+
const expected = `${realRoot}/responses`;
|
|
34
|
+
async function validate() {
|
|
35
|
+
if ((await realpath(folder)) !== expected || (await realpath(root)) !== realRoot) {
|
|
36
|
+
throw new ResponseFailed({ code: "Conflict", message: "The write boundary changed." });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
await validate();
|
|
40
|
+
const name = `${folder}/${id}.md`;
|
|
41
|
+
const temporary = `${folder}/.${randomUUID()}.tmp`;
|
|
42
|
+
let published = false;
|
|
43
|
+
try {
|
|
44
|
+
const file = await open(temporary, "wx", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
await file.writeFile(content, "utf8");
|
|
47
|
+
await file.sync();
|
|
48
|
+
} finally {
|
|
49
|
+
await file.close();
|
|
50
|
+
}
|
|
51
|
+
await validate();
|
|
52
|
+
await linkOrReconcile(temporary, name, content);
|
|
53
|
+
published = true;
|
|
54
|
+
await target.sync();
|
|
55
|
+
await validate();
|
|
56
|
+
} catch (cause) {
|
|
57
|
+
if (published) {
|
|
58
|
+
throw uncertain(cause);
|
|
59
|
+
}
|
|
60
|
+
throw cause;
|
|
61
|
+
} finally {
|
|
62
|
+
await unlink(temporary).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { ResponseFailed } from "#browser/responses/schema.ts";
|
|
3
|
+
import type { MetadataDocument } from "#metadata/model.ts";
|
|
4
|
+
import type { Snapshot } from "#search/snapshot.ts";
|
|
5
|
+
import { malformedResponse, questionFrom, responsesFor } from "./records.ts";
|
|
6
|
+
|
|
7
|
+
function reject(code: ResponseFailed["code"], message: string) {
|
|
8
|
+
return Effect.fail(new ResponseFailed({ code, message }));
|
|
9
|
+
}
|
|
10
|
+
const registeredQuestion = Effect.fn("WorkBoard.registeredQuestion")(function* (matches: readonly MetadataDocument[] | undefined) {
|
|
11
|
+
if (!matches?.length) {
|
|
12
|
+
return yield* reject("Missing", "The registered question is missing.");
|
|
13
|
+
}
|
|
14
|
+
if (matches.length !== 1) {
|
|
15
|
+
return yield* reject("Conflict", "The registered question identity is ambiguous; response history is unknown.");
|
|
16
|
+
}
|
|
17
|
+
const question = matches[0] && questionFrom(matches[0]);
|
|
18
|
+
if (!question) {
|
|
19
|
+
return yield* reject("Conflict", "This identity is not a valid registered question; response history is unknown.");
|
|
20
|
+
}
|
|
21
|
+
return question;
|
|
22
|
+
});
|
|
23
|
+
export function readResponses(snapshot: Effect.Effect<Snapshot, ResponseFailed>) {
|
|
24
|
+
return Effect.fn("WorkBoard.read")(function* (id: string) {
|
|
25
|
+
const data = yield* snapshot;
|
|
26
|
+
if (data.unavailable.length > 0) {
|
|
27
|
+
return yield* reject("Unavailable", "The workspace index is incomplete; no response can be selected safely.");
|
|
28
|
+
}
|
|
29
|
+
const question = yield* registeredQuestion(data.model.ids.get(id));
|
|
30
|
+
if (new TextEncoder().encode(question.context).length > 256 * 1024) {
|
|
31
|
+
return yield* reject("Unsupported", "The registered context exceeds the 256 KiB limit.");
|
|
32
|
+
}
|
|
33
|
+
if (data.documents.some(malformedResponse)) {
|
|
34
|
+
return yield* reject("Unavailable", "A response record is malformed; response history cannot be established safely.");
|
|
35
|
+
}
|
|
36
|
+
const result = responsesFor(data.documents, question);
|
|
37
|
+
if (result.responses.some((candidate) => candidate.body.length > 32_768)) {
|
|
38
|
+
return yield* reject("Unsupported", "A recorded response exceeds the supported text limit; read its source file directly.");
|
|
39
|
+
}
|
|
40
|
+
if (result.responses.some((candidate) => data.model.ids.get(candidate.id)?.length !== 1)) {
|
|
41
|
+
return yield* reject("Conflict", "A response identity is duplicated; no answer is selected.");
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { stringify } from "yaml";
|
|
3
|
+
import type { DraftInput, Question, Reading, RecordedResponse } from "#browser/responses/schema.ts";
|
|
4
|
+
import type { MetadataDocument } from "#metadata/model.ts";
|
|
5
|
+
|
|
6
|
+
export function revisionOf(source: string) {
|
|
7
|
+
return createHash("sha256").update(source).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
export function questionId(item: string, request: string, revision: string, source: string) {
|
|
10
|
+
return `question.${revisionOf(JSON.stringify([item, request, revision, source]))}`;
|
|
11
|
+
}
|
|
12
|
+
export function questionMarkdown(record: Question): string {
|
|
13
|
+
return `---\n${stringify({ id: record.id, kind: "question", question: record.question })}---\n${record.context}`;
|
|
14
|
+
}
|
|
15
|
+
export function responseMarkdown(record: RecordedResponse): string {
|
|
16
|
+
return `---\n${stringify({ id: record.id, kind: "response", response: record.response })}---\n${record.body}`;
|
|
17
|
+
}
|
|
18
|
+
export function questionFrom(document: MetadataDocument): Question | undefined {
|
|
19
|
+
const { id, kind, question } = document.parsed.fields;
|
|
20
|
+
return id
|
|
21
|
+
&& kind === "question"
|
|
22
|
+
&& question
|
|
23
|
+
&& question.reviewedRevision === revisionOf(document.parsed.body)
|
|
24
|
+
&& id === questionId(question.item, question.request, question.reviewedRevision, question.source)
|
|
25
|
+
? { context: document.parsed.body, id, question }
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
export function responseFrom(document: MetadataDocument): RecordedResponse | undefined {
|
|
29
|
+
const { id, kind, response } = document.parsed.fields;
|
|
30
|
+
return id && kind === "response" && response ? { body: document.parsed.body, id, response } : undefined;
|
|
31
|
+
}
|
|
32
|
+
export function responsesFor(documents: readonly MetadataDocument[], question: Question): Reading {
|
|
33
|
+
const responses = documents.flatMap((document) => {
|
|
34
|
+
const record = responseFrom(document);
|
|
35
|
+
return record?.response.question === question.id && record.response.reviewedRevision === question.question.reviewedRevision ? [record] : [];
|
|
36
|
+
});
|
|
37
|
+
responses.sort((a, b) => a.response.recordedAt - b.response.recordedAt || a.id.localeCompare(b.id));
|
|
38
|
+
return { question, responses };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function matchesResponse(record: RecordedResponse, input: DraftInput) {
|
|
42
|
+
return (
|
|
43
|
+
record.body === input.body
|
|
44
|
+
&& JSON.stringify(record.response.answers) === JSON.stringify(input.answers)
|
|
45
|
+
&& record.response.supersedes === input.supersedes
|
|
46
|
+
&& record.response.author === input.author
|
|
47
|
+
&& record.response.type === input.type
|
|
48
|
+
&& record.response.question === input.question
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function malformedResponse(document: MetadataDocument) {
|
|
53
|
+
const fields = document.parsed.fields;
|
|
54
|
+
return fields.kind === "response" && !(fields.id && fields.response);
|
|
55
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type DraftInput, type RecordedResponse, ResponseFailed } from "#browser/responses/schema.ts";
|
|
3
|
+
import type { Snapshot } from "#search/snapshot.ts";
|
|
4
|
+
import { publish } from "./publish.ts";
|
|
5
|
+
import { matchesResponse, responseMarkdown } from "./records.ts";
|
|
6
|
+
import type { ResponseOptions } from "./service.ts";
|
|
7
|
+
|
|
8
|
+
export const retryResponse = Effect.fn("WorkBoard.retryResponse")(function* (
|
|
9
|
+
existing: RecordedResponse,
|
|
10
|
+
input: DraftInput,
|
|
11
|
+
snapshot: Effect.Effect<Snapshot, ResponseFailed>,
|
|
12
|
+
options: ResponseOptions,
|
|
13
|
+
) {
|
|
14
|
+
if (!matchesResponse(existing, input)) {
|
|
15
|
+
return yield* Effect.fail(new ResponseFailed({ code: "Conflict", message: "The response identity already records different content." }));
|
|
16
|
+
}
|
|
17
|
+
if ((yield* snapshot).model.ids.get(input.id)?.[0]?.file !== `responses/${input.id}.md`) {
|
|
18
|
+
return yield* Effect.fail(
|
|
19
|
+
new ResponseFailed({ code: "Conflict", message: "The saved response moved. Read its existing record instead of retrying a write." }),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
yield* publish(options.root, options.changes.realRoot, existing.id, responseMarkdown(existing));
|
|
23
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Clock, Effect, FileSystem, Semaphore } from "effect";
|
|
2
|
+
import { type DraftInput, type Question, ResponseFailed } from "#browser/responses/schema.ts";
|
|
3
|
+
import type { Changes } from "#files/changes.ts";
|
|
4
|
+
import type { Snapshot } from "#search/snapshot.ts";
|
|
5
|
+
import { awaitResponse } from "./await.ts";
|
|
6
|
+
import { responseHistory } from "./history.ts";
|
|
7
|
+
import { locate as locateQuestion } from "./locate.ts";
|
|
8
|
+
import { makeResponse } from "./make.ts";
|
|
9
|
+
import { publish } from "./publish.ts";
|
|
10
|
+
import { readResponses } from "./read.ts";
|
|
11
|
+
import { questionMarkdown, responseMarkdown } from "./records.ts";
|
|
12
|
+
import { retryResponse } from "./retry.ts";
|
|
13
|
+
import { validatedInput } from "./validatedInput.ts";
|
|
14
|
+
import { validateSupersedes } from "./validateSupersedes.ts";
|
|
15
|
+
|
|
16
|
+
export interface ResponseOptions {
|
|
17
|
+
readonly changes: Changes;
|
|
18
|
+
readonly enabled: boolean;
|
|
19
|
+
readonly index: Effect.Effect<Snapshot, unknown>;
|
|
20
|
+
readonly root: string;
|
|
21
|
+
}
|
|
22
|
+
function reject(code: ResponseFailed["code"], message: string) {
|
|
23
|
+
return Effect.fail(new ResponseFailed({ code, message }));
|
|
24
|
+
}
|
|
25
|
+
const HOURS_48 = 48 * 60 * 60 * 1000;
|
|
26
|
+
export const responseService = Effect.fn("WorkBoard.responseService")(function* (options: ResponseOptions) {
|
|
27
|
+
const fs = yield* FileSystem.FileSystem;
|
|
28
|
+
const lock = yield* Semaphore.make(1);
|
|
29
|
+
const snapshot = options.index.pipe(Effect.catch(() => reject("Unavailable", "The workspace could not be indexed.")));
|
|
30
|
+
const enabled = options.enabled ? Effect.void : reject("Disabled", "Start with --responses to explicitly enable local question/response writes.");
|
|
31
|
+
const locate = locateQuestion(options.root, options.changes.realRoot, snapshot, fs);
|
|
32
|
+
const read = readResponses(snapshot);
|
|
33
|
+
function register(input: { readonly item: string; readonly request: string; readonly revision: string }) {
|
|
34
|
+
return lock.withPermit(
|
|
35
|
+
Effect.gen(function* () {
|
|
36
|
+
yield* enabled;
|
|
37
|
+
const preview = yield* locate(input.item, input.request);
|
|
38
|
+
if (preview.reviewedRevision !== input.revision) {
|
|
39
|
+
return yield* reject("Stale", "Source changed since review. Read it again before registering this question.");
|
|
40
|
+
}
|
|
41
|
+
const data = yield* snapshot;
|
|
42
|
+
const matches = data.model.ids.get(preview.id);
|
|
43
|
+
if (matches?.length) {
|
|
44
|
+
if (matches.length !== 1 || matches[0]?.file !== `responses/${preview.id}.md`) {
|
|
45
|
+
return yield* reject("Conflict", "The registered identity moved or is duplicated. Read the existing record instead of retrying a write.");
|
|
46
|
+
}
|
|
47
|
+
const saved = (yield* read(preview.id)).question;
|
|
48
|
+
yield* publish(options.root, options.changes.realRoot, saved.id, questionMarkdown(saved));
|
|
49
|
+
return saved;
|
|
50
|
+
}
|
|
51
|
+
const now = yield* Clock.currentTimeMillis;
|
|
52
|
+
const question: Question = {
|
|
53
|
+
context: preview.context,
|
|
54
|
+
id: preview.id,
|
|
55
|
+
question: {
|
|
56
|
+
deadline: now + HOURS_48,
|
|
57
|
+
item: preview.item,
|
|
58
|
+
reason: preview.reason,
|
|
59
|
+
registeredAt: now,
|
|
60
|
+
request: preview.request,
|
|
61
|
+
reviewedRevision: preview.reviewedRevision,
|
|
62
|
+
source: preview.source,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
yield* publish(options.root, options.changes.realRoot, question.id, questionMarkdown(question)).pipe(
|
|
66
|
+
Effect.ensuring(options.changes.refresh ?? Effect.void),
|
|
67
|
+
);
|
|
68
|
+
return question;
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function record(draft: DraftInput) {
|
|
73
|
+
return lock.withPermit(
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
yield* enabled;
|
|
76
|
+
const result = yield* read(draft.question);
|
|
77
|
+
const input = yield* Effect.try({
|
|
78
|
+
catch: (cause) => new ResponseFailed({ code: "Conflict", message: String(cause) }),
|
|
79
|
+
try: () => validatedInput(draft, result.question.context, result.question.question.request, result.question.question.source),
|
|
80
|
+
});
|
|
81
|
+
const existing = result.responses.find((candidate) => candidate.id === input.id);
|
|
82
|
+
if (existing) {
|
|
83
|
+
yield* retryResponse(existing, input, snapshot, options);
|
|
84
|
+
return existing;
|
|
85
|
+
}
|
|
86
|
+
const { question } = result;
|
|
87
|
+
const current = yield* locate(question.question.item, question.question.request);
|
|
88
|
+
if (current.reviewedRevision !== question.question.reviewedRevision || current.source !== question.question.source) {
|
|
89
|
+
return yield* reject("Stale", "Source changed or moved. Keep the draft and review the new revision before responding.");
|
|
90
|
+
}
|
|
91
|
+
const data = yield* snapshot;
|
|
92
|
+
yield* validateSupersedes(input, question, data);
|
|
93
|
+
if (data.model.ids.has(input.id)) {
|
|
94
|
+
return yield* reject("Conflict", "This response identity is already used.");
|
|
95
|
+
}
|
|
96
|
+
const response = makeResponse(
|
|
97
|
+
input,
|
|
98
|
+
question,
|
|
99
|
+
result.responses.reduce((time, saved) => Math.max(time, saved.response.recordedAt + 1), yield* Clock.currentTimeMillis),
|
|
100
|
+
);
|
|
101
|
+
yield* publish(options.root, options.changes.realRoot, response.id, responseMarkdown(response)).pipe(
|
|
102
|
+
Effect.ensuring(options.changes.refresh ?? Effect.void),
|
|
103
|
+
);
|
|
104
|
+
return response;
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
function history(preview: Parameters<typeof responseHistory>[1]) {
|
|
109
|
+
return Effect.flatMap(snapshot, (data) =>
|
|
110
|
+
Effect.try({
|
|
111
|
+
catch: (cause) => new ResponseFailed({ code: "Unavailable", message: String(cause) }),
|
|
112
|
+
try: () => responseHistory(data, preview),
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { awaitResponse: awaitResponse(read), history, locate, read, record, register };
|
|
117
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { RootContent } from "mdast";
|
|
2
|
+
import type { ContainerDirective } from "mdast-util-directive";
|
|
3
|
+
import remarkDirective from "remark-directive";
|
|
4
|
+
import remarkParse from "remark-parse";
|
|
5
|
+
import { unified } from "unified";
|
|
6
|
+
import type { Prompt } from "#browser/responses/Prompt.ts";
|
|
7
|
+
import { metadataParse } from "#metadata/parse.ts";
|
|
8
|
+
import { templateLinks } from "./templateLinks.ts";
|
|
9
|
+
import { isContainer, promptFrom, sliceNode } from "./templateNodes.ts";
|
|
10
|
+
|
|
11
|
+
export interface Template {
|
|
12
|
+
readonly context: string;
|
|
13
|
+
readonly prompts: readonly Prompt[];
|
|
14
|
+
}
|
|
15
|
+
function nestedControl(node: RootContent): boolean {
|
|
16
|
+
return (
|
|
17
|
+
((node.type === "leafDirective" || node.type === "textDirective" || node.type === "containerDirective")
|
|
18
|
+
&& ["question", "option"].includes(node.name))
|
|
19
|
+
|| ("children" in node && node.children.some(nestedControl))
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
function scopedPrompt(
|
|
23
|
+
node: ContainerDirective,
|
|
24
|
+
source: string,
|
|
25
|
+
requests: readonly { readonly id: string; readonly state: string }[],
|
|
26
|
+
request: string,
|
|
27
|
+
) {
|
|
28
|
+
const result = promptFrom(node, source);
|
|
29
|
+
if (result.request !== undefined && !requests.some((entry) => entry.id === result.request)) {
|
|
30
|
+
throw new Error("A question's request= must name an attention request in this document.");
|
|
31
|
+
}
|
|
32
|
+
if (result.request === undefined && requests.filter((entry) => entry.state === "open").length > 1) {
|
|
33
|
+
throw new Error("Use request= on question directives when a document has several open attention requests.");
|
|
34
|
+
}
|
|
35
|
+
return result.request === undefined || result.request === request ? result.prompt : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function questionTemplate(context: string, request: string, file?: string): Template {
|
|
38
|
+
const parsed = metadataParse(context);
|
|
39
|
+
const parser = unified().use(remarkParse).use(remarkDirective);
|
|
40
|
+
const body = parsed.body.replace(/\r\n?/gu, "\n");
|
|
41
|
+
const original = parser.parse(body);
|
|
42
|
+
const source = file === undefined ? body : templateLinks(body, original.children, file);
|
|
43
|
+
const tree = parser.parse(source);
|
|
44
|
+
const definitions = tree.children
|
|
45
|
+
.filter((node) => node.type === "definition")
|
|
46
|
+
.map((node) => sliceNode(source, node))
|
|
47
|
+
.join("\n\n");
|
|
48
|
+
const prompts: Prompt[] = [];
|
|
49
|
+
const plain: string[] = [];
|
|
50
|
+
|
|
51
|
+
for (const node of tree.children) {
|
|
52
|
+
if (isContainer(node) && node.name === "question") {
|
|
53
|
+
const prompt = scopedPrompt(node, source, parsed.fields.attention ?? [], request);
|
|
54
|
+
if (prompt) {
|
|
55
|
+
prompts.push({
|
|
56
|
+
...prompt,
|
|
57
|
+
options: prompt.options.map((option) => ({ ...option, source: `${option.source}\n\n${definitions}`.trim() })),
|
|
58
|
+
source: `${prompt.source}\n\n${definitions}`.trim(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
if (nestedControl(node)) {
|
|
63
|
+
throw new Error("Use top-level question containers with direct option children.");
|
|
64
|
+
}
|
|
65
|
+
plain.push(sliceNode(source, node));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (prompts.length > 32 || new Set(prompts.map((prompt) => prompt.id)).size !== prompts.length) {
|
|
69
|
+
throw new Error("Use at most 32 questions with unique IDs per attention request.");
|
|
70
|
+
}
|
|
71
|
+
return { context: plain.join("\n\n"), prompts };
|
|
72
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { escapeHtml } from "#page/escape.ts";
|
|
3
|
+
import { renderMarkdown } from "#render/markdown.ts";
|
|
4
|
+
import { questionTemplate } from "./template.ts";
|
|
5
|
+
|
|
6
|
+
export const templateHtml = Effect.fn("WorkBoard.templateHtml")(function* (context: string, request: string, file: string) {
|
|
7
|
+
const template = questionTemplate(context, request, file);
|
|
8
|
+
function render(source: string) {
|
|
9
|
+
return renderMarkdown(source, { file, safe: true });
|
|
10
|
+
}
|
|
11
|
+
const background = yield* render(template.context);
|
|
12
|
+
const controls = yield* Effect.forEach(template.prompts, (prompt, index) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const description = yield* render(prompt.source);
|
|
15
|
+
const options = yield* Effect.forEach(prompt.options, (option, choice) =>
|
|
16
|
+
Effect.map(
|
|
17
|
+
render(option.source),
|
|
18
|
+
(html) =>
|
|
19
|
+
`<div class="question-option"><input id="question-${index}-${choice}" type="${prompt.mode === "one" ? "radio" : "checkbox"}" name="choice-${escapeHtml(prompt.id)}" value="${escapeHtml(option.id)}" data-option-source="${escapeHtml(option.source)}"><div><label for="question-${index}-${choice}">Option ${escapeHtml(option.id)}</label>${html}</div></div>`,
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
return `<fieldset data-prompt="${escapeHtml(prompt.id)}" data-mode="${prompt.mode}" data-source="${escapeHtml(prompt.source)}"><legend>Question ${index + 1} · ${escapeHtml(prompt.id)}</legend>${description}<p>${prompt.mode === "text" ? "Answer in your own words" : `${prompt.mode === "one" ? "Choose one option" : "Choose any combination"}, or answer in your own words`}. Nothing is selected for you.</p>${options.join("")}${prompt.mode === "text" ? "" : '<button type="button" data-clear-choices>Clear selections</button>'}<label>Additional text / another answer<textarea data-answer-text maxlength="8192" rows="3"></textarea></label></fieldset>`;
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
return { background, controls: controls.join(""), hasPrompts: template.prompts.length > 0 };
|
|
26
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { RootContent } from "mdast";
|
|
2
|
+
import { fileUrl } from "#files/url.ts";
|
|
3
|
+
|
|
4
|
+
function destination(url: string, file: string) {
|
|
5
|
+
if (url.startsWith("//") || url.search(/^[a-z][a-z\d+.-]*:/iu) === 0) {
|
|
6
|
+
return url;
|
|
7
|
+
}
|
|
8
|
+
const resolved = new URL(url, `http://work-board.local${fileUrl(file)}`);
|
|
9
|
+
return resolved.pathname + resolved.search + resolved.hash;
|
|
10
|
+
}
|
|
11
|
+
function titleSuffix(title: string | null | undefined) {
|
|
12
|
+
return title ? ` "${title.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"` : "";
|
|
13
|
+
}
|
|
14
|
+
export function templateLinks(source: string, nodes: readonly RootContent[], file: string) {
|
|
15
|
+
const edits: { readonly start: number; readonly end: number; readonly text: string }[] = [];
|
|
16
|
+
function visit(node: RootContent) {
|
|
17
|
+
const start = node.position?.start.offset;
|
|
18
|
+
const end = node.position?.end.offset;
|
|
19
|
+
if (start === undefined || end === undefined) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (node.type === "link") {
|
|
23
|
+
const label = source.slice(node.children.at(0)?.position?.start.offset, node.children.at(-1)?.position?.end.offset);
|
|
24
|
+
edits.push({ end, start, text: `[${label}](<${destination(node.url, file)}>${titleSuffix(node.title)})` });
|
|
25
|
+
} else if (node.type === "image") {
|
|
26
|
+
edits.push({
|
|
27
|
+
end,
|
|
28
|
+
start,
|
|
29
|
+
text: `![${(node.alt ?? "").replaceAll("[", "\\[").replaceAll("]", "\\]")}](<${destination(node.url, file)}>${titleSuffix(node.title)})`,
|
|
30
|
+
});
|
|
31
|
+
} else if (node.type === "definition") {
|
|
32
|
+
edits.push({ end, start, text: `[${node.label ?? node.identifier}]: <${destination(node.url, file)}>${titleSuffix(node.title)}` });
|
|
33
|
+
} else if ("children" in node) {
|
|
34
|
+
node.children.forEach(visit);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
nodes.forEach(visit);
|
|
38
|
+
let result = source;
|
|
39
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
40
|
+
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|