pi-model-sync 0.1.0

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/lib/sync.js ADDED
@@ -0,0 +1,484 @@
1
+ /**
2
+ * pi-model-sync orchestration.
3
+ *
4
+ * One provider at a time for merging, all providers at once for discovery:
5
+ * live listing is I/O-bound and fully isolated per provider, so discoveries
6
+ * run concurrently while the models.json merge stays sequential and
7
+ * deterministic. A dead token, a missing list endpoint, or a failed
8
+ * enrichment fetch degrades that provider to a skip-with-reason and never
9
+ * aborts the run. Writes go through store.js, which only touches tagged
10
+ * entries and only prunes on successful non-empty discovery.
11
+ */
12
+
13
+ import { cachePathFor, loadCatalog } from "./cache.js";
14
+ import { defined, isFunction, isNonEmptyString, isObject, isString } from "./decode.js";
15
+ import { DiscoveryError, discoveryFamily, listModels } from "./discover.js";
16
+ import { enrichModel } from "./modelsdev.js";
17
+ import { planOrphanSweep, planProviderUpdate, readModelsFile, writeModelsFile } from "./store.js";
18
+ import { buildThinking } from "./thinking.js";
19
+
20
+ function providerIdsOf(models) {
21
+ const ids = [];
22
+
23
+ for (const model of models) {
24
+ if (isObject(model) && isString(model.provider) && !ids.includes(model.provider)) {
25
+ ids.push(model.provider);
26
+ }
27
+ }
28
+
29
+ ids.sort();
30
+
31
+ return ids;
32
+ }
33
+
34
+ function firstApiOf(models, providerId) {
35
+ const found = models.find((model) => isObject(model) && model.provider === providerId && isString(model.api));
36
+
37
+ return found?.api;
38
+ }
39
+
40
+ function isLocalBaseUrl(baseUrl) {
41
+ if (!isString(baseUrl)) {
42
+ return false;
43
+ }
44
+
45
+ let hostname = "";
46
+
47
+ try {
48
+ hostname = new URL(baseUrl).hostname.toLowerCase();
49
+ } catch {
50
+ return false;
51
+ }
52
+
53
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
54
+ }
55
+
56
+ // Resolved request auth with a uniform shape: missing or malformed auth
57
+ // reads as empty rather than throwing through optional chains.
58
+ function requestAuth(auth) {
59
+ return isObject(auth?.auth) ? auth.auth : {};
60
+ }
61
+
62
+ function hasHeaderCredential(headers) {
63
+ return isObject(headers) && Object.keys(headers).length > 0;
64
+ }
65
+
66
+ function hasUsableCredential(auth, baseUrl) {
67
+ const { apiKey, headers } = requestAuth(auth);
68
+
69
+ return (
70
+ isNonEmptyString(apiKey) ||
71
+ hasHeaderCredential(headers) ||
72
+ isLocalBaseUrl(baseUrl ?? requestAuth(auth).baseUrl)
73
+ );
74
+ }
75
+
76
+ function buildEntry(live, enriched, target) {
77
+ const thinking = buildThinking(enriched.reasoning, enriched.explicitEfforts ?? null);
78
+
79
+ const known = target.models.get(live.id);
80
+
81
+ const entry = defined({
82
+ id: live.id,
83
+ api: known?.api ?? target.api,
84
+ baseUrl: isNonEmptyString(known?.baseUrl) ? known.baseUrl : target.modelBaseUrl,
85
+ name: enriched.name,
86
+ reasoning: thinking.reasoning,
87
+ input: enriched.input,
88
+ contextWindow: enriched.contextWindow,
89
+ maxTokens: enriched.maxTokens,
90
+ cost: enriched.cost,
91
+ thinkingLevelMap: thinking.thinkingLevelMap,
92
+ });
93
+
94
+ return { entry, trainingRetained: enriched.trainingRetained === true };
95
+ }
96
+
97
+ function skipped(providerId, reason) {
98
+ return { providerId, skipped: reason, entries: [], liveCount: 0, dropped: 0, trainingNotes: [], residentIds: [] };
99
+ }
100
+
101
+ // Private control flow: provider-level skips unwind to discoverProvider.
102
+ // Only Skip and locally generated DiscoveryError messages reach reports;
103
+ // raw provider exceptions can carry credentials.
104
+ class Skip extends Error {}
105
+
106
+ // Resolve where and how to list one provider. Throws Skip with the exact
107
+ // skip reason when the provider cannot be listed.
108
+ async function listingTarget(deps, providerId, models) {
109
+ const provider = deps.registry.getProvider(providerId);
110
+
111
+ if (provider === undefined) {
112
+ throw new Skip("unknown to registry");
113
+ }
114
+
115
+ const registration = isFunction(deps.registry.getRegisteredProviderConfig)
116
+ ? deps.registry.getRegisteredProviderConfig(providerId) : undefined;
117
+
118
+ if (Array.isArray(registration?.models)) {
119
+ throw new Skip("extension owns an explicit model list; models.json cannot extend it");
120
+ }
121
+
122
+ let auth;
123
+
124
+ try {
125
+ auth = await deps.registry.getProviderAuth(providerId);
126
+ } catch {
127
+ throw new Skip("auth resolution failed");
128
+ }
129
+
130
+ return listingTransport(provider, auth, providerId, models);
131
+ }
132
+
133
+ function listingTransport(provider, auth, providerId, models) {
134
+ const api = firstApiOf(models, providerId);
135
+ const knownModels = models.filter(model => isObject(model) && model.provider === providerId);
136
+ const families = [];
137
+
138
+ for (const model of knownModels) {
139
+ if (!isNonEmptyString(model.api)) {
140
+ continue;
141
+ }
142
+
143
+ const family = discoveryFamily(model.api);
144
+
145
+ if (!families.includes(family)) {
146
+ families.push(family);
147
+ }
148
+ }
149
+
150
+ // Copilot/Fireworks mix Anthropic and OpenAI wire APIs. Stamping firstApiOf
151
+ // on a new id makes Pi send the wrong request shape.
152
+ if (families.length > 1) {
153
+ throw new Skip("provider models use mixed APIs; models.json cannot assign a single api");
154
+ }
155
+
156
+ const reference = knownModels.find(model => model.api === api);
157
+ // Persist configured transport defaults, never the resolved credential-bearing request URL.
158
+ const modelBaseUrl = isNonEmptyString(reference?.baseUrl) ? reference.baseUrl : provider.baseUrl;
159
+ const { apiKey, headers, baseUrl: authBaseUrl } = requestAuth(auth);
160
+ const baseUrl = authBaseUrl ?? provider.baseUrl ?? modelBaseUrl;
161
+
162
+ if (!isNonEmptyString(baseUrl)) {
163
+ throw new Skip("no base URL to list");
164
+ }
165
+
166
+ if (!hasUsableCredential(auth, baseUrl)) {
167
+ throw new Skip("not logged in");
168
+ }
169
+
170
+ if (!isNonEmptyString(api) || !isNonEmptyString(modelBaseUrl)) {
171
+ throw new Skip("no configured model transport defaults");
172
+ }
173
+
174
+ return {
175
+ baseUrl, apiKey, headers, family: discoveryFamily(api), api, modelBaseUrl,
176
+ models: new Map(knownModels.map(model => [model.id, model])),
177
+ };
178
+ }
179
+
180
+ function buildEntries(catalog, providerId, live, target) {
181
+ const entries = [];
182
+ const trainingNotes = [];
183
+ let dropped = 0;
184
+
185
+ for (const item of live) {
186
+ const enriched = enrichModel(catalog, providerId, item.id, item.meta);
187
+
188
+ if (enriched === undefined) {
189
+ dropped += 1;
190
+
191
+ continue;
192
+ }
193
+
194
+ const built = buildEntry(item, enriched, target);
195
+
196
+ entries.push(built.entry);
197
+
198
+ if (built.trainingRetained) {
199
+ trainingNotes.push(` ! ${providerId}/${item.id}: prompts may be retained for training`);
200
+ }
201
+ }
202
+
203
+ return { providerId, skipped: undefined, entries, liveCount: live.length, dropped, trainingNotes, residentIds: [...target.models.keys()] };
204
+ }
205
+
206
+ // Discovery phase: network only, no merging, safe to run concurrently.
207
+ // Resolves per provider to entries or a skip reason; never rejects.
208
+ async function discoverProvider(deps, catalog, providerId, models) {
209
+ try {
210
+ const target = await listingTarget(deps, providerId, models);
211
+
212
+ const live = await listModels(
213
+ target.baseUrl,
214
+ { apiKey: target.apiKey, headers: target.headers },
215
+ target.family,
216
+ deps.fetchImpl,
217
+ deps.userAgent,
218
+ );
219
+
220
+ return buildEntries(catalog, providerId, live, target);
221
+ } catch (error) {
222
+ return skipped(providerId, error instanceof Skip || error instanceof DiscoveryError ? error.message : "provider lookup failed");
223
+ }
224
+ }
225
+
226
+ // Resolve the run scope: which registry providers to sync. Returns
227
+ // {ids, allModels}, or {abort} with report lines when the run cannot start.
228
+ function planRun(deps) {
229
+ let models;
230
+
231
+ try {
232
+ models = deps.registry.getAll();
233
+ } catch (error) {
234
+ const reason = error instanceof Error ? error.message : String(error);
235
+
236
+ return { abort: [`model-sync: cannot read provider catalog (${reason})`] };
237
+ }
238
+
239
+ if (!isFunction(deps.registry.getProvider) || !isFunction(deps.registry.getProviderAuth)) {
240
+ return { abort: ["model-sync: this Pi version exposes no provider registry; update Pi and retry."] };
241
+ }
242
+
243
+ const allModels = Array.isArray(models) ? models : [];
244
+ const ids = providerIdsOf(allModels);
245
+
246
+ if (isNonEmptyString(deps.filter)) {
247
+ if (!ids.includes(deps.filter)) {
248
+ return { abort: [`model-sync: unknown provider "${deps.filter}"`, `known: ${ids.join(", ") || "(none)"}`] };
249
+ }
250
+
251
+ return { ids: [deps.filter], allModels };
252
+ }
253
+
254
+ return { ids, allModels };
255
+ }
256
+
257
+ // Merge one provider result per line, accumulating totals and training
258
+ // notes. An empty live list is trusted for additions (there are none) but
259
+ // never for pruning: a transient empty response must not wipe a catalog.
260
+ function mergeDiscovered(doc, discovered, lines, totals, trainingNotes) {
261
+ for (const result of discovered) {
262
+ if (result.skipped !== undefined) {
263
+ lines.push(` ${result.providerId}: skipped (${result.skipped})`);
264
+ totals.skipped += 1;
265
+
266
+ continue;
267
+ }
268
+
269
+ const plan = planProviderUpdate(doc, result.providerId, result.entries, result.liveCount > 0, result.residentIds);
270
+ doc = plan.next;
271
+ totals.synced += 1;
272
+ totals.added += plan.added;
273
+ totals.updated += plan.updated;
274
+ totals.removed += plan.removed;
275
+
276
+ const droppedNote = result.dropped > 0 ? `, ${result.dropped} dropped non-text` : "";
277
+ const emptyNote = result.liveCount === 0 ? "; kept existing" : "";
278
+
279
+ lines.push(
280
+ ` ${result.providerId}: +${plan.added} ~${plan.updated} -${plan.removed} =${plan.kept} ` +
281
+ `(${result.liveCount} live${droppedNote}${emptyNote})`,
282
+ );
283
+
284
+ for (const note of result.trainingNotes) {
285
+ trainingNotes.push(note);
286
+ }
287
+ }
288
+
289
+ return doc;
290
+ }
291
+
292
+ // Orphan sweep last: providers that left the registry (uninstalled
293
+ // extensions) can never sync again, and Pi composition-errors on their
294
+ // stale managed sections every startup. Filtered runs touch only the
295
+ // filter; an empty registry proves nothing and sweeps nothing.
296
+ function sweepOrphans(doc, registry, filter, lines, totals) {
297
+ if (isNonEmptyString(filter)) {
298
+ return doc;
299
+ }
300
+
301
+ let ids;
302
+
303
+ try {
304
+ ids = providerIdsOf(registry.getAll());
305
+
306
+ if (ids.length === 0) return doc;
307
+
308
+ if (isFunction(registry.getRegisteredProviderIds)) {
309
+ for (const id of registry.getRegisteredProviderIds()) {
310
+ if (isString(id) && !ids.includes(id)) ids.push(id);
311
+ }
312
+ }
313
+ } catch {
314
+ return doc;
315
+ }
316
+
317
+ const sweep = planOrphanSweep(doc, ids);
318
+
319
+ for (const { providerId, removed } of sweep.swept) {
320
+ lines.push(` ${providerId}: -${removed} (orphaned; provider not in registry)`);
321
+ totals.removed += removed;
322
+ }
323
+
324
+ return sweep.next;
325
+ }
326
+
327
+ // Training flags are per-model signal but provider-scale noise: list a
328
+ // handful, summarize a crowd.
329
+ function reportTraining(trainingNotes, lines) {
330
+ if (trainingNotes.length > 5) {
331
+ lines.push(` ! ${trainingNotes.length} models report no zero-retention (prompts may train)`);
332
+
333
+ return;
334
+ }
335
+
336
+ for (const note of trainingNotes) {
337
+ lines.push(note);
338
+ }
339
+ }
340
+
341
+ function discoveredModelsVisible(registry, discovered) {
342
+ if (!isFunction(registry.find)) return true;
343
+
344
+ for (const result of discovered) {
345
+ for (const entry of result.entries) {
346
+ if (!registry.find(result.providerId, entry.id)) return false;
347
+ }
348
+ }
349
+
350
+ return true;
351
+ }
352
+
353
+ async function announceRefresh(deps, discovered, lines) {
354
+ if (isFunction(deps.registry.refresh)) {
355
+ try {
356
+ await deps.registry.refresh({ allowNetwork: false });
357
+
358
+ if (isFunction(deps.registry.getError) && deps.registry.getError()) {
359
+ lines.push("models.json saved, but Pi reports catalog errors; check the model configuration");
360
+
361
+ return false;
362
+ }
363
+
364
+ if (!discoveredModelsVisible(deps.registry, discovered)) {
365
+ lines.push("models.json saved, but Pi did not expose every discovered model; check provider model ownership");
366
+
367
+ return false;
368
+ }
369
+
370
+ lines.push("catalog live now; no restart needed");
371
+
372
+ return true;
373
+ } catch {
374
+ lines.push("models.json saved, but Pi could not reload the catalog; check the configuration and retry");
375
+
376
+ return false;
377
+ }
378
+ }
379
+
380
+ lines.push("restart Pi to pick up the new catalog");
381
+
382
+ return true;
383
+ }
384
+
385
+ async function finishRun(deps, doc, lines, totals, discovered) {
386
+ lines.push(`+added ~updated -removed =kept (${totals.added} added, ${totals.updated} updated, ${totals.removed} removed)`);
387
+
388
+ if (deps.dryRun) {
389
+ lines.push("dry run: models.json untouched");
390
+
391
+ return { ok: true, lines, totals };
392
+ }
393
+
394
+ if (totals.added === 0 && totals.updated === 0 && totals.removed === 0) {
395
+ lines.push("already up to date");
396
+
397
+ // A previous write may have succeeded while its reload failed. Disk equality
398
+ // does not establish activation; a successful discovery must retry the reload.
399
+ const ok = totals.synced > 0 && isFunction(deps.registry.refresh)
400
+ ? await announceRefresh(deps, discovered, lines) : true;
401
+
402
+ return { ok, lines, totals };
403
+ }
404
+
405
+ let backupPath = null;
406
+
407
+ try {
408
+ backupPath = writeModelsFile(deps.modelsPath, doc, deps.fs).backupPath;
409
+ } catch (error) {
410
+ const reason = error instanceof Error ? error.message : String(error);
411
+
412
+ return { ok: false, lines: [...lines, `model-sync failed to write: ${reason}`], totals };
413
+ }
414
+
415
+ if (backupPath !== null) {
416
+ lines.push(`backup: ${backupPath}`);
417
+ }
418
+
419
+ lines.push(`wrote ${deps.modelsPath}`);
420
+ const ok = await announceRefresh(deps, discovered, lines);
421
+
422
+ return { ok, lines, totals };
423
+ }
424
+
425
+ // deps: {registry, fetchImpl, fs, modelsPath, userAgent, filter?, dryRun?, refresh?}.
426
+ // Report lines are deterministic: sorted providers, fixed legend.
427
+ export async function runSync(deps) {
428
+ const lines = [];
429
+ const totals = { added: 0, updated: 0, removed: 0, synced: 0, skipped: 0 };
430
+ const trainingNotes = [];
431
+ const scope = planRun(deps);
432
+
433
+ if (scope.abort !== undefined) {
434
+ return { ok: false, lines: scope.abort, totals };
435
+ }
436
+
437
+ const { ids, allModels } = scope;
438
+
439
+ lines.push(`model-sync${deps.dryRun ? " (dry run)" : ""}: ${ids.length} provider${ids.length === 1 ? "" : "s"}`);
440
+
441
+ let doc;
442
+
443
+ try {
444
+ doc = readModelsFile(deps.modelsPath, deps.fs);
445
+ } catch (error) {
446
+ const reason = error instanceof Error ? error.message : String(error);
447
+
448
+ return { ok: false, lines: [...lines, `model-sync aborted: ${reason}`], totals };
449
+ }
450
+
451
+ let catalog = null;
452
+
453
+ if (ids.length > 0) {
454
+ const loaded = await loadCatalog({
455
+ fetchImpl: deps.fetchImpl,
456
+ userAgent: deps.userAgent,
457
+ cachePath: cachePathFor(deps.modelsPath),
458
+ fs: deps.fs,
459
+ refresh: deps.refresh,
460
+ dryRun: deps.dryRun,
461
+ });
462
+
463
+ catalog = loaded.catalog;
464
+ lines.push(` ${loaded.note}`);
465
+ }
466
+
467
+ // Discovery can yield to another sync or a user edit. The final read/merge/write
468
+ // stays synchronous; unrelated changes completed during discovery survive.
469
+ const discovered = await Promise.all(ids.map((id) => discoverProvider(deps, catalog, id, allModels)));
470
+
471
+ try {
472
+ doc = readModelsFile(deps.modelsPath, deps.fs);
473
+ } catch (error) {
474
+ const reason = error instanceof Error ? error.message : String(error);
475
+
476
+ return { ok: false, lines: [...lines, `model-sync aborted: ${reason}`], totals };
477
+ }
478
+
479
+ doc = mergeDiscovered(doc, discovered, lines, totals, trainingNotes);
480
+ doc = sweepOrphans(doc, deps.registry, deps.filter, lines, totals);
481
+ reportTraining(trainingNotes, lines);
482
+
483
+ return finishRun(deps, doc, lines, totals, discovered);
484
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * pi-model-sync thinking maps.
3
+ *
4
+ * Pi selects a thinking level (off/minimal/low/medium/high/xhigh/max) and each
5
+ * API adapter translates the mapped value to the wire. Unknown models get the
6
+ * conservative ladder (standard names, no xhigh/max); explicit wire values
7
+ * from live metadata or models.dev always win via nearest-match, ties to the
8
+ * lower rung. Map values of null hide that Pi level (getSupportedThinkingLevels
9
+ * skips them), so off is omitted unless the source advertises none/off.
10
+ */
11
+
12
+ import { defined, isString } from "./decode.js";
13
+
14
+ // Ordered reasoning ladder. Index distance drives nearest-match.
15
+ export const LADDER = ["minimal", "low", "medium", "high", "xhigh"];
16
+
17
+ function ladderIndex(value) {
18
+ return LADDER.indexOf(value);
19
+ }
20
+
21
+ function nearestLadderValue(want, have) {
22
+ const wantIndex = ladderIndex(want);
23
+ let best = null;
24
+ let bestDistance = Infinity;
25
+
26
+ for (const candidate of have) {
27
+ const candidateIndex = ladderIndex(candidate);
28
+
29
+ if (candidateIndex < 0) {
30
+ continue;
31
+ }
32
+
33
+ const distance = Math.abs(candidateIndex - wantIndex);
34
+
35
+ if (distance < bestDistance) {
36
+ best = candidate;
37
+ bestDistance = distance;
38
+ }
39
+ }
40
+
41
+ return best;
42
+ }
43
+
44
+ // Map one Pi level onto explicit wire values. Falls back to the Pi level
45
+ // name itself when the values carry no ladder member (custom dialects the
46
+ // engine cannot interpret keep Pi default behavior).
47
+ function mapLevel(level, values) {
48
+ if (values === null) {
49
+ return level;
50
+ }
51
+
52
+ const ladderValues = [...new Set(values.filter((value) => isString(value) && ladderIndex(value) >= 0))];
53
+
54
+ if (ladderValues.length === 0) {
55
+ return level;
56
+ }
57
+
58
+ // Sort by ladder index so ties-to-lower is deterministic regardless of
59
+ // advertisement order; the nearest loop keeps the first on ties.
60
+ ladderValues.sort((a, b) => ladderIndex(a) - ladderIndex(b));
61
+
62
+ return nearestLadderValue(level, ladderValues) ?? level;
63
+ }
64
+
65
+ // xhigh/max are only advertised when explicitly offered (unknown models
66
+ // get neither). Anything else leaves them unsupported so Pi clamps down
67
+ // instead of sending a value the server may reject with 400.
68
+ function gatedLevel(level, values) {
69
+ if (values === null) {
70
+ return null;
71
+ }
72
+
73
+ return values.includes(level) ? level : null;
74
+ }
75
+
76
+ // Pi treats thinkingLevelMap.off === null as "off is unsupported". Prefer an
77
+ // advertised none/off wire value; otherwise omit the key so /thinking off stays.
78
+ function offLevel(values) {
79
+ if (values === null) {
80
+ return undefined;
81
+ }
82
+
83
+ if (values.includes("none")) {
84
+ return "none";
85
+ }
86
+
87
+ if (values.includes("off")) {
88
+ return "off";
89
+ }
90
+
91
+ return undefined;
92
+ }
93
+
94
+ // Build the thinking config for one model. explicitValues is the wire
95
+ // effort list from live metadata or models.dev, or null when unknown.
96
+ export function buildThinking(reasoning, explicitValues) {
97
+ if (reasoning !== true) {
98
+ return { reasoning: false };
99
+ }
100
+
101
+ const values = explicitValues === undefined ? null : explicitValues;
102
+
103
+ return {
104
+ reasoning: true,
105
+ thinkingLevelMap: defined({
106
+ off: offLevel(values),
107
+ minimal: mapLevel("minimal", values),
108
+ low: mapLevel("low", values),
109
+ medium: mapLevel("medium", values),
110
+ high: mapLevel("high", values),
111
+ xhigh: gatedLevel("xhigh", values),
112
+ max: gatedLevel("max", values),
113
+ }),
114
+ };
115
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-model-sync",
3
+ "version": "0.1.0",
4
+ "description": "Keep Pi models fresh: /model-sync discovers live provider catalogs, enriches from models.dev, and writes missing models to models.json.",
5
+ "type": "module",
6
+ "author": "AdityaVG13",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "pi-extension",
13
+ "extension",
14
+ "models",
15
+ "catalog",
16
+ "sync",
17
+ "discovery"
18
+ ],
19
+ "engines": {
20
+ "node": ">=22.0.0"
21
+ },
22
+ "main": "./index.js",
23
+ "exports": "./index.js",
24
+ "files": [
25
+ "index.js",
26
+ "lib/",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test tests/*.test.mjs",
32
+ "prepublishOnly": "node ../../scripts/preflight.mjs"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./index.js"
37
+ ]
38
+ },
39
+ "peerDependencies": {
40
+ "@earendil-works/pi-coding-agent": "*"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@earendil-works/pi-coding-agent": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/AdityaVG13/pi-stack.git",
53
+ "directory": "packages/pi-model-sync"
54
+ },
55
+ "bugs": {
56
+ "url": "https://github.com/AdityaVG13/pi-stack/issues"
57
+ },
58
+ "homepage": "https://github.com/AdityaVG13/pi-stack/tree/main/packages/pi-model-sync#readme"
59
+ }