@agentprojectcontext/apx 1.67.0 → 1.69.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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/core/config/redact.js +44 -0
  3. package/src/host/daemon/api/agents.js +37 -1
  4. package/src/host/daemon/api/config.js +17 -5
  5. package/src/host/daemon/api/sessions.js +9 -0
  6. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js +803 -0
  7. package/src/interfaces/web/dist/assets/index-_2zKBH4O.js.map +1 -0
  8. package/src/interfaces/web/dist/assets/index-xQYf6_ab.css +1 -0
  9. package/src/interfaces/web/dist/index.html +2 -2
  10. package/src/interfaces/web/package-lock.json +3 -3
  11. package/src/interfaces/web/src/components/config/ConfigTabsEditor.tsx +46 -31
  12. package/src/interfaces/web/src/components/config/project-config-sections.ts +9 -11
  13. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +162 -0
  14. package/src/interfaces/web/src/i18n/en.ts +10 -0
  15. package/src/interfaces/web/src/i18n/es.ts +10 -0
  16. package/src/interfaces/web/src/lib/api/agents.ts +2 -1
  17. package/src/interfaces/web/src/lib/api/sessions.ts +2 -1
  18. package/src/interfaces/web/src/screens/ProjectScreen.tsx +50 -60
  19. package/src/interfaces/web/src/screens/base/SessionsTab.tsx +11 -5
  20. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +169 -47
  21. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +108 -34
  22. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +72 -16
  23. package/src/interfaces/web/src/screens/project/ConfigTab.tsx +110 -25
  24. package/src/interfaces/web/src/screens/project/MemoriesTab.tsx +7 -128
  25. package/src/interfaces/web/src/screens/project/Overview.tsx +93 -3
  26. package/src/interfaces/web/src/types/daemon.ts +10 -0
  27. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js +0 -803
  28. package/src/interfaces/web/dist/assets/index-B3pEwe1m.js.map +0 -1
  29. package/src/interfaces/web/dist/assets/index-BPGECxzm.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.67.0",
3
+ "version": "1.69.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -67,6 +67,50 @@ export function redactConfig(cfg) {
67
67
  return out;
68
68
  }
69
69
 
70
+ /** Walk a dotted path on an object, returning the value or undefined. */
71
+ function getDotted(obj, dotted) {
72
+ let cur = obj;
73
+ for (const part of dotted.split(".")) {
74
+ if (!cur || typeof cur !== "object") return undefined;
75
+ cur = cur[part];
76
+ }
77
+ return cur;
78
+ }
79
+
80
+ /** Set a dotted path on an object, creating intermediate objects. */
81
+ function setDotted(obj, dotted, value) {
82
+ const parts = dotted.split(".");
83
+ let cur = obj;
84
+ for (let i = 0; i < parts.length - 1; i++) {
85
+ if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
86
+ cur = cur[parts[i]];
87
+ }
88
+ cur[parts[parts.length - 1]] = value;
89
+ }
90
+
91
+ /**
92
+ * Merge a possibly-redacted config `next` against the prior on-disk config.
93
+ * Anywhere a secret path in `next` holds a marker (the value a redacted view
94
+ * echoes back), restore the prior real secret — so saving the redacted view
95
+ * never clobbers a real key with the literal "*** set ***" string. Mutates and
96
+ * returns `next`.
97
+ */
98
+ export function mergeRedactedSecrets(next, prior) {
99
+ if (!next || typeof next !== "object") return next;
100
+ for (const dotted of SECRET_PATHS) {
101
+ if (dotted.includes("*")) continue;
102
+ if (isSecretMarker(getDotted(next, dotted))) {
103
+ const priorVal = getDotted(prior, dotted);
104
+ if (typeof priorVal === "string" && priorVal.length) setDotted(next, dotted, priorVal);
105
+ }
106
+ }
107
+ const nextChannels = next?.telegram?.channels;
108
+ if (Array.isArray(nextChannels)) {
109
+ next.telegram.channels = mergeRedactedChannels(nextChannels, prior?.telegram?.channels);
110
+ }
111
+ return next;
112
+ }
113
+
70
114
  /** Redact a single Telegram channel record. */
