granttap-mcp 0.8.21 → 0.8.23
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/bin/cli/connect.ts +2 -1
- package/apps/bridge/src/bin/cli/phone.ts +54 -0
- 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/bridge.js +7 -0
- package/apps/mcp/src/connection-center/widget.html +1 -1
- package/apps/mcp/src/mcp-tools/connect/widget.ts +1 -1
- package/bin/granttap-mcp.mjs +6 -2
- package/cursor-plugin/.cursor-plugin/plugin.json +2 -2
- package/cursor-plugin/README.md +7 -4
- package/cursor-plugin/commands/connect.md +4 -3
- package/cursor-plugin/mcp.json +1 -1
- package/cursor-plugin/skills/connect/SKILL.md +8 -7
- 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
|
|
@@ -28,7 +28,8 @@ async function connect(relayUrl: string): Promise<void> {
|
|
|
28
28
|
"Background helper Run granttap setup to verify",
|
|
29
29
|
"",
|
|
30
30
|
"Existing pairing reused. No QR or key rotation was needed.",
|
|
31
|
-
"
|
|
31
|
+
"To link another iPhone or iPad, open the GrantTap connection card in your coding app and choose Add another device.",
|
|
32
|
+
"To repair the same phone, choose Reconnect in that card. Do not reset the computer pairing for either action.",
|
|
32
33
|
"",
|
|
33
34
|
].join("\n"));
|
|
34
35
|
return;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import QRCode from "qrcode";
|
|
2
|
+
import { loadConfig } from "../../config";
|
|
3
|
+
import { reloadPairingHelper } from "../../install";
|
|
4
|
+
import { createOneTimePairing, DEFAULT_RELAY, PAIRING_CODE_TTL_MINUTES } from "../../pairing";
|
|
5
|
+
import { isMachineConfigured, readOnlyMachineConfigPath } from "../../../../mcp/src/status/pairing-status";
|
|
6
|
+
import { installHttpMcpService } from "../../../../mcp/src/http-service";
|
|
7
|
+
|
|
8
|
+
const mode = process.argv[2];
|
|
9
|
+
|
|
10
|
+
async function main(): Promise<void> {
|
|
11
|
+
if (mode !== "add" && mode !== "reconnect") {
|
|
12
|
+
process.stderr.write("Usage: granttap phone add|reconnect\n");
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!process.stdout.isTTY) {
|
|
17
|
+
process.stderr.write("Open an interactive terminal and run this command yourself; the private pairing QR is never written to agent logs.\n");
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const paired = isMachineConfigured();
|
|
22
|
+
if (!paired && mode === "reconnect") {
|
|
23
|
+
process.stderr.write("This computer has no saved pairing. Run granttap phone add instead.\n");
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const relay = paired ? loadConfig(readOnlyMachineConfigPath()).relayUrl : DEFAULT_RELAY;
|
|
28
|
+
const result = await createOneTimePairing(relay, {
|
|
29
|
+
addController: paired && mode === "add",
|
|
30
|
+
installHooks: false,
|
|
31
|
+
});
|
|
32
|
+
const helper = reloadPairingHelper({ firstPairing: !paired });
|
|
33
|
+
const mcp = installHttpMcpService({ forceReload: true });
|
|
34
|
+
const qr = await QRCode.toString(result.qrPayload, {
|
|
35
|
+
type: "terminal", small: true, errorCorrectionLevel: "L",
|
|
36
|
+
});
|
|
37
|
+
process.stdout.write([
|
|
38
|
+
"",
|
|
39
|
+
mode === "add" ? "Add a controller iPhone or iPad" : "Reconnect the same controller phone",
|
|
40
|
+
"Open GrantTap on the phone → Settings → Connections → Add a device (Scan QR).",
|
|
41
|
+
"",
|
|
42
|
+
qr,
|
|
43
|
+
"",
|
|
44
|
+
`The QR expires in ${PAIRING_CODE_TTL_MINUTES} minutes. Never paste it into chat.`,
|
|
45
|
+
...(helper?.status === "manual" || mcp.status === "manual"
|
|
46
|
+
? ["Background connection needs attention. Run granttap setup after scanning."] : []),
|
|
47
|
+
"",
|
|
48
|
+
].join("\n"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
void main().catch((error: unknown) => {
|
|
52
|
+
process.stderr.write(`GrantTap could not issue a phone QR: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
});
|
|
@@ -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
|
}
|