@rubytech/create-maxy 1.0.744 → 1.0.746
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/__tests__/peer-brand-detect.test.js +103 -0
- package/dist/index.js +63 -4
- package/dist/peer-brand-detect.js +39 -0
- package/package.json +1 -1
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts +37 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
- package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
- package/payload/platform/lib/graph-mcp/dist/index.js +146 -3
- package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
- package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
- package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
- package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
- package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
- package/payload/platform/lib/graph-mcp/src/index.ts +216 -4
- package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
- package/payload/platform/plugins/docs/references/deployment.md +2 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +3 -22
- package/payload/server/chunk-SPTD7L7Z.js +9474 -0
- package/payload/server/maxy-edge.js +1 -1
- package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
- package/payload/server/public/graph.html +1 -1
- package/payload/server/server.js +1 -1
|
@@ -38,7 +38,16 @@ import {
|
|
|
38
38
|
formatAuditLine,
|
|
39
39
|
type AuditWarning,
|
|
40
40
|
} from "../../graph-write/dist/audit.js";
|
|
41
|
-
import { SchemaCache, neo4jSchemaFetcher } from "./schema-cache.js";
|
|
41
|
+
import { SchemaCache, getSharedDriver, neo4jSchemaFetcher } from "./schema-cache.js";
|
|
42
|
+
import { rewriteWithProvenanceStamps } from "./cypher-rewrite-stamp.js";
|
|
43
|
+
import {
|
|
44
|
+
OrphanRollbackError,
|
|
45
|
+
runWriteTxBody,
|
|
46
|
+
synthesiseOrphanRollback,
|
|
47
|
+
synthesiseShimError,
|
|
48
|
+
synthesiseWriteResponse,
|
|
49
|
+
type GraphDriver,
|
|
50
|
+
} from "./cypher-shim-write.js";
|
|
42
51
|
|
|
43
52
|
const SERVER_NAME = "graph";
|
|
44
53
|
const UPSTREAM_PACKAGE = "mcp-neo4j-cypher@0.6.0";
|
|
@@ -443,6 +452,174 @@ function wrapReadWarnings(msg: JsonRpcMessage, warnings: UnknownToken[]): string
|
|
|
443
452
|
return JSON.stringify(wrapped);
|
|
444
453
|
}
|
|
445
454
|
|
|
455
|
+
// --- Task 797: shim-owned write path -----------------------------------
|
|
456
|
+
// write_neo4j_cypher is intercepted (not forwarded to upstream). The shim
|
|
457
|
+
// runs the operator's cypher inside its own driver session under
|
|
458
|
+
// `executeWrite`, so provenance auto-stamping and orphan-rollback are
|
|
459
|
+
// transactionally atomic. Reads still pass through to upstream unchanged.
|
|
460
|
+
// Pure helpers (runWriteTxBody, synthesise*, OrphanRollbackError) live in
|
|
461
|
+
// cypher-shim-write.ts so they can be unit-tested without booting this
|
|
462
|
+
// module's stdin/stdout pipe and uvx spawn.
|
|
463
|
+
|
|
464
|
+
async function runShimWriteCypher(
|
|
465
|
+
id: string | number,
|
|
466
|
+
cypherFull: string,
|
|
467
|
+
cypherPrefix: string,
|
|
468
|
+
sessionIdParam: string | null,
|
|
469
|
+
operatorArgs: Record<string, unknown> | undefined,
|
|
470
|
+
startMs: number,
|
|
471
|
+
writeUnknownTokens: UnknownToken[],
|
|
472
|
+
validated: boolean,
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
const agentName = process.env.AGENT_SLUG ?? "unknown";
|
|
475
|
+
const sessionIdField =
|
|
476
|
+
sessionIdParam ?? process.env.SESSION_ID ?? "unknown";
|
|
477
|
+
const operatorParams =
|
|
478
|
+
(operatorArgs?.["params"] as Record<string, unknown> | undefined) ?? {};
|
|
479
|
+
const safePrefix = cypherPrefix.replace(/"/g, "'");
|
|
480
|
+
|
|
481
|
+
let rewrite: ReturnType<typeof rewriteWithProvenanceStamps>;
|
|
482
|
+
try {
|
|
483
|
+
rewrite = rewriteWithProvenanceStamps(cypherFull);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
// Defensive — the rewriter is regex-only with no catastrophic-backtrack
|
|
486
|
+
// patterns, but a thrown error before driver acquisition would otherwise
|
|
487
|
+
// emit no [graph-query] line at all (review F3).
|
|
488
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
489
|
+
console.error(
|
|
490
|
+
`[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="rewrite-failed: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
|
|
491
|
+
);
|
|
492
|
+
process.stdout.write(
|
|
493
|
+
`${synthesiseShimError(id, "cypher rewrite failed — write NOT executed", errMsg)}\n`,
|
|
494
|
+
);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (rewrite.stampsAppended > 0) {
|
|
498
|
+
console.error(
|
|
499
|
+
`[graph-cypher-write] auto-stamp applied query="${safePrefix}" creates=${rewrite.stampsAppended}`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
let driver: GraphDriver;
|
|
504
|
+
try {
|
|
505
|
+
driver = (await getSharedDriver(resolvedNeo4jUri, neo4jUser, neo4jPassword)) as GraphDriver;
|
|
506
|
+
} catch (err) {
|
|
507
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
508
|
+
console.error(
|
|
509
|
+
`[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="driver-unavailable: ${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
|
|
510
|
+
);
|
|
511
|
+
process.stdout.write(
|
|
512
|
+
`${synthesiseShimError(id, "graph driver unavailable — write NOT executed", errMsg)}\n`,
|
|
513
|
+
);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const session = driver.session();
|
|
518
|
+
let outcome: { nodesCreated: number; relsCreated: number; propertiesSet: number; serializedRecords: Array<Record<string, unknown>> } | null = null;
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
outcome = await session.executeWrite((tx) =>
|
|
522
|
+
runWriteTxBody(tx, rewrite.cypher, operatorParams, {
|
|
523
|
+
agent: agentName,
|
|
524
|
+
session: sessionIdField,
|
|
525
|
+
}),
|
|
526
|
+
);
|
|
527
|
+
} catch (err) {
|
|
528
|
+
if (err instanceof OrphanRollbackError) {
|
|
529
|
+
console.error(
|
|
530
|
+
`[graph-cypher-write] orphan-rollback query="${safePrefix}" orphanLabels=${err.sampleLabels.join(",")}`,
|
|
531
|
+
);
|
|
532
|
+
console.error(
|
|
533
|
+
formatAuditLine({
|
|
534
|
+
kind: "orphan-warning",
|
|
535
|
+
cypherPrefix,
|
|
536
|
+
orphanIds: err.orphanIds,
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
console.error(
|
|
540
|
+
`[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rolledBack=true orphans=${err.orphanIds.length} validated=${validated} ms=${Date.now() - startMs}`,
|
|
541
|
+
);
|
|
542
|
+
process.stdout.write(
|
|
543
|
+
`${synthesiseOrphanRollback(id, err.orphanIds.length, err.sampleLabels)}\n`,
|
|
544
|
+
);
|
|
545
|
+
} else {
|
|
546
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
547
|
+
console.error(
|
|
548
|
+
`[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" error="${errMsg.replace(/"/g, "'")}" validated=${validated} ms=${Date.now() - startMs}`,
|
|
549
|
+
);
|
|
550
|
+
process.stdout.write(
|
|
551
|
+
`${synthesiseShimError(id, "Neo4j error", errMsg)}\n`,
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
} finally {
|
|
556
|
+
await session.close().catch(() => {
|
|
557
|
+
/* session already closed by the driver on commit/rollback */
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// outcome is non-null on the success path (the catch above returns early
|
|
562
|
+
// on rollback / shim error). Defensive narrow.
|
|
563
|
+
if (!outcome) return;
|
|
564
|
+
|
|
565
|
+
console.error(
|
|
566
|
+
`[graph-query] op=${WRITE_CYPHER_TOOL} brand=${brand} port=${neo4jPort} cypher="${safePrefix}" rows=${outcome.serializedRecords.length} validated=${validated} ms=${Date.now() - startMs}`,
|
|
567
|
+
);
|
|
568
|
+
console.error(
|
|
569
|
+
formatAuditLine({
|
|
570
|
+
kind: "accepted",
|
|
571
|
+
cypherPrefix,
|
|
572
|
+
nodesCreated: outcome.nodesCreated,
|
|
573
|
+
relsCreated: outcome.relsCreated,
|
|
574
|
+
agentName,
|
|
575
|
+
sessionId: sessionIdField,
|
|
576
|
+
}),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
for (const t of writeUnknownTokens.filter((u) => u.kind === "relationship")) {
|
|
580
|
+
console.error(
|
|
581
|
+
formatAuditLine({
|
|
582
|
+
kind: "unknown-type-warning",
|
|
583
|
+
cypherPrefix,
|
|
584
|
+
type: t.token,
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const auditWarnings: AuditWarning[] = auditCypherWrite({
|
|
590
|
+
cypher: rewrite.cypher,
|
|
591
|
+
schema: schemaCache.snapshot(),
|
|
592
|
+
agentName,
|
|
593
|
+
sessionId: sessionIdField,
|
|
594
|
+
nodesCreated: outcome.nodesCreated,
|
|
595
|
+
relsCreated: outcome.relsCreated,
|
|
596
|
+
orphanIds: [],
|
|
597
|
+
});
|
|
598
|
+
for (const w of auditWarnings) {
|
|
599
|
+
if (w.kind === "missing-provenance-warning") {
|
|
600
|
+
console.error(
|
|
601
|
+
formatAuditLine({
|
|
602
|
+
kind: "missing-provenance-warning",
|
|
603
|
+
cypherPrefix,
|
|
604
|
+
created: w.created,
|
|
605
|
+
stamped: w.stamped,
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
// unknown-type already emitted from the validator's writeUnknownTokens above;
|
|
610
|
+
// orphan-warning fires only on the rollback path.
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
process.stdout.write(
|
|
614
|
+
`${synthesiseWriteResponse(id, {
|
|
615
|
+
nodesCreated: outcome.nodesCreated,
|
|
616
|
+
relsCreated: outcome.relsCreated,
|
|
617
|
+
propertiesSet: outcome.propertiesSet,
|
|
618
|
+
records: outcome.serializedRecords,
|
|
619
|
+
})}\n`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
446
623
|
type RequestDecision = "forward" | "intercepted";
|
|
447
624
|
|
|
448
625
|
function handleRequestLine(line: string): RequestDecision {
|
|
@@ -481,6 +658,36 @@ function handleRequestLine(line: string): RequestDecision {
|
|
|
481
658
|
return "forward";
|
|
482
659
|
}
|
|
483
660
|
|
|
661
|
+
// Task 797: writes are intercepted into the shim-owned driver path.
|
|
662
|
+
// Helper so each branch below routes consistently.
|
|
663
|
+
const interceptWrite = (): RequestDecision => {
|
|
664
|
+
void runShimWriteCypher(
|
|
665
|
+
msg.id as string | number,
|
|
666
|
+
cypherFull,
|
|
667
|
+
cypherPrefix ?? "",
|
|
668
|
+
sessionIdParam,
|
|
669
|
+
msg.params?.arguments,
|
|
670
|
+
entry.startMs,
|
|
671
|
+
entry.writeUnknownTokens,
|
|
672
|
+
entry.validated,
|
|
673
|
+
).catch((err) => {
|
|
674
|
+
// Defensive catch — runShimWriteCypher already handles its own errors,
|
|
675
|
+
// but never let an unexpected throw leak past this point.
|
|
676
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
677
|
+
console.error(
|
|
678
|
+
`[graph-mcp] runShimWriteCypher unhandled error: ${errMsg.replace(/"/g, "'")}`,
|
|
679
|
+
);
|
|
680
|
+
try {
|
|
681
|
+
process.stdout.write(
|
|
682
|
+
`${synthesiseShimError(msg.id as string | number, "shim-internal error", errMsg)}\n`,
|
|
683
|
+
);
|
|
684
|
+
} catch {
|
|
685
|
+
/* stdout closed */
|
|
686
|
+
}
|
|
687
|
+
});
|
|
688
|
+
return "intercepted";
|
|
689
|
+
};
|
|
690
|
+
|
|
484
691
|
const snapshot = schemaCache.snapshot();
|
|
485
692
|
const cacheReady = schemaCache.ready();
|
|
486
693
|
|
|
@@ -488,6 +695,11 @@ function handleRequestLine(line: string): RequestDecision {
|
|
|
488
695
|
console.error(
|
|
489
696
|
`[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=skipped reason=cache-not-ready cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
|
|
490
697
|
);
|
|
698
|
+
if (isWriteCall) {
|
|
699
|
+
// Task 797: still rewrite + run inside shim tx even when validator
|
|
700
|
+
// is cold. Auto-stamp + orphan rollback don't depend on schema.
|
|
701
|
+
return interceptWrite();
|
|
702
|
+
}
|
|
491
703
|
pending.set(msg.id, entry);
|
|
492
704
|
return "forward";
|
|
493
705
|
}
|
|
@@ -516,6 +728,7 @@ function handleRequestLine(line: string): RequestDecision {
|
|
|
516
728
|
console.error(
|
|
517
729
|
`[cypher-validate] tool=${isWriteCall ? "write" : "read"} outcome=accepted labels=${result.labelTokens.length} relationships=${result.edgeTokens.length}`,
|
|
518
730
|
);
|
|
731
|
+
if (isWriteCall) return interceptWrite();
|
|
519
732
|
pending.set(msg.id, entry);
|
|
520
733
|
return "forward";
|
|
521
734
|
}
|
|
@@ -530,13 +743,12 @@ function handleRequestLine(line: string): RequestDecision {
|
|
|
530
743
|
// legitimately introduce new labels (REMOVE n:Old SET n:New) and edges
|
|
531
744
|
// pending an ontology update. The post-write audit emits one
|
|
532
745
|
// unknown-type-warning per unknown so operators see the gap; the cypher
|
|
533
|
-
//
|
|
746
|
+
// still commits via the shim-owned write path (Task 797).
|
|
534
747
|
entry.writeUnknownTokens = result.unknown;
|
|
535
748
|
console.error(
|
|
536
749
|
`[cypher-validate] tool=write outcome=warned unknown=${tokenSummary} cypher="${(cypherPrefix ?? "").replace(/"/g, "'")}"`,
|
|
537
750
|
);
|
|
538
|
-
|
|
539
|
-
return "forward";
|
|
751
|
+
return interceptWrite();
|
|
540
752
|
}
|
|
541
753
|
|
|
542
754
|
entry.readWarnings = result.unknown;
|
|
@@ -156,20 +156,50 @@ export class SchemaCache {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
159
|
+
* Module-scope driver singleton (Task 797). Both the schema-cache and the
|
|
160
|
+
* write-path (graph-mcp/src/index.ts `runShimWriteCypher`) borrow sessions
|
|
161
|
+
* from the same `neo4j-driver` Driver instance — one connection pool per
|
|
162
|
+
* process. The first call binds the URI/credentials; subsequent calls
|
|
163
|
+
* return the same driver regardless of arguments. The driver lives for
|
|
164
|
+
* the process lifetime; closing it is the process exit's responsibility.
|
|
162
165
|
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
166
|
+
* The import is deferred so test files can exercise the SchemaCache class
|
|
167
|
+
* without pulling neo4j-driver into their module graph.
|
|
168
|
+
*/
|
|
169
|
+
let sharedDriverPromise: Promise<unknown> | null = null;
|
|
170
|
+
|
|
171
|
+
export async function getSharedDriver(
|
|
172
|
+
uri: string,
|
|
173
|
+
user: string,
|
|
174
|
+
password: string,
|
|
175
|
+
): Promise<unknown> {
|
|
176
|
+
if (!sharedDriverPromise) {
|
|
177
|
+
// Clear the cached promise on rejection so a transient failure (e.g. a
|
|
178
|
+
// slow-to-import neo4j-driver during boot) doesn't wedge every subsequent
|
|
179
|
+
// call for the process lifetime (review F4).
|
|
180
|
+
sharedDriverPromise = (async () => {
|
|
181
|
+
const neo4j = await import("neo4j-driver");
|
|
182
|
+
return neo4j.default.driver(uri, neo4j.default.auth.basic(user, password));
|
|
183
|
+
})().catch((err) => {
|
|
184
|
+
sharedDriverPromise = null;
|
|
185
|
+
throw err;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return sharedDriverPromise;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Build a SchemaFetcher over the shared neo4j-driver Driver. Each call
|
|
193
|
+
* opens a fresh session (cheap, sub-ms) and closes it in finally.
|
|
165
194
|
*/
|
|
166
195
|
export async function neo4jSchemaFetcher(
|
|
167
196
|
uri: string,
|
|
168
197
|
user: string,
|
|
169
198
|
password: string,
|
|
170
199
|
): Promise<SchemaFetcher> {
|
|
171
|
-
const
|
|
172
|
-
|
|
200
|
+
const driver = (await getSharedDriver(uri, user, password)) as {
|
|
201
|
+
session: () => { run: (q: string) => Promise<{ records: Array<{ get: (k: number) => unknown }> }>; close: () => Promise<void> };
|
|
202
|
+
};
|
|
173
203
|
const query = async (cypher: string): Promise<string[]> => {
|
|
174
204
|
const session = driver.session();
|
|
175
205
|
try {
|
|
@@ -100,7 +100,8 @@ systemctl --user daemon-reload
|
|
|
100
100
|
A single Pi or laptop can host more than one brand (for example Maxy and Real Agent) side by side. Each brand runs as its own service on its own port, with its own install directory and its own data. Installing one brand does not touch the other.
|
|
101
101
|
|
|
102
102
|
- **Separate:** each brand has its own install folder (`~/maxy/`, `~/realagent/`), its own config folder (`~/.maxy/`, `~/.realagent/`), its own web port, its own Cloudflare tunnel state, its own edge systemd unit (`maxy-edge.service` vs `realagent-edge.service`), and by default its own Neo4j database (Maxy on bolt port 7687, Real Agent on 7688). Action runner units are transient and per-invocation, not per-brand, so no naming conflict is possible.
|
|
103
|
-
- **Brand-isolated Neo4j (Task 787):** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so
|
|
103
|
+
- **Brand-isolated Neo4j (Task 787):** when a brand provisions a dedicated Neo4j instance (any port other than 7687), the installer stops and disables the apt-package's system `neo4j.service` after enabling the brand-dedicated unit, so only one Neo4j process holds the shared `/var/lib/neo4j/run/` PID file. The seed step receives the brand-correct `NEO4J_URI` and `NEO4J_PASSWORD` as explicit environment variables — the seed script no longer carries a `bolt://localhost:7687` default. A failed dedicated start aborts the install loudly with a journalctl tail; there is no silent fallback to the system instance. Stop/disable targets the literal `neo4j.service` only, so peer brands running their own `neo4j-{brand}.service` are unaffected.
|
|
104
|
+
- **Peer-aware system-unit guard (Task 800):** before stopping the system `neo4j.service`, the installer checks whether any other brand on the device still depends on it — that is, has `NEO4J_URI=bolt://localhost:7687` in its `~/.<peer>/.env`. If so, the system unit is left enabled and active, and the install log shows `[neo4j] system unit kept active — peer brand <name> depends on port 7687 (Task 800)` instead of the usual `[neo4j] disabling system unit` line. This prevents a `create-realagent` install from disabling Maxy's database on a host where Maxy still uses the shared system instance (the Task 797 reproducer on Neo's laptop, 2026-04-28). On single-brand hosts and on multi-brand hosts where every peer runs a dedicated port, behaviour is unchanged from Task 787.
|
|
104
105
|
- **Shared:** both brands share the system Chromium/VNC stack, the Ollama model server, and the `cloudflared` command itself. Browser automation is serialised — one admin session at a time across both brands.
|
|
105
106
|
|
|
106
107
|
To install a second brand on a device that already runs the first, just run the other installer. No flags needed for isolation:
|
|
@@ -128,34 +128,15 @@ You wield two write surfaces. Wrapped writers (`memory-write`, `memory-update`,
|
|
|
128
128
|
|
|
129
129
|
### Graph stewardship doctrine — raw Cypher writes
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
Two rules govern every raw Cypher write you author. They require LLM judgement — the structural gate cannot make these calls for you. Two further rules (provenance, orphan-prevention) are now structurally enforced by the shim and need no prose discipline.
|
|
132
132
|
|
|
133
133
|
**1. Every node has ≥1 typed edge in the same transaction.** A bare `CREATE (n:Person {name: 'Ian'})` is data, not knowledge — retrieval finds the node but it carries no context. Anchor every node creation to the relationship that explains it: `MATCH (anchor) WHERE … CREATE (anchor)-[:TYPE]->(n:Label {…})`, or `MERGE` the node and its edge in the same statement. *Why:* a graph that accumulates islands defeats relationship-traversal queries — the value of the graph is in the edges, not the rows.
|
|
134
134
|
|
|
135
135
|
**2. Every edge type is in the live ontology.** Inventing types fragments retrieval — `KNOWS` ≠ `knows` ≠ `HAS_KNOWN`. Call `mcp__graph__maxy-graph-get_neo4j_schema` before authoring any write whose edge type you are not certain about; if no fitting type exists, stop and ask the admin agent for ontology guidance — never coin a synonym. *Why:* edge typology compounds over time. A synonym today blocks every future query that expected the canonical type, and the only fix is a label-rewrite Cypher pass that touches the same edge from both sides.
|
|
136
136
|
|
|
137
|
-
**
|
|
137
|
+
**Structural enforcement (Task 797).** The shim auto-stamps `createdAt`, `createdByAgent`, `createdByTool`, `createdBySession` on every `CREATE`/`MERGE` alias before forwarding to Neo4j — you do not write these properties yourself. The shim runs the cypher inside a managed `executeWrite` and self-audits for unattached nodes before committing; if any node you created has zero edges in the same transaction, the entire transaction rolls back and you receive a structured error naming the orphan label(s). Treat the rollback as a hard failure (do not retry the same cypher); your job is to author atomic CREATE/MERGE-with-edge statements per Rule 1, not to write defensive WITH/MATCH/RETURN audits or hand-written SET clauses for `createdBy*` fields. The `[graph-cypher-write]` audit lines (`auto-stamp applied`, `accepted`, `orphan-rollback`, `orphan-warning`, `missing-provenance-warning`, `unknown-type-warning`) name what the structural enforcement saw — they are observation surfaces, not duties.
|
|
138
138
|
|
|
139
|
-
|
|
140
|
-
SET n.createdAt = datetime(),
|
|
141
|
-
n.createdByAgent = 'database-operator',
|
|
142
|
-
n.createdByTool = 'graph-cypher-write',
|
|
143
|
-
n.createdBySession = $sessionId
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Apply the same SET to relationships (`SET r....`). The session id is the conversation correlation key; the admin dispatch passes it as a parameter. *Why:* a write without provenance cannot be attributed back to the dispatch that produced it. The post-write audit emits `missing-provenance-warning created=<n> stamped=<m>` when the static parser counts CREATE/MERGE statements that exceed the count of `createdBy*` tokens — this is the directional signal, not a structural gate.
|
|
147
|
-
|
|
148
|
-
**4. Orphan prevention is your first-class duty.** Multi-statement transactions self-audit before completing. Capture the elementIds of every node the transaction creates, then run the orphan check inline:
|
|
149
|
-
|
|
150
|
-
```cypher
|
|
151
|
-
WITH collect(elementId(n)) AS writtenIds
|
|
152
|
-
MATCH (n) WHERE elementId(n) IN writtenIds AND NOT (n)--()
|
|
153
|
-
RETURN count(n) AS orphans
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
If `orphans > 0`, do not commit a "fix it up later" transaction — roll back, anchor the unattached nodes to their semantic parent, and re-run. *Why:* the hourly `[graph-health] orphans total=<N>` signal is your scorecard. A transaction that leaves orphans is a regression the role owns; the operator's stewardship is the primary defense against landfill.
|
|
157
|
-
|
|
158
|
-
The four rules together replace the LOUD-FAIL improvisation pattern that prior versions of this prompt prescribed when a wrapped writer lacked an edge-between-existing-nodes path. You no longer loud-fail on missing graph-write tools — you have them. You loud-fail on credentials, on out-of-surface tools (a skill prescribing a non-graph MCP token you do not hold), and on dispatched skills whose prerequisites are unmet — exactly as the LOUD-FAIL prerogative names. Discipline failures within the graph-write surface produce audit warnings, not blockers; the role's prompt-internalised doctrine is the contract.
|
|
139
|
+
The two rules together replace the LOUD-FAIL improvisation pattern that prior versions of this prompt prescribed when a wrapped writer lacked an edge-between-existing-nodes path. You no longer loud-fail on missing graph-write tools — you have them. You loud-fail on credentials, on out-of-surface tools (a skill prescribing a non-graph MCP token you do not hold), and on dispatched skills whose prerequisites are unmet — exactly as the LOUD-FAIL prerogative names.
|
|
159
140
|
|
|
160
141
|
### Before writing any Cypher
|
|
161
142
|
|