71
115
  export function redactChannel(channel) {
72
116
  if (!channel?.bot_token) return channel;
@@ -25,6 +25,40 @@ import {
25
25
  import { agentToResponse } from "./shared.js";
26
26
  import { normalizeVaultPatch } from "#core/apc/agents-vault.js";
27
27
  import { PERMISSION_MODES } from "#core/constants/permissions.js";
28
+ import { listConversations } from "#core/stores/conversations.js";
29
+ import { listTasks } from "#core/stores/tasks.js";
30
+ import { listRoutines } from "#core/stores/routines.js";
31
+ import { readProjectMessages } from "#core/stores/messages.js";
32
+
33
+ // Attach a per-agent activity summary ({ threads, records, tasks, heartbeats })
34
+ // to a list of agent responses. Reads each store once and tallies by agent, so
35
+ // the whole list costs O(stores) rather than O(agents × stores). Gated behind
36
+ // `?stats=1` on the list endpoint since it touches the message ledger.
37
+ function attachAgentStats(p, agents) {
38
+ const store = p.storagePath || p.path;
39
+ const tally = (rows, key) => {
40
+ const m = Object.create(null);
41
+ for (const r of rows) {
42
+ const a = typeof key === "function" ? key(r) : r?.[key];
43
+ if (a) m[a] = (m[a] || 0) + 1;
44
+ }
45
+ return m;
46
+ };
47
+ let tasksByAgent = {}, hbByAgent = {}, recByAgent = {};
48
+ try { tasksByAgent = tally(listTasks(store, { state: "all" }), "agent"); } catch { /* no task store */ }
49
+ try { hbByAgent = tally(listRoutines(store), (r) => r?.spec?.agent); } catch { /* no routines */ }
50
+ try { recByAgent = tally(readProjectMessages(store, { limit: 1000 }), "agent_slug"); } catch { /* no ledger */ }
51
+ for (const a of agents) {
52
+ let threads = 0;
53
+ try { threads = listConversations(store, a.slug).length; } catch { /* none */ }
54
+ a.stats = {
55
+ threads,
56
+ records: recByAgent[a.slug] || 0,
57
+ tasks: tasksByAgent[a.slug] || 0,
58
+ heartbeats: hbByAgent[a.slug] || 0,
59
+ };
60
+ }
61
+ }
28
62
 
29
63
  // Autonomy mirrors the super-agent permission modes (total/automatico/permiso).
30
64
  // An invalid value is dropped rather than persisted so a typo can't silently
@@ -123,7 +157,9 @@ export function register(app, { projects, project }) {
123
157
  app.get("/projects/:pid/agents", (req, res) => {
124
158
  const p = project(req, res);
125
159
  if (!p) return;
126
- res.json(readAgents(p.path).map(agentToResponse));
160
+ const agents = readAgents(p.path).map(agentToResponse);
161
+ if (req.query.stats === "1") attachAgentStats(p, agents);
162
+ res.json(agents);
127
163
  });
128
164
 
129
165
  app.get("/projects/:pid/agents/:slug", (req, res) => {
@@ -11,6 +11,7 @@ import {
11
11
  unsetDottedKey,
12
12
  } from "../project-config.js";
13
13
  import { apcProjectFile, apcProjectConfigFile } from "#core/apc/paths.js";
14
+ import { redactConfig, mergeRedactedSecrets, isSecretMarker } from "#core/config/redact.js";
14
15
 
15
16
  const projectJsonPath = apcProjectFile;
16
17
 
@@ -30,9 +31,12 @@ export function register(app, { projects, project }) {
30
31
  app.get("/projects/:pid/config", (req, res) => {
31
32
  const p = project(req, res);
32
33
  if (!p) return;
34
+ // Redact secrets (engine api_keys, telegram bot tokens) the same way the
35
+ // global admin config does — the UI shows "…XXXXX" and echoes the marker
36
+ // back unchanged, which the write paths below restore from disk.
33
37
  res.json({
34
- effective: p.config || {},
35
- project_only: readProjectConfig(p.path),
38
+ effective: redactConfig(p.config || {}),
39
+ project_only: redactConfig(readProjectConfig(p.path)),
36
40
  project_config_path: apcProjectConfigFile(p.path),
37
41
  apc_project: readProjectJson(p.path),
38
42
  project_json_path: projectJsonPath(p.path),
@@ -45,7 +49,10 @@ export function register(app, { projects, project }) {
45
49
  const body = req.body || {};
46
50
  if (typeof body !== "object" || Array.isArray(body))
47
51
  return res.status(400).json({ error: "body must be a JSON object" });
48
- writeProjectConfig(p.path, body);
52
+ // A redacted secret echoed back means "keep the real value" — restore it
53
+ // from disk so a full replace of the redacted view can't wipe secrets.
54
+ const merged = mergeRedactedSecrets(body, readProjectConfig(p.path));
55
+ writeProjectConfig(p.path, merged);
49
56
  projects.rebuild(p.id);
50
57
  res.json({ ok: true });
51
58
  });
@@ -56,14 +63,19 @@ export function register(app, { projects, project }) {
56
63
  const { set, unset } = req.body || {};
57
64
  const cfg = readProjectConfig(p.path);
58
65
  if (set && typeof set === "object") {
59
- for (const [k, v] of Object.entries(set)) setDottedKey(cfg, k, v);
66
+ // Skip echoed secret markers so a redacted field left untouched keeps its
67
+ // real value instead of being overwritten with the marker string.
68
+ for (const [k, v] of Object.entries(set)) {
69
+ if (isSecretMarker(v)) continue;
70
+ setDottedKey(cfg, k, v);
71
+ }
60
72
  }
61
73
  if (Array.isArray(unset)) {
62
74
  for (const k of unset) unsetDottedKey(cfg, k);
63
75
  }
64
76
  writeProjectConfig(p.path, cfg);
65
77
  projects.rebuild(p.id);
66
- res.json({ ok: true, project_only: cfg });
78
+ res.json({ ok: true, project_only: redactConfig(cfg) });
67
79
  });
68
80
 
69
81
  app.put("/projects/:pid/apc-project", (req, res) => {
@@ -25,12 +25,21 @@ export function register(app, { projects, project }) {
25
25
  const engineId = req.query.engine ? String(req.query.engine) : null;
26
26
  const q = req.query.q ? String(req.query.q) : "";
27
27
  const deep = req.query.deep === "1" || req.query.deep === "true";
28
+ // Optional ?cwd= scopes to sessions whose working dir is that folder (or a
29
+ // child of it) — used by the per-project Sessions view. Omitted = all.
30
+ const cwd = req.query.cwd ? String(req.query.cwd).replace(/\/+$/, "") : "";
28
31
  let rows = [];
29
32
  try {
30
33
  rows = collectAllSessions({}, { engineId });
31
34
  } catch (e) {
32
35
  return res.status(500).json({ error: e.message, meta: { total: 0, offset: 0, limit: null, pageSize: 0, page: 1, pageCount: 1 }, data: [] });
33
36
  }
37
+ if (cwd) {
38
+ rows = rows.filter((r) => {
39
+ const c = (r.cwd || "").replace(/\/+$/, "");
40
+ return c === cwd || c.startsWith(cwd + "/");
41
+ });
42
+ }
34
43
  if (q.trim()) {
35
44
  // filterSessionsByQuery already de-dupes and sorts newest-first.
36
45
  rows = filterSessionsByQuery(rows, { query: q, deep });