@piagent/platform 1.2.8 → 1.2.9

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 (35) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/docs/command-reference-vietnamese.md +2 -2
  4. package/docs/context-engine.md +11 -1
  5. package/docs/distribution-standard.md +7 -7
  6. package/docs/operator-manual-vietnamese.md +7 -7
  7. package/docs/publishing-for-teams.md +1 -0
  8. package/docs/release-install-policy.md +6 -6
  9. package/docs/security-threat-model.md +2 -1
  10. package/docs/team-onboarding.md +2 -2
  11. package/docs-site/architecture.html +1 -1
  12. package/docs-site/commands.html +1 -1
  13. package/docs-site/context-engine.html +1 -1
  14. package/docs-site/index.html +1 -1
  15. package/docs-site/mcp-approval.html +1 -1
  16. package/docs-site/mcp-auth.html +1 -1
  17. package/docs-site/mcp-servers.html +1 -1
  18. package/docs-site/mcp.html +1 -1
  19. package/docs-site/permissions.html +1 -1
  20. package/docs-site/profiles.html +1 -1
  21. package/docs-site/quickstart.html +1 -1
  22. package/docs-site/runtime.html +1 -1
  23. package/docs-site/scope.html +1 -1
  24. package/docs-site/security.html +1 -1
  25. package/docs-site/team.html +1 -1
  26. package/docs-site/workflows.html +1 -1
  27. package/package.json +1 -1
  28. package/packages/piagent-core/capabilities/capability-core.js +3 -0
  29. package/packages/piagent-core/extensions/context-engine.js +149 -71
  30. package/packages/piagent-core/extensions/context-index-policy.js +136 -0
  31. package/packages/piagent-core/extensions/piagent-guard.ts +67 -67
  32. package/packages/piagent-core/package.json +1 -1
  33. package/scripts/context-engine.mjs +22 -20
  34. package/scripts/update-global.mjs +1 -1
  35. package/scripts/verify-local.sh +2 -0
@@ -3,6 +3,13 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
5
 
6
+ import {
7
+ contextIndexExcludeDigest,
8
+ contextIndexExcludePolicyVersion,
9
+ normalizeContextIndexExcludePatterns
10
+ } from "./context-index-policy.js";
11
+ import { matchesProtectedPath } from "./policy-core.js";
12
+
6
13
  const INDEX_SCHEMA_VERSION = 2;
7
14
  const TELEMETRY_SCHEMA_VERSION = 1;
8
15
  const RRF_K = 60;
@@ -64,6 +71,13 @@ function clampInteger(value, fallback, min, max) {
64
71
  return Math.max(min, Math.min(max, Math.trunc(number)));
65
72
  }
66
73
 
74
+ function requireExplicitExcludePatterns(options, operation) {
75
+ if (!Array.isArray(options?.excludePatterns)) {
76
+ throw new TypeError(`${operation} requires an explicit excludePatterns array`);
77
+ }
78
+ return normalizeContextIndexExcludePatterns(options.excludePatterns);
79
+ }
80
+
67
81
  export function estimateContextTokens(value) {
68
82
  return Math.max(0, Math.ceil(String(value ?? "").length / 4));
69
83
  }
@@ -184,41 +198,28 @@ function shouldIndexPath(relativePath, options) {
184
198
  const segments = rel.split("/");
185
199
  if (segments.some((segment) => IGNORED_SEGMENTS.has(segment))) return false;
186
200
  if (SECRET_FILE_PATTERNS.some((pattern) => pattern.test(rel))) return false;
187
- if ((options.excludePatterns ?? []).some((pattern) => simpleGlobMatch(rel, pattern))) return false;
201
+ if (matchesProtectedPath(rel, options.excludePatterns ?? [])) return false;
188
202
  const name = path.posix.basename(rel);
189
203
  return SOURCE_NAMES.has(name) || SOURCE_EXTENSIONS.has(path.posix.extname(name).toLowerCase());
190
204
  }
191
205
 
