@tekmidian/pai 0.18.0 → 0.18.1

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 (59) hide show
  1. package/dist/auto-route-tE6ymgYb.mjs +86 -0
  2. package/dist/auto-route-tE6ymgYb.mjs.map +1 -0
  3. package/dist/checkpoint-block-CkvwYA5y.mjs +1062 -0
  4. package/dist/checkpoint-block-CkvwYA5y.mjs.map +1 -0
  5. package/dist/cli/index.mjs +6 -5
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/cli/program.mjs +6 -5
  8. package/dist/cli/program.mjs.map +1 -1
  9. package/dist/clusters-CRXU2T6Z.mjs +201 -0
  10. package/dist/clusters-CRXU2T6Z.mjs.map +1 -0
  11. package/dist/config-CcdkNSWa.mjs +204 -0
  12. package/dist/config-CcdkNSWa.mjs.map +1 -0
  13. package/dist/daemon/index.mjs +9 -9
  14. package/dist/daemon-DPHtKQB2.mjs +1576 -0
  15. package/dist/daemon-DPHtKQB2.mjs.map +1 -0
  16. package/dist/daemon-mcp/index.mjs +2 -2
  17. package/dist/detector-BGw8SNWe.mjs +74 -0
  18. package/dist/detector-BGw8SNWe.mjs.map +1 -0
  19. package/dist/factory-1UI19STL.mjs +72 -0
  20. package/dist/factory-1UI19STL.mjs.map +1 -0
  21. package/dist/indexer-backend-B93q9ICi.mjs +299 -0
  22. package/dist/indexer-backend-B93q9ICi.mjs.map +1 -0
  23. package/dist/ipc-client-aVKVERjJ.mjs +152 -0
  24. package/dist/ipc-client-aVKVERjJ.mjs.map +1 -0
  25. package/dist/kg-entity-r8duqhi9.mjs +176 -0
  26. package/dist/kg-entity-r8duqhi9.mjs.map +1 -0
  27. package/dist/latent-ideas-CHrgdkSW.mjs +191 -0
  28. package/dist/latent-ideas-CHrgdkSW.mjs.map +1 -0
  29. package/dist/main-resolver-BozXCqpl.mjs +1340 -0
  30. package/dist/main-resolver-BozXCqpl.mjs.map +1 -0
  31. package/dist/neighborhood-jPQBkbB-.mjs +135 -0
  32. package/dist/neighborhood-jPQBkbB-.mjs.map +1 -0
  33. package/dist/note-context-D2S2sR38.mjs +126 -0
  34. package/dist/note-context-D2S2sR38.mjs.map +1 -0
  35. package/dist/pick-BRdJ4gpT.mjs +12666 -0
  36. package/dist/pick-BRdJ4gpT.mjs.map +1 -0
  37. package/dist/postgres-D96XWfwI.mjs +891 -0
  38. package/dist/postgres-D96XWfwI.mjs.map +1 -0
  39. package/dist/query-feedback-CdBJ2icj.mjs +76 -0
  40. package/dist/query-feedback-CdBJ2icj.mjs.map +1 -0
  41. package/dist/runtime-paths-B0P1TvUr.mjs +50 -0
  42. package/dist/runtime-paths-B0P1TvUr.mjs.map +1 -0
  43. package/dist/sqlite-CR9aMImG.mjs +271 -0
  44. package/dist/sqlite-CR9aMImG.mjs.map +1 -0
  45. package/dist/state-DTvy-jRB.mjs +102 -0
  46. package/dist/state-DTvy-jRB.mjs.map +1 -0
  47. package/dist/themes-HzMKPTas.mjs +148 -0
  48. package/dist/themes-HzMKPTas.mjs.map +1 -0
  49. package/dist/tools-DuorP_SS.mjs +1939 -0
  50. package/dist/tools-DuorP_SS.mjs.map +1 -0
  51. package/dist/trace-Bf7OprC5.mjs +137 -0
  52. package/dist/trace-Bf7OprC5.mjs.map +1 -0
  53. package/dist/vault-indexer-qAfX09RY.mjs +536 -0
  54. package/dist/vault-indexer-qAfX09RY.mjs.map +1 -0
  55. package/dist/work-queue-worker-Bov9eWMC.mjs +1856 -0
  56. package/dist/work-queue-worker-Bov9eWMC.mjs.map +1 -0
  57. package/dist/zettelkasten-DcZk-XSm.mjs +1063 -0
  58. package/dist/zettelkasten-DcZk-XSm.mjs.map +1 -0
  59. package/package.json +1 -1
