nolo-cli 0.1.41 → 0.1.42

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 (136) hide show
  1. package/README.md +27 -12
  2. package/agent-runtime/agentThread.ts +222 -0
  3. package/agent-runtime/agentThreadAdmission.ts +74 -0
  4. package/agent-runtime/dialogWritePlan.ts +42 -0
  5. package/agent-runtime/hostAdapter.ts +1 -0
  6. package/agent-runtime/index.ts +43 -0
  7. package/agent-runtime/localLoop.ts +31 -2
  8. package/agent-runtime/localToolPolicy.ts +1 -0
  9. package/agent-runtime/localWorkspaceTools.ts +1258 -78
  10. package/agent-runtime/noloWorkspaceTools.ts +20 -1
  11. package/agent-runtime/runtimeToolPolicy.ts +1 -5
  12. package/agent-runtime/runtimeToolSurface.ts +1 -0
  13. package/agentAliases.ts +29 -9
  14. package/agentInternalCommandEntries.ts +11 -3
  15. package/agentMachineCommands.ts +1 -0
  16. package/agentRecordCommands.ts +2 -2
  17. package/agentRecordHelpers.ts +59 -6
  18. package/agentRunCommand.ts +84 -19
  19. package/ai/agent/buildSystemPrompt.ts +14 -0
  20. package/ai/agent/cliExecutor.ts +202 -43
  21. package/ai/agent/createAgentSchema.ts +2 -2
  22. package/ai/agent/liveAudioModel.ts +1 -1
  23. package/ai/agent/streamAgentChatTurn.ts +154 -20
  24. package/ai/agent/streamAgentChatTurnUtils.ts +63 -0
  25. package/ai/ai.locale.ts +70 -0
  26. package/ai/chat/inlineImageUrlsForCustomProvider.ts +7 -2
  27. package/ai/chat/sendOpenAICompletionsRequest.ts +25 -1
  28. package/ai/llm/kimi.ts +0 -1
  29. package/ai/llm/openrouterModels.ts +27 -2
  30. package/ai/memory/policy.ts +49 -0
  31. package/ai/memory/queryShared.ts +10 -4
  32. package/ai/memory/recentRelationshipRecap.ts +4 -0
  33. package/ai/memory/remember.ts +48 -104
  34. package/ai/memory/scope.ts +74 -0
  35. package/ai/memory/types.ts +6 -0
  36. package/ai/memory/understanding.ts +10 -18
  37. package/ai/policy/runtimePolicy.ts +95 -23
  38. package/ai/tools/agent/agentTools.ts +0 -21
  39. package/ai/tools/agent/startAgentDialogTool.ts +2 -1
  40. package/ai/tools/index.ts +2 -20
  41. package/ai/tools/prepareTools.ts +171 -1
  42. package/ai/tools/readXhsProfileTool.ts +80 -69
  43. package/ai/tools/table/createTableTool.ts +11 -0
  44. package/ai/tools/toolPacks.ts +19 -0
  45. package/ai/types.ts +3 -0
  46. package/app/favorite/deletedFavoriteProjection.ts +31 -0
  47. package/app/hooks/deleteDbKey.ts +24 -3
  48. package/app/i18n/translations/interface.locale.ts +48 -16
  49. package/app/types/appSummary.ts +4 -0
  50. package/app/utils/desktopAgentRuntimeTurnClient.ts +2 -0
  51. package/auth/adminPermissions.ts +64 -4
  52. package/auth/routes.ts +5 -0
  53. package/chat/chat.locale.ts +4 -0
  54. package/chat/dialog/actions/handleSendMessageAction.ts +7 -2
  55. package/chat/dialog/dialogAttachmentCleanup.ts +135 -0
  56. package/chat/dialog/dialogSlice.ts +117 -4
  57. package/chat/messages/activityCompletion.ts +97 -0
  58. package/chat/messages/imagePayloadPersistence.ts +22 -0
  59. package/chat/messages/messageContent.ts +20 -16
  60. package/chat/messages/messageSlice.ts +20 -1
  61. package/chat/messages/toolThunks.ts +32 -11
  62. package/chat/messages/types.ts +8 -1
  63. package/chat/messages/web/toolDisplayName.ts +686 -0
  64. package/cli/agentAliases.ts +29 -9
  65. package/cli/agentInternalCommandEntries.ts +11 -3
  66. package/cli/agentMachineCommands.ts +1 -0
  67. package/cli/agentRecordCommands.ts +2 -2
  68. package/cli/agentRecordHelpers.ts +59 -6
  69. package/cli/agentRunCommand.ts +84 -19
  70. package/cli/client/agentRun.ts +65 -253
  71. package/cli/client/localRuntimeAdapter.ts +179 -53
  72. package/cli/commandRegistry.ts +7 -4
  73. package/cli/dialogAttachmentCleanup.ts +151 -0
  74. package/cli/dialogCommands.ts +163 -61
  75. package/cli/dialogInternalCommandEntries.ts +1 -1
  76. package/cli/docCreateCommands.ts +345 -0
  77. package/cli/docInternalCommandEntries.ts +11 -0
  78. package/cli/docPageHelpers.ts +80 -0
  79. package/cli/docSkillHelpers.ts +67 -0
  80. package/cli/docSpaceHelpers.ts +46 -0
  81. package/cli/internalCommandEntries.ts +4 -0
  82. package/cli/machineCommands.ts +18 -34
  83. package/cli/memoryCommands.ts +253 -0
  84. package/cli/memoryInternalCommandEntries.ts +9 -0
  85. package/cli/qoderUsageProbe.ts +252 -0
  86. package/cli/scriptCommandEntries.ts +0 -3
  87. package/cli/secretScan.ts +31 -0
  88. package/cli/tableCommands.ts +248 -13
  89. package/cli/workflowInternalCommandEntries.ts +2 -3
  90. package/client/agentRun.test.ts +68 -84
  91. package/client/agentRun.ts +65 -253
  92. package/client/localRuntimeAdapter.test.ts +505 -74
  93. package/client/localRuntimeAdapter.ts +179 -53
  94. package/commandRegistry.ts +7 -4
  95. package/connector-experimental/capabilities.ts +10 -9
  96. package/create/space/sidebarVisibleTypes.ts +19 -0
  97. package/dialogAttachmentCleanup.ts +151 -0
  98. package/dialogCommands.ts +163 -61
  99. package/dialogInternalCommandEntries.ts +1 -1
  100. package/docCreateCommands.ts +345 -0
  101. package/docInternalCommandEntries.ts +11 -0
  102. package/docPageHelpers.ts +80 -0
  103. package/docSkillHelpers.ts +67 -0
  104. package/docSpaceHelpers.ts +46 -0
  105. package/integrations/google/models.ts +4 -4
  106. package/integrations/openai/generateOpenAIRequestBody.ts +0 -3
  107. package/integrations/xhs-reader/types.ts +36 -3
  108. package/integrations/xhs-reader/url.ts +24 -1
  109. package/internalCommandEntries.ts +4 -0
  110. package/machineCommands.ts +18 -34
  111. package/memoryCommands.ts +253 -0
  112. package/memoryInternalCommandEntries.ts +9 -0
  113. package/package.json +133 -114
  114. package/qoderUsageProbe.ts +252 -0
  115. package/render/contentIcon/types.ts +167 -0
  116. package/render/page/docSlice.ts +30 -3
  117. package/render/page/types.ts +2 -0
  118. package/render/table/createTableAction.ts +4 -0
  119. package/render/table/tableSlice.ts +61 -0
  120. package/render/table/types.ts +10 -0
  121. package/scriptCommandEntries.ts +0 -3
  122. package/secretScan.ts +31 -0
  123. package/server/handlers/agentRun/types.ts +24 -23
  124. package/tableCommands.ts +248 -13
  125. package/workflowInternalCommandEntries.ts +2 -3
  126. package/ai/tools/agent/taskRunTool.ts +0 -112
  127. package/cli/client/taskRunPrompt.ts +0 -27
  128. package/cli/taskRunCommand.ts +0 -265
  129. package/client/taskRunPrompt.ts +0 -27
  130. package/integrations/xhs-reader/analyze/profileAnalyzer.ts +0 -205
  131. package/integrations/xhs-reader/backends/playwrightProfileCollector.ts +0 -624
  132. package/integrations/xhs-reader/bridge/readXhsProfileWithBridge.ts +0 -227
  133. package/integrations/xhs-reader/client/xhsWebApiClient.ts +0 -229
  134. package/integrations/xhs-reader/normalize.ts +0 -390
  135. package/integrations/xhs-reader/orchestrator.ts +0 -178
  136. package/taskRunCommand.ts +0 -265