192
- function simpleGlobMatch(relativePath, pattern) {
193
- const rel = normalizeRelative(relativePath);
194
- const normalized = normalizeRelative(pattern);
195
- let source = "";
196
- for (let index = 0; index < normalized.length; index += 1) {
197
- const char = normalized[index];
198
- if (char === "*" && normalized[index + 1] === "*") {
199
- if (normalized[index + 2] === "/") {
200
- source += "(?:.*/)?";
201
- index += 2;
202
- } else {
203
- source += ".*";
204
- index += 1;
205
- }
206
- continue;
207
- }
208
- if (char === "*") {
209
- source += "[^/]*";
210
- continue;
211
- }
212
- if (char === "?") {
213
- source += "[^/]";
214
- continue;
215
- }
216
- source += /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
217
- }
206
+ function projectFileInfo(cwd, relativePath, root = fs.realpathSync.native(cwd), directoryCache = new Map()) {
207
+ const expected = path.resolve(root, relativePath);
208
+ if (expected !== root && !expected.startsWith(`${root}${path.sep}`)) return undefined;
218
209
  try {
219
- return new RegExp(`^${source}$`, "i").test(rel);
210
+ const directPath = path.resolve(cwd, relativePath);
211
+ const directParent = path.dirname(directPath);
212
+ let canonicalParent = directoryCache.get(directParent);
213
+ if (canonicalParent === undefined) {
214
+ canonicalParent = fs.realpathSync.native(directParent);
215
+ directoryCache.set(directParent, canonicalParent);
216
+ }
217
+ if (canonicalParent !== path.dirname(expected)) return undefined;
218
+ const direct = fs.lstatSync(directPath);
219
+ if (direct.isSymbolicLink() || !direct.isFile()) return undefined;
220
+ return { absolute: expected, stat: direct };
220
221
  } catch {
221
- return rel === normalized;
222
+ return undefined;
222
223
  }
223
224
  }
224
225
 
@@ -463,11 +464,14 @@ export function classifyContextTask(prompt) {
463
464
  }
464
465
 
465
466
  export async function buildContextIndexV2(cwd, options = {}) {
467
+ const excludePatterns = requireExplicitExcludePatterns(options, "buildContextIndexV2");
466
468
  const startedAt = Date.now();
469
+ const projectRoot = fs.realpathSync.native(cwd);
470
+ const directoryCache = new Map();
467
471
  const maxFiles = clampInteger(options.maxFiles, DEFAULT_MAX_FILES, 1, 50_000);
468
472
  const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 1_024, 2 * 1024 * 1024);
469
473
  const listed = gitFileList(cwd) ?? walkedFileList(cwd, maxFiles);
470
- const candidates = listed.filter((file) => shouldIndexPath(file, options)).slice(0, maxFiles).sort();
474
+ const candidates = listed.filter((file) => shouldIndexPath(file, { ...options, excludePatterns })).slice(0, maxFiles).sort();
471
475
  const db = await openDatabase(cwd);
472
476
  const existing = new Map(db.prepare("SELECT path, hash, bytes, mtime_ms FROM files").all().map((row) => [row.path, row]));
473
477
  const current = new Set();
@@ -480,18 +484,12 @@ export async function buildContextIndexV2(cwd, options = {}) {
480
484
  const skippedPaths = [];
481
485
 
482
486
  for (const relativePath of candidates) {
483
- const absolute = path.resolve(cwd, relativePath);
484
- if (!absolute.startsWith(`${path.resolve(cwd)}${path.sep}`) && absolute !== path.resolve(cwd)) continue;
485
- let stat;
486
- try {
487
- stat = fs.statSync(absolute);
488
- if (!stat.isFile()) continue;
489
- if (stat.size > maxFileBytes) {
490
- skippedLarge += 1;
491
- skippedPaths.push(relativePath);
492
- continue;
493
- }
494
- } catch {
487
+ const file = projectFileInfo(cwd, relativePath, projectRoot, directoryCache);
488
+ if (!file) continue;
489
+ const { absolute, stat } = file;
490
+ if (stat.size > maxFileBytes) {
491
+ skippedLarge += 1;
492
+ skippedPaths.push(relativePath);
495
493
  continue;
496
494
  }
497
495
  const previous = existing.get(relativePath);
@@ -575,7 +573,9 @@ export async function buildContextIndexV2(cwd, options = {}) {
575
573
  symbolCount: counts.symbols,
576
574
  importCount: counts.imports,
577
575
  maxFileBytes,
578
- excludePatterns: JSON.stringify(options.excludePatterns ?? []),
576
+ excludePatterns: JSON.stringify(excludePatterns),
577
+ excludeDigest: contextIndexExcludeDigest(excludePatterns),
578
+ excludePolicyVersion: contextIndexExcludePolicyVersion(),
579
579
  skippedPaths: JSON.stringify(skippedPaths)
580
580
  });
581
581
  db.exec("COMMIT");
@@ -599,10 +599,26 @@ export async function buildContextIndexV2(cwd, options = {}) {
599
599
  }
600
600
  }
601
601
 
602
- export async function contextIndexV2Status(cwd) {
602
+ export async function contextIndexV2Status(cwd, options = {}) {
603
603
  const paths = contextEnginePaths(cwd);
604
+ const projectRoot = fs.realpathSync.native(cwd);
605
+ const directoryCache = new Map();
606
+ const expectedExcludePatterns = options.excludePatterns === undefined
607
+ ? undefined
608
+ : normalizeContextIndexExcludePatterns(options.excludePatterns);
609
+ const expectedExcludeDigest = expectedExcludePatterns
610
+ ? contextIndexExcludeDigest(expectedExcludePatterns)
611
+ : undefined;
604
612
  if (!fs.existsSync(paths.database)) {
605
- return { schemaVersion: INDEX_SCHEMA_VERSION, exists: false, path: normalizeRelative(path.relative(cwd, paths.database)), warnings: ["index-v2 missing"] };
613
+ return {
614
+ schemaVersion: INDEX_SCHEMA_VERSION,
615
+ exists: false,
616
+ path: normalizeRelative(path.relative(cwd, paths.database)),
617
+ stale: false,
618
+ policyStale: false,
619
+ expectedExcludeDigest,
620
+ warnings: ["index-v2 missing"]
621
+ };
606
622
  }
607
623
  const db = await openDatabase(cwd, { create: false });
608
624
  try {
@@ -612,24 +628,29 @@ export async function contextIndexV2Status(cwd) {
612
628
  const symbols = Number(db.prepare("SELECT COUNT(*) count FROM symbols").get().count);
613
629
  const imports = Number(db.prepare("SELECT COUNT(*) count FROM imports").get().count);
614
630
  const indexedPaths = new Set(fileRows.map((row) => row.path));
631
+ let storedExcludePatterns = [];
632
+ try {
633
+ const parsed = JSON.parse(metadata.excludePatterns ?? "[]");
634
+ if (Array.isArray(parsed)) storedExcludePatterns = normalizeContextIndexExcludePatterns(parsed);
635
+ } catch {
636
+ storedExcludePatterns = [];
637
+ }
638
+ const activeExcludePatterns = expectedExcludePatterns ?? storedExcludePatterns;
639
+ const storedExcludeDigest = metadata.excludeDigest;
640
+ const policyStale = expectedExcludeDigest !== undefined
641
+ && (typeof storedExcludeDigest !== "string" || storedExcludeDigest !== expectedExcludeDigest);
615
642
  const statChanged = [];
616
643
  for (const row of fileRows) {
617
- try {
618
- const stat = fs.statSync(path.resolve(cwd, row.path));
619
- if (!stat.isFile() || stat.size !== Number(row.bytes) || Math.abs(stat.mtimeMs - Number(row.mtime_ms)) >= 0.5) {
620
- statChanged.push(row.path);
621
- }
622
- } catch {
644
+ if (!shouldIndexPath(row.path, { excludePatterns: activeExcludePatterns })) continue;
645
+ const file = projectFileInfo(cwd, row.path, projectRoot, directoryCache);
646
+ if (
647
+ !file
648
+ || file.stat.size !== Number(row.bytes)
649
+ || Math.abs(file.stat.mtimeMs - Number(row.mtime_ms)) >= 0.5
650
+ ) {
623
651
  statChanged.push(row.path);
624
652
  }
625
653
  }
626
- let excludePatterns = [];
627
- try {
628
- const parsed = JSON.parse(metadata.excludePatterns ?? "[]");
629
- if (Array.isArray(parsed)) excludePatterns = parsed.filter((value) => typeof value === "string");
630
- } catch {
631
- excludePatterns = [];
632
- }
633
654
  let skippedPaths = new Set();
634
655
  try {
635
656
  const parsed = JSON.parse(metadata.skippedPaths ?? "[]");
@@ -639,9 +660,12 @@ export async function contextIndexV2Status(cwd) {
639
660
  }
640
661
  const listed = gitFileList(cwd);
641
662
  const newlyVisible = listed
642
- ? listed.filter((file) => !indexedPaths.has(file) && !skippedPaths.has(file) && shouldIndexPath(file, { excludePatterns }))
663
+ ? listed.filter((file) => !indexedPaths.has(file) && !skippedPaths.has(file) && shouldIndexPath(file, { excludePatterns: activeExcludePatterns }))
643
664
  : [];
644
665
  const stalePaths = [...new Set([...statChanged, ...newlyVisible])];
666
+ const warnings = [];
667
+ if (policyStale) warnings.push("context index exclusion policy changed");
668
+ if (stalePaths.length > 0) warnings.push(`${stalePaths.length} changed file(s) since the last index build`);
645
669
  return {
646
670
  schemaVersion: Number(metadata.schemaVersion ?? INDEX_SCHEMA_VERSION),
647
671
  exists: true,
@@ -650,15 +674,37 @@ export async function contextIndexV2Status(cwd) {
650
674
  files,
651
675
  symbols,
652
676
  imports,
653
- stale: stalePaths.length > 0,
677
+ stale: policyStale || stalePaths.length > 0,
678
+ policyStale,
679
+ excludePolicyVersion: metadata.excludePolicyVersion,
680
+ excludeDigest: storedExcludeDigest,
681
+ expectedExcludeDigest,
654
682
  stalePaths: stalePaths.slice(0, 20),
655
- warnings: stalePaths.length > 0 ? [`${stalePaths.length} changed file(s) since the last index build`] : []
683
+ warnings
656
684
  };
657
685
  } finally {
658
686
  db.close();
659
687
  }
660
688
  }
661
689
 
690
+ export async function ensureContextIndexV2(cwd, options = {}) {
691
+ const excludePatterns = requireExplicitExcludePatterns(options, "ensureContextIndexV2");
692
+ let status = await contextIndexV2Status(cwd, { excludePatterns });
693
+ const reason = options.refresh
694
+ ? "refresh"
695
+ : status.policyStale
696
+ ? "exclusion-policy"
697
+ : !status.exists && options.rebuildMissing
698
+ ? "missing"
699
+ : undefined;
700
+ let build;
701
+ if (reason) {
702
+ build = await buildContextIndexV2(cwd, { ...options, excludePatterns });
703
+ status = await contextIndexV2Status(cwd, { excludePatterns });
704
+ }
705
+ return { status, rebuilt: Boolean(reason), reason, build, excludePatterns };
706
+ }
707
+
662
708
  function addRankedList(scores, rows, source, weight = 1) {
663
709
  rows.forEach((row, index) => {
664
710
  const filePath = row.path ?? row.file_path;
@@ -783,8 +829,11 @@ function retrievalFeedback(cwd) {
783
829
  }
784
830
 
785
831
  export async function searchContextIndexV2(cwd, query, options = {}) {
832
+ const excludePatterns = requireExplicitExcludePatterns(options, "searchContextIndexV2");
786
833
  const limit = clampInteger(options.limit, 12, 1, 50);
787
- const status = await contextIndexV2Status(cwd);
834
+ const projectRoot = fs.realpathSync.native(cwd);
835
+ const directoryCache = new Map();
836
+ const status = await contextIndexV2Status(cwd, { excludePatterns });
788
837
  if (!status.exists) return { query, terms: [], confidence: "none", results: [], status };
789
838
  const terms = tokenizeQuery(query);
790
839
  const explicitPaths = queryPathCandidates(query);
@@ -792,7 +841,8 @@ export async function searchContextIndexV2(cwd, query, options = {}) {
792
841
  const db = await openDatabase(cwd, { create: false });
793
842
  try {
794
843
  const scores = new Map();
795
- const allFiles = db.prepare("SELECT path, mtime_ms FROM files").all();
844
+ const pathAllowed = (filePath) => shouldIndexPath(filePath, { excludePatterns });
845
+ const allFiles = db.prepare("SELECT path, mtime_ms FROM files").all().filter((file) => pathAllowed(file.path));
796
846
  const explicitRows = [];
797
847
  for (const file of allFiles) {
798
848
  const basename = path.posix.basename(file.path).toLowerCase();
@@ -809,7 +859,7 @@ export async function searchContextIndexV2(cwd, query, options = {}) {
809
859
  lexicalRows = db.prepare(`
810
860
  SELECT path, bm25(file_fts, 0.0, 1.0, 2.2) lexical_score
811
861
  FROM file_fts WHERE file_fts MATCH ? ORDER BY lexical_score LIMIT ?
812
- `).all(ftsQuery(terms), Math.max(limit * 4, 30));
862
+ `).all(ftsQuery(terms), Math.max(limit * 4, 30)).filter((row) => pathAllowed(row.path));
813
863
  addRankedList(scores, lexicalRows, "lexical", 1.5);
814
864
  }
815
865
 
@@ -820,7 +870,7 @@ export async function searchContextIndexV2(cwd, query, options = {}) {
820
870
  ORDER BY CASE WHEN lower(name) = ? THEN 0 ELSE 1 END, length(name) LIMIT ?
821
871
  `);
822
872
  for (const term of terms.slice(0, 8)) {
823
- symbolRows.push(...symbolStatement.all(term, `%${term}%`, term, Math.max(limit * 2, 20)));
873
+ symbolRows.push(...symbolStatement.all(term, `%${term}%`, term, Math.max(limit * 2, 20)).filter((row) => pathAllowed(row.file_path)));
824
874
  }
825
875
  const dedupedSymbols = [...new Map(symbolRows.map((row) => [`${row.file_path}:${row.line}`, row])).values()];
826
876
  addRankedList(scores, dedupedSymbols, "symbol", 2.2);
@@ -841,11 +891,19 @@ export async function searchContextIndexV2(cwd, query, options = {}) {
841
891
  addRankedList(scores, feedbackRows, "feedback", 0.45);
842
892
 
843
893
  const graphSeeds = new Map([...scores.entries()].map(([filePath, value]) => [filePath, value.score]));
844
- const importRows = db.prepare("SELECT file_path, target_path FROM imports WHERE target_path IS NOT NULL").all();
894
+ const importRows = db.prepare("SELECT file_path, target_path FROM imports WHERE target_path IS NOT NULL")
895
+ .all()
896
+ .filter((row) => pathAllowed(row.file_path) && pathAllowed(row.target_path));
845
897
  const graphRows = personalizedPageRank(importRows, graphSeeds).slice(0, Math.max(limit * 3, 30));
846
898
  addRankedList(scores, graphRows, "graph", 1);
847
899
 
848
- const ranked = [...scores.values()].sort((left, right) => right.score - left.score).slice(0, limit);
900
+ const ranked = [...scores.values()]
901
+ .filter((candidate) => (
902
+ shouldIndexPath(candidate.path, { excludePatterns })
903
+ && projectFileInfo(cwd, candidate.path, projectRoot, directoryCache)
904
+ ))
905
+ .sort((left, right) => right.score - left.score)
906
+ .slice(0, limit);
849
907
  const symbolLookup = db.prepare("SELECT name, kind, line, end_line, signature FROM symbols WHERE file_path = ? ORDER BY line LIMIT 12");
850
908
  const results = ranked.map((candidate) => {
851
909
  const matchingSymbols = candidate.rows
@@ -897,9 +955,15 @@ function lineRangesForFile(text, result, terms, includeCode) {
897
955
  }
898
956
 
899
957
  export async function buildContextPack(cwd, query, options = {}) {
958
+ const excludePatterns = requireExplicitExcludePatterns(options, "buildContextPack");
900
959
  const budgetTokens = clampInteger(options.budgetTokens, DEFAULT_PACK_TOKENS, 200, 50_000);
901
960
  const includeCode = options.includeCode !== false;
902
- const search = await searchContextIndexV2(cwd, query, { limit: clampInteger(options.limit, 16, 1, 50) });
961
+ const projectRoot = fs.realpathSync.native(cwd);
962
+ const directoryCache = new Map();
963
+ const search = await searchContextIndexV2(cwd, query, {
964
+ limit: clampInteger(options.limit, 16, 1, 50),
965
+ excludePatterns
966
+ });
903
967
  const selected = [];
904
968
  const mapBudget = Math.min(1_200, Math.max(40, Math.trunc(budgetTokens * 0.2)));
905
969
  const mapLines = [];
@@ -935,7 +999,9 @@ export async function buildContextPack(cwd, query, options = {}) {
935
999
  for (const result of search.results) {
936
1000
  let text;
937
1001
  try {
938
- text = fs.readFileSync(path.resolve(cwd, result.path), "utf8");
1002
+ const file = projectFileInfo(cwd, result.path, projectRoot, directoryCache);
1003
+ if (!file) continue;
1004
+ text = fs.readFileSync(file.absolute, "utf8");
939
1005
  } catch {
940
1006
  continue;
941
1007
  }
@@ -979,7 +1045,10 @@ export async function buildContextPack(cwd, query, options = {}) {
979
1045
  }
980
1046
 
981
1047
  export async function buildTestImpact(cwd, changedFiles = [], options = {}) {
982
- const status = await contextIndexV2Status(cwd);
1048
+ const excludePatterns = requireExplicitExcludePatterns(options, "buildTestImpact");
1049
+ const projectRoot = fs.realpathSync.native(cwd);
1050
+ const directoryCache = new Map();
1051
+ const status = await contextIndexV2Status(cwd, { excludePatterns });
983
1052
  const normalizedChanged = [...new Set((changedFiles.length > 0 ? changedFiles : gitChangedFiles(cwd)).map(normalizeRelative).filter(Boolean))];
984
1053
  if (!status.exists) return { changedFiles: normalizedChanged, impactedFiles: [], tests: [], status };
985
1054
  const db = await openDatabase(cwd, { create: false });
@@ -993,13 +1062,22 @@ export async function buildTestImpact(cwd, changedFiles = [], options = {}) {
993
1062
  const current = queue.shift();
994
1063
  if (current.depth >= maxDepth) continue;
995
1064
  for (const row of reverseStatement.all(current.filePath)) {
1065
+ if (
1066
+ !shouldIndexPath(row.file_path, { excludePatterns })
1067
+ || !projectFileInfo(cwd, row.file_path, projectRoot, directoryCache)
1068
+ ) continue;
996
1069
  if (seen.has(row.file_path)) continue;
997
1070
  seen.add(row.file_path);
998
1071
  impacted.push({ path: row.file_path, via: current.filePath, depth: current.depth + 1 });
999
1072
  queue.push({ filePath: row.file_path, depth: current.depth + 1 });
1000
1073
  }
1001
1074
  }
1002
- const allFiles = db.prepare("SELECT path FROM files").all().map((row) => row.path);
1075
+ const allFiles = db.prepare("SELECT path FROM files").all()
1076
+ .map((row) => row.path)
1077
+ .filter((filePath) => (
1078
+ shouldIndexPath(filePath, { excludePatterns })
1079
+ && projectFileInfo(cwd, filePath, projectRoot, directoryCache)
1080
+ ));
1003
1081
  const stemTerms = normalizedChanged.flatMap((filePath) => {
1004
1082
  const basename = path.posix.basename(filePath, path.posix.extname(filePath)).replace(/\.(?:test|spec)$/, "");
1005
1083
  return basename.split(/[-_.]/).filter((term) => term.length >= 3);
@@ -0,0 +1,136 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { resolveProjectProfileDocument } from "../capabilities/project-profile.js";
6
+
7
+ const DEFAULT_CONTEXT_INDEX_PATH = ".pi/context-index.json";
8
+ const CONTEXT_ENGINE_STATE_PATTERN = ".pi/piagent-state/**";
9
+ const EXCLUDE_POLICY_VERSION = 1;
10
+
11
+ function stringArray(value) {
12
+ return Array.isArray(value)
13
+ ? value.filter((item) => typeof item === "string" && item.trim().length > 0)
14
+ : [];
15
+ }
16
+
17
+ function uniqueStrings(values) {
18
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
19
+ }
20
+
21
+ function compareCodePoints(left, right) {
22
+ return left < right ? -1 : left > right ? 1 : 0;
23
+ }
24
+
25
+ function normalizePattern(value) {
26
+ return String(value ?? "")
27
+ .trim()
28
+ .replaceAll("\\", "/")
29
+ .replace(/^\.\/+/, "")
30
+ .replace(/\/+/g, "/");
31
+ }
32
+
33
+ function readJsonObject(file, label, { optional = false } = {}) {
34
+ if (!fs.existsSync(file)) {
35
+ if (optional) return undefined;
36
+ throw new Error(`${label} is missing at ${file}`);
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
41
+ } catch {
42
+ throw new Error(`${label} is not valid JSON: ${file}`);
43
+ }
44
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
45
+ throw new Error(`${label} must contain a JSON object: ${file}`);
46
+ }
47
+ return parsed;
48
+ }
49
+
50
+ export function contextIndexStatePath(profile) {
51
+ const configured = profile?.contextIndex?.path;
52
+ return typeof configured === "string" && configured.trim()
53
+ ? configured.trim()
54
+ : DEFAULT_CONTEXT_INDEX_PATH;
55
+ }
56
+
57
+ export function effectiveProtectedPaths(policy = {}, profile = {}) {
58
+ const policyProtectedPaths = stringArray(policy.protectedPaths);
59
+ const baseReadProtectedPaths = Array.isArray(policy.shellProtectedPaths)
60
+ ? stringArray(policy.shellProtectedPaths)
61
+ : policyProtectedPaths;
62
+ const profileProtectedPaths = stringArray(profile.protectedPaths);
63
+ const profileShellProtectedPaths = Array.isArray(profile.shellProtectedPaths)
64
+ ? stringArray(profile.shellProtectedPaths)
65
+ : profileProtectedPaths;
66
+ const readOnlyPaths = stringArray(profile.readOnlyPaths);
67
+ const contextIndexPath = contextIndexStatePath(profile);
68
+ return {
69
+ readProtectedPaths: uniqueStrings([
70
+ ...baseReadProtectedPaths,
71
+ ...profileProtectedPaths,
72
+ contextIndexPath
73
+ ]),
74
+ writeProtectedPaths: uniqueStrings([
75
+ ...policyProtectedPaths,
76
+ ...profileProtectedPaths,
77
+ ...readOnlyPaths,
78
+ contextIndexPath
79
+ ]),
80
+ shellProtectedPaths: uniqueStrings([
81
+ ...baseReadProtectedPaths,
82
+ ...profileShellProtectedPaths,
83
+ ...readOnlyPaths,
84
+ contextIndexPath
85
+ ]),
86
+ readOnlyPaths: uniqueStrings(readOnlyPaths)
87
+ };
88
+ }
89
+
90
+ export function normalizeContextIndexExcludePatterns(patterns) {
91
+ return uniqueStrings(stringArray(patterns).map(normalizePattern)).sort(compareCodePoints);
92
+ }
93
+
94
+ export function contextIndexExcludePatterns(policy, profile) {
95
+ const effective = effectiveProtectedPaths(policy, profile);
96
+ return normalizeContextIndexExcludePatterns([
97
+ ...effective.readProtectedPaths,
98
+ ...effective.writeProtectedPaths,
99
+ ...effective.shellProtectedPaths,
100
+ CONTEXT_ENGINE_STATE_PATTERN
101
+ ]);
102
+ }
103
+
104
+ export function contextIndexExcludeDigest(patterns) {
105
+ return crypto
106
+ .createHash("sha256")
107
+ .update(JSON.stringify({
108
+ version: EXCLUDE_POLICY_VERSION,
109
+ patterns: normalizeContextIndexExcludePatterns(patterns)
110
+ }))
111
+ .digest("hex");
112
+ }
113
+
114
+ export function contextIndexExcludePolicyVersion() {
115
+ return EXCLUDE_POLICY_VERSION;
116
+ }
117
+
118
+ export function loadProjectContextIndexPolicy(platformRoot, projectPath, options = {}) {
119
+ const policyPath = path.join(platformRoot, "packages", "piagent-core", "policies", "base-policy.json");
120
+ const policy = readJsonObject(policyPath, "Piagent base policy");
121
+ const explicitProfilePath = typeof options.profilePath === "string" && options.profilePath.trim()
122
+ ? path.resolve(options.profilePath)
123
+ : undefined;
124
+ const profilePath = explicitProfilePath ?? path.join(projectPath, ".pi", "piagent-profile.json");
125
+ const stored = readJsonObject(profilePath, "Piagent project profile", { optional: !explicitProfilePath });
126
+ const profile = stored
127
+ ? resolveProjectProfileDocument(platformRoot, stored).profile
128
+ : {};
129
+ return {
130
+ policy,
131
+ profile,
132
+ profilePath,
133
+ profileSource: explicitProfilePath ? "env" : stored ? "project" : "fallback",
134
+ excludePatterns: contextIndexExcludePatterns(policy, profile)
135
+ };
136
+ }