@vellumai/assistant 0.5.2 → 0.5.4
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/ARCHITECTURE.md +109 -0
- package/docs/architecture/memory.md +105 -0
- package/docs/skills.md +100 -0
- package/package.json +1 -1
- package/src/__tests__/archive-recall.test.ts +560 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
- package/src/__tests__/conversation-agent-loop.test.ts +7 -0
- package/src/__tests__/conversation-clear-safety.test.ts +259 -0
- package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
- package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
- package/src/__tests__/conversation-switch-memory-reduction.test.ts +474 -0
- package/src/__tests__/conversation-wipe.test.ts +226 -0
- package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
- package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
- package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
- package/src/__tests__/db-schedule-syntax-migration.test.ts +3 -0
- package/src/__tests__/inline-command-runner.test.ts +311 -0
- package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
- package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
- package/src/__tests__/list-messages-attachments.test.ts +96 -0
- package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
- package/src/__tests__/memory-brief-time.test.ts +285 -0
- package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
- package/src/__tests__/memory-chunk-archive.test.ts +400 -0
- package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
- package/src/__tests__/memory-episode-archive.test.ts +370 -0
- package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
- package/src/__tests__/memory-observation-archive.test.ts +375 -0
- package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
- package/src/__tests__/memory-recall-quality.test.ts +2 -2
- package/src/__tests__/memory-reducer-job.test.ts +538 -0
- package/src/__tests__/memory-reducer-scheduling.test.ts +473 -0
- package/src/__tests__/memory-reducer-store.test.ts +728 -0
- package/src/__tests__/memory-reducer-types.test.ts +707 -0
- package/src/__tests__/memory-reducer.test.ts +704 -0
- package/src/__tests__/memory-regressions.test.ts +30 -8
- package/src/__tests__/memory-simplified-config.test.ts +281 -0
- package/src/__tests__/parse-identity-fields.test.ts +129 -0
- package/src/__tests__/simplified-memory-e2e.test.ts +666 -0
- package/src/__tests__/simplified-memory-runtime.test.ts +616 -0
- package/src/__tests__/skill-load-inline-command.test.ts +598 -0
- package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
- package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
- package/src/__tests__/skills-transitive-hash.test.ts +333 -0
- package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
- package/src/cli/commands/conversations.ts +18 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
- package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/raw-config-utils.ts +28 -0
- package/src/config/schema.ts +12 -0
- package/src/config/schemas/memory-simplified.ts +101 -0
- package/src/config/schemas/memory.ts +4 -0
- package/src/config/skills.ts +50 -4
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
- package/src/daemon/conversation-agent-loop.ts +71 -1
- package/src/daemon/conversation-lifecycle.ts +11 -1
- package/src/daemon/conversation-memory.ts +117 -0
- package/src/daemon/conversation-runtime-assembly.ts +3 -1
- package/src/daemon/conversation-surfaces.ts +31 -8
- package/src/daemon/conversation.ts +40 -23
- package/src/daemon/handlers/config-embeddings.ts +10 -2
- package/src/daemon/handlers/config-model.ts +0 -9
- package/src/daemon/handlers/conversations.ts +11 -0
- package/src/daemon/handlers/identity.ts +12 -1
- package/src/daemon/lifecycle.ts +52 -1
- package/src/daemon/message-types/conversations.ts +0 -1
- package/src/daemon/server.ts +1 -1
- package/src/followups/followup-store.ts +47 -1
- package/src/memory/archive-recall.ts +516 -0
- package/src/memory/archive-store.ts +400 -0
- package/src/memory/brief-formatting.ts +33 -0
- package/src/memory/brief-open-loops.ts +266 -0
- package/src/memory/brief-time.ts +162 -0
- package/src/memory/brief.ts +75 -0
- package/src/memory/conversation-crud.ts +455 -101
- package/src/memory/conversation-key-store.ts +33 -4
- package/src/memory/db-init.ts +16 -0
- package/src/memory/indexer.ts +106 -15
- package/src/memory/job-handlers/backfill-simplified-memory.ts +462 -0
- package/src/memory/job-handlers/conversation-starters.ts +9 -3
- package/src/memory/job-handlers/embedding.test.ts +1 -0
- package/src/memory/job-handlers/embedding.ts +83 -0
- package/src/memory/job-handlers/reduce-conversation-memory.ts +229 -0
- package/src/memory/job-utils.ts +1 -1
- package/src/memory/jobs-store.ts +8 -0
- package/src/memory/jobs-worker.ts +20 -0
- package/src/memory/migrations/036-normalize-phone-identities.ts +49 -14
- package/src/memory/migrations/135-backfill-contact-interaction-stats.ts +9 -1
- package/src/memory/migrations/141-rename-verification-table.ts +8 -0
- package/src/memory/migrations/142-rename-verification-session-id-column.ts +7 -2
- package/src/memory/migrations/174-rename-thread-starters-table.ts +8 -0
- package/src/memory/migrations/185-memory-brief-state.ts +52 -0
- package/src/memory/migrations/186-memory-archive.ts +109 -0
- package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
- package/src/memory/migrations/188-schedule-quiet-flag.ts +13 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/qdrant-client.ts +23 -4
- package/src/memory/reducer-scheduler.ts +242 -0
- package/src/memory/reducer-store.ts +271 -0
- package/src/memory/reducer-types.ts +106 -0
- package/src/memory/reducer.ts +467 -0
- package/src/memory/schema/conversations.ts +3 -0
- package/src/memory/schema/index.ts +2 -0
- package/src/memory/schema/infrastructure.ts +1 -0
- package/src/memory/schema/memory-archive.ts +121 -0
- package/src/memory/schema/memory-brief.ts +55 -0
- package/src/memory/search/semantic.ts +17 -4
- package/src/oauth/oauth-store.ts +3 -1
- package/src/permissions/checker.ts +89 -6
- package/src/permissions/defaults.ts +14 -0
- package/src/runtime/auth/route-policy.ts +10 -1
- package/src/runtime/routes/conversation-management-routes.ts +94 -2
- package/src/runtime/routes/conversation-query-routes.ts +7 -0
- package/src/runtime/routes/conversation-routes.ts +52 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +19 -7
- package/src/runtime/routes/identity-routes.ts +2 -35
- package/src/runtime/routes/llm-context-normalization.ts +14 -1
- package/src/runtime/routes/memory-item-routes.ts +90 -5
- package/src/runtime/routes/secret-routes.ts +3 -0
- package/src/runtime/routes/surface-action-routes.ts +68 -1
- package/src/schedule/schedule-store.ts +28 -0
- package/src/schedule/scheduler.ts +6 -2
- package/src/skills/inline-command-expansions.ts +204 -0
- package/src/skills/inline-command-render.ts +127 -0
- package/src/skills/inline-command-runner.ts +242 -0
- package/src/skills/transitive-version-hash.ts +88 -0
- package/src/tasks/task-store.ts +43 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -1
- package/src/tools/filesystem/edit.ts +6 -1
- package/src/tools/filesystem/read.ts +6 -1
- package/src/tools/filesystem/write.ts +6 -1
- package/src/tools/memory/handlers.ts +129 -1
- package/src/tools/permission-checker.ts +8 -1
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +5 -1
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/skills/load.ts +140 -6
- package/src/util/platform.ts +18 -0
- package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
- package/src/workspace/migrations/registry.ts +1 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
index,
|
|
3
|
+
integer,
|
|
4
|
+
sqliteTable,
|
|
5
|
+
text,
|
|
6
|
+
uniqueIndex,
|
|
7
|
+
} from "drizzle-orm/sqlite-core";
|
|
8
|
+
|
|
9
|
+
import { conversations, messages } from "./conversations.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Raw observation records captured from conversation turns. Each observation
|
|
13
|
+
* is a single factual statement extracted from user or assistant messages,
|
|
14
|
+
* annotated with modality and source metadata for downstream recall.
|
|
15
|
+
*/
|
|
16
|
+
export const memoryObservations = sqliteTable(
|
|
17
|
+
"memory_observations",
|
|
18
|
+
{
|
|
19
|
+
id: text("id").primaryKey(),
|
|
20
|
+
scopeId: text("scope_id").notNull().default("default"),
|
|
21
|
+
conversationId: text("conversation_id")
|
|
22
|
+
.notNull()
|
|
23
|
+
.references(() => conversations.id, { onDelete: "cascade" }),
|
|
24
|
+
messageId: text("message_id").references(() => messages.id, {
|
|
25
|
+
onDelete: "set null",
|
|
26
|
+
}),
|
|
27
|
+
/** The role that produced the observation (e.g. "user", "assistant"). */
|
|
28
|
+
role: text("role").notNull(),
|
|
29
|
+
/** Free-text statement capturing the observed fact. */
|
|
30
|
+
content: text("content").notNull(),
|
|
31
|
+
/**
|
|
32
|
+
* Modality of the source material: "text", "voice", "image", etc.
|
|
33
|
+
* Enables downstream filters for recall relevance.
|
|
34
|
+
*/
|
|
35
|
+
modality: text("modality").notNull().default("text"),
|
|
36
|
+
/**
|
|
37
|
+
* Source channel or interface that produced the observation
|
|
38
|
+
* (e.g. "vellum", "telegram", "phone").
|
|
39
|
+
*/
|
|
40
|
+
source: text("source"),
|
|
41
|
+
createdAt: integer("created_at").notNull(),
|
|
42
|
+
},
|
|
43
|
+
(table) => [
|
|
44
|
+
index("idx_memory_observations_scope_id").on(table.scopeId),
|
|
45
|
+
index("idx_memory_observations_conversation_id").on(table.conversationId),
|
|
46
|
+
index("idx_memory_observations_created_at").on(table.createdAt),
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deduplicated content chunks derived from observations. Chunks are the unit
|
|
52
|
+
* of embedding and recall — each chunk carries a contentHash for idempotent
|
|
53
|
+
* dual-write safety so the same content is never stored twice.
|
|
54
|
+
*/
|
|
55
|
+
export const memoryChunks = sqliteTable(
|
|
56
|
+
"memory_chunks",
|
|
57
|
+
{
|
|
58
|
+
id: text("id").primaryKey(),
|
|
59
|
+
scopeId: text("scope_id").notNull().default("default"),
|
|
60
|
+
observationId: text("observation_id")
|
|
61
|
+
.notNull()
|
|
62
|
+
.references(() => memoryObservations.id, { onDelete: "cascade" }),
|
|
63
|
+
/** The chunk text used for embedding and recall. */
|
|
64
|
+
content: text("content").notNull(),
|
|
65
|
+
/** Token count estimate for context-window budgeting. */
|
|
66
|
+
tokenEstimate: integer("token_estimate").notNull(),
|
|
67
|
+
/**
|
|
68
|
+
* SHA-256 hash of the normalized content, used to skip duplicate inserts
|
|
69
|
+
* during dual-write windows.
|
|
70
|
+
*/
|
|
71
|
+
contentHash: text("content_hash").notNull(),
|
|
72
|
+
createdAt: integer("created_at").notNull(),
|
|
73
|
+
},
|
|
74
|
+
(table) => [
|
|
75
|
+
index("idx_memory_chunks_scope_id").on(table.scopeId),
|
|
76
|
+
index("idx_memory_chunks_observation_id").on(table.observationId),
|
|
77
|
+
uniqueIndex("idx_memory_chunks_content_hash").on(
|
|
78
|
+
table.scopeId,
|
|
79
|
+
table.contentHash,
|
|
80
|
+
),
|
|
81
|
+
index("idx_memory_chunks_created_at").on(table.createdAt),
|
|
82
|
+
],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Episode records that group related observations into coherent narrative
|
|
87
|
+
* units. An episode represents a meaningful interaction or topic span,
|
|
88
|
+
* with source-link metadata for provenance tracking.
|
|
89
|
+
*/
|
|
90
|
+
export const memoryEpisodes = sqliteTable(
|
|
91
|
+
"memory_episodes",
|
|
92
|
+
{
|
|
93
|
+
id: text("id").primaryKey(),
|
|
94
|
+
scopeId: text("scope_id").notNull().default("default"),
|
|
95
|
+
conversationId: text("conversation_id")
|
|
96
|
+
.notNull()
|
|
97
|
+
.references(() => conversations.id, { onDelete: "cascade" }),
|
|
98
|
+
/** Human-readable title summarizing the episode. */
|
|
99
|
+
title: text("title").notNull(),
|
|
100
|
+
/** Longer narrative summary of the episode content. */
|
|
101
|
+
summary: text("summary").notNull(),
|
|
102
|
+
/** Token count estimate for the summary. */
|
|
103
|
+
tokenEstimate: integer("token_estimate").notNull(),
|
|
104
|
+
/**
|
|
105
|
+
* Source channel or interface that produced the episode
|
|
106
|
+
* (mirrors observation.source for episode-level filtering).
|
|
107
|
+
*/
|
|
108
|
+
source: text("source"),
|
|
109
|
+
/** Epoch-ms timestamp of the earliest observation in the episode. */
|
|
110
|
+
startAt: integer("start_at").notNull(),
|
|
111
|
+
/** Epoch-ms timestamp of the latest observation in the episode. */
|
|
112
|
+
endAt: integer("end_at").notNull(),
|
|
113
|
+
createdAt: integer("created_at").notNull(),
|
|
114
|
+
updatedAt: integer("updated_at").notNull(),
|
|
115
|
+
},
|
|
116
|
+
(table) => [
|
|
117
|
+
index("idx_memory_episodes_scope_id").on(table.scopeId),
|
|
118
|
+
index("idx_memory_episodes_conversation_id").on(table.conversationId),
|
|
119
|
+
index("idx_memory_episodes_created_at").on(table.createdAt),
|
|
120
|
+
],
|
|
121
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Time contexts represent bounded temporal windows that are relevant to the
|
|
5
|
+
* assistant's current awareness — e.g. "user is traveling next week",
|
|
6
|
+
* "quarterly planning period ends Friday". Each row captures one window
|
|
7
|
+
* with an activation range and a human-readable summary the brief can surface.
|
|
8
|
+
*/
|
|
9
|
+
export const timeContexts = sqliteTable(
|
|
10
|
+
"time_contexts",
|
|
11
|
+
{
|
|
12
|
+
id: text("id").primaryKey(),
|
|
13
|
+
scopeId: text("scope_id").notNull(),
|
|
14
|
+
summary: text("summary").notNull(),
|
|
15
|
+
source: text("source").notNull(), // e.g. 'conversation', 'schedule', 'manual'
|
|
16
|
+
activeFrom: integer("active_from").notNull(), // epoch ms — window start
|
|
17
|
+
activeUntil: integer("active_until").notNull(), // epoch ms — window end
|
|
18
|
+
createdAt: integer("created_at").notNull(),
|
|
19
|
+
updatedAt: integer("updated_at").notNull(),
|
|
20
|
+
},
|
|
21
|
+
(table) => [
|
|
22
|
+
index("idx_time_contexts_scope_active_until").on(
|
|
23
|
+
table.scopeId,
|
|
24
|
+
table.activeUntil,
|
|
25
|
+
),
|
|
26
|
+
],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open loops track unresolved items the assistant should follow up on —
|
|
31
|
+
* e.g. "waiting for Bob's reply", "need to file taxes before April 15".
|
|
32
|
+
* Each row carries a status and an optional due date so the brief can
|
|
33
|
+
* prioritise which loops to surface.
|
|
34
|
+
*/
|
|
35
|
+
export const openLoops = sqliteTable(
|
|
36
|
+
"open_loops",
|
|
37
|
+
{
|
|
38
|
+
id: text("id").primaryKey(),
|
|
39
|
+
scopeId: text("scope_id").notNull(),
|
|
40
|
+
summary: text("summary").notNull(),
|
|
41
|
+
status: text("status").notNull().default("open"), // 'open' | 'resolved' | 'expired'
|
|
42
|
+
source: text("source").notNull(), // e.g. 'conversation', 'followup', 'manual'
|
|
43
|
+
dueAt: integer("due_at"), // epoch ms — optional deadline
|
|
44
|
+
surfacedAt: integer("surfaced_at"), // epoch ms — last time shown in brief
|
|
45
|
+
createdAt: integer("created_at").notNull(),
|
|
46
|
+
updatedAt: integer("updated_at").notNull(),
|
|
47
|
+
},
|
|
48
|
+
(table) => [
|
|
49
|
+
index("idx_open_loops_scope_status_due").on(
|
|
50
|
+
table.scopeId,
|
|
51
|
+
table.status,
|
|
52
|
+
table.dueAt,
|
|
53
|
+
),
|
|
54
|
+
],
|
|
55
|
+
);
|
|
@@ -61,6 +61,7 @@ export async function semanticSearch(
|
|
|
61
61
|
fetchLimit,
|
|
62
62
|
["item", "summary", "segment", "media"],
|
|
63
63
|
excludedMessageIds,
|
|
64
|
+
scopeIds,
|
|
64
65
|
),
|
|
65
66
|
);
|
|
66
67
|
}
|
|
@@ -277,13 +278,13 @@ export async function semanticSearch(
|
|
|
277
278
|
* Build a Qdrant filter for hybrid search. Mirrors the logic in
|
|
278
279
|
* `searchWithFilter` but as a standalone object for the query API.
|
|
279
280
|
*
|
|
280
|
-
* Scope filtering:
|
|
281
|
-
*
|
|
282
|
-
*
|
|
281
|
+
* Scope filtering: points with a `memory_scope_id` payload field are
|
|
282
|
+
* filtered at the Qdrant level. Legacy points without the field pass
|
|
283
|
+
* through and are caught by post-query DB filtering.
|
|
283
284
|
*/
|
|
284
285
|
function buildHybridFilter(
|
|
285
286
|
excludeMessageIds: string[],
|
|
286
|
-
|
|
287
|
+
scopeIds?: string[],
|
|
287
288
|
): Record<string, unknown> {
|
|
288
289
|
const mustConditions: Array<Record<string, unknown>> = [
|
|
289
290
|
{
|
|
@@ -310,6 +311,18 @@ function buildHybridFilter(
|
|
|
310
311
|
});
|
|
311
312
|
}
|
|
312
313
|
|
|
314
|
+
// Scope filtering: accept points whose memory_scope_id matches one of the
|
|
315
|
+
// allowed scopes, OR points that lack the field entirely (legacy data).
|
|
316
|
+
// Post-query DB filtering remains as defense-in-depth for legacy points.
|
|
317
|
+
if (scopeIds && scopeIds.length > 0) {
|
|
318
|
+
mustConditions.push({
|
|
319
|
+
should: [
|
|
320
|
+
{ key: "memory_scope_id", match: { any: scopeIds } },
|
|
321
|
+
{ is_empty: { key: "memory_scope_id" } },
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
313
326
|
const mustNotConditions: Array<Record<string, unknown>> = [
|
|
314
327
|
{ key: "_meta", match: { value: true } },
|
|
315
328
|
];
|
package/src/oauth/oauth-store.ts
CHANGED
|
@@ -274,10 +274,12 @@ export async function upsertApp(
|
|
|
274
274
|
// can detect that a concurrent caller has claimed this row. Without
|
|
275
275
|
// this, a concurrent inserter's rollback DELETE would still match on
|
|
276
276
|
// the original updatedAt and delete the row we just validated.
|
|
277
|
+
const newUpdatedAt = Date.now();
|
|
277
278
|
db.update(oauthApps)
|
|
278
|
-
.set({ updatedAt:
|
|
279
|
+
.set({ updatedAt: newUpdatedAt })
|
|
279
280
|
.where(eq(oauthApps.id, existingRow.id))
|
|
280
281
|
.run();
|
|
282
|
+
return { ...existingRow, updatedAt: newUpdatedAt };
|
|
281
283
|
}
|
|
282
284
|
if (clientSecretCredentialPath) {
|
|
283
285
|
db.update(oauthApps)
|
|
@@ -2,12 +2,15 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
+
import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
|
|
5
6
|
import { getConfig } from "../config/loader.js";
|
|
6
|
-
import { resolveSkillSelector } from "../config/skills.js";
|
|
7
|
+
import { loadSkillCatalog, resolveSkillSelector } from "../config/skills.js";
|
|
8
|
+
import { indexCatalogById } from "../skills/include-graph.js";
|
|
7
9
|
import {
|
|
8
10
|
isSkillSourcePath,
|
|
9
11
|
normalizeFilePath,
|
|
10
12
|
} from "../skills/path-classifier.js";
|
|
13
|
+
import { computeTransitiveSkillVersionHash } from "../skills/transitive-version-hash.js";
|
|
11
14
|
import { computeSkillVersionHash } from "../skills/version-hash.js";
|
|
12
15
|
import type { ManifestOverride } from "../tools/execution-target.js";
|
|
13
16
|
import {
|
|
@@ -352,6 +355,34 @@ function resolveSkillIdAndHash(
|
|
|
352
355
|
}
|
|
353
356
|
}
|
|
354
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Check whether a skill (by id) has parsed inline command expansions.
|
|
360
|
+
* Returns false when the skill is not found in the catalog.
|
|
361
|
+
*/
|
|
362
|
+
function hasInlineExpansions(skillId: string): boolean {
|
|
363
|
+
const catalog = loadSkillCatalog();
|
|
364
|
+
const skill = catalog.find((s) => s.id === skillId);
|
|
365
|
+
return (
|
|
366
|
+
skill?.inlineCommandExpansions != null &&
|
|
367
|
+
skill.inlineCommandExpansions.length > 0
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Compute the transitive version hash for a skill, returning `undefined`
|
|
373
|
+
* when computation fails (missing includes, cycle, etc.). The permission
|
|
374
|
+
* layer falls back to the any-version candidate in that case.
|
|
375
|
+
*/
|
|
376
|
+
function computeTransitiveHashSafe(skillId: string): string | undefined {
|
|
377
|
+
try {
|
|
378
|
+
const catalog = loadSkillCatalog();
|
|
379
|
+
const index = indexCatalogById(catalog);
|
|
380
|
+
return computeTransitiveSkillVersionHash(skillId, index);
|
|
381
|
+
} catch {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
355
386
|
function canonicalizeWebFetchUrl(parsed: URL): URL {
|
|
356
387
|
parsed.hash = "";
|
|
357
388
|
parsed.username = "";
|
|
@@ -433,13 +464,39 @@ async function buildCommandCandidates(
|
|
|
433
464
|
targets.push("");
|
|
434
465
|
} else {
|
|
435
466
|
const resolved = resolveSkillIdAndHash(rawSelector);
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
467
|
+
|
|
468
|
+
// When the resolved skill contains inline command expansions and the
|
|
469
|
+
// feature flag is on, emit skill_load_dynamic: candidates so the
|
|
470
|
+
// higher-priority default ask rule catches them instead of falling
|
|
471
|
+
// through to the permissive skill_load:* allow rule.
|
|
472
|
+
const config = getConfig();
|
|
473
|
+
const inlineEnabled = isAssistantFeatureFlagEnabled(
|
|
474
|
+
"feature_flags.inline-skill-commands.enabled",
|
|
475
|
+
config,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
if (resolved && inlineEnabled && hasInlineExpansions(resolved.id)) {
|
|
479
|
+
const transitiveHash = computeTransitiveHashSafe(resolved.id);
|
|
480
|
+
if (transitiveHash) {
|
|
481
|
+
targets.push(`skill_load_dynamic:${resolved.id}@${transitiveHash}`);
|
|
482
|
+
}
|
|
483
|
+
targets.push(`skill_load_dynamic:${resolved.id}`);
|
|
484
|
+
// Don't fall through to skill_load:* — dynamic skills use their own
|
|
485
|
+
// candidate namespace so the default ask rule applies.
|
|
486
|
+
} else {
|
|
487
|
+
if (resolved && resolved.versionHash) {
|
|
488
|
+
// Version-specific candidate lets rules pin to an exact skill version
|
|
489
|
+
targets.push(`${resolved.id}@${resolved.versionHash}`);
|
|
490
|
+
}
|
|
491
|
+
targets.push(rawSelector);
|
|
439
492
|
}
|
|
440
|
-
targets.push(rawSelector);
|
|
441
493
|
}
|
|
442
|
-
|
|
494
|
+
|
|
495
|
+
// Dynamic candidates use skill_load_dynamic: prefix; normal ones use skill_load:
|
|
496
|
+
return [...new Set(targets)].map((target) => {
|
|
497
|
+
if (target.startsWith("skill_load_dynamic:")) return target;
|
|
498
|
+
return `${toolName}:${target}`;
|
|
499
|
+
});
|
|
443
500
|
}
|
|
444
501
|
|
|
445
502
|
if (
|
|
@@ -1084,6 +1141,32 @@ function skillLoadAllowlistStrategy(
|
|
|
1084
1141
|
|
|
1085
1142
|
if (rawSelector) {
|
|
1086
1143
|
const resolved = resolveSkillIdAndHash(rawSelector);
|
|
1144
|
+
|
|
1145
|
+
// Check whether this is a dynamic (inline-command) skill load
|
|
1146
|
+
const config = getConfig();
|
|
1147
|
+
const inlineEnabled = isAssistantFeatureFlagEnabled(
|
|
1148
|
+
"feature_flags.inline-skill-commands.enabled",
|
|
1149
|
+
config,
|
|
1150
|
+
);
|
|
1151
|
+
|
|
1152
|
+
if (resolved && inlineEnabled && hasInlineExpansions(resolved.id)) {
|
|
1153
|
+
const transitiveHash = computeTransitiveHashSafe(resolved.id);
|
|
1154
|
+
const options: AllowlistOption[] = [];
|
|
1155
|
+
if (transitiveHash) {
|
|
1156
|
+
options.push({
|
|
1157
|
+
label: `${resolved.id}@${transitiveHash}`,
|
|
1158
|
+
description: "This exact version (pinned)",
|
|
1159
|
+
pattern: `skill_load_dynamic:${resolved.id}@${transitiveHash}`,
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
options.push({
|
|
1163
|
+
label: resolved.id,
|
|
1164
|
+
description: "This skill (any version)",
|
|
1165
|
+
pattern: `skill_load_dynamic:${resolved.id}`,
|
|
1166
|
+
});
|
|
1167
|
+
return options;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1087
1170
|
if (resolved && resolved.versionHash) {
|
|
1088
1171
|
return [
|
|
1089
1172
|
{
|
|
@@ -198,6 +198,19 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
|
|
|
198
198
|
})),
|
|
199
199
|
);
|
|
200
200
|
|
|
201
|
+
// Inline-command skill loads use a distinct candidate namespace
|
|
202
|
+
// (skill_load_dynamic:*) so they prompt by default instead of falling
|
|
203
|
+
// through to the permissive skill_load:* allow rule below. The higher
|
|
204
|
+
// priority ensures this rule wins when both could match.
|
|
205
|
+
const skillLoadDynamicRule: DefaultRuleTemplate = {
|
|
206
|
+
id: "default:ask-skill_load_dynamic-global",
|
|
207
|
+
tool: "skill_load",
|
|
208
|
+
pattern: "skill_load_dynamic:*",
|
|
209
|
+
scope: "everywhere",
|
|
210
|
+
decision: "ask",
|
|
211
|
+
priority: 200,
|
|
212
|
+
};
|
|
213
|
+
|
|
201
214
|
const skillLoadRule: DefaultRuleTemplate = {
|
|
202
215
|
id: "default:allow-skill_load-global",
|
|
203
216
|
tool: "skill_load",
|
|
@@ -294,6 +307,7 @@ export function getDefaultRuleTemplates(): DefaultRuleTemplate[] {
|
|
|
294
307
|
bootstrapDeleteRule,
|
|
295
308
|
updatesDeleteRule,
|
|
296
309
|
...skillSourceMutationRules,
|
|
310
|
+
skillLoadDynamicRule,
|
|
297
311
|
skillLoadRule,
|
|
298
312
|
skillExecuteRule,
|
|
299
313
|
browserNavigateRule,
|
|
@@ -128,7 +128,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
128
128
|
{ endpoint: "messages:POST", scopes: ["chat.write"] },
|
|
129
129
|
{ endpoint: "btw", scopes: ["chat.write"] },
|
|
130
130
|
{ endpoint: "conversations", scopes: ["chat.read"] },
|
|
131
|
-
{ endpoint: "conversations:
|
|
131
|
+
{ endpoint: "conversations:POST", scopes: ["chat.write"] },
|
|
132
132
|
{ endpoint: "conversations/fork", scopes: ["chat.write"] },
|
|
133
133
|
{ endpoint: "conversations/switch", scopes: ["chat.write"] },
|
|
134
134
|
{ endpoint: "conversations/name", scopes: ["chat.write"] },
|
|
@@ -348,6 +348,7 @@ const ACTOR_ENDPOINTS: Array<{ endpoint: string; scopes: Scope[] }> = [
|
|
|
348
348
|
{ endpoint: "config/embeddings:PUT", scopes: ["settings.write"] },
|
|
349
349
|
|
|
350
350
|
// Conversation management
|
|
351
|
+
{ endpoint: "conversations:DELETE", scopes: ["chat.write"] },
|
|
351
352
|
{ endpoint: "conversations/wipe", scopes: ["chat.write"] },
|
|
352
353
|
{ endpoint: "conversations/reorder", scopes: ["chat.write"] },
|
|
353
354
|
|
|
@@ -470,6 +471,14 @@ for (const { endpoint, scopes } of ACTOR_ENDPOINTS) {
|
|
|
470
471
|
});
|
|
471
472
|
}
|
|
472
473
|
|
|
474
|
+
// Clear-all conversations: elevated to settings.write (destructive bulk operation).
|
|
475
|
+
// Uses a distinct key so the single-conversation DELETE (conversations:DELETE)
|
|
476
|
+
// retains the lower chat.write scope.
|
|
477
|
+
registerPolicy("conversations/clear-all", {
|
|
478
|
+
requiredScopes: ["settings.write"],
|
|
479
|
+
allowedPrincipalTypes: ["actor", "svc_gateway", "svc_daemon", "local"],
|
|
480
|
+
});
|
|
481
|
+
|
|
473
482
|
// Channel inbound: gateway-only
|
|
474
483
|
registerPolicy("channels/inbound", {
|
|
475
484
|
requiredScopes: ["ingress.write"],
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Route handlers for conversation management operations.
|
|
3
3
|
*
|
|
4
|
+
* POST /v1/conversations — create a new conversation
|
|
4
5
|
* POST /v1/conversations/switch — switch to an existing conversation
|
|
5
6
|
* POST /v1/conversations/fork — fork an existing conversation
|
|
6
7
|
* PATCH /v1/conversations/:id/name — rename a conversation
|
|
@@ -19,7 +20,9 @@ import {
|
|
|
19
20
|
PRIVATE_CONVERSATION_FORK_ERROR,
|
|
20
21
|
wipeConversation,
|
|
21
22
|
} from "../../memory/conversation-crud.js";
|
|
23
|
+
import { updateConversationTitle } from "../../memory/conversation-crud.js";
|
|
22
24
|
import {
|
|
25
|
+
getOrCreateConversation,
|
|
23
26
|
resolveConversationId,
|
|
24
27
|
setConversationKeyIfAbsent,
|
|
25
28
|
} from "../../memory/conversation-key-store.js";
|
|
@@ -66,6 +69,44 @@ export function conversationManagementRouteDefinitions(
|
|
|
66
69
|
deps: ConversationManagementDeps,
|
|
67
70
|
): RouteDefinition[] {
|
|
68
71
|
return [
|
|
72
|
+
{
|
|
73
|
+
endpoint: "conversations",
|
|
74
|
+
method: "POST",
|
|
75
|
+
policyKey: "conversations",
|
|
76
|
+
handler: async ({ req }) => {
|
|
77
|
+
let body: { conversationKey?: string; conversationType?: string } = {};
|
|
78
|
+
try {
|
|
79
|
+
body = (await req.json()) as typeof body;
|
|
80
|
+
} catch {
|
|
81
|
+
// Empty or malformed body — fall through with defaults.
|
|
82
|
+
}
|
|
83
|
+
const conversationKey = body.conversationKey ?? crypto.randomUUID();
|
|
84
|
+
const requestedType =
|
|
85
|
+
body.conversationType === "private" ? "private" : "standard";
|
|
86
|
+
const result = getOrCreateConversation(conversationKey, {
|
|
87
|
+
conversationType: requestedType,
|
|
88
|
+
});
|
|
89
|
+
if (result.created) {
|
|
90
|
+
updateConversationTitle(result.conversationId, "New Conversation");
|
|
91
|
+
}
|
|
92
|
+
log.info(
|
|
93
|
+
{
|
|
94
|
+
conversationId: result.conversationId,
|
|
95
|
+
conversationKey,
|
|
96
|
+
created: result.created,
|
|
97
|
+
},
|
|
98
|
+
"Created conversation via POST",
|
|
99
|
+
);
|
|
100
|
+
return Response.json(
|
|
101
|
+
{
|
|
102
|
+
id: result.conversationId,
|
|
103
|
+
conversationKey,
|
|
104
|
+
conversationType: result.conversationType,
|
|
105
|
+
},
|
|
106
|
+
{ status: result.created ? 201 : 200 },
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
69
110
|
{
|
|
70
111
|
endpoint: "conversations/fork",
|
|
71
112
|
method: "POST",
|
|
@@ -185,8 +226,17 @@ export function conversationManagementRouteDefinitions(
|
|
|
185
226
|
{
|
|
186
227
|
endpoint: "conversations",
|
|
187
228
|
method: "DELETE",
|
|
188
|
-
policyKey: "conversations",
|
|
189
|
-
handler: () => {
|
|
229
|
+
policyKey: "conversations/clear-all",
|
|
230
|
+
handler: ({ req }) => {
|
|
231
|
+
const confirm = req.headers.get("x-confirm-destructive");
|
|
232
|
+
if (confirm !== "clear-all-conversations") {
|
|
233
|
+
return httpError(
|
|
234
|
+
"BAD_REQUEST",
|
|
235
|
+
"DELETE /v1/conversations permanently deletes ALL conversations, messages, and memory. " +
|
|
236
|
+
"To confirm, set header X-Confirm-Destructive: clear-all-conversations",
|
|
237
|
+
400,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
190
240
|
deps.clearAllConversations();
|
|
191
241
|
return new Response(null, { status: 204 });
|
|
192
242
|
},
|
|
@@ -225,6 +275,24 @@ export function conversationManagementRouteDefinitions(
|
|
|
225
275
|
targetId: summaryId,
|
|
226
276
|
});
|
|
227
277
|
}
|
|
278
|
+
for (const obsId of result.deletedObservationIds) {
|
|
279
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
280
|
+
targetType: "observation",
|
|
281
|
+
targetId: obsId,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
for (const chunkId of result.deletedChunkIds) {
|
|
285
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
286
|
+
targetType: "chunk",
|
|
287
|
+
targetId: chunkId,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
for (const episodeId of result.deletedEpisodeIds) {
|
|
291
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
292
|
+
targetType: "episode",
|
|
293
|
+
targetId: episodeId,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
228
296
|
log.info(
|
|
229
297
|
{
|
|
230
298
|
conversationId: resolvedId,
|
|
@@ -275,6 +343,30 @@ export function conversationManagementRouteDefinitions(
|
|
|
275
343
|
targetId: itemId,
|
|
276
344
|
});
|
|
277
345
|
}
|
|
346
|
+
for (const summaryId of deleted.deletedSummaryIds) {
|
|
347
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
348
|
+
targetType: "summary",
|
|
349
|
+
targetId: summaryId,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
for (const obsId of deleted.deletedObservationIds) {
|
|
353
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
354
|
+
targetType: "observation",
|
|
355
|
+
targetId: obsId,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
for (const chunkId of deleted.deletedChunkIds) {
|
|
359
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
360
|
+
targetType: "chunk",
|
|
361
|
+
targetId: chunkId,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
for (const episodeId of deleted.deletedEpisodeIds) {
|
|
365
|
+
enqueueMemoryJob("delete_qdrant_vectors", {
|
|
366
|
+
targetType: "episode",
|
|
367
|
+
targetId: episodeId,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
278
370
|
log.info({ conversationId: resolvedId }, "Deleted conversation");
|
|
279
371
|
return new Response(null, { status: 204 });
|
|
280
372
|
},
|
|
@@ -225,6 +225,13 @@ export function conversationQueryRouteDefinitions(
|
|
|
225
225
|
400,
|
|
226
226
|
);
|
|
227
227
|
}
|
|
228
|
+
if (body.model !== undefined && typeof body.model !== "string") {
|
|
229
|
+
return httpError(
|
|
230
|
+
"BAD_REQUEST",
|
|
231
|
+
"Field 'model' must be a string",
|
|
232
|
+
400,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
228
235
|
try {
|
|
229
236
|
const info = await setEmbeddingConfig(
|
|
230
237
|
body.provider,
|
|
@@ -81,6 +81,9 @@ import {
|
|
|
81
81
|
|
|
82
82
|
const log = getLogger("conversation-routes");
|
|
83
83
|
|
|
84
|
+
/** Matches the `<no_response/>` sentinel used by channel delivery suppression. */
|
|
85
|
+
const NO_RESPONSE_INLINE_RE = /<no_response\s*\/?>/g;
|
|
86
|
+
|
|
84
87
|
const SUGGESTION_CACHE_MAX = 100;
|
|
85
88
|
|
|
86
89
|
function collectCanonicalGuardianRequestHintIds(
|
|
@@ -363,6 +366,48 @@ export function handleListMessages(
|
|
|
363
366
|
content = msg.content;
|
|
364
367
|
}
|
|
365
368
|
const rendered = renderHistoryContent(content);
|
|
369
|
+
|
|
370
|
+
// Strip <no_response/> markers from assistant messages so web/API
|
|
371
|
+
// clients never see the raw sentinel. Only assistant messages produce
|
|
372
|
+
// this marker; user messages are left untouched.
|
|
373
|
+
if (msg.role === "assistant") {
|
|
374
|
+
const originalSegments = rendered.textSegments;
|
|
375
|
+
const keepIndices: number[] = [];
|
|
376
|
+
const filteredSegments: string[] = [];
|
|
377
|
+
for (let i = 0; i < originalSegments.length; i++) {
|
|
378
|
+
const cleaned = originalSegments[i]
|
|
379
|
+
.replace(NO_RESPONSE_INLINE_RE, "")
|
|
380
|
+
.trim();
|
|
381
|
+
if (cleaned.length > 0) {
|
|
382
|
+
keepIndices.push(i);
|
|
383
|
+
filteredSegments.push(cleaned);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Remap contentOrder text:N indices to account for removed segments
|
|
387
|
+
const indexMap = new Map<number, number>();
|
|
388
|
+
keepIndices.forEach((oldIdx, newIdx) => indexMap.set(oldIdx, newIdx));
|
|
389
|
+
const filteredContentOrder = rendered.contentOrder
|
|
390
|
+
.map((entry) => {
|
|
391
|
+
const m = entry.match(/^text:(\d+)$/);
|
|
392
|
+
if (!m) return entry;
|
|
393
|
+
const newIdx = indexMap.get(Number(m[1]));
|
|
394
|
+
return newIdx !== undefined ? `text:${newIdx}` : undefined;
|
|
395
|
+
})
|
|
396
|
+
.filter((e): e is string => e !== undefined);
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
role: msg.role,
|
|
400
|
+
text: rendered.text.replace(NO_RESPONSE_INLINE_RE, "").trim(),
|
|
401
|
+
timestamp: msg.createdAt,
|
|
402
|
+
toolCalls: rendered.toolCalls,
|
|
403
|
+
toolCallsBeforeText: rendered.toolCallsBeforeText,
|
|
404
|
+
textSegments: filteredSegments,
|
|
405
|
+
contentOrder: filteredContentOrder,
|
|
406
|
+
surfaces: rendered.surfaces,
|
|
407
|
+
id: msg.id,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
366
411
|
return {
|
|
367
412
|
role: msg.role,
|
|
368
413
|
text: rendered.text,
|
|
@@ -1240,11 +1285,13 @@ async function generateLlmSuggestion(
|
|
|
1240
1285
|
return null;
|
|
1241
1286
|
}
|
|
1242
1287
|
if (firstLine.length <= 50) return firstLine;
|
|
1243
|
-
// Truncate at last word boundary within 50 chars
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1288
|
+
// Truncate at last word boundary within 50 chars.
|
|
1289
|
+
// Only strip the trailing partial word if the slice actually cut mid-word;
|
|
1290
|
+
// if the character right after the cut is whitespace, the slice is already clean.
|
|
1291
|
+
const sliced = firstLine.slice(0, 50);
|
|
1292
|
+
const wordTruncated = (
|
|
1293
|
+
/\s/.test(firstLine[50]) ? sliced : sliced.replace(/\s+\S*$/, "")
|
|
1294
|
+
).trim();
|
|
1248
1295
|
if (wordTruncated.length < 15) {
|
|
1249
1296
|
log.debug(
|
|
1250
1297
|
{ rawLength: firstLine.length, truncatedLength: wordTruncated.length },
|