@@ -0,0 +1,252 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ export type QoderQuotaUsage = {
6
+ userId: string | null;
7
+ userType: string | null;
8
+ expiresAt: string | null;
9
+ expiresAtMs: number | null;
10
+ isQuotaExceeded: boolean | null;
11
+ totalUsagePercentage: number | null;
12
+ quota: {
13
+ total: number | null;
14
+ used: number | null;
15
+ remaining: number | null;
16
+ percentage: number | null;
17
+ unit: string | null;
18
+ };
19
+ source: "qoder-cli-log";
20
+ logPath?: string;
21
+ capturedAt?: string;
22
+ capturedAtMs?: number | null;
23
+ };
24
+
25
+ export type QoderUsageFreshness = "fresh" | "stale" | "uncertain";
26
+
27
+ export type QoderUsageProbeResult = {
28
+ ok: boolean;
29
+ provider: "qoder";
30
+ usage?: (QoderQuotaUsage & { logPath: string });
31
+ limitations?: string[];
32
+ freshness?: QoderUsageFreshness;
33
+ ageSeconds?: number | null;
34
+ staleThresholdSeconds?: number;
35
+ scannedLogCount?: number;
36
+ reason?: string;
37
+ error?: string;
38
+ };
39
+
40
+ const QUOTA_RESPONSE_MARKER = "/api/v2/quota/usage response:";
41
+
42
+ const asFiniteNumber = (value: unknown): number | null =>
43
+ typeof value === "number" && Number.isFinite(value) ? value : null;
44
+
45
+ const asString = (value: unknown): string | null =>
46
+ typeof value === "string" && value.trim() ? value.trim() : null;
47
+
48
+ const asBoolean = (value: unknown): boolean | null =>
49
+ typeof value === "boolean" ? value : null;
50
+
51
+ function normalizeQuotaPayload(payload: any): QoderQuotaUsage | null {
52
+ if (!payload || typeof payload !== "object") return null;
53
+ const quota = payload.userQuota && typeof payload.userQuota === "object"
54
+ ? payload.userQuota
55
+ : {};
56
+ const expiresAtMs = asFiniteNumber(payload.expiresAt);
57
+ return {
58
+ userId: asString(payload.userId),
59
+ userType: asString(payload.userType),
60
+ expiresAt: expiresAtMs != null ? new Date(expiresAtMs).toISOString() : null,
61
+ expiresAtMs,
62
+ isQuotaExceeded: asBoolean(payload.isQuotaExceeded),
63
+ totalUsagePercentage: asFiniteNumber(payload.totalUsagePercentage),
64
+ quota: {
65
+ total: asFiniteNumber(quota.total),
66
+ used: asFiniteNumber(quota.used),
67
+ remaining: asFiniteNumber(quota.remaining),
68
+ percentage: asFiniteNumber(quota.percentage),
69
+ unit: asString(quota.unit),
70
+ },
71
+ source: "qoder-cli-log",
72
+ };
73
+ }
74
+
75
+ function isRealQuotaResponseLine(line: string): boolean {
76
+ const markerIndex = line.indexOf(QUOTA_RESPONSE_MARKER);
77
+ if (markerIndex < 0) return false;
78
+ const before = line.slice(0, markerIndex);
79
+ if (!before.includes("[qoderApi]")) return false;
80
+ const jsonStart = markerIndex + QUOTA_RESPONSE_MARKER.length;
81
+ const after = line.slice(jsonStart).trimStart();
82
+ return after.startsWith("{");
83
+ }
84
+
85
+ export function parseQoderQuotaUsageFromText(text: string): QoderQuotaUsage | null {
86
+ let latest: QoderQuotaUsage | null = null;
87
+ for (const line of text.split(/\r?\n/)) {
88
+ if (!isRealQuotaResponseLine(line)) continue;
89
+ const markerIndex = line.indexOf(QUOTA_RESPONSE_MARKER);
90
+ const jsonText = line.slice(markerIndex + QUOTA_RESPONSE_MARKER.length).trim();
91
+ try {
92
+ const parsed = normalizeQuotaPayload(JSON.parse(jsonText));
93
+ if (parsed) latest = parsed;
94
+ } catch {
95
+ // Ignore malformed historical log lines.
96
+ }
97
+ }
98
+ return latest;
99
+ }
100
+
101
+ export function extractLeadingIsoTimestamp(dirName: string): string | null {
102
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})(?:-(\d{3}))?([+-])(\d{2})-(\d{2})/.exec(dirName);
103
+ if (!match) return null;
104
+ const [, Y, M, D, h, m, s, ms, sign, offH, offM] = match;
105
+ const local = Date.UTC(Number(Y), Number(M) - 1, Number(D), Number(h), Number(m), Number(s), Number(ms ?? 0));
106
+ const offsetMs = (Number(offH) * 60 + Number(offM)) * 60_000;
107
+ const utcMs = sign === "+" ? local - offsetMs : local + offsetMs;
108
+ if (!Number.isFinite(utcMs)) return null;
109
+ return new Date(utcMs).toISOString();
110
+ }
111
+
112
+ type RunLogEntry = {
113
+ logPath: string;
114
+ capturedAtMs: number | null;
115
+ capturedAt: string | null;
116
+ dirName: string;
117
+ };
118
+
119
+ function listQoderRunLogs(qoderHome: string): RunLogEntry[] {
120
+ const runsDir = join(qoderHome, "logs", "runs");
121
+ if (!existsSync(runsDir)) return [];
122
+ const entries: RunLogEntry[] = [];
123
+ for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
124
+ if (!entry.isDirectory()) continue;
125
+ const logPath = join(runsDir, entry.name, "qodercli.log");
126
+ if (!existsSync(logPath)) continue;
127
+ const capturedAt = extractLeadingIsoTimestamp(entry.name);
128
+ const capturedAtMs = capturedAt ? Date.parse(capturedAt) : null;
129
+ entries.push({
130
+ logPath,
131
+ capturedAtMs,
132
+ capturedAt,
133
+ dirName: entry.name,
134
+ });
135
+ }
136
+ entries.sort((a, b) => {
137
+ const aMs = a.capturedAtMs ?? statSync(a.logPath).mtimeMs;
138
+ const bMs = b.capturedAtMs ?? statSync(b.logPath).mtimeMs;
139
+ return bMs - aMs;
140
+ });
141
+ return entries;
142
+ }
143
+
144
+ export function findLatestQoderQuotaUsage(options: {
145
+ qoderHome?: string;
146
+ now?: number;
147
+ } = {}): (QoderQuotaUsage & { logPath: string; capturedAtMs: number | null; capturedAt: string | null }) | null {
148
+ const qoderHome = options.qoderHome || join(homedir(), ".qoder");
149
+ for (const run of listQoderRunLogs(qoderHome)) {
150
+ const parsed = parseQoderQuotaUsageFromText(readFileSync(run.logPath, "utf8"));
151
+ if (!parsed) continue;
152
+ return {
153
+ ...parsed,
154
+ logPath: run.logPath,
155
+ capturedAt: run.capturedAt,
156
+ capturedAtMs: run.capturedAtMs,
157
+ };
158
+ }
159
+ return null;
160
+ }
161
+
162
+ const DEFAULT_STALE_THRESHOLD_SECONDS = 6 * 60 * 60;
163
+
164
+ export function classifyQoderUsageFreshness(
165
+ usage: { capturedAtMs: number | null } | null,
166
+ options: { now?: number; staleThresholdSeconds?: number } = {},
167
+ ): QoderUsageFreshness {
168
+ if (!usage || usage.capturedAtMs == null) return "uncertain";
169
+ const now = options.now ?? Date.now();
170
+ const threshold = (options.staleThresholdSeconds ?? DEFAULT_STALE_THRESHOLD_SECONDS) * 1000;
171
+ const age = now - usage.capturedAtMs;
172
+ if (!Number.isFinite(age) || age < 0) return "uncertain";
173
+ return age > threshold ? "stale" : "fresh";
174
+ }
175
+
176
+ function readOption(args: string[], flag: string) {
177
+ const index = args.indexOf(flag);
178
+ return index >= 0 ? args[index + 1] : undefined;
179
+ }
180
+
181
+ export async function runQoderUsageCommand(
182
+ args: string[],
183
+ deps: {
184
+ env?: Record<string, string | undefined>;
185
+ output?: { write(chunk: string): unknown };
186
+ now?: () => number;
187
+ } = {},
188
+ ) {
189
+ const output = deps.output ?? process.stdout;
190
+ const agent = args[0]?.trim();
191
+ if (!agent || agent === "--help" || agent === "-h") {
192
+ output.write("Usage: nolo agent usage qoder [--qoder-home <path>] [--stale-threshold-seconds <n>]\n");
193
+ return agent ? 0 : 1;
194
+ }
195
+ if (agent.toLowerCase() !== "qoder") {
196
+ output.write(`[nolo] agent usage currently supports qoder only, received ${agent}.\n`);
197
+ return 1;
198
+ }
199
+ const qoderHome =
200
+ readOption(args, "--qoder-home") ||
201
+ deps.env?.QODER_HOME ||
202
+ join(homedir(), ".qoder");
203
+ const now = deps.now ? deps.now() : Date.now();
204
+ const thresholdArg = readOption(args, "--stale-threshold-seconds");
205
+ const staleThresholdSeconds = thresholdArg ? Number(thresholdArg) : DEFAULT_STALE_THRESHOLD_SECONDS;
206
+ const usage = findLatestQoderQuotaUsage({ qoderHome, now });
207
+ const freshness = classifyQoderUsageFreshness(usage, { now, staleThresholdSeconds });
208
+ const ageSeconds = usage?.capturedAtMs != null ? Math.max(0, Math.floor((now - usage.capturedAtMs) / 1000)) : null;
209
+
210
+ if (!usage) {
211
+ const result: QoderUsageProbeResult = {
212
+ ok: false,
213
+ provider: "qoder",
214
+ freshness,
215
+ ageSeconds,
216
+ staleThresholdSeconds,
217
+ limitations: [
218
+ "This probe only reads Qoder package Credits from local quota logs.",
219
+ "It does not currently read the Qwen3.7-Max daily free request quota; check Qoder's Usage Overview or CLI /usage for that counter.",
220
+ ],
221
+ error:
222
+ "No Qoder quota usage log found. Open `qoder`, run `/usage`, then retry this command.",
223
+ reason:
224
+ "Probe could not locate any real [qoderApi] quota response line in local run logs.",
225
+ };
226
+ output.write(JSON.stringify(result, null, 2));
227
+ output.write("\n");
228
+ return 1;
229
+ }
230
+
231
+ const result: QoderUsageProbeResult = {
232
+ ok: true,
233
+ provider: "qoder",
234
+ usage,
235
+ limitations: [
236
+ "This is package Credits usage only, parsed from Qoder's /api/v2/quota/usage log.",
237
+ "Qwen3.7-Max daily free requests are a separate counter and are not exposed in this probe's current log source.",
238
+ ],
239
+ freshness,
240
+ ageSeconds,
241
+ staleThresholdSeconds,
242
+ };
243
+ if (freshness !== "fresh") {
244
+ result.reason =
245
+ freshness === "stale"
246
+ ? `Latest local quota log is ${ageSeconds}s old (threshold ${staleThresholdSeconds}s); UI or server may show a different value.`
247
+ : "Latest local quota log has no reliable capture timestamp; treat numbers as uncertain.";
248
+ }
249
+ output.write(JSON.stringify(result, null, 2));
250
+ output.write("\n");
251
+ return freshness === "fresh" ? 0 : 2;
252
+ }
@@ -2,15 +2,12 @@ import { createScriptCommand, type CommandEntry } from "./cliCommandTypes";
2
2
 
