@rubytech/create-realagent 1.0.710 → 1.0.713

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.
Files changed (45) hide show
  1. package/dist/index.js +38 -3
  2. package/package.json +2 -2
  3. package/payload/platform/lib/graph-search/dist/index.d.ts +22 -1
  4. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  5. package/payload/platform/lib/graph-search/dist/index.js +69 -39
  6. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  7. package/payload/platform/lib/graph-search/src/__tests__/bm25-label-gate.test.ts +88 -0
  8. package/payload/platform/lib/graph-search/src/__tests__/expand-batch.test.ts +206 -0
  9. package/payload/platform/lib/graph-search/src/index.ts +100 -43
  10. package/payload/platform/plugins/docs/references/platform.md +3 -1
  11. package/payload/platform/plugins/linkedin-import/PLUGIN.md +1 -0
  12. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +26 -5
  13. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +53 -82
  14. package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/profile.md +42 -49
  15. package/payload/platform/plugins/memory/PLUGIN.md +1 -0
  16. package/payload/platform/plugins/memory/mcp/dist/index.js +48 -0
  17. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  18. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +33 -0
  19. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -0
  20. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +229 -0
  21. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -0
  22. package/payload/platform/scripts/check-sdk-oauth.mjs +178 -0
  23. package/payload/platform/scripts/redact-install-logs.sh +85 -0
  24. package/payload/platform/scripts/setup.sh +20 -3
  25. package/payload/platform/scripts/verify-skill-tool-surface.sh +255 -0
  26. package/payload/platform/templates/specialists/agents/database-operator.md +6 -2
  27. package/payload/server/chunk-U5JPRUYZ.js +12298 -0
  28. package/payload/server/maxy-edge.js +1 -1
  29. package/payload/server/public/assets/{Checkbox-CjbS9JcG.js → Checkbox-Dr9MqNdk.js} +1 -1
  30. package/payload/server/public/assets/{admin-Ce9DbUuu.js → admin-CZ1QdDIj.js} +1 -1
  31. package/payload/server/public/assets/{data-C-SxjLC9.js → data-KcxxS-x3.js} +1 -1
  32. package/payload/server/public/assets/{file-D4cbAAuo.js → file-KlvYstdJ.js} +1 -1
  33. package/payload/server/public/assets/{graph-BRD96pKD.js → graph-BjGlgDDX.js} +8 -8
  34. package/payload/server/public/assets/{house-CYsVygEQ.js → house-CyE0Xd3r.js} +1 -1
  35. package/payload/server/public/assets/{jsx-runtime-DPXE45W9.css → jsx-runtime-CPtXdEwZ.css} +1 -1
  36. package/payload/server/public/assets/{public-BTOF98iO.js → public-C1gnzTxk.js} +1 -1
  37. package/payload/server/public/assets/{share-2-B-sbkB36.js → share-2-Q9lo8ZrW.js} +1 -1
  38. package/payload/server/public/assets/{useVoiceRecorder-DLVFx3ms.js → useVoiceRecorder-BH8HP7l_.js} +1 -1
  39. package/payload/server/public/assets/{x-BNidzSAn.js → x-BwY4lg-U.js} +1 -1
  40. package/payload/server/public/data.html +6 -6
  41. package/payload/server/public/graph.html +7 -7
  42. package/payload/server/public/index.html +8 -8
  43. package/payload/server/public/public.html +5 -5
  44. package/payload/server/server.js +118 -75
  45. /package/payload/server/public/assets/{jsx-runtime-BUs3sHtV.js → jsx-runtime-BKpb2FvO.js} +0 -0
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { hybrid, clearIndexCache } from "../index.js";
3
+
4
+ /**
5
+ * Task 747 — graph expand is one Cypher round-trip per `hybrid()` call,
6
+ * not one per result. Pre-fix the lib looped at index.ts:421-462, issuing
7
+ * `MATCH (n)-[r]-(related)` once per merged node — at slider=2000 that was
8
+ * 2000 round-trips through the driver per search. Post-fix, a single
9
+ * `UNWIND $nodeIds AS nid MATCH (n)-[r]-(related) WHERE elementId(n) = nid …`
10
+ * with `WITH nid, collect({...})[0..20]` returns all expansions in one query.
11
+ *
12
+ * Invariants pinned here:
13
+ * 1. exactly one expand round-trip per hybrid() call (regardless of N)
14
+ * 2. per-result related cap of 20 preserved (slice notation in the lib)
15
+ * 3. each `related` entry carries the neighbour's `elementId` so the
16
+ * canvas can render edges (post-Task-747 lib shape change)
17
+ * 4. `expandHops: 0` short-circuits the expand round-trip entirely
18
+ */
19
+
20
+ interface ScriptedRun {
21
+ match: (query: string) => boolean;
22
+ records: Array<Record<string, unknown>>;
23
+ }
24
+
25
+ function record(fields: Record<string, unknown>) {
26
+ return { get: (k: string) => fields[k] };
27
+ }
28
+
29
+ function makeStubSession(scripted: ScriptedRun[]) {
30
+ const calls: Array<{ query: string; params: Record<string, unknown> }> = [];
31
+ const session = {
32
+ run(query: string, params: Record<string, unknown>) {
33
+ calls.push({ query, params });
34
+ const hit = scripted.find((s) => s.match(query));
35
+ if (!hit) return Promise.resolve({ records: [] });
36
+ return Promise.resolve({ records: hit.records.map(record) });
37
+ },
38
+ } as unknown as import("neo4j-driver").Session;
39
+ return { session, calls };
40
+ }
41
+
42
+ beforeEach(() => {
43
+ clearIndexCache();
44
+ });
45
+
46
+ describe("hybrid — batched expand (Task 747)", () => {
47
+ it("issues exactly one expand round-trip for N merged results", async () => {
48
+ const merged = [
49
+ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 },
50
+ { nodeId: "n2", nodeLabels: ["Person"], node: { properties: { name: "B" } }, score: 0.8 },
51
+ { nodeId: "n3", nodeLabels: ["Person"], node: { properties: { name: "C" } }, score: 0.7 },
52
+ ];
53
+ const { session, calls } = makeStubSession([
54
+ {
55
+ match: (q) => q.includes("SHOW INDEXES"),
56
+ records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
57
+ },
58
+ {
59
+ match: (q) => q.includes("db.index.vector.queryNodes"),
60
+ records: merged,
61
+ },
62
+ {
63
+ match: (q) => q.includes("UNWIND $nodeIds"),
64
+ records: [
65
+ {
66
+ nid: "n1",
67
+ items: [{ relType: "KNOWS", direction: "outgoing", relatedNodeId: "r1", relatedLabels: ["Person"], related: { properties: { name: "X" } } }],
68
+ },
69
+ {
70
+ nid: "n2",
71
+ items: [{ relType: "KNOWS", direction: "incoming", relatedNodeId: "r2", relatedLabels: ["Person"], related: { properties: { name: "Y" } } }],
72
+ },
73
+ ],
74
+ },
75
+ ]);
76
+ const embed = async () => [0.1, 0.2];
77
+ await hybrid(session, embed, {
78
+ query: "test",
79
+ accountId: "acc-1",
80
+ limit: 10,
81
+ labels: ["Person"],
82
+ });
83
+ const expandCalls = calls.filter((c) => c.params && "nodeIds" in (c.params as Record<string, unknown>));
84
+ expect(expandCalls).toHaveLength(1);
85
+ expect((expandCalls[0].params as { nodeIds: string[] }).nodeIds).toEqual(["n1", "n2", "n3"]);
86
+ });
87
+
88
+ it("preserves per-result related cap of 20 via slice notation", async () => {
89
+ const { session, calls } = makeStubSession([
90
+ {
91
+ match: (q) => q.includes("SHOW INDEXES"),
92
+ records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
93
+ },
94
+ {
95
+ match: (q) => q.includes("db.index.vector.queryNodes"),
96
+ records: [
97
+ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
98
+ ],
99
+ },
100
+ ]);
101
+ const embed = async () => [0.1];
102
+ await hybrid(session, embed, {
103
+ query: "test",
104
+ accountId: "acc-1",
105
+ limit: 10,
106
+ labels: ["Person"],
107
+ });
108
+ const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds"));
109
+ expect(expandCall).toBeDefined();
110
+ expect(expandCall!.query).toContain("[0..20]");
111
+ });
112
+
113
+ it("returns related entries carrying neighbour elementId for canvas edge rendering", async () => {
114
+ const { session } = makeStubSession([
115
+ {
116
+ match: (q) => q.includes("SHOW INDEXES"),
117
+ records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
118
+ },
119
+ {
120
+ match: (q) => q.includes("db.index.vector.queryNodes"),
121
+ records: [
122
+ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: { name: "A" } }, score: 0.9 },
123
+ ],
124
+ },
125
+ {
126
+ match: (q) => q.includes("UNWIND $nodeIds"),
127
+ records: [
128
+ {
129
+ nid: "n1",
130
+ items: [
131
+ {
132
+ relType: "KNOWS",
133
+ direction: "outgoing",
134
+ relatedNodeId: "r1",
135
+ relatedLabels: ["Person"],
136
+ related: { properties: { name: "B" } },
137
+ },
138
+ ],
139
+ },
140
+ ],
141
+ },
142
+ ]);
143
+ const embed = async () => [0.1];
144
+ const res = await hybrid(session, embed, {
145
+ query: "test",
146
+ accountId: "acc-1",
147
+ limit: 10,
148
+ labels: ["Person"],
149
+ });
150
+ expect(res.results[0]?.related[0]?.nodeId).toBe("r1");
151
+ });
152
+
153
+ it("skips the expand round-trip entirely when expandHops is 0", async () => {
154
+ const { session, calls } = makeStubSession([
155
+ {
156
+ match: (q) => q.includes("SHOW INDEXES"),
157
+ records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
158
+ },
159
+ {
160
+ match: (q) => q.includes("db.index.vector.queryNodes"),
161
+ records: [
162
+ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
163
+ ],
164
+ },
165
+ ]);
166
+ const embed = async () => [0.1];
167
+ await hybrid(session, embed, {
168
+ query: "test",
169
+ accountId: "acc-1",
170
+ limit: 10,
171
+ labels: ["Person"],
172
+ expandHops: 0,
173
+ });
174
+ const expandCalls = calls.filter((c) => c.query.includes("UNWIND $nodeIds"));
175
+ expect(expandCalls).toHaveLength(0);
176
+ });
177
+
178
+ it("preserves trashed/scope/agent gates on the related neighbour", async () => {
179
+ const { session, calls } = makeStubSession([
180
+ {
181
+ match: (q) => q.includes("SHOW INDEXES"),
182
+ records: [{ name: "vec_person", labelsOrTypes: ["Person"] }],
183
+ },
184
+ {
185
+ match: (q) => q.includes("db.index.vector.queryNodes"),
186
+ records: [
187
+ { nodeId: "n1", nodeLabels: ["Person"], node: { properties: {} }, score: 0.9 },
188
+ ],
189
+ },
190
+ ]);
191
+ const embed = async () => [0.1];
192
+ await hybrid(session, embed, {
193
+ query: "test",
194
+ accountId: "acc-1",
195
+ limit: 10,
196
+ labels: ["Person"],
197
+ allowedScopes: ["public"],
198
+ agentSlug: "support",
199
+ });
200
+ const expandCall = calls.find((c) => c.query.includes("UNWIND $nodeIds"));
201
+ expect(expandCall).toBeDefined();
202
+ expect(expandCall!.query).toContain("related"); // notTrashed predicate target
203
+ expect(expandCall!.query).toContain("$allowedScopes");
204
+ expect(expandCall!.query).toContain("$agentSlug");
205
+ });
206
+ });
@@ -46,6 +46,12 @@ export interface SearchHit {
46
46
 
47
47
  export interface SearchResult extends SearchHit {
48
48
  related: Array<{
49
+ /**
50
+ * Task 747 — neighbour `elementId`. Required by the /graph canvas to
51
+ * render edges in pipeline-collapse mode (search response IS the canvas
52
+ * data). The MCP memory-search tool ignores it; the field is additive.
53
+ */
54
+ nodeId: string;
49
55
  relationship: string;
50
56
  direction: string;
51
57
  labels: string[];
@@ -71,10 +77,17 @@ export interface Bm25OnlyParams {
71
77
  agentSlug?: string;
72
78
  keywords?: string[];
73
79
  keywordMatch?: "any" | "all";
80
+ /**
81
+ * Task 747 — gate BM25 hits to nodes carrying at least one of these labels.
82
+ * Mirrors hybrid()'s vector-half label filter so the Ollama-down fallback
83
+ * honours the same gate the operator applied via /graph chips. Empty array
84
+ * is treated as "no gate" (matches hybrid's `labels && labels.length > 0`
85
+ * guard); admin route enforces non-empty `labels` as a precondition.
86
+ */
87
+ labels?: string[];
74
88
  }
75
89
 
76
90
  export interface HybridParams extends Bm25OnlyParams {
77
- labels?: string[];
78
91
  expandHops?: number;
79
92
  keywordSubscriptions?: string[];
80
93
  /**
@@ -91,6 +104,14 @@ export interface HybridResponse {
91
104
  results: SearchResult[];
92
105
  /** Populated when degradeOnEmbedFailure fired. Caller logs it. */
93
106
  embedError?: string;
107
+ /**
108
+ * Task 747 — milliseconds spent on the batched expand round-trip alone
109
+ * (separate from embed + vector + BM25). The /graph admin route emits
110
+ * this as `expand-ms=N` so a regression on the post-Task-747 batching
111
+ * surfaces in server.log without needing a profiler. Zero when
112
+ * `expandHops === 0` or no merged results.
113
+ */
114
+ expandMs: number;
94
115
  }
95
116
 
96
117
  export type EmbedFn = (text: string) => Promise<number[]>;
@@ -170,13 +191,16 @@ export async function bm25Only(
170
191
  session: Session,
171
192
  params: Bm25OnlyParams,
172
193
  ): Promise<SearchHit[]> {
173
- const { query, accountId, limit, allowedScopes, agentSlug, keywords, keywordMatch } = params;
194
+ const { query, accountId, limit, allowedScopes, agentSlug, keywords, keywordMatch, labels } = params;
174
195
  const scopeClause = allowedScopes
175
196
  ? "AND (node.scope IS NULL OR node.scope IN $allowedScopes)"
176
197
  : "";
177
198
  const agentClause = agentSlug
178
199
  ? "AND node.agents IS NOT NULL AND $agentSlug IN node.agents"
179
200
  : "";
201
+ const labelClause = labels && labels.length > 0
202
+ ? "AND any(l IN labels(node) WHERE l IN $labels)"
203
+ : "";
180
204
  const keywordFilter = buildKeywordFilter(keywords, keywordMatch);
181
205
  const kwClause = keywordFilter?.clause ?? "";
182
206
  const escaped = escapeLucene(query);
@@ -188,6 +212,7 @@ export async function bm25Only(
188
212
  WHERE node.accountId = $accountId
189
213
  ${scopeClause}
190
214
  ${agentClause}
215
+ ${labelClause}
191
216
  AND ${notTrashed("node")}
192
217
  ${kwClause}
193
218
  RETURN node, score, labels(node) AS nodeLabels, elementId(node) AS nodeId
@@ -200,6 +225,7 @@ export async function bm25Only(
200
225
  limit: int(limit),
201
226
  ...(allowedScopes ? { allowedScopes } : {}),
202
227
  ...(agentSlug ? { agentSlug } : {}),
228
+ ...(labels && labels.length > 0 ? { labels } : {}),
203
229
  ...(keywordFilter?.params ?? {}),
204
230
  },
205
231
  );
@@ -270,7 +296,7 @@ export async function hybrid(
270
296
  const msg = err instanceof Error ? err.message : String(err);
271
297
  const bm25Hits = await bm25Only(session, params);
272
298
  const results: SearchResult[] = bm25Hits.map((h) => ({ ...h, related: [] }));
273
- return { mode: "bm25", results, embedError: msg };
299
+ return { mode: "bm25", results, embedError: msg, expandMs: 0 };
274
300
  }
275
301
 
276
302
  const labelToIndex = await discoverIndexes(session);
@@ -288,7 +314,7 @@ export async function hybrid(
288
314
  .map((l) => labelToIndex.get(l))
289
315
  .filter((idx): idx is string => idx !== undefined);
290
316
  if (indexesToQuery.length === 0) {
291
- return { mode: "hybrid", results: [] };
317
+ return { mode: "hybrid", results: [], expandMs: 0 };
292
318
  }
293
319
  } else {
294
320
  indexesToQuery = [...new Set(labelToIndex.values())];
@@ -418,50 +444,81 @@ export async function hybrid(
418
444
  .sort((a, b) => b.combinedScore - a.combinedScore)
419
445
  .slice(0, limit);
420
446
 
421
- // --- Graph expand ---
422
- const results: SearchResult[] = [];
423
- for (const node of merged) {
424
- const result: SearchResult = {
425
- nodeId: node.nodeId,
426
- labels: node.labels,
427
- properties: node.properties,
428
- score: node.combinedScore,
429
- related: [],
430
- };
431
- if (expandHops > 0) {
432
- const expandScopeClause = allowedScopes
433
- ? "AND (related.scope IS NULL OR related.scope IN $allowedScopes)"
434
- : "";
435
- const expandAgentClause = agentSlug
436
- ? "AND (related.agents IS NULL OR $agentSlug IN related.agents)"
437
- : "";
438
- const expandResult = await session.run(
439
- `MATCH (n)-[r]-(related)
440
- WHERE elementId(n) = $nodeId
441
- AND ${notTrashed("related")}
442
- ${expandScopeClause}
443
- ${expandAgentClause}
444
- RETURN type(r) AS relType,
445
- CASE WHEN startNode(r) = n THEN 'outgoing' ELSE 'incoming' END AS direction,
446
- labels(related) AS relatedLabels,
447
- related
448
- LIMIT 20`,
449
- { nodeId: node.nodeId, ...scopeParams, ...agentParams },
450
- );
451
- for (const rec of expandResult.records) {
452
- const related = rec.get("related") as { properties: Record<string, unknown> };
453
- result.related.push({
454
- relationship: rec.get("relType") as string,
455
- direction: rec.get("direction") as string,
456
- labels: rec.get("relatedLabels") as string[],
457
- properties: plainProperties(related.properties),
447
+ // --- Graph expand (Task 747 — single batched round-trip) ---
448
+ //
449
+ // Pre-Task-747: one Cypher per merged node, in a JS for-loop. At the admin
450
+ // route's slider=2000 this produced 2000 driver round-trips per search.
451
+ //
452
+ // Post-Task-747: one UNWIND-driven query for all merged nodeIds. The
453
+ // `WITH nid, collect({...})[0..20]` clause preserves the per-result cap of
454
+ // 20 neighbours that the per-node query enforced via `LIMIT 20`. Slice
455
+ // notation is order-preserving over the rows it consumes (no upstream
456
+ // ORDER BY changes that), so canvas-edge density per hit is unchanged.
457
+ //
458
+ // Each `related` entry now carries `relatedNodeId` (the neighbour's
459
+ // elementId) so the /graph canvas can render edges in pipeline-collapse
460
+ // mode (search response IS the canvas data when `q` is set).
461
+ const results: SearchResult[] = merged.map((node) => ({
462
+ nodeId: node.nodeId,
463
+ labels: node.labels,
464
+ properties: node.properties,
465
+ score: node.combinedScore,
466
+ related: [],
467
+ }));
468
+
469
+ let expandMs = 0;
470
+ if (expandHops > 0 && results.length > 0) {
471
+ const expandScopeClause = allowedScopes
472
+ ? "AND (related.scope IS NULL OR related.scope IN $allowedScopes)"
473
+ : "";
474
+ const expandAgentClause = agentSlug
475
+ ? "AND (related.agents IS NULL OR $agentSlug IN related.agents)"
476
+ : "";
477
+ const expandStart = Date.now();
478
+ const expandResult = await session.run(
479
+ `UNWIND $nodeIds AS nid
480
+ MATCH (n)-[r]-(related)
481
+ WHERE elementId(n) = nid
482
+ AND ${notTrashed("related")}
483
+ ${expandScopeClause}
484
+ ${expandAgentClause}
485
+ WITH nid, n, r, related
486
+ WITH nid, collect({
487
+ relType: type(r),
488
+ direction: CASE WHEN startNode(r) = n THEN 'outgoing' ELSE 'incoming' END,
489
+ relatedNodeId: elementId(related),
490
+ relatedLabels: labels(related),
491
+ related: related
492
+ })[0..20] AS items
493
+ RETURN nid, items`,
494
+ { nodeIds: results.map((r) => r.nodeId), ...scopeParams, ...agentParams },
495
+ );
496
+ expandMs = Date.now() - expandStart;
497
+ const byNodeId = new Map<string, SearchResult>(results.map((r) => [r.nodeId, r]));
498
+ for (const rec of expandResult.records) {
499
+ const nid = rec.get("nid") as string;
500
+ const target = byNodeId.get(nid);
501
+ if (!target) continue;
502
+ const items = rec.get("items") as Array<{
503
+ relType: string;
504
+ direction: string;
505
+ relatedNodeId: string;
506
+ relatedLabels: string[];
507
+ related: { properties: Record<string, unknown> };
508
+ }>;
509
+ for (const item of items) {
510
+ target.related.push({
511
+ nodeId: item.relatedNodeId,
512
+ relationship: item.relType,
513
+ direction: item.direction,
514
+ labels: item.relatedLabels,
515
+ properties: plainProperties(item.related.properties),
458
516
  });
459
517
  }
460
518
  }
461
- results.push(result);
462
519
  }
463
520
 
464
- return { mode: "hybrid", results };
521
+ return { mode: "hybrid", results, expandMs };
465
522
  }
466
523
 
467
524
  function mergeBm25Hit(
@@ -17,7 +17,7 @@ The Pi runs the web interface, the AI agent, and all the plugin servers. When yo
17
17
 
18
18
  Maxy runs two agents simultaneously:
19
19
 
20
- **Admin agent (you)** — full access to all tools and plugins. This is the agent you interact with at your local or remote URL. It can read and write contacts, send Telegram messages, manage your account, and perform any task you have plugins for. Protected by your PIN.
20
+ **Admin agent (you)** — full access to all tools and plugins. This is the agent you interact with at your local or remote URL. It can read and write contacts, send Telegram messages, manage your account, and perform any task you have plugins for. Protected by your PIN. Your admin agent runs through your own Claude Code OAuth session — it never bills the Anthropic API. Authentication and SDK details are documented in the developer doc `.docs/platform.md` admin-agent section.
21
21
 
22
22
  **Public agent (visitors)** — read-only access. Handles enquiries from people who reach your public URL. It can answer questions about your business and collect waitlist signups, but it cannot access your private data or take actions.
23
23
 
@@ -51,6 +51,8 @@ Maxy maintains a graph database (Neo4j) of everything you've told it. People, co
51
51
 
52
52
  The memory graph is stored on your Pi. It never leaves your network.
53
53
 
54
+ The graph view (at `/graph`) lets you explore the memory directly. Pick a category from the filter, then type to search inside it — typing makes the canvas narrower, not wider. Drag the slider to control how many matches you see (1 to 2000). If the search shows a yellow banner saying "Vector ranking unavailable," it means the local AI ranking model is offline; results are still returned using keyword match, but ordering is less semantic until the ranker recovers.
55
+
54
56
  ## The Web Interface
55
57
 
56
58
  The web app runs on your Pi on port 19200. A small always-on front door (`maxy-edge`) owns that port and the remote terminal transport — so when the Software Update command restarts the app server, the browser-side terminal keeps streaming bytes exactly like an SSH session would. The edge also hosts the update flow's own routes (the sudo prompt, the action launcher, the SSE progress stream, the installed-version poll), so the Software Update modal's log panel does not go blank during the app-server restart window — it keeps receiving lines, heartbeats, and the final exit event unbroken. Login cookies are HMAC-signed with a shared key on disk, so both processes recognise the same session without any coordination and you do not have to log in again after an update. Every request is also classified as LAN or external based on the network shape it arrived on — LAN browsers reach admin directly; the remote password screen only appears on the tunnel-exposed admin domain. It provides:
@@ -4,6 +4,7 @@ description: "Import a LinkedIn Basic Data Export into the Maxy Neo4j graph. Ski
4
4
  tools: []
5
5
  always: false
6
6
  embed: false
7
+ specialist: database-operator
7
8
  metadata: {"platform":{"optional":true,"pluginKey":"linkedin-import"}}
8
9
  ---
9
10
 
@@ -42,7 +42,7 @@ When the owner is an external Person (non-operator archive), the anchor is the c
42
42
 
43
43
  ## Invariants
44
44
 
45
- 1. **Schema first.** The LinkedIn additions (`person_linkedin_url` index, `:Credential` constraint) live in [`platform/neo4j/schema.cypher`](../../../../neo4j/schema.cypher) and are applied by `platform/scripts/seed-neo4j.sh` on every install / upgrade. If running against a Neo4j that hasn't been reseeded since shipping, pipe `schema.cypher` into `cypher-shell` once before startingevery statement is `IF NOT EXISTS`.
45
+ 1. **Schema first.** The LinkedIn additions (`person_linkedin_url` index, `:Credential` constraint) live in [`platform/neo4j/schema.cypher`](../../../../neo4j/schema.cypher) and are applied by `platform/scripts/seed-neo4j.sh` on every install / upgrade. The skill assumes the schema has been seeded; it does not bootstrap schema itself. If a constraint or index is missing, the operator re-runs `seed-neo4j.sh` from the installerschema-bootstrap is installer-side, never agent-side.
46
46
  2. **Owner confirmed first.** No reference runs until `$ownerUserId` (or `$ownerPersonId`) is persisted and echo-confirmed. The reference set is parameterised — no hard-coded owner.
47
47
  3. **Natural edges only.** Every edge written is one the CSV actually expresses. `Connections.csv` encodes "I am connected on LinkedIn to this person" — that becomes `CONNECTED_ON_LINKEDIN`. No synthetic attach-to-owner pattern bolted onto rows that don't describe a relationship to the owner.
48
48
  4. **Reuse Maxy labels.** Schema-extension is last resort. The LinkedIn set maps onto existing labels wherever semantics align:
@@ -60,10 +60,31 @@ When the owner is an external Person (non-operator archive), the anchor is the c
60
60
 
61
61
  ## Execution model
62
62
 
63
- 1. Confirm `schema.cypher` is applied (one-liner: `cypher-shell ... < platform/neo4j/schema.cypher`; safe to re-run).
64
- 2. Run the owner-confirmation flow, persist `$ownerUserId` / `$ownerPersonId`.
65
- 3. For each file the operator approves, load its reference, parse the CSV, batch rows (default 500 per tx), execute the reference's Cypher with `$rows` + owner parameter.
66
- 4. After each file emit `[linkedin-import] file=<name> rows=<n> created=<n> matched=<n> ms=<elapsed>`.
63
+ 1. Run the owner-confirmation flow, persist `$ownerUserId` / `$ownerPersonId`. The owner identity resolves to a single `ownerNodeId` (elementId of the AdminUser or external Person) used in every write call.
64
+ 2. For each file the operator approves, load its reference, parse the CSV into typed `rows[]` matching the reference's row schema.
65
+ 3. **Selective-ingest gate.** Before invoking any write tool, check the parsed row count against the reference's `selectiveIngestThreshold`. If the count exceeds the threshold, pause and ask the operator to filter the import along the natural axes named in the reference (for `Connections.csv`: Company, Position, Connected On). Apply the filter to `rows[]` before continuing. Compress on write, never after — a 5,000-row blanket import is a landfill, a 200-row filtered import is signal. See [§Selective-ingest](#selective-ingest-threshold-bulk-archives).
66
+ 4. Invoke the deterministic write tool the reference names. For all archive references this is `mcp__memory__memory-archive-write` with `{archiveType, ownerNodeId, rows}` the Cypher body is fixed server-side per `archiveType`, so the agent supplies parsed rows, never Cypher. The tool batches rows at 500 per transaction internally.
67
+ 5. After each file emit `[linkedin-import] file=<name> rows=<n> created=<n> matched=<n> ms=<elapsed>` using the counters returned by the write tool.
68
+
69
+ **Doctrine:** raw Cypher and `cypher-shell` invocations are forbidden in this skill and its references. Writes route through `mcp__memory__memory-archive-write` (bulk archives) or `mcp__memory__memory-write` / `mcp__memory__memory-update` (single-node enrichments like `profile.md`). If a CSV needs a write shape no current MCP tool supports, file a task to extend `memory-archive-write` with a new `archiveType` handler — never improvise via Bash. See [database-operator's LOUD-FAIL prerogative](../../../../templates/specialists/agents/database-operator.md#prerogatives).
70
+
71
+ ## Selective-ingest threshold (bulk archives)
72
+
73
+ A LinkedIn export typically contains 3,000–10,000 connections. Writing all of them in one shot defeats compression-on-write — most rows will never be queried, and the noise compounds with every subsequent ingest. The skill compresses by interrogating the operator before bulk writes.
74
+
75
+ **Threshold:** when a parsed reference's `rows[]` exceeds **100 rows**, pause and ask the operator to filter along the reference's natural axes before invoking the write tool.
76
+
77
+ For `Connections.csv` the natural filter axes are:
78
+
79
+ - **Company** — "only people at LargeCorp", "only Female Founders Fund alumni"
80
+ - **Position** — "only Partners", "only Engineering Managers"
81
+ - **Connected On** (date range) — "only my last two years", "since 2024-01-01"
82
+
83
+ The operator picks one axis or a combination. The agent applies the filter to `rows[]` and writes only the filtered subset.
84
+
85
+ **Re-importing is idempotent.** Coming back later with a wider filter (`"add anyone at LargeCorp"`, `"include 2022 too"`) hits the same `linkedinUrl` natural key — existing `:Person` nodes are matched and updated; only the new-only delta is created. The operator can grow the slice over time without dedup work.
86
+
87
+ **Why the threshold lives in the skill, not the server.** Different archive types have different "interesting" thresholds — 100 LinkedIn connections is a lot; 100 LinkedIn skills is small. The MCP tool accepts whatever rows are passed; the conversational gate is the skill's responsibility.
67
88
 
68
89
  ## File roster
69
90