granttap-mcp 0.8.21 → 0.8.22
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/README.md +23 -1
- package/apps/bridge/src/capabilities/README.md +5 -0
- package/apps/bridge/src/cortex/integration.ts +10 -7
- package/apps/bridge/src/engine/protocol/engine-memory-protocol.ts +3 -0
- package/apps/bridge/src/engine/runtime/engine-memory.ts +56 -34
- package/apps/bridge/src/mesh/catalog/project/observations.ts +7 -0
- package/apps/bridge/src/mesh/context/env.ts +32 -2
- package/apps/bridge/src/mesh/context/project.ts +13 -2
- package/apps/bridge/src/mesh/knowledge/README.md +14 -0
- package/apps/bridge/src/mesh/knowledge/active.ts +64 -0
- package/apps/bridge/src/mesh/knowledge/write.ts +45 -0
- package/apps/bridge/src/mesh/runtime/index.ts +13 -2
- package/apps/bridge/src/mesh/snapshot/scoped-view.ts +5 -1
- package/apps/bridge/src/mesh/store/index.ts +10 -1
- package/apps/bridge/src/monitor/controller-knowledge/index.ts +51 -0
- package/apps/bridge/src/monitor/handlers/inbound.ts +21 -7
- package/apps/bridge/src/monitor/support/runtime.ts +1 -1
- package/apps/bridge/src/pairing/controller-invites.ts +92 -0
- package/apps/mcp/src/connection-center/widget.html +1 -1
- package/cursor-plugin/.cursor-plugin/plugin.json +1 -1
- package/cursor-plugin/README.md +1 -1
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/stdio-bootstrap.cjs +1 -1
- package/docs/images/iphone-health-code-towers.png +0 -0
- package/docs/images/iphone-linked-projects.png +0 -0
- package/docs/images/iphone-weavatrix-graph.png +0 -0
- package/package.json +2 -2
- package/packages/core/relay-client.ts +21 -0
- package/packages/protocol/messages/README.md +6 -0
- package/packages/protocol/messages/device/controller-pairing.ts +32 -0
- package/packages/protocol/messages/mesh/knowledge/index.ts +1 -0
- package/packages/protocol/messages/mesh/knowledge/write.ts +27 -0
- package/packages/protocol/messages/mesh/snapshot.ts +6 -0
- package/packages/protocol/schema.ts +8 -0
package/README.md
CHANGED
|
@@ -80,12 +80,34 @@ separate from their path identity. Missing content evidence remains unknown.
|
|
|
80
80
|
|
|
81
81
|
<p align="center">
|
|
82
82
|
<img src="docs/images/iphone-projects-shared.png" width="200" alt="Projects: one owned by this phone, one shared by another">
|
|
83
|
+
<img src="docs/images/iphone-linked-projects.png" width="200" alt="Two linked Projects with separate access scopes">
|
|
83
84
|
<img src="docs/images/iphone-project-mesh.png" width="200" alt="A Project: Governance, members and computers, Mesh status, repositories, Tasks">
|
|
84
85
|
<img src="docs/images/iphone-task-route.png" width="200" alt="A Task: executions, Runtime history, and resource claims">
|
|
85
86
|
<img src="docs/images/iphone-handoff.png" width="200" alt="Task handoff: destination, push, and readiness checks">
|
|
86
87
|
</p>
|
|
87
88
|
|
|
88
|
-
<p align="center"><em>Projects
|
|
89
|
+
<p align="center"><em>Shared Projects · linked repository bindings with separate Project access · Project management · Task execution history · handoff readiness</em></p>
|
|
90
|
+
|
|
91
|
+
<p align="center">
|
|
92
|
+
<img src="docs/images/iphone-weavatrix-graph.png" width="200" alt="Full-screen architecture graph with sample evidence">
|
|
93
|
+
<img src="docs/images/iphone-health-code-towers.png" width="200" alt="Full-screen Health code towers with sample evidence">
|
|
94
|
+
</p>
|
|
95
|
+
|
|
96
|
+
<p align="center"><em>These are deterministic simulator fixtures, not reports from a live customer repository.</em></p>
|
|
97
|
+
|
|
98
|
+
An authenticated controller can request a separate, one-time device credential
|
|
99
|
+
for another iPhone or iPad from each Live linked computer. The phone packages
|
|
100
|
+
the resulting links in an encrypted, single-use QR. This adds the new phone to
|
|
101
|
+
the same device network; it does not grant Project membership. Explicit human
|
|
102
|
+
decisions for a bound Project Task can be recorded through Engine Memory, and
|
|
103
|
+
a same-scope correction supersedes an earlier decision while retaining history.
|
|
104
|
+
The computer returns `recorded` only after the Engine confirms storage.
|
|
105
|
+
|
|
106
|
+
The capability catalog distinguishes local observations from installed and
|
|
107
|
+
initialized state. It does not yet transfer and apply complete MCP or skill
|
|
108
|
+
bundles to every host. Usage figures do not enforce spending caps. Project
|
|
109
|
+
Environment secrets injected into an agent process remain readable by that
|
|
110
|
+
process; a use-only credential broker is not part of this runtime yet.
|
|
89
111
|
|
|
90
112
|
For Claude Code, Codex, and Cursor, the provider hook runs inside the agent's
|
|
91
113
|
own session and sees the exact call. GrantTap attributes every `notify` to the
|
|
@@ -2,4 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
This module discovers provider-configured MCP servers and available skills. Metadata probing is isolated because it may start an MCP transport; local descriptors and skill discovery remain read-only.
|
|
4
4
|
|
|
5
|
+
Skill digests cover the bounded bundle, including scripts and references.
|
|
6
|
+
Catalog observations and Project requests do not install a skill or native MCP
|
|
7
|
+
configuration on another host. Availability requires a separate native apply
|
|
8
|
+
and independently observed initialization; that rollout is still unfinished.
|
|
9
|
+
|
|
5
10
|
License: this module is distributed under the MIT License in the repository-root `LICENSE` file.
|
|
@@ -14,6 +14,7 @@ import type { ScopedMeshView } from "../mesh/snapshot/scoped-view";
|
|
|
14
14
|
import { loadRuntimeConfig } from "../config/runtime";
|
|
15
15
|
import { computerId } from "../mesh/identity/computer";
|
|
16
16
|
import { defaultCortexConfig } from "./config";
|
|
17
|
+
import { activeProjectKnowledge, activeScopedKnowledge, unrecordedKnowledgeEvents } from "../mesh/knowledge/active";
|
|
17
18
|
|
|
18
19
|
type CortexDependencies = {
|
|
19
20
|
provenance?: typeof projectEngineProvenance;
|
|
@@ -109,15 +110,16 @@ export function cortexSnapshotEvidence(
|
|
|
109
110
|
`repository.${graph.repositoryId}`, "weavatrix.repository", JSON.stringify(graph),
|
|
110
111
|
"high", "verified", "graph", graph.revision,
|
|
111
112
|
));
|
|
112
|
-
const knowledge = (snapshot.knowledge ?? []).filter((record) =>
|
|
113
|
+
const knowledge = activeProjectKnowledge(snapshot.projectId, snapshot.knowledge ?? []).filter((record) =>
|
|
113
114
|
record.taskId === taskId || record.visibility === "project").slice(0, 16);
|
|
114
115
|
for (const record of knowledge) evidence.push(item(
|
|
115
116
|
`knowledge.${record.recordId}`, "weavatrix.memory", JSON.stringify(record),
|
|
116
117
|
"high", "unverified", "memory", `memory:${record.streamVersion}`,
|
|
117
118
|
));
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
for (const event of unrecordedKnowledgeEvents(
|
|
120
|
+
snapshot.events.filter((value) => value.taskId === taskId),
|
|
121
|
+
snapshot.knowledge ?? [], snapshot.supersededKnowledgeRecordIds,
|
|
122
|
+
).slice(-32)) {
|
|
121
123
|
evidence.push(item(
|
|
122
124
|
`event.${event.eventId}`, "mesh.event", JSON.stringify(event),
|
|
123
125
|
"normal", "unverified", "memory", `event:${event.createdAt}`,
|
|
@@ -145,13 +147,14 @@ export function cortexScopedEvidence(view: ScopedMeshView): EngineContextEvidenc
|
|
|
145
147
|
`repository.${graph.repositoryId}`, "weavatrix.repository", JSON.stringify(graph),
|
|
146
148
|
"high", "verified", "graph", graph.revision,
|
|
147
149
|
));
|
|
148
|
-
const knowledge = (view.knowledge ?? []).slice(0, 16);
|
|
150
|
+
const knowledge = activeScopedKnowledge(view.project.projectId, view.knowledge ?? [], taskId).slice(0, 16);
|
|
149
151
|
for (const record of knowledge) evidence.push(item(
|
|
150
152
|
`knowledge.${record.recordId}`, "weavatrix.memory", JSON.stringify(record),
|
|
151
153
|
"high", "unverified", "memory", `memory:${record.streamVersion}`,
|
|
152
154
|
));
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
+
for (const event of unrecordedKnowledgeEvents(
|
|
156
|
+
view.events, view.knowledge ?? [], view.supersededKnowledgeRecordIds,
|
|
157
|
+
).slice(-32)) evidence.push(item(
|
|
155
158
|
`event.${event.eventId}`, "mesh.event", JSON.stringify(event),
|
|
156
159
|
"normal", "unverified", "memory", `event:${event.createdAt}`,
|
|
157
160
|
));
|
|
@@ -12,6 +12,7 @@ export type KnowledgeRecordInput = {
|
|
|
12
12
|
visibility: KnowledgeVisibility;
|
|
13
13
|
repository_id?: string | null;
|
|
14
14
|
commit_sha?: string | null;
|
|
15
|
+
supersedes_record_id?: string | null;
|
|
15
16
|
recorded_at: number;
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -28,6 +29,7 @@ export type MemoryEngineOperation =
|
|
|
28
29
|
| { operation: "memory.history"; input: {
|
|
29
30
|
project_id: string; task_id?: string | null; visibility?: KnowledgeVisibility | null;
|
|
30
31
|
before_version?: number | null; limit?: number | null;
|
|
32
|
+
include_superseded?: boolean;
|
|
31
33
|
} };
|
|
32
34
|
export type MemoryEngineResult =
|
|
33
35
|
| { operation: "memory.recorded"; record_id: string; stream_version: number }
|
|
@@ -62,6 +64,7 @@ export function parseMemoryResult(result: Record<string, unknown>, invalid: () =
|
|
|
62
64
|
if (entry.repository_id != null) bounded(entry.repository_id, 512, invalid);
|
|
63
65
|
if (entry.commit_sha != null && (typeof entry.commit_sha !== "string"
|
|
64
66
|
|| !/^[0-9a-f]{7,64}$/i.test(entry.commit_sha))) invalid();
|
|
67
|
+
if (entry.supersedes_record_id != null) bounded(entry.supersedes_record_id, 128, invalid);
|
|
65
68
|
integer(entry.recorded_at, invalid);
|
|
66
69
|
integer(entry.stream_version, invalid);
|
|
67
70
|
}
|
|
@@ -7,10 +7,11 @@ import { EngineClient } from "./engine-client";
|
|
|
7
7
|
import { engineFeatureEnabled, type EngineClientLike } from "./engine-supervisor";
|
|
8
8
|
|
|
9
9
|
type Options = { env?: NodeJS.ProcessEnv; client?: EngineClientLike };
|
|
10
|
+
type KnowledgeSourceWindow = Pick<MeshSnapshot, "projectId" | "events">;
|
|
10
11
|
let sharedClient: EngineClient | undefined;
|
|
11
12
|
const synced = new Set<string>();
|
|
12
13
|
const pendingSyncs = new Map<string, Promise<void>>();
|
|
13
|
-
const
|
|
14
|
+
const queuedSyncs = new Map<string, { snapshot: KnowledgeSourceWindow; options: Options }>();
|
|
14
15
|
|
|
15
16
|
function client(options: Options): EngineClientLike {
|
|
16
17
|
return options.client ?? (sharedClient ??= new EngineClient({
|
|
@@ -34,23 +35,36 @@ export async function projectKnowledge(
|
|
|
34
35
|
): Promise<ProjectKnowledgeRecord[] | undefined> {
|
|
35
36
|
if (!engineFeatureEnabled(options.env ?? process.env)) return undefined;
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
38
|
+
const records: ProjectKnowledgeRecord[] = [];
|
|
39
|
+
let beforeVersion: number | undefined;
|
|
40
|
+
// The local Mesh cache holds at most 256 records. Read that same bounded
|
|
41
|
+
// window, including corrections, before deciding which entries are active.
|
|
42
|
+
for (let page = 0; page < 4; page += 1) {
|
|
43
|
+
const result = await client(options).request({
|
|
44
|
+
operation: "memory.history",
|
|
45
|
+
input: { project_id: projectId, task_id: taskId,
|
|
46
|
+
visibility: taskId ? undefined : "project", limit: 64,
|
|
47
|
+
include_superseded: true, before_version: beforeVersion },
|
|
48
|
+
}, { timeoutMs: 250 });
|
|
49
|
+
if (result.operation !== "memory.history" || result.page.project_id !== projectId) return undefined;
|
|
50
|
+
records.push(...result.page.entries.filter((entry) => taskId
|
|
51
|
+
? entry.task_id === taskId || entry.visibility === "project"
|
|
52
|
+
: entry.visibility === "project").map((entry) => ({
|
|
53
|
+
projectId: entry.project_id, taskId: entry.task_id,
|
|
54
|
+
recordId: entry.record_id, category: entry.category,
|
|
55
|
+
content: entry.content, source: entry.source,
|
|
56
|
+
sourceRef: entry.source_ref, visibility: entry.visibility,
|
|
57
|
+
repositoryId: entry.repository_id ?? undefined,
|
|
58
|
+
commitSha: entry.commit_sha ?? undefined,
|
|
59
|
+
supersedesRecordId: entry.supersedes_record_id ?? undefined,
|
|
60
|
+
recordedAt: entry.recorded_at, streamVersion: entry.stream_version,
|
|
61
|
+
})));
|
|
62
|
+
const next = result.page.next_before_version;
|
|
63
|
+
if (!result.page.incomplete || next == null
|
|
64
|
+
|| beforeVersion != null && next >= beforeVersion) break;
|
|
65
|
+
beforeVersion = next;
|
|
66
|
+
}
|
|
67
|
+
return records;
|
|
54
68
|
} catch { return undefined; }
|
|
55
69
|
}
|
|
56
70
|
|
|
@@ -86,31 +100,39 @@ export function recordsFromEvent(event: MeshEvent): KnowledgeRecordInput[] {
|
|
|
86
100
|
return [];
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
/** Backfill
|
|
90
|
-
export async function syncProjectKnowledge(snapshot:
|
|
103
|
+
/** Backfill retained structured events; a failed Engine write stops this pass. */
|
|
104
|
+
export async function syncProjectKnowledge(snapshot: KnowledgeSourceWindow, options: Options = {}): Promise<void> {
|
|
91
105
|
if (!engineFeatureEnabled(options.env ?? process.env)) return;
|
|
92
106
|
const candidates = snapshot.events.flatMap(recordsFromEvent)
|
|
93
|
-
.filter((entry) =>
|
|
107
|
+
.filter((entry) => entry.project_id === snapshot.projectId
|
|
108
|
+
&& !synced.has(`${entry.project_id}\0${entry.record_id}`))
|
|
109
|
+
.slice(-512);
|
|
94
110
|
if (candidates.length === 0) return;
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
for (let offset = 0; offset < count; offset += 1) {
|
|
99
|
-
const entry = candidates[(start + offset) % candidates.length]!;
|
|
100
|
-
const key = `${entry.project_id}\0${entry.record_id}`;
|
|
101
|
-
if (await recordProjectKnowledge(entry, options)) {
|
|
102
|
-
synced.add(key);
|
|
103
|
-
if (synced.size > 4_096) synced.delete(synced.values().next().value!);
|
|
111
|
+
for (const [index, entry] of candidates.entries()) {
|
|
112
|
+
if (index > 0 && index % 16 === 0) {
|
|
113
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
104
114
|
}
|
|
115
|
+
const key = `${entry.project_id}\0${entry.record_id}`;
|
|
116
|
+
if (!await recordProjectKnowledge(entry, options)) break;
|
|
117
|
+
synced.add(key);
|
|
118
|
+
if (synced.size > 4_096) synced.delete(synced.values().next().value!);
|
|
105
119
|
}
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
/** Publish never waits for the memory backfill; one bounded writer runs per Project. */
|
|
109
|
-
export function queueProjectKnowledgeSync(snapshot:
|
|
110
|
-
if (pendingSyncs.has(snapshot.projectId))
|
|
111
|
-
|
|
123
|
+
export function queueProjectKnowledgeSync(snapshot: KnowledgeSourceWindow, options: Options = {}): void {
|
|
124
|
+
if (pendingSyncs.has(snapshot.projectId)) {
|
|
125
|
+
queuedSyncs.set(snapshot.projectId, { snapshot, options });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const pending = syncProjectKnowledge(snapshot, options);
|
|
112
129
|
pendingSyncs.set(snapshot.projectId, pending);
|
|
113
130
|
void pending.catch(() => undefined).finally(() => {
|
|
114
|
-
if (pendingSyncs.get(snapshot.projectId)
|
|
131
|
+
if (pendingSyncs.get(snapshot.projectId) !== pending) return;
|
|
132
|
+
pendingSyncs.delete(snapshot.projectId);
|
|
133
|
+
const next = queuedSyncs.get(snapshot.projectId);
|
|
134
|
+
if (!next) return;
|
|
135
|
+
queuedSyncs.delete(snapshot.projectId);
|
|
136
|
+
queueProjectKnowledgeSync(next.snapshot, next.options);
|
|
115
137
|
});
|
|
116
138
|
}
|
|
@@ -55,6 +55,13 @@ function mcpObservation(
|
|
|
55
55
|
if (candidates.length === 0) return { state: "not_found" };
|
|
56
56
|
const digests = new Set(candidates.map((server) => server.configDigest).filter(Boolean));
|
|
57
57
|
const artifactDigest = digests.size === 1 ? [...digests][0] : undefined;
|
|
58
|
+
if (request.artifactDigest) {
|
|
59
|
+
if (candidates.some((server) => server.configDigest
|
|
60
|
+
&& server.configDigest !== request.artifactDigest)) {
|
|
61
|
+
return { state: "version_conflict", artifactDigest };
|
|
62
|
+
}
|
|
63
|
+
if (candidates.some((server) => !server.configDigest)) return { state: "unsupported" };
|
|
64
|
+
}
|
|
58
65
|
if (!request.artifactDigest) {
|
|
59
66
|
if (digests.size > 1) return { state: "version_conflict" };
|
|
60
67
|
if (digests.size === 1 && candidates.some((server) => !server.configDigest)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import type { ProjectEnvironment, ProjectEnvVar } from "../../../../../packages/protocol/schema";
|
|
4
4
|
import { ProjectEnvironment as EnvironmentSchema } from "../../../../../packages/protocol/schema";
|
|
@@ -10,6 +10,7 @@ type StoreFile = { environments: ProjectEnvironment[] };
|
|
|
10
10
|
const PROTECTED_KEYS = new Set([
|
|
11
11
|
"HOME", "PATH", "SHELL", "USER", "LOGNAME", "TMPDIR", "NODE_OPTIONS",
|
|
12
12
|
]);
|
|
13
|
+
const MANAGED_HEADER = "# GrantTap Project environment. Secrets stay on the Mesh, not here.";
|
|
13
14
|
|
|
14
15
|
function protectedRuntimeKey(key: string): boolean {
|
|
15
16
|
return PROTECTED_KEYS.has(key)
|
|
@@ -76,6 +77,7 @@ export function rememberEnvironment(
|
|
|
76
77
|
): ProjectEnvironment | undefined {
|
|
77
78
|
const others = loadAll().filter((item) => item.projectId !== projectId);
|
|
78
79
|
if (!environment) {
|
|
80
|
+
if (repositoryRoot) removeManagedRepoEnv(repositoryRoot);
|
|
79
81
|
saveAll(others);
|
|
80
82
|
return undefined;
|
|
81
83
|
}
|
|
@@ -83,6 +85,8 @@ export function rememberEnvironment(
|
|
|
83
85
|
const stored = mergeEnvironment({ ...environment, projectId }, loadEnvironment(projectId));
|
|
84
86
|
if (stored.shareNonSecretsWithRepo && repositoryRoot) {
|
|
85
87
|
writeRepoEnv(repositoryRoot, stored.variables);
|
|
88
|
+
} else if (repositoryRoot) {
|
|
89
|
+
removeManagedRepoEnv(repositoryRoot);
|
|
86
90
|
}
|
|
87
91
|
saveAll([...others, stored]);
|
|
88
92
|
return stored;
|
|
@@ -120,13 +124,39 @@ export function writeRepoEnv(root: string, variables: ProjectEnvVar[]): void {
|
|
|
120
124
|
.map((item) => `${item.key}=${escapeEnv(item.value ?? "")}`);
|
|
121
125
|
const path = repoEnvPath(root);
|
|
122
126
|
mkdirSync(dirname(path), { recursive: true });
|
|
127
|
+
if (!lstatSync(dirname(path)).isDirectory()) throw new Error("repository export directory is unsafe");
|
|
128
|
+
const stat = lstatSync(path, { throwIfNoEntry: false });
|
|
129
|
+
if (stat) {
|
|
130
|
+
if (!stat.isFile() || !readFileSync(path, "utf8").startsWith(`${MANAGED_HEADER}\n`)) {
|
|
131
|
+
throw new Error("repository environment file is not managed by GrantTap");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
123
134
|
writeFileSync(
|
|
124
135
|
path,
|
|
125
|
-
`${[
|
|
136
|
+
`${[MANAGED_HEADER, ...lines].join("\n")}\n`,
|
|
126
137
|
{ mode: 0o644 },
|
|
127
138
|
);
|
|
128
139
|
}
|
|
129
140
|
|
|
141
|
+
/** Revoking a share removes a managed export and reports unsafe leftovers. */
|
|
142
|
+
export function removeManagedRepoEnv(root: string): boolean {
|
|
143
|
+
const path = repoEnvPath(root);
|
|
144
|
+
try {
|
|
145
|
+
if (!lstatSync(dirname(path)).isDirectory()) {
|
|
146
|
+
throw new Error("repository environment directory is unsafe");
|
|
147
|
+
}
|
|
148
|
+
if (!lstatSync(path).isFile()
|
|
149
|
+
|| !readFileSync(path, "utf8").startsWith(`${MANAGED_HEADER}\n`)) {
|
|
150
|
+
throw new Error("repository environment file is not managed by GrantTap");
|
|
151
|
+
}
|
|
152
|
+
unlinkSync(path);
|
|
153
|
+
return true;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
130
160
|
function escapeEnv(value: string): string {
|
|
131
161
|
if (!/[\s#"\\]/.test(value)) return value;
|
|
132
162
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import type { ScopedMeshView } from "../snapshot/scoped-view";
|
|
6
6
|
import { compileMeshContext } from "./packet";
|
|
7
7
|
import { projectKnowledge } from "../../engine/runtime/engine-memory";
|
|
8
|
+
import {
|
|
9
|
+
activeScopedKnowledge, supersededScopedKnowledgeIds, unrecordedKnowledgeEvents,
|
|
10
|
+
} from "../knowledge/active";
|
|
8
11
|
|
|
9
12
|
export type ProjectContextMode = "compact" | "full" | "legacy";
|
|
10
13
|
|
|
@@ -139,8 +142,16 @@ export async function renderProjectContext(
|
|
|
139
142
|
const local = await projectKnowledge(view.project.projectId, view.execution.taskId);
|
|
140
143
|
const records = new Map((view.knowledge ?? []).map((item) => [item.recordId, item]));
|
|
141
144
|
for (const item of local ?? []) records.set(item.recordId, item);
|
|
142
|
-
const
|
|
143
|
-
|
|
145
|
+
const all = [...records.values()];
|
|
146
|
+
const supersededKnowledgeRecordIds = [...new Set([
|
|
147
|
+
...(view.supersededKnowledgeRecordIds ?? []),
|
|
148
|
+
...supersededScopedKnowledgeIds(view.project.projectId, all, view.execution.taskId),
|
|
149
|
+
])];
|
|
150
|
+
const scoped = { ...view,
|
|
151
|
+
knowledge: activeScopedKnowledge(view.project.projectId, all, view.execution.taskId)
|
|
152
|
+
.sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 32),
|
|
153
|
+
supersededKnowledgeRecordIds,
|
|
154
|
+
events: unrecordedKnowledgeEvents(view.events, all, supersededKnowledgeRecordIds) };
|
|
144
155
|
const contextPacket = await compileMeshContext(scoped);
|
|
145
156
|
if (mode === "compact") return { ...renderCompactProjectContext(scoped), contextPacket };
|
|
146
157
|
if (mode === "legacy") {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Mesh knowledge projection
|
|
2
|
+
|
|
3
|
+
`active.ts` filters a Project's bounded phone projection using recorded
|
|
4
|
+
supersession links. The Engine journal remains the durable source and retains
|
|
5
|
+
the complete audit history. A correction only hides an earlier record when both
|
|
6
|
+
records have the same Project, visibility, category, and repository scope.
|
|
7
|
+
|
|
8
|
+
`write.ts` accepts an explicit human decision for an existing Task on a locally
|
|
9
|
+
bound Project, records it through Engine Memory, and returns a `recorded` receipt
|
|
10
|
+
only after the Engine confirms. A correction carries the old record ID; the
|
|
11
|
+
Engine validates its scope. The phone may replay the same ID after a lost reply.
|
|
12
|
+
This is not a migration of old chats into verified knowledge.
|
|
13
|
+
|
|
14
|
+
MIT License.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { MeshEvent, ProjectKnowledgeRecord } from "../../../../../packages/protocol/schema";
|
|
3
|
+
|
|
4
|
+
/** Supersession is monotone across stale Mesh snapshots; only a same-scope record can hide another. */
|
|
5
|
+
export function activeProjectKnowledge(
|
|
6
|
+
projectId: string, records: ProjectKnowledgeRecord[],
|
|
7
|
+
): ProjectKnowledgeRecord[] {
|
|
8
|
+
return activeScopedKnowledge(projectId, records);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Task-private records stay available only to their own Task. */
|
|
12
|
+
export function activeScopedKnowledge(
|
|
13
|
+
projectId: string, records: ProjectKnowledgeRecord[], taskId?: string,
|
|
14
|
+
): ProjectKnowledgeRecord[] {
|
|
15
|
+
const scoped = records.filter((item) => item.projectId === projectId
|
|
16
|
+
&& (item.visibility === "project" || item.visibility === "task" && item.taskId === taskId));
|
|
17
|
+
const byId = new Map(scoped.map((item) => [item.recordId, item]));
|
|
18
|
+
const superseded = new Set(scoped.flatMap((item) => {
|
|
19
|
+
const previous = item.supersedesRecordId && byId.get(item.supersedesRecordId);
|
|
20
|
+
return previous && previous.category === item.category
|
|
21
|
+
&& previous.visibility === item.visibility
|
|
22
|
+
&& previous.repositoryId === item.repositoryId
|
|
23
|
+
&& (item.visibility !== "task" || previous.taskId === item.taskId)
|
|
24
|
+
? [previous.recordId] : [];
|
|
25
|
+
}));
|
|
26
|
+
return scoped.filter((item) => !superseded.has(item.recordId));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Keep correction tombstones even when the old record is outside the phone's bounded page. */
|
|
30
|
+
export function supersededProjectKnowledgeIds(
|
|
31
|
+
projectId: string, records: ProjectKnowledgeRecord[],
|
|
32
|
+
): string[] {
|
|
33
|
+
return supersededScopedKnowledgeIds(projectId, records);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function supersededScopedKnowledgeIds(
|
|
37
|
+
projectId: string, records: ProjectKnowledgeRecord[], taskId?: string,
|
|
38
|
+
): string[] {
|
|
39
|
+
return [...new Set(records.filter((item) => item.projectId === projectId
|
|
40
|
+
&& (item.visibility === "project" || item.visibility === "task" && item.taskId === taskId))
|
|
41
|
+
.sort((left, right) => right.recordedAt - left.recordedAt)
|
|
42
|
+
.flatMap((item) => item.supersedesRecordId ? [item.supersedesRecordId] : [])
|
|
43
|
+
.slice(0, 128))].sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Keep unrelated event context, but remove decisions already recorded or corrected. */
|
|
47
|
+
export function unrecordedKnowledgeEvents(
|
|
48
|
+
events: MeshEvent[], records: ProjectKnowledgeRecord[], supersededIds: string[] = [],
|
|
49
|
+
): MeshEvent[] {
|
|
50
|
+
const covered = new Set([...records.map((item) => item.recordId), ...supersededIds]);
|
|
51
|
+
return events.flatMap((event) => {
|
|
52
|
+
const capsule = event.eventType === "HANDOFF_REQUEST" ? event.payload.capsule : undefined;
|
|
53
|
+
if (capsule) {
|
|
54
|
+
const importantDecisions = capsule.importantDecisions.filter((_, index) => {
|
|
55
|
+
const id = `capsule-${createHash("sha256")
|
|
56
|
+
.update(JSON.stringify([event.eventId, index])).digest("hex")}`;
|
|
57
|
+
return !covered.has(id);
|
|
58
|
+
});
|
|
59
|
+
return [{ ...event, payload: { ...event.payload,
|
|
60
|
+
capsule: { ...capsule, importantDecisions } } }];
|
|
61
|
+
}
|
|
62
|
+
return covered.has(event.eventId) ? [] : [event];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { KnowledgeWrite, KnowledgeWriteResult } from "../../../../../packages/protocol/schema";
|
|
2
|
+
import type { KnowledgeRecordInput } from "../../engine/protocol/engine-memory-protocol";
|
|
3
|
+
|
|
4
|
+
type Scope = {
|
|
5
|
+
tasks: Array<{ taskId: string }>;
|
|
6
|
+
bindings: Array<{ endpointId: string }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type Dependencies = {
|
|
10
|
+
now: () => number;
|
|
11
|
+
endpointId: string;
|
|
12
|
+
snapshot: (projectId: string) => Scope | undefined;
|
|
13
|
+
record: (input: KnowledgeRecordInput) => Promise<boolean>;
|
|
14
|
+
send: (result: KnowledgeWriteResult) => Promise<void>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A person's confirmed statement enters Memory only through a bound Project/Task. */
|
|
18
|
+
export async function handleKnowledgeWrite(
|
|
19
|
+
request: KnowledgeWrite, deps: Dependencies,
|
|
20
|
+
): Promise<boolean> {
|
|
21
|
+
const now = deps.now();
|
|
22
|
+
const scope = deps.snapshot(request.projectId);
|
|
23
|
+
let reason: string | undefined;
|
|
24
|
+
if (!scope || !scope.bindings.some((item) => item.endpointId === deps.endpointId)
|
|
25
|
+
|| !scope.tasks.some((item) => item.taskId === request.taskId)) {
|
|
26
|
+
reason = "Project, Task, or local computer binding is unavailable.";
|
|
27
|
+
} else if (Math.abs(now - request.createdAt) > 5 * 60_000) {
|
|
28
|
+
reason = "Decision request expired. Try again.";
|
|
29
|
+
} else {
|
|
30
|
+
const input: KnowledgeRecordInput = {
|
|
31
|
+
project_id: request.projectId, task_id: request.taskId,
|
|
32
|
+
record_id: request.recordId, category: "decision", content: request.content,
|
|
33
|
+
source: "user_decision", source_ref: `phone:${request.recordId}`,
|
|
34
|
+
visibility: "project", recorded_at: request.createdAt,
|
|
35
|
+
...(request.repositoryId ? { repository_id: request.repositoryId } : {}),
|
|
36
|
+
...(request.supersedesRecordId
|
|
37
|
+
? { supersedes_record_id: request.supersedesRecordId } : {}),
|
|
38
|
+
};
|
|
39
|
+
if (!await deps.record(input)) reason = "Memory did not confirm the decision.";
|
|
40
|
+
}
|
|
41
|
+
await deps.send({ type: "knowledge.write.result", projectId: request.projectId,
|
|
42
|
+
taskId: request.taskId, recordId: request.recordId,
|
|
43
|
+
status: reason ? "rejected" : "recorded", ...(reason ? { reason } : {}), createdAt: now });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
@@ -38,6 +38,7 @@ import { handoffReadiness } from "../tasks/readiness";
|
|
|
38
38
|
import { createHandoffFlow } from "./handoff";
|
|
39
39
|
import type { MeshRuntimeDependencies } from "./dependencies";
|
|
40
40
|
import { localMeshStore } from "../local-remote/local";
|
|
41
|
+
import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
|
|
41
42
|
import { createHandoffWorktree, repositoryHasCommit } from "./worktree";
|
|
42
43
|
import { fetchRevision, pushBranch } from "../local-remote/remote";
|
|
43
44
|
import { projectSessionCapabilityInventory, projectExecutionCapabilitySessions } from "./capabilities";
|
|
@@ -249,7 +250,10 @@ export function requestProjectCapability(input: ProjectCapabilityRequestSet): bo
|
|
|
249
250
|
export async function meshSnapshotsWithEngine(): Promise<MeshSnapshot[]> {
|
|
250
251
|
return Promise.all(meshSnapshots().map(async (snapshot) => {
|
|
251
252
|
await waitForProjectBindingSync(snapshot.projectId);
|
|
252
|
-
queueProjectKnowledgeSync(
|
|
253
|
+
queueProjectKnowledgeSync({
|
|
254
|
+
projectId: snapshot.projectId,
|
|
255
|
+
events: localMeshStore().historyEventsForProject(snapshot.projectId),
|
|
256
|
+
});
|
|
253
257
|
const [backbone, repositoryGraphs, knowledge] = await Promise.all([
|
|
254
258
|
projectBackbone(snapshot.projectId),
|
|
255
259
|
projectRepositoryGraphs(snapshot.projectId, snapshot.bindings ?? [], {
|
|
@@ -262,8 +266,15 @@ export async function meshSnapshotsWithEngine(): Promise<MeshSnapshot[]> {
|
|
|
262
266
|
if (sharedKnowledge) localMeshStore().cacheKnowledge(snapshot.projectId, sharedKnowledge);
|
|
263
267
|
const memoryRows = new Map((snapshot.knowledge ?? []).map((item) => [item.recordId, item]));
|
|
264
268
|
for (const item of sharedKnowledge ?? []) memoryRows.set(item.recordId, item);
|
|
269
|
+
const allMemory = [...memoryRows.values()];
|
|
270
|
+
const correctedIds = [...new Set([
|
|
271
|
+
...(snapshot.supersededKnowledgeRecordIds ?? []),
|
|
272
|
+
...supersededProjectKnowledgeIds(snapshot.projectId, allMemory),
|
|
273
|
+
])].sort().slice(-128);
|
|
265
274
|
const enriched = { ...snapshot, backbone, repositoryGraphs,
|
|
266
|
-
knowledge:
|
|
275
|
+
knowledge: activeProjectKnowledge(snapshot.projectId, allMemory)
|
|
276
|
+
.sort((a, b) => b.recordedAt - a.recordedAt).slice(0, 16),
|
|
277
|
+
...(correctedIds.length > 0 ? { supersededKnowledgeRecordIds: correctedIds } : {}) };
|
|
267
278
|
const cortex = await projectCortexIntegration(enriched);
|
|
268
279
|
return {
|
|
269
280
|
...enriched,
|
|
@@ -10,6 +10,7 @@ import type { ExecutionCapability } from "../catalog/capability";
|
|
|
10
10
|
import { liveExecutionScope } from "../catalog/capability";
|
|
11
11
|
import { otherSide, type OtherSideRow } from "../handoff/other-side";
|
|
12
12
|
import { scopedOverlapKind } from "../store/support";
|
|
13
|
+
import { unrecordedKnowledgeEvents } from "../knowledge/active";
|
|
13
14
|
|
|
14
15
|
const MAX_SCOPED_EVENTS = 32;
|
|
15
16
|
const MAX_NEIGHBOURS = 32;
|
|
@@ -44,6 +45,7 @@ export type ScopedMeshView = {
|
|
|
44
45
|
environment?: MeshSnapshot["environment"];
|
|
45
46
|
cortex?: NonNullable<MeshSnapshot["cortex"]>;
|
|
46
47
|
knowledge?: NonNullable<MeshSnapshot["knowledge"]>;
|
|
48
|
+
supersededKnowledgeRecordIds?: NonNullable<MeshSnapshot["supersededKnowledgeRecordIds"]>;
|
|
47
49
|
/** Other Tasks working right now on the far side of this Task's repository. */
|
|
48
50
|
otherSide: OtherSideRow[];
|
|
49
51
|
dependencies: MeshSnapshot["dependencies"];
|
|
@@ -121,9 +123,11 @@ export function scopedMeshView(
|
|
|
121
123
|
cortex: snapshot.cortex ?? [],
|
|
122
124
|
knowledge: snapshot.knowledge?.filter((item) => item.taskId === capability.taskId
|
|
123
125
|
|| item.visibility === "project"),
|
|
126
|
+
supersededKnowledgeRecordIds: snapshot.supersededKnowledgeRecordIds,
|
|
124
127
|
otherSide: otherSide(snapshot, capability.taskId),
|
|
125
128
|
dependencies: snapshot.dependencies,
|
|
126
|
-
events: scopedEvents(snapshot, capability),
|
|
129
|
+
events: unrecordedKnowledgeEvents(scopedEvents(snapshot, capability),
|
|
130
|
+
snapshot.knowledge ?? [], snapshot.supersededKnowledgeRecordIds),
|
|
127
131
|
allowedEventTypes: capability.allowedEventTypes,
|
|
128
132
|
};
|
|
129
133
|
}
|
|
@@ -31,6 +31,7 @@ import { loadExecutionPolicy } from "../runtime/execution-policy";
|
|
|
31
31
|
import { loadEnvironment, redactEnvironment } from "../context/env";
|
|
32
32
|
import { loadRestrictions } from "../restrictions";
|
|
33
33
|
import { MeshStoreBase } from "./base";
|
|
34
|
+
import { activeProjectKnowledge, supersededProjectKnowledgeIds } from "../knowledge/active";
|
|
34
35
|
import {
|
|
35
36
|
projectCapabilityRequests,
|
|
36
37
|
} from "../catalog/project/requests";
|
|
@@ -268,6 +269,12 @@ export class MeshStore extends MeshStoreBase {
|
|
|
268
269
|
return this.state.events.filter((event) => event.projectId === projectId).slice(-128);
|
|
269
270
|
}
|
|
270
271
|
|
|
272
|
+
/** Retained, structured evidence for a one-time Memory backfill. */
|
|
273
|
+
historyEventsForProject(projectId: string): MeshEventValue[] {
|
|
274
|
+
this.sync();
|
|
275
|
+
return this.state.events.filter((event) => event.projectId === projectId);
|
|
276
|
+
}
|
|
277
|
+
|
|
271
278
|
projectIds(): string[] {
|
|
272
279
|
this.sync();
|
|
273
280
|
return this.state.projects.map((project) => project.projectId);
|
|
@@ -331,6 +338,7 @@ export class MeshStore extends MeshStoreBase {
|
|
|
331
338
|
const execution = loadExecutionPolicy(projectId);
|
|
332
339
|
const restrictions = loadRestrictions(projectId);
|
|
333
340
|
const environment = redactEnvironment(loadEnvironment(projectId));
|
|
341
|
+
const correctedIds = supersededProjectKnowledgeIds(projectId, this.state.knowledge);
|
|
334
342
|
return MeshSnapshot.parse({
|
|
335
343
|
type: "mesh.snapshot",
|
|
336
344
|
sessionId: projectId,
|
|
@@ -360,8 +368,9 @@ export class MeshStore extends MeshStoreBase {
|
|
|
360
368
|
claims: this.activeClaims().filter((item) => item.projectId === projectId && taskIds.has(item.taskId)).slice(-128),
|
|
361
369
|
dependencies: this.state.dependencies.filter((item) => taskIds.has(item.taskId)).slice(-128),
|
|
362
370
|
events: this.eventsForProject(projectId).filter((event) => taskIds.has(event.taskId)),
|
|
363
|
-
knowledge: this.state.knowledge
|
|
371
|
+
knowledge: activeProjectKnowledge(projectId, this.state.knowledge)
|
|
364
372
|
.slice(-16).reverse(),
|
|
373
|
+
...(correctedIds.length > 0 ? { supersededKnowledgeRecordIds: correctedIds } : {}),
|
|
365
374
|
generatedAt: this.now(),
|
|
366
375
|
});
|
|
367
376
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RelayClient } from "../../../../../packages/core/relay-client";
|
|
2
|
+
import type { ControllerPairRequest, KnowledgeWrite } from "../../../../../packages/protocol/schema";
|
|
3
|
+
import { loadRuntimeConfig } from "../../config";
|
|
4
|
+
import { loadConfig, machineConfigPath } from "../../config";
|
|
5
|
+
import { localMeshStore } from "../../mesh/local-remote/local";
|
|
6
|
+
import { createOneTimePairing, parkedPairingHalves } from "../../pairing";
|
|
7
|
+
import { issueControllerOffer } from "../../pairing/controller-invites";
|
|
8
|
+
import { handleKnowledgeWrite } from "../../mesh/knowledge/write";
|
|
9
|
+
import { recordProjectKnowledge } from "../../engine/runtime/engine-memory";
|
|
10
|
+
import { computerId } from "../../mesh/identity/computer";
|
|
11
|
+
|
|
12
|
+
/** The requester's device key scopes the new controller code, not Project access. */
|
|
13
|
+
export async function handleControllerPairRequest(
|
|
14
|
+
client: RelayClient, request: ControllerPairRequest, peerPublicKey?: string,
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
if (!peerPublicKey || !parkedPairingHalves()) return false;
|
|
17
|
+
const machine = loadConfig(machineConfigPath());
|
|
18
|
+
if (machine.room !== client.room) return false;
|
|
19
|
+
return issueControllerOffer(client, peerPublicKey, request, async () => {
|
|
20
|
+
const pairing = await createOneTimePairing(machine.relayUrl, {
|
|
21
|
+
installHooks: false, addController: true,
|
|
22
|
+
});
|
|
23
|
+
return { room: pairing.machineCfg.room, peerPublicKey: pairing.phoneCfg.myPublicKey,
|
|
24
|
+
uri: pairing.qrPayload };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A phone gets a recorded receipt only after the local Engine commits Memory. */
|
|
29
|
+
export async function handleInboundKnowledgeWrite(
|
|
30
|
+
client: RelayClient, request: KnowledgeWrite, peerPublicKey: string | undefined,
|
|
31
|
+
publish: () => Promise<void>,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
if (!peerPublicKey) return false;
|
|
34
|
+
const endpointId = computerId();
|
|
35
|
+
let recorded = false;
|
|
36
|
+
const handled = await handleKnowledgeWrite(request, {
|
|
37
|
+
now: Date.now, endpointId,
|
|
38
|
+
snapshot: (projectId) => {
|
|
39
|
+
if (!loadRuntimeConfig().meshEnabled) return undefined;
|
|
40
|
+
const snapshot = localMeshStore().snapshot(projectId, endpointId);
|
|
41
|
+
return snapshot && { tasks: snapshot.tasks, bindings: snapshot.bindings ?? [] };
|
|
42
|
+
},
|
|
43
|
+
record: async (entry) => {
|
|
44
|
+
recorded = await recordProjectKnowledge(entry);
|
|
45
|
+
return recorded;
|
|
46
|
+
},
|
|
47
|
+
send: (result) => client.sendToPeer(result, peerPublicKey),
|
|
48
|
+
});
|
|
49
|
+
if (recorded) void publish().catch(() => {});
|
|
50
|
+
return handled;
|
|
51
|
+
}
|
|
@@ -34,6 +34,7 @@ import { handleUserMessage } from "./user-message";
|
|
|
34
34
|
import { publishSessionEvents } from "./catalog";
|
|
35
35
|
import { publishHistoryPage } from "../history/pages";
|
|
36
36
|
import { requestProjectGraphRefresh } from "../graph-refresh";
|
|
37
|
+
import { handleControllerPairRequest, handleInboundKnowledgeWrite } from "../controller-knowledge";
|
|
37
38
|
|
|
38
39
|
export async function handleMonitorMessage(
|
|
39
40
|
client: RelayClient,
|
|
@@ -42,6 +43,7 @@ export async function handleMonitorMessage(
|
|
|
42
43
|
leadership: { acquire: () => boolean };
|
|
43
44
|
subscriptions: Set<string>;
|
|
44
45
|
publish: (forceHistory?: boolean) => Promise<void>;
|
|
46
|
+
peerPublicKey?: string;
|
|
45
47
|
},
|
|
46
48
|
): Promise<boolean> {
|
|
47
49
|
const { leadership, subscriptions, publish } = input;
|
|
@@ -52,6 +54,12 @@ export async function handleMonitorMessage(
|
|
|
52
54
|
// Codex may start one MCP server per open task. Exactly one instance owns
|
|
53
55
|
// phone routing, so a single phone message can never create duplicate tasks.
|
|
54
56
|
if (!leadership.acquire()) return false;
|
|
57
|
+
if (payload.type === "controller.pair.request") {
|
|
58
|
+
return handleControllerPairRequest(client, payload, input.peerPublicKey);
|
|
59
|
+
}
|
|
60
|
+
if (payload.type === "knowledge.write") {
|
|
61
|
+
return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
|
|
62
|
+
}
|
|
55
63
|
if (payload.type === "user.message") {
|
|
56
64
|
return handleInboundUserMessage(client, payload, publish);
|
|
57
65
|
} else if (payload.type === "user.attachment") {
|
|
@@ -121,17 +129,23 @@ export async function handleMonitorMessage(
|
|
|
121
129
|
if (prepared) void publish().catch(() => {});
|
|
122
130
|
return prepared;
|
|
123
131
|
} else if (payload.type === "mesh.claim.release" && loadRuntimeConfig().meshEnabled) {
|
|
124
|
-
|
|
125
|
-
// applied, and answered either way, so a refusal is seen where it was asked.
|
|
126
|
-
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
127
|
-
await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
|
|
128
|
-
.catch(() => {});
|
|
129
|
-
if (outcome.released) void publish().catch(() => {});
|
|
130
|
-
return true;
|
|
132
|
+
return handleInboundClaimRelease(client, payload, publish);
|
|
131
133
|
}
|
|
132
134
|
return false;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
async function handleInboundClaimRelease(
|
|
138
|
+
client: RelayClient, payload: Extract<Payload, { type: "mesh.claim.release" }>,
|
|
139
|
+
publish: (forceHistory?: boolean) => Promise<void>,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
// The person's authority is recorded and answered even when release fails.
|
|
142
|
+
const outcome = releaseClaimByPerson(localMeshStore(), payload);
|
|
143
|
+
await sendProjectPayload(client, releaseResult(payload, outcome), "phone", { ttlMs: 15 * 60_000 })
|
|
144
|
+
.catch(() => {});
|
|
145
|
+
if (outcome.released) void publish().catch(() => {});
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
function handleInboundRefresh(
|
|
136
150
|
client: RelayClient, payload: Extract<Payload, { type: "sessions.refresh" }>,
|
|
137
151
|
publish: (forceHistory?: boolean) => Promise<void>,
|
|
@@ -273,7 +273,7 @@ export function startSessionMonitor(client: RelayClient): SessionMonitor {
|
|
|
273
273
|
if (payload.type === "hello" && payload.role === "phone") {
|
|
274
274
|
confirmPendingController(client.room, peerPublicKey);
|
|
275
275
|
}
|
|
276
|
-
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish });
|
|
276
|
+
return handleMonitorMessage(client, payload, { leadership, subscriptions, publish, peerPublicKey });
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
const stopCatalog = startPublishLoop({
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ControllerPairOffer, ControllerPairRequest } from "../../../../packages/protocol/schema";
|
|
4
|
+
import { configDir } from "../config/runtime/paths";
|
|
5
|
+
import { writePrivateFile } from "../config/access/write-private";
|
|
6
|
+
|
|
7
|
+
type Record = { requestId: string; requesterKey: string; offer: ControllerPairOffer };
|
|
8
|
+
type Transport = {
|
|
9
|
+
room: string;
|
|
10
|
+
addControllerPeer: (key: string) => void;
|
|
11
|
+
sendToPeer: (offer: ControllerPairOffer, key: string) => Promise<void>;
|
|
12
|
+
};
|
|
13
|
+
type Mint = () => Promise<{ room: string; peerPublicKey: string; uri: string }>;
|
|
14
|
+
|
|
15
|
+
const pending = new Map<string, Promise<boolean>>();
|
|
16
|
+
let issuance = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
function journalPath(): string { return join(configDir(), "controller-pair-offers.json"); }
|
|
19
|
+
|
|
20
|
+
function load(now: number): Record[] | null {
|
|
21
|
+
try {
|
|
22
|
+
const parsed: unknown = JSON.parse(readFileSync(journalPath(), "utf8"));
|
|
23
|
+
if (!Array.isArray(parsed) || parsed.length > 16) return null;
|
|
24
|
+
const valid = (value: unknown): value is Record => {
|
|
25
|
+
if (!value || typeof value !== "object") return false;
|
|
26
|
+
const record = value as Partial<Record>;
|
|
27
|
+
return typeof record.requestId === "string" && typeof record.requesterKey === "string"
|
|
28
|
+
&& typeof record.offer?.expiresAt === "number" && typeof record.offer.uri === "string"
|
|
29
|
+
&& record.offer.status === "ready";
|
|
30
|
+
};
|
|
31
|
+
if (!parsed.every(valid)) return null;
|
|
32
|
+
return parsed.filter((record: Record) => (record.offer.expiresAt ?? 0) > now);
|
|
33
|
+
} catch { return existsSync(journalPath()) ? null : []; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function persist(records: Record[]): void {
|
|
37
|
+
writePrivateFile(journalPath(), `${JSON.stringify(records)}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rejected(requestId: string, room: string, reason: string, now: number): ControllerPairOffer {
|
|
41
|
+
return { type: "controller.pair.offer", requestId, room, status: "rejected", reason,
|
|
42
|
+
createdAt: now };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function issue(
|
|
46
|
+
client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
|
|
47
|
+
now: number,
|
|
48
|
+
): Promise<boolean> {
|
|
49
|
+
const records = load(now);
|
|
50
|
+
let offer: ControllerPairOffer;
|
|
51
|
+
if (!records) {
|
|
52
|
+
offer = rejected(request.requestId, client.room, "Pairing journal needs repair on the computer.", now);
|
|
53
|
+
} else {
|
|
54
|
+
const prior = records.find((item) => item.requestId === request.requestId);
|
|
55
|
+
if (prior && prior.requesterKey !== requesterKey) {
|
|
56
|
+
offer = rejected(request.requestId, client.room, "This request belongs to another controller.", now);
|
|
57
|
+
} else if (prior) {
|
|
58
|
+
offer = prior.offer;
|
|
59
|
+
} else if (Math.abs(now - request.createdAt) > 5 * 60_000) {
|
|
60
|
+
offer = rejected(request.requestId, client.room, "Request expired. Try again.", now);
|
|
61
|
+
} else {
|
|
62
|
+
try {
|
|
63
|
+
const created = await mint();
|
|
64
|
+
if (created.room !== client.room) throw new Error("room changed");
|
|
65
|
+
client.addControllerPeer(created.peerPublicKey);
|
|
66
|
+
offer = { type: "controller.pair.offer", requestId: request.requestId,
|
|
67
|
+
room: client.room, status: "ready", uri: created.uri,
|
|
68
|
+
expiresAt: now + 15 * 60_000, createdAt: now };
|
|
69
|
+
persist([...records, { requestId: request.requestId, requesterKey, offer }].slice(-16));
|
|
70
|
+
} catch {
|
|
71
|
+
offer = rejected(request.requestId, client.room, "Could not create a controller code.", now);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
await client.sendToPeer(offer, requesterKey);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Durable, scoped offer: a relay retry cannot mint a second controller key. */
|
|
80
|
+
export function issueControllerOffer(
|
|
81
|
+
client: Transport, requesterKey: string, request: ControllerPairRequest, mint: Mint,
|
|
82
|
+
now = Date.now(),
|
|
83
|
+
): Promise<boolean> {
|
|
84
|
+
const id = `${client.room}:${request.requestId}`;
|
|
85
|
+
const existing = pending.get(id);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
const work = issuance.then(() => issue(client, requesterKey, request, mint, now));
|
|
88
|
+
issuance = work.then(() => undefined, () => undefined);
|
|
89
|
+
pending.set(id, work);
|
|
90
|
+
void work.finally(() => { pending.delete(id); }).catch(() => undefined);
|
|
91
|
+
return work;
|
|
92
|
+
}
|
|
@@ -38,7 +38,7 @@ details{border-top:1px solid #8884;padding-top:14px}summary{cursor:pointer;font-
|
|
|
38
38
|
<h3>Provider readiness</h3><ul id="providers"></ul>
|
|
39
39
|
<p>No GrantTap account or password is required. Pair using GrantTap on iPhone → Settings → Connections.
|
|
40
40
|
Provider sign-in is separate.</p>
|
|
41
|
-
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.
|
|
41
|
+
<p>Missing hooks or background sync? Run <code>npm install -g granttap-mcp@0.8.22</code> and <code>granttap setup</code> locally.
|
|
42
42
|
A saved pairing alone does not prove your phone or the background helper is online.</p>
|
|
43
43
|
<p><a href="https://granttap.com" target="_blank" rel="noopener noreferrer">Website</a> ·
|
|
44
44
|
<a href="https://granttap.com/privacy" target="_blank" rel="noopener noreferrer">Privacy</a> ·
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap",
|
|
3
3
|
"displayName": "GrantTap",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.15",
|
|
5
5
|
"description": "The agent connects this computer in chat. Authorize in plugin settings opens the device window. Scan the QR in GrantTap — that scan authorizes. Device list and Add another stay there and in the GrantTap app.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "GrantTap",
|
package/cursor-plugin/README.md
CHANGED
|
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
|
|
|
10
10
|
Install the supported CLI on this computer, then run:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
npm install -g granttap-mcp@0.8.
|
|
13
|
+
npm install -g granttap-mcp@0.8.22
|
|
14
14
|
granttap setup
|
|
15
15
|
granttap status
|
|
16
16
|
```
|
package/cursor-plugin/mcp.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"command": "node",
|
|
5
5
|
"args": [
|
|
6
6
|
"-e",
|
|
7
|
-
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.
|
|
7
|
+
"\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.22\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 20+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
|
|
8
8
|
]
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
|
|
|
7
7
|
const { existsSync } = require("node:fs");
|
|
8
8
|
const { delimiter, dirname, join } = require("node:path");
|
|
9
9
|
|
|
10
|
-
const PACKAGE = "granttap-mcp@0.8.
|
|
10
|
+
const PACKAGE = "granttap-mcp@0.8.22";
|
|
11
11
|
const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
|
|
12
12
|
const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
|
|
13
13
|
? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "granttap-mcp",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.22",
|
|
4
4
|
"description": "Personal live control center runtime for local coding agents on iPhone and Apple Watch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"setup": "node bin/granttap-mcp.mjs setup",
|
|
26
26
|
"status": "node bin/granttap-mcp.mjs status",
|
|
27
27
|
"test": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 node scripts/quality/run-tests.mjs",
|
|
28
|
-
"test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines
|
|
28
|
+
"test:coverage": "HOME=$(mktemp -d) GRANTTAP_SKIP_LAUNCHCTL=1 c8 --all --extension .ts --extension .js --exclude '**/tests/**' --exclude 'scripts/**' --exclude 'packages/core/relay-client-types.ts' --exclude 'apps/bridge/src/capabilities/types.ts' --exclude 'apps/bridge/src/reply/payload/types.ts' --exclude 'apps/bridge/src/mesh/runtime/dependencies.ts' --check-coverage --lines 90 --statements 90 --functions 90 --branches 85 node scripts/quality/run-tests.mjs",
|
|
29
29
|
"inventory": "node scripts/quality/source-inventory.mjs",
|
|
30
30
|
"structure": "node scripts/quality/structure.mjs",
|
|
31
31
|
"package:allowlist": "node scripts/quality/package-allowlist.mjs",
|
|
@@ -238,6 +238,27 @@ export class RelayClient {
|
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Add a newly issued controller without waiting for the monitor to restart. */
|
|
242
|
+
addControllerPeer(peerPublicKey: string): void {
|
|
243
|
+
if (this.cfg.role !== "machine" || !this.peerIsAllowed(peerPublicKey)) {
|
|
244
|
+
throw new Error("controller peer is not authorized");
|
|
245
|
+
}
|
|
246
|
+
const peers = this.cfg.extraPeerPublicKeys ?? [];
|
|
247
|
+
if (peerPublicKey !== this.cfg.peerPublicKey && !peers.includes(peerPublicKey)) {
|
|
248
|
+
if (peers.length >= 16) throw new Error("controller device limit reached");
|
|
249
|
+
this.cfg.extraPeerPublicKeys = [...peers, peerPublicKey];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Keep a controller invitation visible only to the requesting phone key. */
|
|
254
|
+
async sendToPeer(payload: Payload, peerPublicKey: string, options: SendOptions = {}): Promise<void> {
|
|
255
|
+
const ws = this.ws;
|
|
256
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) throw new Error("relay not connected");
|
|
257
|
+
if (!peerPublicKeys(this.cfg.peerPublicKey, this.cfg.extraPeerPublicKeys).includes(peerPublicKey)
|
|
258
|
+
|| !this.peerIsAllowed(peerPublicKey)) throw new Error("controller peer is not authorized");
|
|
259
|
+
ws.send(sealEnvelope({ ...this.cfg, peerPublicKey }, payload, this.otherRole(), options));
|
|
260
|
+
}
|
|
261
|
+
|
|
241
262
|
setSessionKey(sessionId: string, key: string): void {
|
|
242
263
|
this.sessionKeys.set(sessionId, key);
|
|
243
264
|
}
|
|
@@ -20,6 +20,12 @@ sealed with the same independent Project key as Mesh snapshots. Coverage keeps
|
|
|
20
20
|
`enforced`, `observed`, `unsupported`, and `unknown` distinct so clients never
|
|
21
21
|
claim a provider can block an action when it cannot.
|
|
22
22
|
|
|
23
|
+
`device/controller-pairing.ts` lets an authenticated controller ask its linked
|
|
24
|
+
computer for another one-time controller credential. The reply is sealed only
|
|
25
|
+
to the requester. It is device-network pairing, not Project membership.
|
|
26
|
+
`mesh/knowledge/write.ts` carries a Task-bound human decision or correction and a
|
|
27
|
+
separate Engine-confirmed result.
|
|
28
|
+
|
|
23
29
|
Tests are discoverable in `tests/core.test.ts` and the feature-level bridge and MCP suites.
|
|
24
30
|
|
|
25
31
|
License: this module is distributed under the MIT License in the repository-root `LICENSE` file.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const RequestId = z.string().uuid();
|
|
4
|
+
|
|
5
|
+
/** A trusted phone asks its paired computer to mint a separate controller key. */
|
|
6
|
+
export const ControllerPairRequest = z.object({
|
|
7
|
+
type: z.literal("controller.pair.request"),
|
|
8
|
+
requestId: RequestId,
|
|
9
|
+
createdAt: z.number().nonnegative(),
|
|
10
|
+
}).strict();
|
|
11
|
+
export type ControllerPairRequest = z.infer<typeof ControllerPairRequest>;
|
|
12
|
+
|
|
13
|
+
/** Sent only through the authenticated device box, never a Project channel. */
|
|
14
|
+
export const ControllerPairOffer = z.object({
|
|
15
|
+
type: z.literal("controller.pair.offer"),
|
|
16
|
+
requestId: RequestId,
|
|
17
|
+
room: z.string().regex(/^[a-f0-9]{16,64}$/),
|
|
18
|
+
status: z.enum(["ready", "rejected"]),
|
|
19
|
+
uri: z.string().max(1024).optional(),
|
|
20
|
+
expiresAt: z.number().nonnegative().optional(),
|
|
21
|
+
reason: z.string().max(200).optional(),
|
|
22
|
+
createdAt: z.number().nonnegative(),
|
|
23
|
+
}).strict().superRefine((offer, ctx) => {
|
|
24
|
+
if (offer.status === "ready" && (!offer.uri?.startsWith("granttap://pair-v2?")
|
|
25
|
+
|| !offer.expiresAt || offer.expiresAt <= offer.createdAt)) {
|
|
26
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "ready offer needs a valid URI and expiry" });
|
|
27
|
+
}
|
|
28
|
+
if (offer.status === "rejected" && offer.uri) {
|
|
29
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "rejected offer cannot carry a URI" });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
export type ControllerPairOffer = z.infer<typeof ControllerPairOffer>;
|
|
@@ -13,6 +13,7 @@ export const ProjectKnowledgeRecord = z.object({
|
|
|
13
13
|
visibility: z.enum(["task", "project"]),
|
|
14
14
|
repositoryId: z.string().trim().min(1).max(512).optional(),
|
|
15
15
|
commitSha: z.string().regex(/^[a-f0-9]{7,64}$/i).optional(),
|
|
16
|
+
supersedesRecordId: Identifier.optional(),
|
|
16
17
|
recordedAt: z.number().int().nonnegative(),
|
|
17
18
|
streamVersion: z.number().int().nonnegative(),
|
|
18
19
|
}).strict();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { Identifier } from "../endpoint";
|
|
3
|
+
|
|
4
|
+
/** A person's explicit statement, never inferred from a chat title. */
|
|
5
|
+
export const KnowledgeWrite = z.object({
|
|
6
|
+
type: z.literal("knowledge.write"),
|
|
7
|
+
projectId: Identifier,
|
|
8
|
+
taskId: Identifier,
|
|
9
|
+
recordId: Identifier,
|
|
10
|
+
content: z.string().trim().min(1).max(4_096),
|
|
11
|
+
repositoryId: z.string().trim().min(1).max(512).optional(),
|
|
12
|
+
supersedesRecordId: Identifier.optional(),
|
|
13
|
+
createdAt: z.number().int().nonnegative(),
|
|
14
|
+
}).strict().refine((request) => request.recordId !== request.supersedesRecordId,
|
|
15
|
+
"a decision cannot supersede itself");
|
|
16
|
+
export type KnowledgeWrite = z.infer<typeof KnowledgeWrite>;
|
|
17
|
+
|
|
18
|
+
export const KnowledgeWriteResult = z.object({
|
|
19
|
+
type: z.literal("knowledge.write.result"),
|
|
20
|
+
projectId: Identifier,
|
|
21
|
+
taskId: Identifier,
|
|
22
|
+
recordId: Identifier,
|
|
23
|
+
status: z.enum(["recorded", "rejected"]),
|
|
24
|
+
reason: z.string().max(200).optional(),
|
|
25
|
+
createdAt: z.number().int().nonnegative(),
|
|
26
|
+
}).strict();
|
|
27
|
+
export type KnowledgeWriteResult = z.infer<typeof KnowledgeWriteResult>;
|
|
@@ -239,6 +239,7 @@ export const MeshSnapshot = z.object({
|
|
|
239
239
|
repositoryGraphs: z.array(ProjectRepositoryGraph).max(64).optional(),
|
|
240
240
|
cortex: z.array(ProjectCortexIntegration).max(32).optional(),
|
|
241
241
|
knowledge: z.array(ProjectKnowledgeRecord).max(32).optional(),
|
|
242
|
+
supersededKnowledgeRecordIds: z.array(Identifier).max(128).optional(),
|
|
242
243
|
tasks: z.array(MeshTask).max(64),
|
|
243
244
|
executions: z.array(ExecutionSessionLink).max(128),
|
|
244
245
|
claims: z.array(ResourceClaim).max(128),
|
|
@@ -295,6 +296,11 @@ export const MeshSnapshot = z.object({
|
|
|
295
296
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["repositoryGraphs"], message: "invalid repository graph scope" });
|
|
296
297
|
}
|
|
297
298
|
const knowledgeIds = new Set(value.knowledge?.map((item) => item.recordId));
|
|
299
|
+
const supersededKnowledgeIds = new Set(value.supersededKnowledgeRecordIds);
|
|
300
|
+
if (supersededKnowledgeIds.size !== (value.supersededKnowledgeRecordIds?.length ?? 0)) {
|
|
301
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["supersededKnowledgeRecordIds"],
|
|
302
|
+
message: "duplicate superseded knowledge identity" });
|
|
303
|
+
}
|
|
298
304
|
if ((value.knowledge?.length ?? 0) !== knowledgeIds.size
|
|
299
305
|
|| value.knowledge?.some((item) => item.projectId !== value.projectId
|
|
300
306
|
|| item.visibility !== "project")) {
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from "./messages/mesh";
|
|
32
32
|
import { MachineLoad } from "./messages/machine";
|
|
33
33
|
import { PairingJoin } from "./messages/pairing-join";
|
|
34
|
+
import { ControllerPairOffer, ControllerPairRequest } from "./messages/device/controller-pairing";
|
|
35
|
+
import { KnowledgeWrite, KnowledgeWriteResult } from "./messages/mesh/knowledge/write";
|
|
34
36
|
import { MeshInvocationPage, MeshInvocationQuery } from "./messages/mesh/invocations";
|
|
35
37
|
import {
|
|
36
38
|
ProjectPolicyAck,
|
|
@@ -63,6 +65,8 @@ export * from "./messages/capabilities";
|
|
|
63
65
|
export * from "./messages/interaction";
|
|
64
66
|
export * from "./messages/machine";
|
|
65
67
|
export * from "./messages/pairing-join";
|
|
68
|
+
export * from "./messages/device/controller-pairing";
|
|
69
|
+
export * from "./messages/mesh/knowledge/write";
|
|
66
70
|
export * from "./messages/mesh";
|
|
67
71
|
export * from "./messages/mesh/invocations";
|
|
68
72
|
export * from "./messages/primitives";
|
|
@@ -147,6 +151,10 @@ export const Payload = z.union([
|
|
|
147
151
|
MachineHeartbeat,
|
|
148
152
|
MachineLoad,
|
|
149
153
|
PairingJoin,
|
|
154
|
+
ControllerPairRequest,
|
|
155
|
+
ControllerPairOffer,
|
|
156
|
+
KnowledgeWrite,
|
|
157
|
+
KnowledgeWriteResult,
|
|
150
158
|
]).superRefine((payload, ctx) => {
|
|
151
159
|
if (payload.type !== "user.message") return;
|
|
152
160
|
const encodedCharacters = payload.attachments?.reduce(
|