3
3
  function getDocScriptCommands(): CommandEntry[] {
4
4
  return [
5
- createScriptCommand(["doc", "create"], "createDoc.ts", "Create a normal doc"),
6
5
  createScriptCommand(["doc", "read"], "readDoc.ts", "Read a normal doc"),
7
6
  createScriptCommand(["doc", "update"], "updateDoc.ts", "Update a normal doc"),
8
7
  createScriptCommand(["doc", "delete"], "deleteDoc.ts", "Delete a normal doc"),
9
- createScriptCommand(["skill-doc", "create"], "createSkillDoc.ts", "Create a skill doc"),
10
8
  createScriptCommand(["skill-doc", "read"], "readSkillDoc.ts", "Read a skill doc"),
11
9
  createScriptCommand(["skill-doc", "update"], "updateSkillDoc.ts", "Update a skill doc"),
12
10
  createScriptCommand(["skill-doc", "delete"], "deleteSkillDoc.ts", "Delete a skill doc"),
13
- createScriptCommand(["skill", "create-doc"], "createSkillDoc.ts", "Create a skill-backed doc"),
14
11
  ];
15
12
  }
16
13
 
@@ -0,0 +1,31 @@
1
+ export type SecretFinding = {
2
+ label: string;
3
+ line: number;
4
+ preview: string;
5
+ };
6
+
7
+ const SECRET_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
8
+ { label: "password", pattern: /\bpassword\s*[:=]\s*["']?[^"',\s#]{8,}/i },
9
+ { label: "psk", pattern: /\bpsk\s*[:=]\s*["']?[^"',\s#]{8,}/i },
10
+ { label: "api key", pattern: /\b(api[_-]?key|apikey)\s*[:=]\s*["']?[^"',\s#]{12,}/i },
11
+ { label: "token", pattern: /\b(token|secret)\s*[:=]\s*["']?[^"',\s#]{12,}/i },
12
+ { label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
13
+ ];
14
+
15
+ export function findPotentialSecrets(text: string): SecretFinding[] {
16
+ return text
17
+ .split(/\r?\n/)
18
+ .flatMap((line, index) =>
19
+ SECRET_PATTERNS.filter(({ pattern }) => pattern.test(line)).map(({ label }) => ({
20
+ label,
21
+ line: index + 1,
22
+ preview: line.length > 120 ? `${line.slice(0, 117)}...` : line,
23
+ }))
24
+ );
25
+ }
26
+
27
+ export function formatSecretFindings(findings: SecretFinding[]) {
28
+ return findings
29
+ .map((finding) => ` line ${finding.line} (${finding.label}): ${finding.preview}`)
30
+ .join("\n");
31
+ }
@@ -55,6 +55,11 @@ function parseOutputMode(raw: string): OutputMode {
55
55
  throw new Error(`--output must be one of full, raw, items, json, jsonl; got ${raw}`);
56
56
  }
57
57
 
58
+ function resolveQueryColumns(columns: string[] | undefined, includeActivity: boolean): string[] | undefined {
59
+ if (!includeActivity) return columns;
60
+ return Array.isArray(columns) ? includeTableActivityColumns(columns) : undefined;
61
+ }
62
+
58
63
  function parseTableArg(raw: string): { tenantId?: string; tableId?: string } {
59
64
  const trimmed = raw.trim();
60
65
  if (!trimmed) return {};
@@ -66,6 +71,32 @@ function parseTableArg(raw: string): { tenantId?: string; tableId?: string } {
66
71
  };
67
72
  }
68
73
 
74
+ function normalizeSpaceInput(raw: string): string {
75
+ const value = raw.trim();
76
+ if (!value) return "";
77
+ if (value.startsWith("http://") || value.startsWith("https://")) {
78
+ const match = new URL(value).pathname.match(/^\/space\/([^/]+)/);
79
+ return match?.[1] ? decodeURIComponent(match[1]).replace(/^space-/, "") : "";
80
+ }
81
+ return value.replace(/^space-/, "");
82
+ }
83
+
84
+ function getSpaceContentKeys(spaceRecord: any): Set<string> {
85
+ const keys = new Set<string>();
86
+ const contents = spaceRecord?.contents;
87
+ if (!contents || typeof contents !== "object") return keys;
88
+ for (const [entryKey, value] of Object.entries(contents)) {
89
+ keys.add(entryKey);
90
+ if (value && typeof value === "object") {
91
+ const contentKey = (value as any).contentKey;
92
+ if (typeof contentKey === "string" && contentKey.trim()) {
93
+ keys.add(contentKey.trim());
94
+ }
95
+ }
96
+ }
97
+ return keys;
98
+ }
99
+
69
100
  function resolveServerUrl(args: string[], env: EnvLike): string {
70
101
  return (
71
102
  readOption(args, "--server-url") ||
@@ -89,16 +120,46 @@ function resolveAuthToken(args: string[], env: EnvLike): string {
89
120
  }
90
121
 
91
122
  function parseJwtTenantId(token: string): string | undefined {
92
- const [, payload] = token.split(".");
93
- if (!payload) return undefined;
94
- try {
95
- const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
96
- const decoded = Buffer.from(normalized, "base64").toString("utf8");
97
- const parsed = JSON.parse(decoded);
98
- return typeof parsed.userId === "string" ? parsed.userId : typeof parsed.sub === "string" ? parsed.sub : undefined;
99
- } catch {
100
- return undefined;
123
+ for (const parsed of parseJwtPayloadCandidates(token)) {
124
+ const userId = parsed.userId;
125
+ const sub = parsed.sub;
126
+ if (typeof userId === "string" && userId) return userId;
127
+ if (typeof sub === "string" && sub) return sub;
101
128
  }
129
+ return undefined;
130
+ }
131
+
132
+ function parseJwtUsername(token: string): string | undefined {
133
+ for (const parsed of parseJwtPayloadCandidates(token)) {
134
+ const username = parsed.username;
135
+ if (typeof username === "string" && username.trim()) return username.trim();
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ function parseJwtPayloadCandidates(token: string): Array<Record<string, unknown>> {
141
+ const trimmed = token.trim();
142
+ if (!trimmed) return [];
143
+ const parts = trimmed.split(".").filter(Boolean);
144
+ const payloadCandidates = parts.length >= 3 ? [parts[1], parts[0]] : [parts[0]];
145
+ const parsed: Array<Record<string, unknown>> = [];
146
+ for (const payload of payloadCandidates) {
147
+ try {
148
+ const decoded = Buffer.from(payload, "base64").toString("utf8");
149
+ const value = JSON.parse(decoded);
150
+ if (value && typeof value === "object" && !Array.isArray(value)) {
151
+ parsed.push(value as Record<string, unknown>);
152
+ }
153
+ } catch {}
154
+ }
155
+ return parsed;
156
+ }
157
+
158
+ function resolveUserId(args: string[], env: EnvLike, authToken: string): string | undefined {
159
+ const explicit = readOption(args, "--tenant-id") || env.USER_ID || parseJwtTenantId(authToken);
160
+ if (explicit) return explicit;
161
+ const username = parseJwtUsername(authToken);
162
+ return username && /^[a-z0-9_-]+$/i.test(username) ? username : undefined;
102
163
  }
103
164
 
104
165
  function formatOutput(input: { envelope: any; rawData: any; mode: OutputMode }): string {
@@ -133,6 +194,182 @@ function usage(): string {
133
194
  ].join("\n");
134
195
  }
135
196
 
197
+ function listUsage(): string {
198
+ return [
199
+ "Usage:",
200
+ " nolo table list [--limit <n>] [--title-query <text>] [--purpose <purpose>] [--output full|raw|items|json|jsonl]",
201
+ "",
202
+ ].join("\n");
203
+ }
204
+
205
+ function tableHasPurpose(table: any, purpose: string): boolean {
206
+ return typeof table?.purpose === "string" && table.purpose.toLowerCase() === purpose.toLowerCase();
207
+ }
208
+
209
+ function getComparableUpdatedAt(record: any): number {
210
+ const raw = record?.updatedAt ?? record?.updated_at ?? record?.createdAt ?? record?.created;
211
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
212
+ if (typeof raw === "string") return Date.parse(raw) || 0;
213
+ return 0;
214
+ }
215
+
216
+ function formatTableListOutput(args: {
217
+ mode: OutputMode;
218
+ tables: any[];
219
+ envelope: any;
220
+ }) {
221
+ if (args.mode === "full") return JSON.stringify(args.envelope, null, 2);
222
+ if (args.mode === "raw") return JSON.stringify(args.tables, null, 2);
223
+ if (args.mode === "items") return JSON.stringify(args.tables, null, 2);
224
+ return args.tables.map((item) => JSON.stringify(item)).join("\n");
225
+ }
226
+
227
+ export async function runTableListCommand(args: string[], deps: TableCommandDeps = {}): Promise<number> {
228
+ const env = deps.env ?? process.env;
229
+ const output = deps.output ?? process.stdout;
230
+ const fetchImpl = deps.fetchImpl ?? fetch;
231
+ if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
232
+ output.write(listUsage());
233
+ return 0;
234
+ }
235
+
236
+ let outputMode: OutputMode;
237
+ try {
238
+ outputMode = parseOutputMode(readOption(args, "--output") || "items");
239
+ } catch (error) {
240
+ output.write(`[nolo] table list failed: ${error instanceof Error ? error.message : String(error)}\n`);
241
+ return 1;
242
+ }
243
+
244
+ const authToken = resolveAuthToken(args, env);
245
+ const userId = resolveUserId(args, env, authToken);
246
+ if (!userId) {
247
+ output.write("[nolo] table list failed: user id is required. Pass --tenant-id or use a login token.\n");
248
+ return 1;
249
+ }
250
+ if (!authToken) {
251
+ output.write("[nolo] table list failed: AUTH_TOKEN is required.\n");
252
+ return 1;
253
+ }
254
+
255
+ const limit = Math.max(1, Math.min(Number(readOption(args, "--limit") || 50), 200));
256
+ const titleQuery = readOption(args, "--title-query").trim().toLowerCase();
257
+ const purpose = readOption(args, "--purpose").trim();
258
+ const spaceId = normalizeSpaceInput(readOption(args, "--space") || readOption(args, "--space-id"));
259
+ const scanLimit = titleQuery || purpose ? Math.max(limit * 5, 200) : limit;
260
+ const serverUrl = resolveServerUrl(args, env);
261
+
262
+ let records: any[];
263
+ try {
264
+ records = spaceId
265
+ ? await fetchSpaceTableRecords({
266
+ authToken,
267
+ fetchImpl,
268
+ serverUrl,
269
+ spaceId,
270
+ })
271
+ : await fetchUserTableRecords({
272
+ authToken,
273
+ fetchImpl,
274
+ limit: Math.min(scanLimit, 500),
275
+ serverUrl,
276
+ userId,
277
+ });
278
+ } catch (error) {
279
+ output.write(`[nolo] table list failed: ${error instanceof Error ? error.message : String(error)}\n`);
280
+ return 1;
281
+ }
282
+ const tables = records
283
+ .filter((table: any) => table && typeof table === "object" && !table.deletedAt)
284
+ .filter((table: any) => !titleQuery || String(table.displayName ?? table.name ?? table.title ?? "").toLowerCase().includes(titleQuery))
285
+ .filter((table: any) => !purpose || tableHasPurpose(table, purpose))
286
+ .sort((left: any, right: any) => getComparableUpdatedAt(right) - getComparableUpdatedAt(left))
287
+ .slice(0, limit)
288
+ .map((table: any) => ({
289
+ dbKey: table.dbKey ?? null,
290
+ tableId: table.tableId ?? null,
291
+ displayName: table.displayName ?? table.name ?? table.title ?? "(untitled)",
292
+ spaceId: table.spaceId ?? null,
293
+ tenantId: table.tenantId ?? null,
294
+ columnCount: Array.isArray(table.columns) ? table.columns.length : 0,
295
+ createdAt: table.createdAt ?? null,
296
+ updatedAt: table.updatedAt ?? table.updated_at ?? null,
297
+ }));
298
+
299
+ const envelope = { success: true, userId, ...(spaceId ? { space: spaceId } : {}), total: tables.length, tables };
300
+ output.write(`${formatTableListOutput({ mode: outputMode, tables, envelope })}\n`);
301
+ return 0;
302
+ }
303
+
304
+ async function readJsonResponse(response: Response) {
305
+ const text = await response.text();
306
+ let payload: any;
307
+ try {
308
+ payload = text ? JSON.parse(text) : {};
309
+ } catch {
310
+ if (!response.ok) throw new Error(text || response.statusText);
311
+ return text;
312
+ }
313
+ if (!response.ok || payload?.error) {
314
+ throw new Error(payload?.error ?? response.statusText);
315
+ }
316
+ return payload;
317
+ }
318
+
319
+ async function fetchUserTableRecords(args: {
320
+ authToken: string;
321
+ fetchImpl: typeof fetch;
322
+ limit: number;
323
+ serverUrl: string;
324
+ userId: string;
325
+ }) {
326
+ const response = await args.fetchImpl(
327
+ `${args.serverUrl}/api/v1/db/query/${encodeURIComponent(args.userId)}?limit=${args.limit}`,
328
+ {
329
+ method: "POST",
330
+ headers: {
331
+ Authorization: `Bearer ${args.authToken}`,
332
+ "Content-Type": "application/json",
333
+ },
334
+ body: JSON.stringify({ type: "table" }),
335
+ }
336
+ );
337
+ const payload = await readJsonResponse(response);
338
+ return Array.isArray(payload?.data?.data)
339
+ ? payload.data.data
340
+ : Array.isArray(payload?.data)
341
+ ? payload.data
342
+ : [];
343
+ }
344
+
345
+ async function fetchSpaceTableRecords(args: {
346
+ authToken: string;
347
+ fetchImpl: typeof fetch;
348
+ serverUrl: string;
349
+ spaceId: string;
350
+ }) {
351
+ const readRecord = async (dbKey: string) => {
352
+ const response = await args.fetchImpl(
353
+ `${args.serverUrl}/api/v1/db/read/${encodeURIComponent(dbKey)}`,
354
+ { headers: { Authorization: `Bearer ${args.authToken}` } }
355
+ );
356
+ const payload = await readJsonResponse(response);
357
+ return payload?.data ?? payload;
358
+ };
359
+ const space = await readRecord(`space-${args.spaceId}`);
360
+ const tableKeys = [...getSpaceContentKeys(space)].filter((key) => key.startsWith("meta-"));
361
+ const records = await Promise.all(
362
+ tableKeys.map(async (key) => {
363
+ try {
364
+ return await readRecord(key);
365
+ } catch {
366
+ return null;
367
+ }
368
+ })
369
+ );
370
+ return records.filter(Boolean);
371
+ }
372
+
136
373
  export async function runTableQueryCommand(args: string[], deps: TableCommandDeps = {}): Promise<number> {
137
374
  const env = deps.env ?? process.env;
138
375
  const output = deps.output ?? process.stdout;
@@ -159,7 +396,7 @@ export async function runTableQueryCommand(args: string[], deps: TableCommandDep
159
396
 
160
397
  const tableArg = parseTableArg(readOption(args, "--table"));
161
398
  const authToken = resolveAuthToken(args, env);
162
- const tenantId = readOption(args, "--tenant-id") || tableArg.tenantId || env.USER_ID || parseJwtTenantId(authToken);
399
+ const tenantId = readOption(args, "--tenant-id") || tableArg.tenantId || resolveUserId(args, env, authToken);
163
400
  const tableId = tableArg.tableId;
164
401
  const hasSingleRowShortcut = Boolean(readOption(args, "--row") || readOption(args, "--row-id") || readOption(args, "--row-dbkey"));
165
402
  if (!tenantId || !tableId) {
@@ -184,9 +421,7 @@ export async function runTableQueryCommand(args: string[], deps: TableCommandDep
184
421
  tenantId,
185
422
  tableId,
186
423
  filters: filters ?? {},
187
- columns: hasFlag(args, "--include-activity")
188
- ? includeTableActivityColumns(columns)
189
- : columns,
424
+ columns: resolveQueryColumns(columns, hasFlag(args, "--include-activity")),
190
425
  includeBaseFields: !hasFlag(args, "--no-base-fields"),
191
426
  limit: Number(readOption(args, "--limit") || (hasSingleRowShortcut ? 1 : 20)),
192
427
  offset: Number(readOption(args, "--offset") || 0),
@@ -1,11 +1,10 @@
1
1
  import { createEnvCommand } from "./cliCommandFactories";
2
2
  import type { CommandEntry } from "./cliCommandTypes";
3
- import { runTableQueryCommand } from "./tableCommands";
4
- import { runTaskRunCommand } from "./taskRunCommand";
3
+ import { runTableListCommand, runTableQueryCommand } from "./tableCommands";
5
4
 
6
5
  export function getWorkflowInternalCommandEntries(): CommandEntry[] {
7
6
  return [
7
+ createEnvCommand(["table", "list"], "List table metadata", runTableListCommand),
8
8
  createEnvCommand(["table", "query"], "Query table rows", runTableQueryCommand),
9
- createEnvCommand(["task-run"], "Update durable task-run state through the runner bridge", runTaskRunCommand),
10
9
  ];
11
10
  }