@rubytech/create-maxy-code 0.1.265 → 0.1.267
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +16 -0
- package/package.json +1 -1
- package/payload/platform/lib/models/dist/index.d.ts +1 -1
- package/payload/platform/lib/models/dist/index.d.ts.map +1 -1
- package/payload/platform/lib/models/dist/index.js +5 -2
- package/payload/platform/lib/models/dist/index.js.map +1 -1
- package/payload/platform/lib/models/src/index.ts +5 -2
- package/payload/platform/neo4j/schema.cypher +13 -0
- package/payload/platform/package-lock.json +16 -0
- package/payload/platform/package.json +3 -2
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js +9 -9
- package/payload/platform/plugins/admin/mcp/dist/__tests__/skill-search.test.js.map +1 -1
- package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +11 -3
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
- package/payload/platform/plugins/business-assistant/PLUGIN.md +1 -5
- package/payload/platform/plugins/docs/references/admin-ui.md +1 -1
- package/payload/platform/plugins/docs/references/voice-mirror-guide.md +9 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +36 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts +10 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +59 -0
- package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts +19 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.d.ts.map +1 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js +31 -0
- package/payload/platform/services/claude-session-manager/dist/wa-channel-mcp.js.map +1 -0
- package/payload/platform/services/whatsapp-channel/package.json +20 -0
- package/payload/platform/templates/account.json +1 -1
- package/payload/platform/templates/specialists/agents/content-producer.md +1 -1
- package/payload/platform/templates/specialists/agents/librarian.md +1 -1
- package/payload/platform/templates/specialists/agents/research-assistant.md +1 -1
- package/payload/premium-plugins/venture-studio/skills/investor-data-room/SKILL.md +1 -1
- package/payload/premium-plugins/writer-craft/PLUGIN.md +4 -4
- package/payload/premium-plugins/writer-craft/mcp/dist/index.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js +44 -9
- package/payload/premium-plugins/writer-craft/mcp/dist/index.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts +31 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js +28 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/lib/voice-corpus.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js +93 -44
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-distil-profile.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js +1 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts +7 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js +14 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-record-feedback.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts +22 -8
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js +93 -84
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts +18 -0
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js +32 -3
- package/payload/premium-plugins/writer-craft/mcp/dist/tools/voice-tag-content.js.map +1 -1
- package/payload/premium-plugins/writer-craft/mcp/scripts/smoke.mjs +35 -2
- package/payload/premium-plugins/writer-craft/mcp/src/index.ts +52 -10
- package/payload/premium-plugins/writer-craft/mcp/src/lib/voice-corpus.ts +39 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-distil-profile.ts +108 -44
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-ingest-session-text.ts +1 -0
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-record-feedback.ts +24 -4
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-retrieve-conditioning.ts +136 -102
- package/payload/premium-plugins/writer-craft/mcp/src/tools/voice-tag-content.ts +45 -3
- package/payload/premium-plugins/writer-craft/skills/voice-mirror/SKILL.md +34 -23
- package/payload/server/{chunk-SOLVVUST.js → chunk-W4EM7RK4.js} +2 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/server.js +345 -14
- package/payload/platform/plugins/business-assistant/references/quote-engine.md +0 -122
- package/payload/platform/plugins/business-assistant/references/quote-generation.md +0 -94
- package/payload/platform/plugins/business-assistant/references/quoting.md +0 -85
- package/payload/platform/plugins/business-assistant/skills/pricing-method/SKILL.md +0 -78
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/learning-from-history.md +0 -51
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/maintenance.md +0 -32
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/manual-definition.md +0 -42
- package/payload/platform/plugins/business-assistant/skills/pricing-method/references/verification.md +0 -37
package/payload/server/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
COMMERCIAL_MODE,
|
|
10
10
|
GREETING_DIRECTIVE,
|
|
11
11
|
Hono,
|
|
12
|
+
HtmlEscapedCallbackPhase,
|
|
12
13
|
INSTALL_OWNER_FILE,
|
|
13
14
|
LOG_DIR,
|
|
14
15
|
MAXY_DIR,
|
|
@@ -71,6 +72,7 @@ import {
|
|
|
71
72
|
requirePortEnv,
|
|
72
73
|
resolveAccount,
|
|
73
74
|
resolveAgentConfig,
|
|
75
|
+
resolveCallback,
|
|
74
76
|
resolveClientIp,
|
|
75
77
|
resolveDefaultAgentSlug,
|
|
76
78
|
resolveUserAccounts,
|
|
@@ -93,7 +95,7 @@ import {
|
|
|
93
95
|
vncLog,
|
|
94
96
|
walkPremiumBundles,
|
|
95
97
|
writeAdminUserAndPerson
|
|
96
|
-
} from "./chunk-
|
|
98
|
+
} from "./chunk-W4EM7RK4.js";
|
|
97
99
|
import {
|
|
98
100
|
__commonJS,
|
|
99
101
|
__toESM
|
|
@@ -1105,24 +1107,24 @@ var pr54206Applied = () => {
|
|
|
1105
1107
|
return major >= 23 || major === 22 && minor >= 7 || major === 20 && minor >= 18;
|
|
1106
1108
|
};
|
|
1107
1109
|
var useReadableToWeb = pr54206Applied();
|
|
1108
|
-
var createStreamBody = (
|
|
1110
|
+
var createStreamBody = (stream2) => {
|
|
1109
1111
|
if (useReadableToWeb) {
|
|
1110
|
-
return Readable.toWeb(
|
|
1112
|
+
return Readable.toWeb(stream2);
|
|
1111
1113
|
}
|
|
1112
1114
|
const body = new ReadableStream({
|
|
1113
1115
|
start(controller) {
|
|
1114
|
-
|
|
1116
|
+
stream2.on("data", (chunk) => {
|
|
1115
1117
|
controller.enqueue(chunk);
|
|
1116
1118
|
});
|
|
1117
|
-
|
|
1119
|
+
stream2.on("error", (err) => {
|
|
1118
1120
|
controller.error(err);
|
|
1119
1121
|
});
|
|
1120
|
-
|
|
1122
|
+
stream2.on("end", () => {
|
|
1121
1123
|
controller.close();
|
|
1122
1124
|
});
|
|
1123
1125
|
},
|
|
1124
1126
|
cancel() {
|
|
1125
|
-
|
|
1127
|
+
stream2.destroy();
|
|
1126
1128
|
}
|
|
1127
1129
|
});
|
|
1128
1130
|
return body;
|
|
@@ -1227,10 +1229,10 @@ var serveStatic = (options = { root: "" }) => {
|
|
|
1227
1229
|
end = size - 1;
|
|
1228
1230
|
}
|
|
1229
1231
|
const chunksize = end - start + 1;
|
|
1230
|
-
const
|
|
1232
|
+
const stream2 = createReadStream(path2, { start, end });
|
|
1231
1233
|
c.header("Content-Length", chunksize.toString());
|
|
1232
1234
|
c.header("Content-Range", `bytes ${start}-${end}/${stats.size}`);
|
|
1233
|
-
result = c.body(createStreamBody(
|
|
1235
|
+
result = c.body(createStreamBody(stream2), 206);
|
|
1234
1236
|
}
|
|
1235
1237
|
await options.onFound?.(path2, c);
|
|
1236
1238
|
return result;
|
|
@@ -2892,9 +2894,9 @@ function getDownloadableContent(message) {
|
|
|
2892
2894
|
if (msg.stickerMessage) return { downloadable: msg.stickerMessage, mediaType: "sticker" };
|
|
2893
2895
|
return void 0;
|
|
2894
2896
|
}
|
|
2895
|
-
async function streamToBuffer(
|
|
2897
|
+
async function streamToBuffer(stream2) {
|
|
2896
2898
|
const chunks = [];
|
|
2897
|
-
for await (const chunk of
|
|
2899
|
+
for await (const chunk of stream2) {
|
|
2898
2900
|
chunks.push(Buffer.from(chunk));
|
|
2899
2901
|
}
|
|
2900
2902
|
return Buffer.concat(chunks);
|
|
@@ -2925,8 +2927,8 @@ async function downloadInboundMedia(msg, sock, opts) {
|
|
|
2925
2927
|
const downloadable = getDownloadableContent(content);
|
|
2926
2928
|
if (downloadable) {
|
|
2927
2929
|
try {
|
|
2928
|
-
const
|
|
2929
|
-
buffer = await streamToBuffer(
|
|
2930
|
+
const stream2 = await downloadContentFromMessage(downloadable.downloadable, downloadable.mediaType);
|
|
2931
|
+
buffer = await streamToBuffer(stream2);
|
|
2930
2932
|
} catch (fallbackErr) {
|
|
2931
2933
|
console.error(`${TAG8} direct download fallback failed: ${String(fallbackErr)}`);
|
|
2932
2934
|
}
|
|
@@ -4671,7 +4673,8 @@ async function managerRcSpawn(opts) {
|
|
|
4671
4673
|
initialMessage: opts.initialMessage,
|
|
4672
4674
|
closeAfterTurn: opts.closeAfterTurn,
|
|
4673
4675
|
sliceToken: opts.sliceToken,
|
|
4674
|
-
personId: opts.personId
|
|
4676
|
+
personId: opts.personId,
|
|
4677
|
+
waChannel: opts.waChannel
|
|
4675
4678
|
})
|
|
4676
4679
|
}).catch((err) => ({ __throw: err instanceof Error ? err.message : String(err) }));
|
|
4677
4680
|
if ("__throw" in res) {
|
|
@@ -15567,6 +15570,300 @@ function startReaper2() {
|
|
|
15567
15570
|
startReaper();
|
|
15568
15571
|
}
|
|
15569
15572
|
|
|
15573
|
+
// app/lib/whatsapp/gateway/inbound-hub.ts
|
|
15574
|
+
var InboundHub = class {
|
|
15575
|
+
senders = /* @__PURE__ */ new Map();
|
|
15576
|
+
state(senderId) {
|
|
15577
|
+
let s = this.senders.get(senderId);
|
|
15578
|
+
if (!s) {
|
|
15579
|
+
s = { subscriber: null, queue: [] };
|
|
15580
|
+
this.senders.set(senderId, s);
|
|
15581
|
+
}
|
|
15582
|
+
return s;
|
|
15583
|
+
}
|
|
15584
|
+
/** Route an inbound message to its sender. Delivers immediately if that
|
|
15585
|
+
* sender's channel server is attached; otherwise queues it (cold start). */
|
|
15586
|
+
deliver(payload) {
|
|
15587
|
+
const s = this.state(payload.senderId);
|
|
15588
|
+
if (s.subscriber) {
|
|
15589
|
+
s.subscriber(payload);
|
|
15590
|
+
} else {
|
|
15591
|
+
s.queue.push(payload);
|
|
15592
|
+
}
|
|
15593
|
+
}
|
|
15594
|
+
/** A sender's channel server connected. Drain any queued messages to it in
|
|
15595
|
+
* arrival order, then route live. Replaces any prior subscriber. */
|
|
15596
|
+
attach(senderId, subscriber) {
|
|
15597
|
+
const s = this.state(senderId);
|
|
15598
|
+
s.subscriber = subscriber;
|
|
15599
|
+
if (s.queue.length > 0) {
|
|
15600
|
+
const pending = s.queue;
|
|
15601
|
+
s.queue = [];
|
|
15602
|
+
for (const payload of pending) subscriber(payload);
|
|
15603
|
+
}
|
|
15604
|
+
}
|
|
15605
|
+
/** A sender's channel server disconnected (session ended/restarting).
|
|
15606
|
+
* Subsequent messages queue again until re-attach, so nothing is lost
|
|
15607
|
+
* across a restart. */
|
|
15608
|
+
detach(senderId) {
|
|
15609
|
+
const s = this.senders.get(senderId);
|
|
15610
|
+
if (s) s.subscriber = null;
|
|
15611
|
+
}
|
|
15612
|
+
/** Whether a sender currently has a live channel server. The gateway uses
|
|
15613
|
+
* this to decide whether an inbound needs a cold-start spawn/resume. */
|
|
15614
|
+
hasSubscriber(senderId) {
|
|
15615
|
+
return this.senders.get(senderId)?.subscriber != null;
|
|
15616
|
+
}
|
|
15617
|
+
};
|
|
15618
|
+
|
|
15619
|
+
// node_modules/hono/dist/utils/stream.js
|
|
15620
|
+
var StreamingApi = class {
|
|
15621
|
+
writer;
|
|
15622
|
+
encoder;
|
|
15623
|
+
writable;
|
|
15624
|
+
abortSubscribers = [];
|
|
15625
|
+
responseReadable;
|
|
15626
|
+
/**
|
|
15627
|
+
* Whether the stream has been aborted.
|
|
15628
|
+
*/
|
|
15629
|
+
aborted = false;
|
|
15630
|
+
/**
|
|
15631
|
+
* Whether the stream has been closed normally.
|
|
15632
|
+
*/
|
|
15633
|
+
closed = false;
|
|
15634
|
+
constructor(writable, _readable) {
|
|
15635
|
+
this.writable = writable;
|
|
15636
|
+
this.writer = writable.getWriter();
|
|
15637
|
+
this.encoder = new TextEncoder();
|
|
15638
|
+
const reader = _readable.getReader();
|
|
15639
|
+
this.abortSubscribers.push(async () => {
|
|
15640
|
+
await reader.cancel();
|
|
15641
|
+
});
|
|
15642
|
+
this.responseReadable = new ReadableStream({
|
|
15643
|
+
async pull(controller) {
|
|
15644
|
+
const { done, value } = await reader.read();
|
|
15645
|
+
done ? controller.close() : controller.enqueue(value);
|
|
15646
|
+
},
|
|
15647
|
+
cancel: () => {
|
|
15648
|
+
this.abort();
|
|
15649
|
+
}
|
|
15650
|
+
});
|
|
15651
|
+
}
|
|
15652
|
+
async write(input) {
|
|
15653
|
+
try {
|
|
15654
|
+
if (typeof input === "string") {
|
|
15655
|
+
input = this.encoder.encode(input);
|
|
15656
|
+
}
|
|
15657
|
+
await this.writer.write(input);
|
|
15658
|
+
} catch {
|
|
15659
|
+
}
|
|
15660
|
+
return this;
|
|
15661
|
+
}
|
|
15662
|
+
async writeln(input) {
|
|
15663
|
+
await this.write(input + "\n");
|
|
15664
|
+
return this;
|
|
15665
|
+
}
|
|
15666
|
+
sleep(ms) {
|
|
15667
|
+
return new Promise((res) => setTimeout(res, ms));
|
|
15668
|
+
}
|
|
15669
|
+
async close() {
|
|
15670
|
+
try {
|
|
15671
|
+
await this.writer.close();
|
|
15672
|
+
} catch {
|
|
15673
|
+
}
|
|
15674
|
+
this.closed = true;
|
|
15675
|
+
}
|
|
15676
|
+
async pipe(body) {
|
|
15677
|
+
this.writer.releaseLock();
|
|
15678
|
+
await body.pipeTo(this.writable, { preventClose: true });
|
|
15679
|
+
this.writer = this.writable.getWriter();
|
|
15680
|
+
}
|
|
15681
|
+
onAbort(listener) {
|
|
15682
|
+
this.abortSubscribers.push(listener);
|
|
15683
|
+
}
|
|
15684
|
+
/**
|
|
15685
|
+
* Abort the stream.
|
|
15686
|
+
* You can call this method when stream is aborted by external event.
|
|
15687
|
+
*/
|
|
15688
|
+
abort() {
|
|
15689
|
+
if (!this.aborted) {
|
|
15690
|
+
this.aborted = true;
|
|
15691
|
+
this.abortSubscribers.forEach((subscriber) => subscriber());
|
|
15692
|
+
}
|
|
15693
|
+
}
|
|
15694
|
+
};
|
|
15695
|
+
|
|
15696
|
+
// node_modules/hono/dist/helper/streaming/utils.js
|
|
15697
|
+
var isOldBunVersion = () => {
|
|
15698
|
+
const version = typeof Bun !== "undefined" ? Bun.version : void 0;
|
|
15699
|
+
if (version === void 0) {
|
|
15700
|
+
return false;
|
|
15701
|
+
}
|
|
15702
|
+
const result = version.startsWith("1.1") || version.startsWith("1.0") || version.startsWith("0.");
|
|
15703
|
+
isOldBunVersion = () => result;
|
|
15704
|
+
return result;
|
|
15705
|
+
};
|
|
15706
|
+
|
|
15707
|
+
// node_modules/hono/dist/helper/streaming/sse.js
|
|
15708
|
+
var SSEStreamingApi = class extends StreamingApi {
|
|
15709
|
+
constructor(writable, readable) {
|
|
15710
|
+
super(writable, readable);
|
|
15711
|
+
}
|
|
15712
|
+
async writeSSE(message) {
|
|
15713
|
+
const data = await resolveCallback(message.data, HtmlEscapedCallbackPhase.Stringify, false, {});
|
|
15714
|
+
const dataLines = data.split(/\r\n|\r|\n/).map((line) => {
|
|
15715
|
+
return `data: ${line}`;
|
|
15716
|
+
}).join("\n");
|
|
15717
|
+
for (const key of ["event", "id", "retry"]) {
|
|
15718
|
+
if (message[key] && /[\r\n]/.test(message[key])) {
|
|
15719
|
+
throw new Error(`${key} must not contain "\\r" or "\\n"`);
|
|
15720
|
+
}
|
|
15721
|
+
}
|
|
15722
|
+
const sseData = [
|
|
15723
|
+
message.event && `event: ${message.event}`,
|
|
15724
|
+
dataLines,
|
|
15725
|
+
message.id && `id: ${message.id}`,
|
|
15726
|
+
message.retry && `retry: ${message.retry}`
|
|
15727
|
+
].filter(Boolean).join("\n") + "\n\n";
|
|
15728
|
+
await this.write(sseData);
|
|
15729
|
+
}
|
|
15730
|
+
};
|
|
15731
|
+
var run = async (stream2, cb, onError) => {
|
|
15732
|
+
try {
|
|
15733
|
+
await cb(stream2);
|
|
15734
|
+
} catch (e) {
|
|
15735
|
+
if (e instanceof Error && onError) {
|
|
15736
|
+
await onError(e, stream2);
|
|
15737
|
+
await stream2.writeSSE({
|
|
15738
|
+
event: "error",
|
|
15739
|
+
data: e.message
|
|
15740
|
+
});
|
|
15741
|
+
} else {
|
|
15742
|
+
console.error(e);
|
|
15743
|
+
}
|
|
15744
|
+
} finally {
|
|
15745
|
+
stream2.close();
|
|
15746
|
+
}
|
|
15747
|
+
};
|
|
15748
|
+
var contextStash = /* @__PURE__ */ new WeakMap();
|
|
15749
|
+
var streamSSE = (c, cb, onError) => {
|
|
15750
|
+
const { readable, writable } = new TransformStream();
|
|
15751
|
+
const stream2 = new SSEStreamingApi(writable, readable);
|
|
15752
|
+
if (isOldBunVersion()) {
|
|
15753
|
+
c.req.raw.signal.addEventListener("abort", () => {
|
|
15754
|
+
if (!stream2.closed) {
|
|
15755
|
+
stream2.abort();
|
|
15756
|
+
}
|
|
15757
|
+
});
|
|
15758
|
+
}
|
|
15759
|
+
contextStash.set(stream2.responseReadable, c);
|
|
15760
|
+
c.header("Transfer-Encoding", "chunked");
|
|
15761
|
+
c.header("Content-Type", "text/event-stream");
|
|
15762
|
+
c.header("Cache-Control", "no-cache");
|
|
15763
|
+
c.header("Connection", "keep-alive");
|
|
15764
|
+
run(stream2, cb, onError);
|
|
15765
|
+
return c.newResponse(stream2.responseReadable);
|
|
15766
|
+
};
|
|
15767
|
+
|
|
15768
|
+
// app/lib/whatsapp/gateway/routes.ts
|
|
15769
|
+
function createWaChannelRoutes(deps) {
|
|
15770
|
+
const app45 = new Hono();
|
|
15771
|
+
app45.get("/wa-channel/inbound", (c) => {
|
|
15772
|
+
const senderId = c.req.query("senderId");
|
|
15773
|
+
if (!senderId) return c.json({ error: "senderId required" }, 400);
|
|
15774
|
+
return streamSSE(c, async (stream2) => {
|
|
15775
|
+
deps.hub.attach(senderId, (payload) => {
|
|
15776
|
+
void stream2.writeSSE({ data: JSON.stringify(payload) });
|
|
15777
|
+
});
|
|
15778
|
+
console.error(`[whatsapp-native] op=channel-attached senderId=${senderId}`);
|
|
15779
|
+
stream2.onAbort(() => {
|
|
15780
|
+
deps.hub.detach(senderId);
|
|
15781
|
+
console.error(`[whatsapp-native] op=channel-detached senderId=${senderId}`);
|
|
15782
|
+
});
|
|
15783
|
+
while (!stream2.aborted) {
|
|
15784
|
+
await stream2.sleep(15e3);
|
|
15785
|
+
await stream2.writeSSE({ data: "", event: "ping" });
|
|
15786
|
+
}
|
|
15787
|
+
});
|
|
15788
|
+
});
|
|
15789
|
+
app45.post("/wa-channel/reply", async (c) => {
|
|
15790
|
+
const body = await c.req.json().catch(() => null);
|
|
15791
|
+
const senderId = body?.senderId;
|
|
15792
|
+
const text = body?.text;
|
|
15793
|
+
if (typeof senderId !== "string" || typeof text !== "string") {
|
|
15794
|
+
return c.json({ error: "senderId and text required" }, 400);
|
|
15795
|
+
}
|
|
15796
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
15797
|
+
try {
|
|
15798
|
+
await deps.sendOutbound(senderId, text);
|
|
15799
|
+
} catch (err) {
|
|
15800
|
+
console.error(
|
|
15801
|
+
`[whatsapp-native] op=reply-failed senderId=${senderId} bytes=${bytes} error=${err instanceof Error ? err.message : String(err)}`
|
|
15802
|
+
);
|
|
15803
|
+
return c.json({ error: "send-failed" }, 500);
|
|
15804
|
+
}
|
|
15805
|
+
console.error(`[whatsapp-native] op=reply-dispatch senderId=${senderId} bytes=${bytes}`);
|
|
15806
|
+
return c.json({ ok: true });
|
|
15807
|
+
});
|
|
15808
|
+
app45.post("/wa-channel/ready", async (c) => {
|
|
15809
|
+
const body = await c.req.json().catch(() => null);
|
|
15810
|
+
const senderId = body?.senderId;
|
|
15811
|
+
if (typeof senderId !== "string") {
|
|
15812
|
+
return c.json({ error: "senderId required" }, 400);
|
|
15813
|
+
}
|
|
15814
|
+
deps.onReady?.(senderId);
|
|
15815
|
+
return c.json({ ok: true });
|
|
15816
|
+
});
|
|
15817
|
+
return app45;
|
|
15818
|
+
}
|
|
15819
|
+
|
|
15820
|
+
// app/lib/whatsapp/gateway/wa-gateway.ts
|
|
15821
|
+
var WaGateway = class {
|
|
15822
|
+
constructor(deps) {
|
|
15823
|
+
this.deps = deps;
|
|
15824
|
+
}
|
|
15825
|
+
hub = new InboundHub();
|
|
15826
|
+
replies = /* @__PURE__ */ new Map();
|
|
15827
|
+
spawning = /* @__PURE__ */ new Set();
|
|
15828
|
+
seq = 0;
|
|
15829
|
+
/** Hono sub-app exposing the loopback channel routes; mount on the UI app. */
|
|
15830
|
+
routes() {
|
|
15831
|
+
return createWaChannelRoutes({
|
|
15832
|
+
hub: this.hub,
|
|
15833
|
+
sendOutbound: (senderId, text) => this.sendOutbound(senderId, text)
|
|
15834
|
+
});
|
|
15835
|
+
}
|
|
15836
|
+
/** Handle one inbound admin WhatsApp message. */
|
|
15837
|
+
async handleInbound(input) {
|
|
15838
|
+
const bytes = Buffer.byteLength(input.text, "utf8");
|
|
15839
|
+
const hadSubscriber = this.hub.hasSubscriber(input.senderId);
|
|
15840
|
+
this.replies.set(input.senderId, input.reply);
|
|
15841
|
+
this.hub.deliver({ senderId: input.senderId, text: input.text, waMessageId: `wa-${++this.seq}` });
|
|
15842
|
+
console.error(
|
|
15843
|
+
`[whatsapp-native] op=inbound senderId=${input.senderId} accountId=${input.accountId} bytes=${bytes} delivery=${hadSubscriber ? "immediate" : "queued"}`
|
|
15844
|
+
);
|
|
15845
|
+
if (!hadSubscriber && !this.spawning.has(input.senderId)) {
|
|
15846
|
+
this.spawning.add(input.senderId);
|
|
15847
|
+
console.error(`[whatsapp-native] op=spawn-trigger senderId=${input.senderId}`);
|
|
15848
|
+
try {
|
|
15849
|
+
await this.deps.ensureChannelSession({
|
|
15850
|
+
accountId: input.accountId,
|
|
15851
|
+
senderId: input.senderId,
|
|
15852
|
+
gatewayUrl: this.deps.gatewayUrl,
|
|
15853
|
+
serverPath: this.deps.serverPath
|
|
15854
|
+
});
|
|
15855
|
+
} finally {
|
|
15856
|
+
this.spawning.delete(input.senderId);
|
|
15857
|
+
}
|
|
15858
|
+
}
|
|
15859
|
+
}
|
|
15860
|
+
async sendOutbound(senderId, text) {
|
|
15861
|
+
const reply = this.replies.get(senderId);
|
|
15862
|
+
if (!reply) throw new Error(`wa-gateway: no reply route for sender ${senderId}`);
|
|
15863
|
+
await reply(text);
|
|
15864
|
+
}
|
|
15865
|
+
};
|
|
15866
|
+
|
|
15570
15867
|
// app/lib/admin-sse-registry.ts
|
|
15571
15868
|
var activeAdminSSEControllers = /* @__PURE__ */ new Set();
|
|
15572
15869
|
function broadcastAdminShutdown(reason) {
|
|
@@ -16524,6 +16821,23 @@ app44.all("*", (c) => {
|
|
|
16524
16821
|
});
|
|
16525
16822
|
var port = requirePortEnv("MAXY_UI_INTERNAL_PORT", { tag: "ui-server" });
|
|
16526
16823
|
var hostname = process.env.HOSTNAME ?? "127.0.0.1";
|
|
16824
|
+
var waNativeGatewayUrl = `http://127.0.0.1:${port}`;
|
|
16825
|
+
var waChannelServerPath = process.env.MAXY_WA_CHANNEL_SERVER_PATH ?? resolve26(process.env.MAXY_PLATFORM_ROOT ?? join18(__dirname, ".."), "services/whatsapp-channel/dist/server.js");
|
|
16826
|
+
var waGateway = new WaGateway({
|
|
16827
|
+
gatewayUrl: waNativeGatewayUrl,
|
|
16828
|
+
serverPath: waChannelServerPath,
|
|
16829
|
+
ensureChannelSession: async ({ accountId, senderId, gatewayUrl, serverPath }) => {
|
|
16830
|
+
const result = await managerRcSpawn({
|
|
16831
|
+
sessionId: adminSessionIdFor(accountId, senderId),
|
|
16832
|
+
waChannel: { senderId, gatewayUrl, serverPath },
|
|
16833
|
+
logContext: `channel=whatsapp-native senderId=${senderId}`
|
|
16834
|
+
});
|
|
16835
|
+
if ("error" in result) {
|
|
16836
|
+
console.error(`[whatsapp-native] spawn-failed senderId=${senderId} error=${result.error} status=${result.status}`);
|
|
16837
|
+
}
|
|
16838
|
+
}
|
|
16839
|
+
});
|
|
16840
|
+
app44.route("/", waGateway.routes());
|
|
16527
16841
|
var httpServer = serve({ fetch: app44.fetch, port, hostname });
|
|
16528
16842
|
console.log(`${BRAND.productName} listening on http://${hostname}:${port}`);
|
|
16529
16843
|
startAuthHealthHeartbeat();
|
|
@@ -16702,6 +17016,23 @@ init({
|
|
|
16702
17016
|
platformRoot: resolve26(process.env.MAXY_PLATFORM_ROOT ?? join18(__dirname, "..")),
|
|
16703
17017
|
accountConfig: bootAccountConfig,
|
|
16704
17018
|
onMessage: async (msg) => {
|
|
17019
|
+
if (process.env.MAXY_WA_NATIVE_CHANNEL === "1" && msg.agentType === "admin" && !msg.isOwnerMirror && msg.text) {
|
|
17020
|
+
try {
|
|
17021
|
+
void msg.composing().catch(() => {
|
|
17022
|
+
});
|
|
17023
|
+
await waGateway.handleInbound({
|
|
17024
|
+
accountId: msg.accountId,
|
|
17025
|
+
senderId: msg.senderPhone,
|
|
17026
|
+
text: msg.text,
|
|
17027
|
+
reply: msg.reply
|
|
17028
|
+
});
|
|
17029
|
+
} catch (err) {
|
|
17030
|
+
console.error(
|
|
17031
|
+
`[whatsapp-native] reject senderId=${msg.senderPhone} message=${err instanceof Error ? err.message : String(err)}`
|
|
17032
|
+
);
|
|
17033
|
+
}
|
|
17034
|
+
return;
|
|
17035
|
+
}
|
|
16705
17036
|
if ((msg.text || msg.mediaPath) && !msg.isOwnerMirror) {
|
|
16706
17037
|
try {
|
|
16707
17038
|
void msg.composing().catch(() => {
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
# Priced-quote compute engine
|
|
2
|
-
|
|
3
|
-
This reference is the **compute core** for a structured, priced quote: it turns a job (the items the
|
|
4
|
-
owner selected, with their measurements or counts) into priced figures — line prices, group
|
|
5
|
-
subtotals, and a final total — computed by **this owner's own pricing method**.
|
|
6
|
-
|
|
7
|
-
It carries no items, no units, no rates, no margins, no overheads, no taxes, and no roll-up sequence.
|
|
8
|
-
Every such value and rule is the owner's, captured as data. A trade pricing by the square metre, a
|
|
9
|
-
caterer pricing by the head, and a printer pricing by per-unit break are all priced by the same
|
|
10
|
-
approach — only their captured method differs.
|
|
11
|
-
|
|
12
|
-
The compute engine itself is **not shipped**. You generate a small bespoke engine for this owner the
|
|
13
|
-
first time they need a priced quote, and regenerate it whenever their business changes. The captured
|
|
14
|
-
method is data the engine reads. Both persist in the owner's workspace and are re-run on demand.
|
|
15
|
-
|
|
16
|
-
## When this path applies
|
|
17
|
-
|
|
18
|
-
Use this for a **priced quote computed from the owner's method** — measured or counted work that has
|
|
19
|
-
to be priced the way this owner prices it, then totalled. This is distinct from the simple
|
|
20
|
-
photo-to-quote / chat-to-quote flow in `references/quoting.md`, which formats a quote the owner has
|
|
21
|
-
already priced. If the owner has already worked out the numbers, stay in `quoting.md`. Come here when
|
|
22
|
-
the numbers have to be **computed**.
|
|
23
|
-
|
|
24
|
-
**Precondition:** a captured method for this owner must exist. Its *content* (the owner's items,
|
|
25
|
-
rules, dials, roll-up, and past-job fixtures) is produced and maintained by the method-capture flow —
|
|
26
|
-
Task 659 — which populates the method/job shape **this reference defines** below. If no captured method
|
|
27
|
-
exists, capture it first; do not invent rates or rules here.
|
|
28
|
-
|
|
29
|
-
## Where the engine and method live
|
|
30
|
-
|
|
31
|
-
The agent runs in the owner's workspace (the spawn cwd, `$ACCOUNT_DIR`). Keep the engine and the
|
|
32
|
-
captured method together under a `quoting/` subdirectory there, addressed by relative path:
|
|
33
|
-
|
|
34
|
-
- the **method** — the owner's items, dials, and roll-up, as data;
|
|
35
|
-
- the **engine** — the bespoke script that reads a method and a job and prices it;
|
|
36
|
-
- the owner's **past jobs**, each with the figures they actually issued, used to verify the engine.
|
|
37
|
-
|
|
38
|
-
These persist across sessions, are edited when the business changes, and are the engine's only inputs.
|
|
39
|
-
Nothing about the owner's business lives in this reference or in the plugin.
|
|
40
|
-
|
|
41
|
-
## The method shape (owner data)
|
|
42
|
-
|
|
43
|
-
This section is the authoritative definition of the method/job shape; the method-capture flow (Task
|
|
44
|
-
659) fills it with one owner's content. The method describes how this owner prices. For each
|
|
45
|
-
**priceable item** it records:
|
|
46
|
-
|
|
47
|
-
- what the item is (an identifier, a human label, a unit);
|
|
48
|
-
- **how a quantity arises** — the item's own rule for turning a measurement into a quantity (for
|
|
49
|
-
example, an area from supplied dimensions, a length, a volume), or that the item carries **no
|
|
50
|
-
measurement rule** because it is counted or taken as a single unit. This is the item's *default*
|
|
51
|
-
quantity rule; a quantity entered on the job line always overrides it (see the job shape);
|
|
52
|
-
- **how a line price is computed** — the owner's own rule for that item, in whatever form their
|
|
53
|
-
history shows it takes;
|
|
54
|
-
- which **group** the line rolls into (the owner's own section headings);
|
|
55
|
-
- whether the item is **standing-rule priced** (a rule fixes its price) or **judgement priced** (the
|
|
56
|
-
owner sets the price per job) — so the quote flow knows when it must ask the owner for a figure
|
|
57
|
-
rather than computing one.
|
|
58
|
-
|
|
59
|
-
Alongside the items the method holds:
|
|
60
|
-
|
|
61
|
-
- named **dials** — the owner's own scalars that their rules reference (a labour day-rate, a margin
|
|
62
|
-
multiplier, a percentage — whatever their method uses, named by them);
|
|
63
|
-
- the **roll-up** — an ordered list of steps applied above the line items to reach the final total.
|
|
64
|
-
Each step names its rule, the prior figures it reads, and the label of the figure it produces. This
|
|
65
|
-
is whatever sequence the owner actually applies. It is **not** a fixed margin-then-overhead-then-tax
|
|
66
|
-
chain: an owner may apply an overhead, a contingency, a discount, a tax, or nothing, in their own
|
|
67
|
-
order. The engine implements exactly the step kinds this owner's roll-up uses, and no others.
|
|
68
|
-
|
|
69
|
-
## The job shape (per quote)
|
|
70
|
-
|
|
71
|
-
A job is the work selected for one quote: a list of lines, each naming an item and supplying **either**
|
|
72
|
-
measurements **or** a directly-entered quantity, plus — for a judgement-priced item — the price the
|
|
73
|
-
owner set for this line. The quantity source is decided per line by precedence: a quantity entered on
|
|
74
|
-
the line is used as-is; otherwise the engine derives the quantity from the line's measurements via the
|
|
75
|
-
item's measurement rule. An item with no measurement rule therefore requires an entered quantity (or
|
|
76
|
-
its single-unit default). The job also carries the header the issued quote needs (who it is for, the
|
|
77
|
-
address, the reference).
|
|
78
|
-
|
|
79
|
-
## What the generated engine must do
|
|
80
|
-
|
|
81
|
-
The engine reads one method and one job and produces the priced result. It must:
|
|
82
|
-
|
|
83
|
-
1. **Resolve each line's quantity** — an entered quantity takes precedence; otherwise derive it from
|
|
84
|
-
the line's measurements via the item's measurement rule — and record which source was used.
|
|
85
|
-
2. **Resolve each line's price** — by the item's own rule, or by the per-line figure the owner gave for
|
|
86
|
-
a judgement item — and record which was used.
|
|
87
|
-
3. **Group** the priced lines under the owner's own headings.
|
|
88
|
-
4. **Run the roll-up** steps in their defined order to reach the final total.
|
|
89
|
-
5. **Emit observability** so a wrong figure is diagnosable from the log, not just the document: per
|
|
90
|
-
line, the resolved quantity and its source (measurement-derived vs entered) and the price rule
|
|
91
|
-
applied and its source (item rule vs per-line override); and each roll-up step with its result.
|
|
92
|
-
6. **Reconcile** the sum of the group subtotals against the running line-item total and report it as an
|
|
93
|
-
explicit pass/fail — not a silent assumption.
|
|
94
|
-
7. **Verify** on demand: given one of the owner's own past jobs, compare every computed figure
|
|
95
|
-
(line, group, final) against the figures the owner actually issued and report each as a match or
|
|
96
|
-
a mismatch.
|
|
97
|
-
|
|
98
|
-
## Expressiveness — bounded by the owner's history
|
|
99
|
-
|
|
100
|
-
Build the engine to support **exactly** the quantity, pricing, and roll-up forms the owner's own
|
|
101
|
-
history exercises — discovered when their method was captured. Do not add quantity bases, price-rule
|
|
102
|
-
forms, or roll-up step kinds the owner does not use. Unused generality is a liability: it is untested
|
|
103
|
-
against their figures and invites a wrong quote later. If a new job needs a form the method has never
|
|
104
|
-
expressed, that is a change to the captured method (and a re-verification), not a guess made here.
|
|
105
|
-
|
|
106
|
-
## Acceptance — reproduction of the owner's own figures
|
|
107
|
-
|
|
108
|
-
The engine is correct only when it **reproduces the owner's own past jobs** — line prices, group
|
|
109
|
-
subtotals, and final totals — to the owner's own precision. The fixtures are that owner's
|
|
110
|
-
history, supplied with their method; there are no built-in figures to test against. Reproducing their
|
|
111
|
-
history, not parsing cleanly, is the signal that the engine prices the way they do. Verify on every
|
|
112
|
-
generate and every method change; a reproduction failure blocks issuing a computed quote.
|
|
113
|
-
|
|
114
|
-
## Boundaries
|
|
115
|
-
|
|
116
|
-
- **Capturing and maintaining** the content of the method (items, rules, dials, roll-up, past-job
|
|
117
|
-
fixtures) is the method-capture flow — Task 659; it populates the shape this reference defines. This
|
|
118
|
-
reference consumes that method; it does not build it.
|
|
119
|
-
- **Rendering** the priced result into the documents the owner issues (internal view, client
|
|
120
|
-
breakdown, branded quote) is Task 658. Those documents are printed to PDF through the existing
|
|
121
|
-
`browser-pdf-save` path (see `references/invoicing.md`); this compute reference produces only the
|
|
122
|
-
figures, not the documents.
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# Quote generation — output contract
|
|
2
|
-
|
|
3
|
-
This is the **output half** of the engine-driven quote path. The engine (the operator's captured
|
|
4
|
-
pricing method applied to a job) produces the figures; this contract turns those figures into the
|
|
5
|
-
documents the operator issues, each rendered to PDF.
|
|
6
|
-
|
|
7
|
-
It holds **no** business content. There are no fixed section names, no fixed wording, no fixed brand,
|
|
8
|
-
and no fixed page layout here. What each document contains and how it looks is the operator's, held
|
|
9
|
-
as their own data (their headings, their narrative, their charts, their logo, colours, contact and
|
|
10
|
-
terms, their page size). The same contract serves any trade or service business by reading that
|
|
11
|
-
operator's own document definitions.
|
|
12
|
-
|
|
13
|
-
This is distinct from the lightweight WhatsApp quote in `references/quoting.md`. Use that one for a
|
|
14
|
-
quick text quote. Use this one when the operator prices a job from a captured method and issues a set
|
|
15
|
-
of reconciling documents.
|
|
16
|
-
|
|
17
|
-
## What the engine hands you
|
|
18
|
-
|
|
19
|
-
You render from the engine result. Read it as:
|
|
20
|
-
|
|
21
|
-
- an ordered set of priced **groups** (the operator's own stages or categories);
|
|
22
|
-
- each group an ordered set of **lines**; each line carries its quantity and the quantity's source
|
|
23
|
-
(measurement-derived or entered), its unit, its rate, its cost, and its sell;
|
|
24
|
-
- each group's **subtotal**;
|
|
25
|
-
- the operator's named **roll-up** steps — whatever sequence of adjustments and taxes they apply
|
|
26
|
-
above the line items, in order;
|
|
27
|
-
- the **totals**: the client-visible subtotal and the final total.
|
|
28
|
-
|
|
29
|
-
The engine guarantees the reconciliation invariant: the group subtotals carried through the roll-up
|
|
30
|
-
equal the final total. You never recompute these figures — you display them.
|
|
31
|
-
|
|
32
|
-
The engine itself, and how a line's quantity, rate, cost and sell are derived, are out of scope here
|
|
33
|
-
(that is the compute core). This contract only reads the result.
|
|
34
|
-
|
|
35
|
-
## The three documents
|
|
36
|
-
|
|
37
|
-
The operator defines the content and styling of each; the **roles** are fixed, the **contents** are
|
|
38
|
-
not. A document is **engine-driven**: a section or line appears **only where the job priced it**.
|
|
39
|
-
|
|
40
|
-
| Role | Shows | Withholds | Filing scope |
|
|
41
|
-
|---|---|---|---|
|
|
42
|
-
| **Internal margin-bearing view** | every line's quantity and its source, unit, rate, cost and sell; each group subtotal; the full roll-up; the totals | nothing — this is the operator's own check on profitability before issuing | **admin-only** |
|
|
43
|
-
| **Client breakdown** | how the price is composed, at the granularity the operator chooses: the client-facing amount per group or line, the roll-up steps the client sees, and the headline figure | any cost, margin, or internal rate figure (the operator's own unit rate, not a client-facing per-unit price) | client-facing (shared) |
|
|
44
|
-
| **Client quote document** | the issued document in the operator's brand: their cover, their narrative or schedule, the client breakdown, the headline price, the acceptance block, and their standing terms | any cost, margin, or internal rate figure (the operator's own unit rate, not a client-facing per-unit price) | client-facing (shared) |
|
|
45
|
-
|
|
46
|
-
The internal view exists so the operator can confirm the job is profitable before it goes out; it may
|
|
47
|
-
expose cost and margin freely. The two client documents **never** surface a cost, margin, or
|
|
48
|
-
internal-rate figure. A column or line that shows the price to the client is fine; a figure that
|
|
49
|
-
reveals what the work cost the operator, or the markup applied, is a leak.
|
|
50
|
-
|
|
51
|
-
### The margin boundary is the filing scope
|
|
52
|
-
|
|
53
|
-
The leak boundary is structural, not a matter of remembering to omit a column. File each document by
|
|
54
|
-
its scope, exactly as `references/document-management.md` defines:
|
|
55
|
-
|
|
56
|
-
- the **internal margin-bearing view** goes to the **admin-only** customer folder
|
|
57
|
-
(`memory/admin/customers/{phone}/`), which the customer never receives;
|
|
58
|
-
- the **client breakdown** and the **client quote** go to the customer-facing documents folder
|
|
59
|
-
(`memory/users/{phone}/documents/`).
|
|
60
|
-
|
|
61
|
-
A document that carries cost or margin must never be written to the shared scope.
|
|
62
|
-
|
|
63
|
-
## Print fidelity
|
|
64
|
-
|
|
65
|
-
Each document becomes a PDF through the device's Chromium print pipeline, so the HTML must survive
|
|
66
|
-
that pipeline. Start from the A4 print conventions already in `references/invoicing.md` and
|
|
67
|
-
`references/document-management.md` — an `@page` size directive, `@media print` styles, and
|
|
68
|
-
`page-break-inside: avoid` on blocks that must not split — and keep the operator's own page size on
|
|
69
|
-
`@page` (their stationery may not be A4).
|
|
70
|
-
|
|
71
|
-
A multi-page issued quote needs two things beyond what those siblings show; state them explicitly in
|
|
72
|
-
the document:
|
|
73
|
-
|
|
74
|
-
- page-level chrome — a running header or footer and page numbers — via `@page` margin boxes, never
|
|
75
|
-
absolutely-positioned elements that drift across pages;
|
|
76
|
-
- `print-color-adjust: exact` on coloured fills, so the operator's brand fills survive print. The
|
|
77
|
-
renderer prints backgrounds, but make the intent explicit rather than relying on the default.
|
|
78
|
-
|
|
79
|
-
## Producing the PDF
|
|
80
|
-
|
|
81
|
-
Render the document as self-contained HTML (styles inline, images by absolute local path), then use
|
|
82
|
-
the same path as every other business-assistant document:
|
|
83
|
-
|
|
84
|
-
1. write the HTML to the document's filing-scope path;
|
|
85
|
-
2. `browser-navigate` to it via a `file://` absolute URL;
|
|
86
|
-
3. `browser-pdf-save` to an absolute `.pdf` path alongside the HTML;
|
|
87
|
-
4. confirm a non-zero byte count from the `Saved PDF to <path> (<bytes>)` line before treating the PDF
|
|
88
|
-
as done.
|
|
89
|
-
|
|
90
|
-
## Before you hand anything to the PDF tool
|
|
91
|
-
|
|
92
|
-
Log, for the run: which document roles were produced, each one's page count, and the reconciliation —
|
|
93
|
-
each document's displayed total against the engine total. If a document's total does not reconcile to
|
|
94
|
-
the engine total, fail loudly and stop. Never ship a document whose figures disagree with the engine.
|