@@ -0,0 +1,176 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ //#region src/memory/kg.ts
4
+ function rowToTriple(row) {
5
+ return {
6
+ id: row.id,
7
+ subject: row.subject,
8
+ predicate: row.predicate,
9
+ object: row.object,
10
+ project_id: row.project_id,
11
+ source_session: row.source_session,
12
+ valid_from: new Date(row.valid_from),
13
+ valid_to: row.valid_to ? new Date(row.valid_to) : void 0,
14
+ confidence: row.confidence,
15
+ created_at: new Date(row.created_at)
16
+ };
17
+ }
18
+ /**
19
+ * Add a new triple to the knowledge graph.
20
+ * Returns the inserted triple.
21
+ */
22
+ async function kgAdd(pool, params) {
23
+ const confidence = params.confidence ?? "EXTRACTED";
24
+ return rowToTriple((await pool.query(`INSERT INTO kg_triples
25
+ (subject, predicate, object, project_id, source_session, confidence)
26
+ VALUES ($1, $2, $3, $4, $5, $6)
27
+ RETURNING *`, [
28
+ params.subject,
29
+ params.predicate,
30
+ params.object,
31
+ params.project_id ?? null,
32
+ params.source_session ?? null,
33
+ confidence
34
+ ])).rows[0]);
35
+ }
36
+ /**
37
+ * Query triples by subject, predicate, object, and/or project.
38
+ * Supports point-in-time queries via as_of.
39
+ * By default only returns currently-valid triples (valid_to IS NULL).
40
+ */
41
+ async function kgQuery(pool, params) {
42
+ const conditions = [];
43
+ const values = [];
44
+ let idx = 1;
45
+ if (params.subject !== void 0) {
46
+ conditions.push(`subject = $${idx++}`);
47
+ values.push(params.subject);
48
+ }
49
+ if (params.predicate !== void 0) {
50
+ conditions.push(`predicate = $${idx++}`);
51
+ values.push(params.predicate);
52
+ }
53
+ if (params.object !== void 0) {
54
+ conditions.push(`object = $${idx++}`);
55
+ values.push(params.object);
56
+ }
57
+ if (params.project_id !== void 0) {
58
+ conditions.push(`project_id = $${idx++}`);
59
+ values.push(params.project_id);
60
+ }
61
+ if (params.as_of !== void 0) {
62
+ conditions.push(`valid_from <= $${idx++}`);
63
+ values.push(params.as_of);
64
+ conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);
65
+ values.push(params.as_of);
66
+ } else if (!params.include_invalidated) conditions.push(`valid_to IS NULL`);
67
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
68
+ return (await pool.query(`SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`, values)).rows.map(rowToTriple);
69
+ }
70
+ /**
71
+ * Invalidate a triple by setting valid_to = NOW().
72
+ * Does not delete the row — preserves history.
73
+ */
74
+ async function kgInvalidate(pool, tripleId) {
75
+ await pool.query(`UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`, [tripleId]);
76
+ }
77
+ /**
78
+ * Find contradictions: cases where the same (subject, predicate) pair has
79
+ * multiple currently-valid objects.
80
+ */
81
+ async function kgContradictions(pool, subject) {
82
+ return (await pool.query(`SELECT subject, predicate, array_agg(object ORDER BY object) AS objects
83
+ FROM kg_triples
84
+ WHERE subject = $1
85
+ AND valid_to IS NULL
86
+ GROUP BY subject, predicate
87
+ HAVING COUNT(*) > 1`, [subject])).rows.map((row) => ({
88
+ subject: row.subject,
89
+ predicate: row.predicate,
90
+ objects: row.objects
91
+ }));
92
+ }
93
+
94
+ //#endregion
95
+ //#region src/memory/kg-entity.ts
96
+ /**
97
+ * kg-entity.ts — Entity content-addressing with multi-tenant support.
98
+ *
99
+ * Provides UUID5-style deterministic content hashes for KG entities and edges,
100
+ * ensuring that the same entity name always maps to the same ID within a tenant.
101
+ * This enables idempotent upserts and stable foreign keys for kg_triples.
102
+ *
103
+ * Multi-tenant support: each tenant namespace gets its own entity ID space.
104
+ * The default tenant is "default" for single-user deployments.
105
+ */
106
+ /**
107
+ * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.
108
+ *
109
+ * The ID is a hex digest derived from "tenant_id:name" so the same entity
110
+ * always receives the same ID within a tenant namespace.
111
+ *
112
+ * @param name Entity name (case-preserved)
113
+ * @param tenantId Tenant namespace (default: "default")
114
+ */
115
+ function entityContentId(name, tenantId = "default") {
116
+ return createHash("sha256").update(`entity:${tenantId}:${name}`).digest("hex").slice(0, 32);
117
+ }
118
+ /**
119
+ * Upsert a KG entity in the federation SQLite database.
120
+ *
121
+ * If the entity already exists for this tenant:
122
+ * - Updates last_seen to now
123
+ * - Increments mention_count
124
+ * - Updates description if provided (overwrites older description)
125
+ *
126
+ * Returns the entity_id for use as a foreign key in kg_triples.
127
+ */
128
+ function upsertKgEntity(db, params) {
129
+ const tenantId = params.tenantId ?? "default";
130
+ const entityId = entityContentId(params.name, tenantId);
131
+ const now = Date.now();
132
+ db.prepare(`
133
+ INSERT INTO kg_entities
134
+ (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)
135
+ VALUES
136
+ (?, ?, ?, ?, ?, ?, ?, 1, 0.5)
137
+ ON CONFLICT(entity_id) DO UPDATE SET
138
+ last_seen = excluded.last_seen,
139
+ mention_count = mention_count + 1,
140
+ description = COALESCE(excluded.description, description),
141
+ type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END
142
+ `).run(entityId, tenantId, params.name, params.type ?? "unknown", params.description ?? null, now, now);
143
+ return entityId;
144
+ }
145
+ /**
146
+ * List KG entities for a tenant, optionally filtered by type.
147
+ *
148
+ * @param db Federation SQLite database
149
+ * @param tenantId Tenant namespace (default: "default")
150
+ * @param type Optional entity type filter
151
+ * @param limit Maximum entities to return (default: 100)
152
+ */
153
+ function listKgEntities(db, tenantId = "default", type, limit = 100) {
154
+ if (type) return db.prepare("SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?").all(tenantId, type, limit);
155
+ return db.prepare("SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?").all(tenantId, limit);
156
+ }
157
+ /**
158
+ * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.
159
+ *
160
+ * EMA formula: new_weight = old_weight + alpha * (target - old_weight)
161
+ *
162
+ * @param db Federation SQLite database
163
+ * @param entityId Entity ID to update
164
+ * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)
165
+ * @param alpha EMA learning rate (default: 0.1)
166
+ */
167
+ function updateEntityFeedbackWeight(db, entityId, normalizedRating, alpha = .1) {
168
+ const row = db.prepare("SELECT feedback_weight FROM kg_entities WHERE entity_id = ?").get(entityId);
169
+ if (!row) return;
170
+ const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);
171
+ db.prepare("UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?").run(newWeight, entityId);
172
+ }
173
+
174
+ //#endregion
175
+ export { kgContradictions as a, kgAdd as i, updateEntityFeedbackWeight as n, kgInvalidate as o, upsertKgEntity as r, kgQuery as s, listKgEntities as t };
176
+ //# sourceMappingURL=kg-entity-r8duqhi9.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kg-entity-r8duqhi9.mjs","names":[],"sources":["../src/memory/kg.ts","../src/memory/kg-entity.ts"],"sourcesContent":["/**\n * Temporal Knowledge Graph — kg_triples CRUD layer.\n *\n * Uses the Postgres connection pool from the storage backend.\n * Triples are time-scoped: valid_from/valid_to enable point-in-time queries.\n * Invalidation sets valid_to = NOW() instead of deleting rows.\n */\n\nimport type { Pool } from \"pg\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgTriple {\n id: number;\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n valid_from: Date;\n valid_to?: Date;\n confidence: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n created_at: Date;\n}\n\nexport interface KgAddParams {\n subject: string;\n predicate: string;\n object: string;\n project_id?: number;\n source_session?: string;\n confidence?: \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\";\n}\n\nexport interface KgQueryParams {\n subject?: string;\n predicate?: string;\n object?: string;\n project_id?: number;\n as_of?: Date;\n include_invalidated?: boolean;\n}\n\nexport interface KgContradiction {\n subject: string;\n predicate: string;\n objects: string[];\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nfunction rowToTriple(row: Record<string, unknown>): KgTriple {\n return {\n id: row.id as number,\n subject: row.subject as string,\n predicate: row.predicate as string,\n object: row.object as string,\n project_id: row.project_id as number | undefined,\n source_session: row.source_session as string | undefined,\n valid_from: new Date(row.valid_from as string),\n valid_to: row.valid_to ? new Date(row.valid_to as string) : undefined,\n confidence: row.confidence as \"EXTRACTED\" | \"INFERRED\" | \"AMBIGUOUS\",\n created_at: new Date(row.created_at as string),\n };\n}\n\n// ---------------------------------------------------------------------------\n// Core operations\n// ---------------------------------------------------------------------------\n\n/**\n * Add a new triple to the knowledge graph.\n * Returns the inserted triple.\n */\nexport async function kgAdd(pool: Pool, params: KgAddParams): Promise<KgTriple> {\n const confidence = params.confidence ?? \"EXTRACTED\";\n const result = await pool.query<Record<string, unknown>>(\n `INSERT INTO kg_triples\n (subject, predicate, object, project_id, source_session, confidence)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING *`,\n [\n params.subject,\n params.predicate,\n params.object,\n params.project_id ?? null,\n params.source_session ?? null,\n confidence,\n ]\n );\n return rowToTriple(result.rows[0]);\n}\n\n/**\n * Query triples by subject, predicate, object, and/or project.\n * Supports point-in-time queries via as_of.\n * By default only returns currently-valid triples (valid_to IS NULL).\n */\nexport async function kgQuery(pool: Pool, params: KgQueryParams): Promise<KgTriple[]> {\n const conditions: string[] = [];\n const values: unknown[] = [];\n let idx = 1;\n\n if (params.subject !== undefined) {\n conditions.push(`subject = $${idx++}`);\n values.push(params.subject);\n }\n if (params.predicate !== undefined) {\n conditions.push(`predicate = $${idx++}`);\n values.push(params.predicate);\n }\n if (params.object !== undefined) {\n conditions.push(`object = $${idx++}`);\n values.push(params.object);\n }\n if (params.project_id !== undefined) {\n conditions.push(`project_id = $${idx++}`);\n values.push(params.project_id);\n }\n\n if (params.as_of !== undefined) {\n // Valid at the given timestamp: started before or at as_of, and not yet ended\n conditions.push(`valid_from <= $${idx++}`);\n values.push(params.as_of);\n conditions.push(`(valid_to IS NULL OR valid_to > $${idx++})`);\n values.push(params.as_of);\n } else if (!params.include_invalidated) {\n // Default: only currently-valid (no valid_to set)\n conditions.push(`valid_to IS NULL`);\n }\n\n const where = conditions.length > 0 ? `WHERE ${conditions.join(\" AND \")}` : \"\";\n const result = await pool.query<Record<string, unknown>>(\n `SELECT * FROM kg_triples ${where} ORDER BY valid_from DESC`,\n values\n );\n return result.rows.map(rowToTriple);\n}\n\n/**\n * Invalidate a triple by setting valid_to = NOW().\n * Does not delete the row — preserves history.\n */\nexport async function kgInvalidate(pool: Pool, tripleId: number): Promise<void> {\n await pool.query(\n `UPDATE kg_triples SET valid_to = NOW() WHERE id = $1 AND valid_to IS NULL`,\n [tripleId]\n );\n}\n\n/**\n * Find contradictions: cases where the same (subject, predicate) pair has\n * multiple currently-valid objects.\n */\nexport async function kgContradictions(\n pool: Pool,\n subject: string\n): Promise<KgContradiction[]> {\n const result = await pool.query<{ subject: string; predicate: string; objects: string[] }>(\n `SELECT subject, predicate, array_agg(object ORDER BY object) AS objects\n FROM kg_triples\n WHERE subject = $1\n AND valid_to IS NULL\n GROUP BY subject, predicate\n HAVING COUNT(*) > 1`,\n [subject]\n );\n return result.rows.map((row) => ({\n subject: row.subject,\n predicate: row.predicate,\n objects: row.objects,\n }));\n}\n","/**\n * kg-entity.ts — Entity content-addressing with multi-tenant support.\n *\n * Provides UUID5-style deterministic content hashes for KG entities and edges,\n * ensuring that the same entity name always maps to the same ID within a tenant.\n * This enables idempotent upserts and stable foreign keys for kg_triples.\n *\n * Multi-tenant support: each tenant namespace gets its own entity ID space.\n * The default tenant is \"default\" for single-user deployments.\n */\n\nimport { createHash } from \"node:crypto\";\nimport type { Database } from \"better-sqlite3\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface KgEntity {\n entity_id: string;\n tenant_id: string;\n name: string;\n type: string;\n description?: string;\n first_seen?: number;\n last_seen?: number;\n mention_count: number;\n feedback_weight: number;\n}\n\nexport interface KgEntityUpsertParams {\n name: string;\n type?: string;\n description?: string;\n tenantId?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Content addressing\n// ---------------------------------------------------------------------------\n\n/**\n * Generate a deterministic entity ID (UUID5-style) for a given name and tenant.\n *\n * The ID is a hex digest derived from \"tenant_id:name\" so the same entity\n * always receives the same ID within a tenant namespace.\n *\n * @param name Entity name (case-preserved)\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function entityContentId(name: string, tenantId = \"default\"): string {\n return createHash(\"sha256\")\n .update(`entity:${tenantId}:${name}`)\n .digest(\"hex\")\n .slice(0, 32); // 128-bit hex string — UUID5-compatible length\n}\n\n/**\n * Generate a deterministic edge ID for a (source, relation, target) triple\n * within a tenant namespace.\n *\n * @param source Source entity name\n * @param relation Relation/predicate verb phrase\n * @param target Target entity name\n * @param tenantId Tenant namespace (default: \"default\")\n */\nexport function edgeContentId(\n source: string,\n relation: string,\n target: string,\n tenantId = \"default\"\n): string {\n return createHash(\"sha256\")\n .update(`edge:${tenantId}:${source}:${relation}:${target}`)\n .digest(\"hex\")\n .slice(0, 32);\n}\n\n// ---------------------------------------------------------------------------\n// SQLite entity upsert (for federation.db)\n// ---------------------------------------------------------------------------\n\n/**\n * Upsert a KG entity in the federation SQLite database.\n *\n * If the entity already exists for this tenant:\n * - Updates last_seen to now\n * - Increments mention_count\n * - Updates description if provided (overwrites older description)\n *\n * Returns the entity_id for use as a foreign key in kg_triples.\n */\nexport function upsertKgEntity(\n db: Database,\n params: KgEntityUpsertParams\n): string {\n const tenantId = params.tenantId ?? \"default\";\n const entityId = entityContentId(params.name, tenantId);\n const now = Date.now();\n\n db.prepare(`\n INSERT INTO kg_entities\n (entity_id, tenant_id, name, type, description, first_seen, last_seen, mention_count, feedback_weight)\n VALUES\n (?, ?, ?, ?, ?, ?, ?, 1, 0.5)\n ON CONFLICT(entity_id) DO UPDATE SET\n last_seen = excluded.last_seen,\n mention_count = mention_count + 1,\n description = COALESCE(excluded.description, description),\n type = CASE WHEN excluded.type != 'unknown' THEN excluded.type ELSE type END\n `).run(\n entityId,\n tenantId,\n params.name,\n params.type ?? \"unknown\",\n params.description ?? null,\n now,\n now\n );\n\n return entityId;\n}\n\n/**\n * Look up a KG entity by name within a tenant.\n * Returns null if the entity does not exist.\n */\nexport function findKgEntity(\n db: Database,\n name: string,\n tenantId = \"default\"\n): KgEntity | null {\n const entityId = entityContentId(name, tenantId);\n const row = db.prepare(\n \"SELECT * FROM kg_entities WHERE entity_id = ? AND tenant_id = ?\"\n ).get(entityId, tenantId) as KgEntity | undefined;\n return row ?? null;\n}\n\n/**\n * List KG entities for a tenant, optionally filtered by type.\n *\n * @param db Federation SQLite database\n * @param tenantId Tenant namespace (default: \"default\")\n * @param type Optional entity type filter\n * @param limit Maximum entities to return (default: 100)\n */\nexport function listKgEntities(\n db: Database,\n tenantId = \"default\",\n type?: string,\n limit = 100\n): KgEntity[] {\n if (type) {\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? AND type = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, type, limit) as KgEntity[];\n }\n return db.prepare(\n \"SELECT * FROM kg_entities WHERE tenant_id = ? ORDER BY mention_count DESC LIMIT ?\"\n ).all(tenantId, limit) as KgEntity[];\n}\n\n// ---------------------------------------------------------------------------\n// Feedback weight update (MR2 — EMA)\n// ---------------------------------------------------------------------------\n\n/**\n * Apply an EMA (Exponential Moving Average) feedback update to an entity's weight.\n *\n * EMA formula: new_weight = old_weight + alpha * (target - old_weight)\n *\n * @param db Federation SQLite database\n * @param entityId Entity ID to update\n * @param normalizedRating Rating normalized to [0, 1] (e.g., rating/5 for 1-5 scale)\n * @param alpha EMA learning rate (default: 0.1)\n */\nexport function updateEntityFeedbackWeight(\n db: Database,\n entityId: string,\n normalizedRating: number,\n alpha = 0.1\n): void {\n const row = db.prepare(\n \"SELECT feedback_weight FROM kg_entities WHERE entity_id = ?\"\n ).get(entityId) as { feedback_weight: number } | undefined;\n\n if (!row) return;\n\n const newWeight = row.feedback_weight + alpha * (normalizedRating - row.feedback_weight);\n db.prepare(\n \"UPDATE kg_entities SET feedback_weight = ? WHERE entity_id = ?\"\n ).run(newWeight, entityId);\n}\n"],"mappings":"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"}
@@ -0,0 +1,191 @@
1
+ import "./embeddings-Bn86ssxR.mjs";
2
+ import { n as TITLE_STOP_WORDS } from "./stop-words-BaMEGVeY.mjs";
3
+ import { t as zettelThemes } from "./themes-HzMKPTas.mjs";
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+
7
+ //#region src/graph/latent-ideas.ts
8
+ /**
9
+ * latent-ideas.ts — graph_latent_ideas and idea_materialize endpoint handlers
10
+ *
11
+ * "Latent ideas" are recurring themes in the vault that exist as embedding
12
+ * clusters but have NO dedicated note written about them yet. PAI surfaces
13
+ * these by running the same agglomerative clustering used by graph_clusters /
14
+ * zettelThemes and then filtering OUT any cluster whose label is well-matched
15
+ * by an existing note title.
16
+ *
17
+ * The materialize endpoint writes a new Markdown note to the vault filesystem
18
+ * and returns its content so the plugin can open it immediately.
19
+ */
20
+ /**
21
+ * Returns true when any existing vault note title closely matches the cluster
22
+ * label — meaning a dedicated note already exists for this topic.
23
+ *
24
+ * Matching strategy (simple, fast, no embeddings needed):
25
+ * 1. Lowercase both sides and split into words.
26
+ * 2. Remove stop words from the label words.
27
+ * 3. If ≥ 60% of the significant label words appear in a note title → match.
28
+ */
29
+ function labelMatchesTitle(label, title) {
30
+ const labelWords = label.toLowerCase().split(/[\s\-_/]+/).filter((w) => w.length > 2 && !TITLE_STOP_WORDS.has(w));
31
+ if (labelWords.length === 0) return false;
32
+ const titleLower = title.toLowerCase();
33
+ return labelWords.filter((w) => titleLower.includes(w)).length / labelWords.length >= .6;
34
+ }
35
+ /**
36
+ * Check whether any note indexed in the vault has a title matching the label.
37
+ * Fetches all vault file rows via StorageBackend for efficiency.
38
+ */
39
+ async function clusterHasMatchingNote(backend, label, notePaths) {
40
+ const pathSet = new Set(notePaths);
41
+ const rows = await backend.getAllVaultFiles();
42
+ for (const row of rows) {
43
+ if (!row.title) continue;
44
+ if (pathSet.has(row.vaultPath)) continue;
45
+ if (labelMatchesTitle(label, row.title)) return true;
46
+ }
47
+ return false;
48
+ }
49
+ function toSuggestedTitle(label) {
50
+ return label.trim().split(/\s+/).map((w, i) => {
51
+ const lower = w.toLowerCase();
52
+ if (i === 0 && TITLE_STOP_WORDS.has(lower) && label.trim().split(/\s+/).length > 1) return "";
53
+ return w.charAt(0).toUpperCase() + w.slice(1);
54
+ }).filter(Boolean).join(" ") || label;
55
+ }
56
+ function mostCommonFolder(vaultPaths) {
57
+ const counts = /* @__PURE__ */ new Map();
58
+ for (const p of vaultPaths) {
59
+ const parts = p.split("/");
60
+ const folder = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
61
+ counts.set(folder, (counts.get(folder) ?? 0) + 1);
62
+ }
63
+ let best = "";
64
+ let bestCount = 0;
65
+ for (const [folder, count] of counts) if (count > bestCount) {
66
+ bestCount = count;
67
+ best = folder;
68
+ }
69
+ return best;
70
+ }
71
+ /**
72
+ * Heuristic: vault notes are often stored in date-based folders like
73
+ * "2026/03/15" or "Daily/2026-03". We extract the first numeric path
74
+ * segment that looks like a year (2020-2030) and group by year+month.
75
+ *
76
+ * Falls back to counting distinct top-level folders.
77
+ */
78
+ function countDistinctSessions(vaultPaths) {
79
+ const sessions = /* @__PURE__ */ new Set();
80
+ const yearMonthRe = /\b(202\d)\D?(0[1-9]|1[0-2])\b/;
81
+ for (const p of vaultPaths) {
82
+ const m = yearMonthRe.exec(p);
83
+ if (m) sessions.add(`${m[1]}-${m[2]}`);
84
+ else {
85
+ const topFolder = p.split("/")[0];
86
+ sessions.add(topFolder);
87
+ }
88
+ }
89
+ return sessions.size;
90
+ }
91
+ /**
92
+ * Confidence combines:
93
+ * - Cluster size (normalized, capped at 20 for max contribution)
94
+ * - Folder diversity (0-1 already)
95
+ * - Sessions count (normalized, capped at 5)
96
+ *
97
+ * Formula: 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore
98
+ */
99
+ function calcConfidence(size, folderDiversity, sessionsCount) {
100
+ const sizeScore = Math.min(size / 20, 1);
101
+ const sessionScore = Math.min(sessionsCount / 5, 1);
102
+ const raw = .4 * sizeScore + .35 * folderDiversity + .25 * sessionScore;
103
+ return Math.round(raw * 100) / 100;
104
+ }
105
+ async function handleGraphLatentIdeas(backend, params) {
106
+ const minClusterSize = params.min_cluster_size ?? 3;
107
+ const maxIdeas = params.max_ideas ?? 15;
108
+ const lookbackDays = params.lookback_days ?? 180;
109
+ const similarityThreshold = params.similarity_threshold ?? .65;
110
+ const { project_id: vaultProjectId } = params;
111
+ if (!vaultProjectId) throw new Error("graph_latent_ideas: project_id is required (pass the vault project's numeric ID)");
112
+ const themeResult = await zettelThemes(backend, {
113
+ vaultProjectId,
114
+ lookbackDays,
115
+ minClusterSize,
116
+ maxThemes: maxIdeas * 3,
117
+ similarityThreshold
118
+ });
119
+ const ideas = [];
120
+ let materializedCount = 0;
121
+ for (const theme of themeResult.themes) {
122
+ const notePaths = theme.notes.map((n) => n.path);
123
+ if (await clusterHasMatchingNote(backend, theme.label, notePaths)) {
124
+ materializedCount++;
125
+ continue;
126
+ }
127
+ const suggestedFolder = mostCommonFolder(notePaths);
128
+ const sessionsCount = countDistinctSessions(notePaths);
129
+ const confidence = calcConfidence(theme.size, theme.folderDiversity, sessionsCount);
130
+ const sourceNotes = theme.notes.map((n, idx) => ({
131
+ vault_path: n.path,
132
+ title: n.title ?? n.path.split("/").pop()?.replace(/\.md$/i, "") ?? n.path,
133
+ relevance: Math.round((1 - idx / Math.max(theme.notes.length - 1, 1) * .5) * 100) / 100
134
+ }));
135
+ ideas.push({
136
+ id: theme.id,
137
+ label: theme.label,
138
+ size: theme.size,
139
+ confidence,
140
+ source_notes: sourceNotes,
141
+ suggested_title: toSuggestedTitle(theme.label),
142
+ suggested_folder: suggestedFolder,
143
+ sessions_count: sessionsCount
144
+ });
145
+ if (ideas.length >= maxIdeas) break;
146
+ }
147
+ ideas.sort((a, b) => b.confidence - a.confidence);
148
+ return {
149
+ ideas,
150
+ total_clusters_analyzed: themeResult.themes.length + materializedCount,
151
+ materialized_count: materializedCount
152
+ };
153
+ }
154
+ function handleIdeaMaterialize(params, vaultPath) {
155
+ const { idea_label, title, folder, source_paths } = params;
156
+ const fileName = `${title.replace(/[/\\:*?"<>|]/g, "-")}.md`;
157
+ const relFolder = folder.replace(/^\/+|\/+$/g, "");
158
+ const vault_path = relFolder ? `${relFolder}/${fileName}` : fileName;
159
+ const absPath = join(vaultPath, vault_path);
160
+ const absDir = dirname(absPath);
161
+ const wikilinks = source_paths.map((p) => {
162
+ return `- [[${p.split("/").pop()?.replace(/\.md$/i, "") ?? p}]]`;
163
+ }).join("\n");
164
+ const links_created = source_paths.length;
165
+ const content = [
166
+ `# ${title}`,
167
+ "",
168
+ `*Materialized from latent idea: "${idea_label}"*`,
169
+ `*Sources: ${links_created} notes*`,
170
+ "",
171
+ "## Related Notes",
172
+ "",
173
+ wikilinks || "*(no source notes)*",
174
+ "",
175
+ "## Notes",
176
+ "",
177
+ "<!-- Add your thoughts about this idea here -->",
178
+ ""
179
+ ].join("\n");
180
+ mkdirSync(absDir, { recursive: true });
181
+ writeFileSync(absPath, content, "utf-8");
182
+ return {
183
+ vault_path,
184
+ content,
185
+ links_created
186
+ };
187
+ }
188
+
189
+ //#endregion
190
+ export { handleGraphLatentIdeas, handleIdeaMaterialize };
191
+ //# sourceMappingURL=latent-ideas-CHrgdkSW.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"latent-ideas-CHrgdkSW.mjs","names":[],"sources":["../src/graph/latent-ideas.ts"],"sourcesContent":["/**\n * latent-ideas.ts — graph_latent_ideas and idea_materialize endpoint handlers\n *\n * \"Latent ideas\" are recurring themes in the vault that exist as embedding\n * clusters but have NO dedicated note written about them yet. PAI surfaces\n * these by running the same agglomerative clustering used by graph_clusters /\n * zettelThemes and then filtering OUT any cluster whose label is well-matched\n * by an existing note title.\n *\n * The materialize endpoint writes a new Markdown note to the vault filesystem\n * and returns its content so the plugin can open it immediately.\n */\n\nimport { mkdirSync, writeFileSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { TITLE_STOP_WORDS } from \"../utils/stop-words.js\";\nimport type { StorageBackend } from \"../storage/interface.js\";\nimport { zettelThemes } from \"../zettelkasten/themes.js\";\n\n// ---------------------------------------------------------------------------\n// Public param / result types\n// ---------------------------------------------------------------------------\n\nexport interface GraphLatentIdeasParams {\n project_id: number;\n /** Minimum notes in a cluster (default: 3) */\n min_cluster_size?: number;\n /** Cap on returned ideas (default: 15) */\n max_ideas?: number;\n /** How far back to look in days (default: 180) */\n lookback_days?: number;\n /** Cosine similarity clustering threshold (default: 0.65) */\n similarity_threshold?: number;\n}\n\nexport interface LatentIdeaSourceNote {\n vault_path: string;\n title: string;\n /** How strongly this note relates to the theme (0-1) */\n relevance: number;\n}\n\nexport interface LatentIdea {\n id: number;\n /** Auto-generated cluster label from zettelThemes */\n label: string;\n /** Number of notes touching this theme */\n size: number;\n /** 0-1, how likely this is a real coherent idea */\n confidence: number;\n /** Notes that contribute to this theme */\n source_notes: LatentIdeaSourceNote[];\n /** Cleaned-up version of label for a potential note title */\n suggested_title: string;\n /** Most common folder among source notes */\n suggested_folder: string;\n /** Number of distinct session date-folders (e.g. \"2026/03\") touching this theme */\n sessions_count: number;\n}\n\nexport interface GraphLatentIdeasResult {\n ideas: LatentIdea[];\n total_clusters_analyzed: number;\n /** How many clusters already have a matching note (excluded from results) */\n materialized_count: number;\n}\n\n// ---------------------------------------------------------------------------\n// Materialize params / result\n// ---------------------------------------------------------------------------\n\nexport interface IdeaMaterializeParams {\n idea_label: string;\n /** User-chosen title for the new note */\n title: string;\n /** Vault-relative folder path where the note should be created */\n folder: string;\n /** Vault-relative paths of the source notes to link from the new note */\n source_paths: string[];\n project_id: number;\n}\n\nexport interface IdeaMaterializeResult {\n /** Vault-relative path of the created note */\n vault_path: string;\n /** Generated markdown content */\n content: string;\n /** Number of wikilinks inserted */\n links_created: number;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: check if a cluster already has a matching note\n// ---------------------------------------------------------------------------\n\n/**\n * Returns true when any existing vault note title closely matches the cluster\n * label — meaning a dedicated note already exists for this topic.\n *\n * Matching strategy (simple, fast, no embeddings needed):\n * 1. Lowercase both sides and split into words.\n * 2. Remove stop words from the label words.\n * 3. If ≥ 60% of the significant label words appear in a note title → match.\n */\n// TITLE_STOP_WORDS imported from utils/stop-words.ts\n\nfunction labelMatchesTitle(label: string, title: string): boolean {\n const labelWords = label\n .toLowerCase()\n .split(/[\\s\\-_/]+/)\n .filter((w) => w.length > 2 && !TITLE_STOP_WORDS.has(w));\n\n if (labelWords.length === 0) return false;\n\n const titleLower = title.toLowerCase();\n const matchCount = labelWords.filter((w) => titleLower.includes(w)).length;\n return matchCount / labelWords.length >= 0.6;\n}\n\n/**\n * Check whether any note indexed in the vault has a title matching the label.\n * Fetches all vault file rows via StorageBackend for efficiency.\n */\nasync function clusterHasMatchingNote(\n backend: StorageBackend,\n label: string,\n notePaths: string[]\n): Promise<boolean> {\n // First check the notes already in the cluster themselves — if any cluster\n // member's title matches the label it IS the index note → materialized.\n const pathSet = new Set(notePaths);\n\n // Fetch all vault files (bounded — vault rarely > 50k notes)\n const rows = await backend.getAllVaultFiles();\n\n for (const row of rows) {\n if (!row.title) continue;\n // Skip notes already counted inside the cluster — they don't count as\n // \"dedicated notes\"; we only skip a cluster if a SEPARATE note exists.\n if (pathSet.has(row.vaultPath)) continue;\n if (labelMatchesTitle(label, row.title)) return true;\n }\n return false;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: generate a clean suggested title\n// ---------------------------------------------------------------------------\n\nfunction toSuggestedTitle(label: string): string {\n // Remove leading/trailing whitespace, capitalize each word, remove stop words\n // that are all-lowercase at the start of the title.\n const words = label\n .trim()\n .split(/\\s+/)\n .map((w, i) => {\n const lower = w.toLowerCase();\n // Drop leading stop words (but keep if they're the only word)\n if (i === 0 && TITLE_STOP_WORDS.has(lower) && label.trim().split(/\\s+/).length > 1) {\n return \"\";\n }\n return w.charAt(0).toUpperCase() + w.slice(1);\n })\n .filter(Boolean);\n\n return words.join(\" \") || label;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: find most common folder\n// ---------------------------------------------------------------------------\n\nfunction mostCommonFolder(vaultPaths: string[]): string {\n const counts = new Map<string, number>();\n for (const p of vaultPaths) {\n const parts = p.split(\"/\");\n const folder = parts.length > 1 ? parts.slice(0, -1).join(\"/\") : \"\";\n counts.set(folder, (counts.get(folder) ?? 0) + 1);\n }\n\n let best = \"\";\n let bestCount = 0;\n for (const [folder, count] of counts) {\n if (count > bestCount) {\n bestCount = count;\n best = folder;\n }\n }\n return best;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: count distinct session date-folders\n// ---------------------------------------------------------------------------\n\n/**\n * Heuristic: vault notes are often stored in date-based folders like\n * \"2026/03/15\" or \"Daily/2026-03\". We extract the first numeric path\n * segment that looks like a year (2020-2030) and group by year+month.\n *\n * Falls back to counting distinct top-level folders.\n */\nfunction countDistinctSessions(vaultPaths: string[]): number {\n const sessions = new Set<string>();\n const yearMonthRe = /\\b(202\\d)\\D?(0[1-9]|1[0-2])\\b/;\n\n for (const p of vaultPaths) {\n const m = yearMonthRe.exec(p);\n if (m) {\n sessions.add(`${m[1]}-${m[2]}`);\n } else {\n // Fallback: use top-level folder as a proxy for \"session bucket\"\n const topFolder = p.split(\"/\")[0];\n sessions.add(topFolder);\n }\n }\n return sessions.size;\n}\n\n// ---------------------------------------------------------------------------\n// Helper: calculate confidence score\n// ---------------------------------------------------------------------------\n\n/**\n * Confidence combines:\n * - Cluster size (normalized, capped at 20 for max contribution)\n * - Folder diversity (0-1 already)\n * - Sessions count (normalized, capped at 5)\n *\n * Formula: 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore\n */\nfunction calcConfidence(\n size: number,\n folderDiversity: number,\n sessionsCount: number\n): number {\n const sizeScore = Math.min(size / 20, 1.0);\n const sessionScore = Math.min(sessionsCount / 5, 1.0);\n const raw = 0.4 * sizeScore + 0.35 * folderDiversity + 0.25 * sessionScore;\n return Math.round(raw * 100) / 100;\n}\n\n// ---------------------------------------------------------------------------\n// Main handler: graph_latent_ideas\n// ---------------------------------------------------------------------------\n\nexport async function handleGraphLatentIdeas(\n backend: StorageBackend,\n params: GraphLatentIdeasParams\n): Promise<GraphLatentIdeasResult> {\n const minClusterSize = params.min_cluster_size ?? 3;\n const maxIdeas = params.max_ideas ?? 15;\n const lookbackDays = params.lookback_days ?? 180;\n const similarityThreshold = params.similarity_threshold ?? 0.65;\n\n const { project_id: vaultProjectId } = params;\n if (!vaultProjectId) {\n throw new Error(\n \"graph_latent_ideas: project_id is required (pass the vault project's numeric ID)\"\n );\n }\n\n // Run the same clustering algorithm used by graph_clusters\n const themeResult = await zettelThemes(backend, {\n vaultProjectId,\n lookbackDays,\n minClusterSize,\n maxThemes: maxIdeas * 3, // Over-fetch — many will be filtered as materialized\n similarityThreshold,\n });\n\n const ideas: LatentIdea[] = [];\n let materializedCount = 0;\n\n for (const theme of themeResult.themes) {\n const notePaths = theme.notes.map((n) => n.path);\n\n // Check if a dedicated note already exists for this theme\n if (await clusterHasMatchingNote(backend, theme.label, notePaths)) {\n materializedCount++;\n continue;\n }\n\n // This is a latent idea — no dedicated note exists yet\n const suggestedFolder = mostCommonFolder(notePaths);\n const sessionsCount = countDistinctSessions(notePaths);\n const confidence = calcConfidence(theme.size, theme.folderDiversity, sessionsCount);\n\n // Build source notes with relevance scores\n // Relevance is approximated by position in cluster (centroid-closest first)\n // zettelThemes returns notes in no guaranteed order; assign uniform relevance\n // decreasing from 1.0 to 0.5 across the list.\n const sourceNotes: LatentIdeaSourceNote[] = theme.notes.map((n, idx) => ({\n vault_path: n.path,\n title: n.title ?? n.path.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? n.path,\n relevance: Math.round((1.0 - (idx / Math.max(theme.notes.length - 1, 1)) * 0.5) * 100) / 100,\n }));\n\n ideas.push({\n id: theme.id,\n label: theme.label,\n size: theme.size,\n confidence,\n source_notes: sourceNotes,\n suggested_title: toSuggestedTitle(theme.label),\n suggested_folder: suggestedFolder,\n sessions_count: sessionsCount,\n });\n\n if (ideas.length >= maxIdeas) break;\n }\n\n // Sort by confidence descending\n ideas.sort((a, b) => b.confidence - a.confidence);\n\n return {\n ideas,\n total_clusters_analyzed: themeResult.themes.length + materializedCount,\n materialized_count: materializedCount,\n };\n}\n\n// ---------------------------------------------------------------------------\n// Materialize handler: idea_materialize\n// ---------------------------------------------------------------------------\n\nexport function handleIdeaMaterialize(\n params: IdeaMaterializeParams,\n vaultPath: string\n): IdeaMaterializeResult {\n const { idea_label, title, folder, source_paths } = params;\n\n // Sanitize filename: replace characters illegal in filenames\n const safeTitle = title.replace(/[/\\\\:*?\"<>|]/g, \"-\");\n const fileName = `${safeTitle}.md`;\n\n // Vault-relative path (forward slashes, no leading slash)\n const relFolder = folder.replace(/^\\/+|\\/+$/g, \"\");\n const vault_path = relFolder ? `${relFolder}/${fileName}` : fileName;\n\n // Absolute filesystem path\n const absPath = join(vaultPath, vault_path);\n const absDir = dirname(absPath);\n\n // Build wikilinks from source_paths\n const wikilinks = source_paths\n .map((p) => {\n // Derive a display name: filename without extension\n const name = p.split(\"/\").pop()?.replace(/\\.md$/i, \"\") ?? p;\n // Relative wikilink — use just the filename (Obsidian resolves by title)\n return `- [[${name}]]`;\n })\n .join(\"\\n\");\n\n const links_created = source_paths.length;\n\n const content = [\n `# ${title}`,\n \"\",\n `*Materialized from latent idea: \"${idea_label}\"*`,\n `*Sources: ${links_created} notes*`,\n \"\",\n \"## Related Notes\",\n \"\",\n wikilinks || \"*(no source notes)*\",\n \"\",\n \"## Notes\",\n \"\",\n \"<!-- Add your thoughts about this idea here -->\",\n \"\",\n ].join(\"\\n\");\n\n // Write the file (create parent directories as needed)\n mkdirSync(absDir, { recursive: true });\n writeFileSync(absPath, content, \"utf-8\");\n\n return {\n vault_path,\n content,\n links_created,\n };\n}\n"],"mappings":"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"}