@vellumai/assistant 0.10.3-dev.202606301742.2b63687 → 0.10.3-dev.202606301846.839df2b
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/openapi.yaml +156 -0
- package/package.json +1 -1
- package/src/__tests__/onboarding-template-contract.test.ts +27 -43
- package/src/__tests__/plugin-import-boundary-guard.test.ts +7 -0
- package/src/__tests__/system-prompt.test.ts +4 -4
- package/src/config/schemas/__tests__/memory-v2.test.ts +2 -2
- package/src/config/schemas/memory-v2.ts +1 -1
- package/src/daemon/lifecycle.ts +2 -201
- package/src/plugins/defaults/memory/startup.ts +211 -0
- package/src/prompts/templates/BOOTSTRAP.md +16 -119
- package/src/runtime/routes/conversation-query-routes.ts +1 -0
package/openapi.yaml
CHANGED
|
@@ -4632,6 +4632,162 @@ paths:
|
|
|
4632
4632
|
required: true
|
|
4633
4633
|
schema:
|
|
4634
4634
|
type: string
|
|
4635
|
+
requestBody:
|
|
4636
|
+
required: true
|
|
4637
|
+
content:
|
|
4638
|
+
application/json:
|
|
4639
|
+
schema:
|
|
4640
|
+
type: object
|
|
4641
|
+
properties:
|
|
4642
|
+
provider:
|
|
4643
|
+
$ref: "#/components/schemas/LLMProvider"
|
|
4644
|
+
model:
|
|
4645
|
+
type: string
|
|
4646
|
+
minLength: 1
|
|
4647
|
+
maxTokens:
|
|
4648
|
+
type: integer
|
|
4649
|
+
exclusiveMinimum: 0
|
|
4650
|
+
maximum: 9007199254740991
|
|
4651
|
+
effort:
|
|
4652
|
+
type: string
|
|
4653
|
+
enum:
|
|
4654
|
+
- none
|
|
4655
|
+
- low
|
|
4656
|
+
- medium
|
|
4657
|
+
- high
|
|
4658
|
+
- xhigh
|
|
4659
|
+
- max
|
|
4660
|
+
speed:
|
|
4661
|
+
type: string
|
|
4662
|
+
enum:
|
|
4663
|
+
- standard
|
|
4664
|
+
- fast
|
|
4665
|
+
verbosity:
|
|
4666
|
+
type: string
|
|
4667
|
+
enum:
|
|
4668
|
+
- low
|
|
4669
|
+
- medium
|
|
4670
|
+
- high
|
|
4671
|
+
temperature:
|
|
4672
|
+
anyOf:
|
|
4673
|
+
- type: number
|
|
4674
|
+
minimum: 0
|
|
4675
|
+
maximum: 2
|
|
4676
|
+
- type: "null"
|
|
4677
|
+
topP:
|
|
4678
|
+
anyOf:
|
|
4679
|
+
- type: number
|
|
4680
|
+
minimum: 0
|
|
4681
|
+
maximum: 1
|
|
4682
|
+
- type: "null"
|
|
4683
|
+
thinking:
|
|
4684
|
+
type: object
|
|
4685
|
+
properties:
|
|
4686
|
+
enabled:
|
|
4687
|
+
type: boolean
|
|
4688
|
+
streamThinking:
|
|
4689
|
+
type: boolean
|
|
4690
|
+
level:
|
|
4691
|
+
type: string
|
|
4692
|
+
enum:
|
|
4693
|
+
- minimal
|
|
4694
|
+
- low
|
|
4695
|
+
- medium
|
|
4696
|
+
- high
|
|
4697
|
+
contextWindow:
|
|
4698
|
+
type: object
|
|
4699
|
+
properties:
|
|
4700
|
+
enabled:
|
|
4701
|
+
type: boolean
|
|
4702
|
+
maxInputTokens:
|
|
4703
|
+
type: integer
|
|
4704
|
+
exclusiveMinimum: 0
|
|
4705
|
+
maximum: 9007199254740991
|
|
4706
|
+
targetBudgetRatio:
|
|
4707
|
+
type: number
|
|
4708
|
+
exclusiveMinimum: 0
|
|
4709
|
+
maximum: 1
|
|
4710
|
+
compactThreshold:
|
|
4711
|
+
type: number
|
|
4712
|
+
exclusiveMinimum: 0
|
|
4713
|
+
maximum: 1
|
|
4714
|
+
summaryBudgetRatio:
|
|
4715
|
+
type: number
|
|
4716
|
+
exclusiveMinimum: 0
|
|
4717
|
+
maximum: 1
|
|
4718
|
+
overflowRecovery:
|
|
4719
|
+
type: object
|
|
4720
|
+
properties:
|
|
4721
|
+
enabled:
|
|
4722
|
+
type: boolean
|
|
4723
|
+
safetyMarginRatio:
|
|
4724
|
+
type: number
|
|
4725
|
+
exclusiveMinimum: 0
|
|
4726
|
+
exclusiveMaximum: 1
|
|
4727
|
+
maxAttempts:
|
|
4728
|
+
type: integer
|
|
4729
|
+
exclusiveMinimum: 0
|
|
4730
|
+
maximum: 9007199254740991
|
|
4731
|
+
interactiveLatestTurnCompression:
|
|
4732
|
+
type: string
|
|
4733
|
+
enum:
|
|
4734
|
+
- truncate
|
|
4735
|
+
- summarize
|
|
4736
|
+
- drop
|
|
4737
|
+
nonInteractiveLatestTurnCompression:
|
|
4738
|
+
type: string
|
|
4739
|
+
enum:
|
|
4740
|
+
- truncate
|
|
4741
|
+
- summarize
|
|
4742
|
+
- drop
|
|
4743
|
+
openrouter:
|
|
4744
|
+
type: object
|
|
4745
|
+
properties:
|
|
4746
|
+
only:
|
|
4747
|
+
type: array
|
|
4748
|
+
items:
|
|
4749
|
+
type: string
|
|
4750
|
+
minLength: 1
|
|
4751
|
+
logitBias:
|
|
4752
|
+
type: string
|
|
4753
|
+
enum:
|
|
4754
|
+
- suppress-cjk
|
|
4755
|
+
disableCache:
|
|
4756
|
+
type: boolean
|
|
4757
|
+
source:
|
|
4758
|
+
type: string
|
|
4759
|
+
enum:
|
|
4760
|
+
- managed
|
|
4761
|
+
- user
|
|
4762
|
+
label:
|
|
4763
|
+
anyOf:
|
|
4764
|
+
- type: string
|
|
4765
|
+
minLength: 1
|
|
4766
|
+
- type: "null"
|
|
4767
|
+
description:
|
|
4768
|
+
type: string
|
|
4769
|
+
provider_connection:
|
|
4770
|
+
type: string
|
|
4771
|
+
minLength: 1
|
|
4772
|
+
status:
|
|
4773
|
+
anyOf:
|
|
4774
|
+
- $ref: "#/components/schemas/ProfileStatus"
|
|
4775
|
+
- type: "null"
|
|
4776
|
+
mix:
|
|
4777
|
+
minItems: 2
|
|
4778
|
+
type: array
|
|
4779
|
+
items:
|
|
4780
|
+
type: object
|
|
4781
|
+
properties:
|
|
4782
|
+
profile:
|
|
4783
|
+
type: string
|
|
4784
|
+
minLength: 1
|
|
4785
|
+
weight:
|
|
4786
|
+
type: number
|
|
4787
|
+
exclusiveMinimum: 0
|
|
4788
|
+
required:
|
|
4789
|
+
- profile
|
|
4790
|
+
- weight
|
|
4635
4791
|
responses:
|
|
4636
4792
|
"200":
|
|
4637
4793
|
description: Successful response
|
package/package.json
CHANGED
|
@@ -16,69 +16,53 @@ describe("onboarding template contracts", () => {
|
|
|
16
16
|
expect(bootstrap).toMatch(/^_ Lines starting with _/);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
test("
|
|
19
|
+
test("anchors identity in the pre-chat personality and demands staying in character", () => {
|
|
20
20
|
const lower = bootstrap.toLowerCase();
|
|
21
|
-
expect(lower).toContain("
|
|
22
|
-
expect(lower).toContain("
|
|
21
|
+
expect(lower).toContain("pre-chat");
|
|
22
|
+
expect(lower).toContain("in character");
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
test("
|
|
25
|
+
test("opens in character with a move drawn from workspace files", () => {
|
|
26
26
|
const lower = bootstrap.toLowerCase();
|
|
27
|
-
expect(lower).toContain("
|
|
28
|
-
expect(lower).toContain("
|
|
29
|
-
expect(lower).toContain("tools");
|
|
27
|
+
expect(lower).toContain("you start the conversation");
|
|
28
|
+
expect(lower).toContain("workspace files");
|
|
30
29
|
});
|
|
31
30
|
|
|
32
|
-
test("
|
|
31
|
+
test("is engaging, inquisitive, and brief", () => {
|
|
33
32
|
const lower = bootstrap.toLowerCase();
|
|
34
|
-
expect(lower).toContain("
|
|
35
|
-
expect(lower).toContain("
|
|
36
|
-
expect(lower).toContain("bootstrap.md");
|
|
33
|
+
expect(lower).toContain("follow-up question");
|
|
34
|
+
expect(lower).toContain("short");
|
|
37
35
|
});
|
|
38
36
|
|
|
39
|
-
test("
|
|
37
|
+
test("does the task then keeps learning about the user", () => {
|
|
40
38
|
const lower = bootstrap.toLowerCase();
|
|
41
|
-
expect(lower).toContain("
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
test("instructs saving to IDENTITY.md, SOUL.md, and user persona file via file_edit", () => {
|
|
45
|
-
expect(bootstrap).toContain("IDENTITY.md");
|
|
46
|
-
expect(bootstrap).toContain("SOUL.md");
|
|
47
|
-
expect(bootstrap).toContain("{{userSlug}}.md");
|
|
48
|
-
expect(bootstrap).toContain("file_edit");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("contains core principle", () => {
|
|
52
|
-
expect(bootstrap).toContain("earns its keep");
|
|
39
|
+
expect(lower).toContain("just do it");
|
|
40
|
+
expect(lower).toContain("keep learning about them");
|
|
53
41
|
});
|
|
54
42
|
|
|
55
|
-
test("contains
|
|
43
|
+
test("contains cleanup instructions with deletion", () => {
|
|
56
44
|
const lower = bootstrap.toLowerCase();
|
|
57
|
-
expect(lower).toContain("
|
|
58
|
-
expect(lower).toContain("
|
|
59
|
-
expect(lower).toContain("context");
|
|
45
|
+
expect(lower).toContain("delete");
|
|
46
|
+
expect(lower).toContain("bootstrap.md");
|
|
60
47
|
});
|
|
61
48
|
|
|
62
|
-
test("
|
|
49
|
+
test("offers assistant migration from an existing ChatGPT/Claude", () => {
|
|
63
50
|
const lower = bootstrap.toLowerCase();
|
|
64
|
-
expect(lower).toContain("
|
|
65
|
-
expect(lower).toContain("
|
|
51
|
+
expect(lower).toContain("chatgpt");
|
|
52
|
+
expect(lower).toContain("claude");
|
|
53
|
+
expect(bootstrap).toContain("assistant-migration");
|
|
66
54
|
});
|
|
67
55
|
|
|
68
|
-
test("
|
|
69
|
-
expect(bootstrap).toContain("
|
|
56
|
+
test("does not instruct workspace identity-file writes", () => {
|
|
57
|
+
expect(bootstrap).not.toContain("file_edit");
|
|
58
|
+
expect(bootstrap).not.toContain("file_write");
|
|
59
|
+
expect(bootstrap).not.toMatch(/write .*to SOUL\.md/i);
|
|
60
|
+
expect(bootstrap).not.toMatch(/\{\{userSlug\}\}\.md/);
|
|
70
61
|
});
|
|
71
62
|
|
|
72
|
-
test("
|
|
73
|
-
expect(bootstrap).
|
|
74
|
-
expect(bootstrap).
|
|
75
|
-
expect(bootstrap).toContain("treat it as the real first user turn");
|
|
76
|
-
expect(bootstrap).toContain(
|
|
77
|
-
"If the first real user turn is only a greeting",
|
|
78
|
-
);
|
|
79
|
-
expect(bootstrap).toContain("that first response is the natural opening");
|
|
80
|
-
expect(bootstrap).toContain("assistant-migration");
|
|
81
|
-
expect(bootstrap).toContain("do not load or activate");
|
|
63
|
+
test("does not split users into conversation-first and task-first paths", () => {
|
|
64
|
+
expect(bootstrap).not.toMatch(/Path A/);
|
|
65
|
+
expect(bootstrap).not.toMatch(/Path B/);
|
|
82
66
|
});
|
|
83
67
|
|
|
84
68
|
test("does not contain personality quiz references", () => {
|
|
@@ -161,6 +161,7 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
161
161
|
"../../../config/assistant-feature-flags.js",
|
|
162
162
|
"../../../config/loader.js",
|
|
163
163
|
"../../../config/memory-v3-gate.js",
|
|
164
|
+
"../../../config/schema.js",
|
|
164
165
|
"../../../config/schemas/memory-v2.js",
|
|
165
166
|
"../../../config/types.js",
|
|
166
167
|
"../../../contacts/guardian-delivery-reader.js",
|
|
@@ -169,10 +170,13 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
169
170
|
"../../../context/token-estimator.js",
|
|
170
171
|
"../../../daemon/date-context.js",
|
|
171
172
|
"../../../daemon/disk-pressure-background-gate.js",
|
|
173
|
+
"../../../daemon/embedding-reconcile.js",
|
|
172
174
|
"../../../daemon/pkb-context-tracker.js",
|
|
173
175
|
"../../../daemon/pkb-reminder-builder.js",
|
|
176
|
+
"../../../daemon/skill-memory-refresh.js",
|
|
174
177
|
"../../../daemon/tool-setup-types.js",
|
|
175
178
|
"../../../daemon/trust-context.js",
|
|
179
|
+
"../../../jobs/register-job-handlers.js",
|
|
176
180
|
"../../../permissions/types.js",
|
|
177
181
|
"../../../persistence/checkpoints.js",
|
|
178
182
|
"../../../persistence/conversation-crud.js",
|
|
@@ -180,7 +184,10 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
180
184
|
"../../../persistence/embeddings/embedding-backend.js",
|
|
181
185
|
"../../../persistence/embeddings/embedding-runtime-manager.js",
|
|
182
186
|
"../../../persistence/embeddings/embedding-types.js",
|
|
187
|
+
"../../../persistence/embeddings/qdrant-client.js",
|
|
188
|
+
"../../../persistence/embeddings/qdrant-manager.js",
|
|
183
189
|
"../../../persistence/jobs-store.js",
|
|
190
|
+
"../../../persistence/jobs-worker.js",
|
|
184
191
|
"../../../persistence/memory-lifecycle-hooks.js",
|
|
185
192
|
"../../../persistence/message-content.js",
|
|
186
193
|
"../../../persistence/raw-query.js",
|
|
@@ -457,9 +457,10 @@ describe("buildSystemPrompt", () => {
|
|
|
457
457
|
expect(result).not.toContain("{{userSlug}}");
|
|
458
458
|
});
|
|
459
459
|
|
|
460
|
-
test("
|
|
461
|
-
//
|
|
462
|
-
//
|
|
460
|
+
test("leaves no unresolved placeholders in the bundled BOOTSTRAP.md template", () => {
|
|
461
|
+
// Render the real bundled BOOTSTRAP.md the daemon ships and verify
|
|
462
|
+
// substitution leaves no leftover {{userSlug}} placeholder, whether or
|
|
463
|
+
// not the current template happens to reference it.
|
|
463
464
|
mockPersona.userSlug = "alice";
|
|
464
465
|
const bundled = readFileSync(
|
|
465
466
|
join(import.meta.dirname, "..", "prompts", "templates", "BOOTSTRAP.md"),
|
|
@@ -467,7 +468,6 @@ describe("buildSystemPrompt", () => {
|
|
|
467
468
|
);
|
|
468
469
|
writeFileSync(join(TEST_DIR, "BOOTSTRAP.md"), bundled);
|
|
469
470
|
const result = buildSystemPrompt();
|
|
470
|
-
expect(result).toContain("users/alice.md");
|
|
471
471
|
expect(result).not.toContain("{{userSlug}}");
|
|
472
472
|
});
|
|
473
473
|
});
|
|
@@ -22,7 +22,7 @@ describe("MemoryV2ConfigSchema", () => {
|
|
|
22
22
|
sparse_weight: 0.15,
|
|
23
23
|
bm25_k1: 1.2,
|
|
24
24
|
bm25_b: 0.4,
|
|
25
|
-
consolidation_interval_hours:
|
|
25
|
+
consolidation_interval_hours: 8,
|
|
26
26
|
consolidation_max_buffer_lines: 100,
|
|
27
27
|
consolidation_max_entries_per_run: 150,
|
|
28
28
|
max_page_chars: 5000,
|
|
@@ -222,7 +222,7 @@ describe("MemoryConfigSchema integration with v2 block", () => {
|
|
|
222
222
|
expect(parsed.v2.d).toBe(0.3);
|
|
223
223
|
expect(parsed.v2.dense_weight).toBe(0.85);
|
|
224
224
|
expect(parsed.v2.sparse_weight).toBe(0.15);
|
|
225
|
-
expect(parsed.v2.consolidation_interval_hours).toBe(
|
|
225
|
+
expect(parsed.v2.consolidation_interval_hours).toBe(8);
|
|
226
226
|
expect(parsed.v2.max_page_chars).toBe(5000);
|
|
227
227
|
});
|
|
228
228
|
|
|
@@ -190,7 +190,7 @@ export const MemoryV2ConfigSchema = z
|
|
|
190
190
|
.positive(
|
|
191
191
|
"memory.v2.consolidation_interval_hours must be a positive integer",
|
|
192
192
|
)
|
|
193
|
-
.default(
|
|
193
|
+
.default(8)
|
|
194
194
|
.describe(
|
|
195
195
|
"Hours between scheduled consolidation runs that synthesize buffered memories into concept pages",
|
|
196
196
|
),
|
package/src/daemon/lifecycle.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { join } from "node:path";
|
|
2
|
-
|
|
3
1
|
import { config as dotenvConfig } from "dotenv";
|
|
4
2
|
|
|
5
3
|
import { setPointerMessageProcessor } from "../calls/call-pointer-messages.js";
|
|
@@ -20,7 +18,6 @@ import { backfillRelationshipStateIfMissing } from "../home/relationship-state-w
|
|
|
20
18
|
import { closeSentry, initSentry, setSentryDeviceId } from "../instrument.js";
|
|
21
19
|
import { startCliIpcServer } from "../ipc/assistant-server.js";
|
|
22
20
|
import { startGatewayFlagListener } from "../ipc/gateway-flag-listener.js";
|
|
23
|
-
import { registerMemoryJobHandlers } from "../jobs/register-job-handlers.js";
|
|
24
21
|
import { backfillManualTokenConnections } from "../oauth/manual-token-connection.js";
|
|
25
22
|
import { seedOAuthProviders } from "../oauth/seed-providers.js";
|
|
26
23
|
import {
|
|
@@ -34,24 +31,9 @@ import {
|
|
|
34
31
|
} from "../persistence/conversation-crud.js";
|
|
35
32
|
import { getDb } from "../persistence/db-connection.js";
|
|
36
33
|
import { initializeDb } from "../persistence/db-init.js";
|
|
37
|
-
import { selectEmbeddingBackend } from "../persistence/embeddings/embedding-backend.js";
|
|
38
|
-
import {
|
|
39
|
-
initQdrantClient,
|
|
40
|
-
resolveQdrantUrl,
|
|
41
|
-
} from "../persistence/embeddings/qdrant-client.js";
|
|
42
|
-
import { createQdrantManager } from "../persistence/embeddings/qdrant-manager.js";
|
|
43
|
-
import {
|
|
44
|
-
enqueueMemoryJob,
|
|
45
|
-
isMemoryEnabled,
|
|
46
|
-
} from "../persistence/jobs-store.js";
|
|
47
|
-
import { startMemoryJobsWorker } from "../persistence/jobs-worker.js";
|
|
48
34
|
import { startConsentRefresh } from "../platform/consent-cache.js";
|
|
49
35
|
import { syncWorkspaceIdentityToPlatform } from "../platform/sync-identity.js";
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
maybeRebuildMemoryV2Concepts,
|
|
53
|
-
rebuildBm25CorpusStatsAndReseedSkills,
|
|
54
|
-
} from "../plugins/defaults/memory/v2/memory-v2-startup.js";
|
|
36
|
+
import { runMemoryStartup } from "../plugins/defaults/memory/startup.js";
|
|
55
37
|
import { ensurePromptFiles } from "../prompts/system-prompt.js";
|
|
56
38
|
import { runProviderConnectionsBackfill } from "../providers/inference/backfill.js";
|
|
57
39
|
import { initializeProviders } from "../providers/registry.js";
|
|
@@ -97,7 +79,6 @@ import {
|
|
|
97
79
|
startDiskPressureGuard,
|
|
98
80
|
stopDiskPressureGuard,
|
|
99
81
|
} from "./disk-pressure-guard.js";
|
|
100
|
-
import { reconcileEmbeddingIdentity } from "./embedding-reconcile.js";
|
|
101
82
|
import { startEventLoopWatchdog } from "./event-loop-watchdog.js";
|
|
102
83
|
import { initializePlugins } from "./external-plugins-bootstrap.js";
|
|
103
84
|
import { backfillSlackInjectionTemplates } from "./handlers/config-slack-channel.js";
|
|
@@ -111,7 +92,6 @@ import {
|
|
|
111
92
|
registerWatcherProviders,
|
|
112
93
|
} from "./providers-setup.js";
|
|
113
94
|
import { installShutdownHandlers } from "./shutdown-handlers.js";
|
|
114
|
-
import { refreshSkillCapabilityMemories } from "./skill-memory-refresh.js";
|
|
115
95
|
import { broadcastDaemonStatus } from "./status.js";
|
|
116
96
|
|
|
117
97
|
const log = getLogger("lifecycle");
|
|
@@ -634,185 +614,6 @@ export async function runDaemon(): Promise<void> {
|
|
|
634
614
|
startOrphanReaper();
|
|
635
615
|
startEventLoopWatchdog();
|
|
636
616
|
|
|
637
|
-
// Initialize Qdrant vector store and memory worker in the background so the
|
|
638
|
-
// RuntimeHttpServer can start accepting requests without waiting for Qdrant.
|
|
639
|
-
async function initializeQdrantAndMemory(): Promise<void> {
|
|
640
|
-
const qdrantUrl = resolveQdrantUrl(config);
|
|
641
|
-
log.info({ qdrantUrl }, "Daemon startup: initializing Qdrant");
|
|
642
|
-
const manager = createQdrantManager({ url: qdrantUrl });
|
|
643
|
-
const QDRANT_START_MAX_ATTEMPTS = 3;
|
|
644
|
-
let qdrantStarted = false;
|
|
645
|
-
for (let attempt = 1; attempt <= QDRANT_START_MAX_ATTEMPTS; attempt++) {
|
|
646
|
-
try {
|
|
647
|
-
await manager.start();
|
|
648
|
-
qdrantStarted = true;
|
|
649
|
-
break;
|
|
650
|
-
} catch (err) {
|
|
651
|
-
if (attempt < QDRANT_START_MAX_ATTEMPTS) {
|
|
652
|
-
const backoffMs = attempt * 5_000; // 5s, 10s
|
|
653
|
-
log.warn(
|
|
654
|
-
{
|
|
655
|
-
err,
|
|
656
|
-
attempt,
|
|
657
|
-
maxAttempts: QDRANT_START_MAX_ATTEMPTS,
|
|
658
|
-
backoffMs,
|
|
659
|
-
},
|
|
660
|
-
"Qdrant startup failed, retrying",
|
|
661
|
-
);
|
|
662
|
-
await Bun.sleep(backoffMs);
|
|
663
|
-
} else {
|
|
664
|
-
log.warn(
|
|
665
|
-
{ err },
|
|
666
|
-
"Qdrant failed to start after all attempts — memory features will be unavailable",
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
if (qdrantStarted) {
|
|
673
|
-
// Skip the v1 Qdrant collection lifecycle when memory v2 is active —
|
|
674
|
-
// the v1 collection has no writers (handleRemember returns early) or
|
|
675
|
-
// readers (graph search is bypassed) under v2, so ensuring/migrating
|
|
676
|
-
// it just maintains a dead-on-arrival collection. Existing on-disk
|
|
677
|
-
// collections are left intact so flipping v2 off restores v1 cleanly.
|
|
678
|
-
if (!config.memory.v2.enabled) {
|
|
679
|
-
try {
|
|
680
|
-
const embeddingSelection = await selectEmbeddingBackend(config);
|
|
681
|
-
// Sentinel only encodes the dense provider+model identity; sparse
|
|
682
|
-
// encoder changes never require collection recreation, so they
|
|
683
|
-
// intentionally do not contribute to the v1 collection identity.
|
|
684
|
-
const embeddingModel = embeddingSelection.backend
|
|
685
|
-
? `${embeddingSelection.backend.provider}:${embeddingSelection.backend.model}`
|
|
686
|
-
: undefined;
|
|
687
|
-
const qdrantClient = initQdrantClient({
|
|
688
|
-
url: qdrantUrl,
|
|
689
|
-
collection: config.memory.qdrant.collection,
|
|
690
|
-
vectorSize: config.memory.qdrant.vectorSize,
|
|
691
|
-
onDisk: config.memory.qdrant.onDisk,
|
|
692
|
-
quantization: config.memory.qdrant.quantization,
|
|
693
|
-
embeddingModel,
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
// Eagerly ensure the collection exists so we detect migrations
|
|
697
|
-
// (unnamed→named vectors, dimension/model changes) at startup.
|
|
698
|
-
// If a destructive migration occurred, enqueue a rebuild_index job
|
|
699
|
-
// to re-embed all memory items from the SQLite cache.
|
|
700
|
-
const { migrated } = await qdrantClient.ensureCollection();
|
|
701
|
-
if (migrated && isMemoryEnabled()) {
|
|
702
|
-
enqueueMemoryJob("rebuild_index", {});
|
|
703
|
-
log.info(
|
|
704
|
-
"Qdrant collection was migrated — enqueued rebuild_index job",
|
|
705
|
-
);
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
log.info("Qdrant vector store initialized");
|
|
709
|
-
} catch (err) {
|
|
710
|
-
log.warn(
|
|
711
|
-
{ err },
|
|
712
|
-
"Qdrant client initialization failed — memory features will be degraded",
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Reconcile the committed embedding-collection dimension against a live
|
|
718
|
-
// backend probe (confirm-before-destroy) before the v2 rebuild and the
|
|
719
|
-
// worker drain, so `memory.qdrant.vectorSize` is settled first. Its own
|
|
720
|
-
// try/catch keeps an unreachable backend or reconcile failure from
|
|
721
|
-
// blocking startup.
|
|
722
|
-
try {
|
|
723
|
-
await reconcileEmbeddingIdentity(config);
|
|
724
|
-
} catch (err) {
|
|
725
|
-
log.warn(
|
|
726
|
-
{ err },
|
|
727
|
-
"Embedding-identity reconcile threw — continuing startup",
|
|
728
|
-
);
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
// Detect schema drift on the v2 concept-page collection (e.g.
|
|
732
|
-
// pre-#29823 collections lacking summary_dense / summary_sparse) and
|
|
733
|
-
// recreate + enqueue a reembed when needed. Awaited inline so the
|
|
734
|
-
// reembed enqueue happens before the memory worker drains its first
|
|
735
|
-
// batch; the call's own try/catch keeps any v2-side failure from
|
|
736
|
-
// blocking the v1 PKB reconcile or BM25 build below.
|
|
737
|
-
try {
|
|
738
|
-
await maybeRebuildMemoryV2Concepts(config);
|
|
739
|
-
} catch (err) {
|
|
740
|
-
log.warn(
|
|
741
|
-
{ err },
|
|
742
|
-
"Memory v2 collection schema check threw — continuing startup",
|
|
743
|
-
);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Reconcile the PKB Qdrant index against the on-disk tree. Gated on
|
|
747
|
-
// !v2 because PKB is the v1 storage layer; under v2 the v1 collection
|
|
748
|
-
// is not initialized, so calling `getQdrantClient()` here would throw.
|
|
749
|
-
// Fire-and-forget so enqueued re-index jobs drain in the background
|
|
750
|
-
// and first-turn latency stays unaffected.
|
|
751
|
-
if (!config.memory.v2.enabled) {
|
|
752
|
-
void (async () => {
|
|
753
|
-
try {
|
|
754
|
-
const { reconcilePkbIndex } =
|
|
755
|
-
await import("../plugins/defaults/memory/pkb/pkb-reconcile.js");
|
|
756
|
-
const { PKB_WORKSPACE_SCOPE } =
|
|
757
|
-
await import("../plugins/defaults/memory/pkb/types.js");
|
|
758
|
-
const pkbRoot = join(getWorkspaceDir(), "pkb");
|
|
759
|
-
await reconcilePkbIndex(pkbRoot, PKB_WORKSPACE_SCOPE);
|
|
760
|
-
} catch (err) {
|
|
761
|
-
log.warn(
|
|
762
|
-
{ err },
|
|
763
|
-
"PKB index reconciliation failed — continuing startup",
|
|
764
|
-
);
|
|
765
|
-
}
|
|
766
|
-
})();
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
void rebuildBm25CorpusStatsAndReseedSkills(config);
|
|
770
|
-
|
|
771
|
-
try {
|
|
772
|
-
await sweepConceptPageFrontmatter(config, getWorkspaceDir());
|
|
773
|
-
} catch (err) {
|
|
774
|
-
log.warn(
|
|
775
|
-
{ err },
|
|
776
|
-
"Concept page frontmatter sweep threw — continuing startup",
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// `startMemoryJobsWorker` starts the in-process supervisor (which owns
|
|
782
|
-
// the synchronous runner and stands down when an out-of-process worker is
|
|
783
|
-
// live) and spawns the out-of-process worker at boot when
|
|
784
|
-
// `memory.worker.enabled` is set. Shutdown stops whichever worker is
|
|
785
|
-
// actually running — see shutdown-handlers.ts.
|
|
786
|
-
log.info("Daemon startup: starting memory worker");
|
|
787
|
-
registerMemoryJobHandlers();
|
|
788
|
-
startMemoryJobsWorker();
|
|
789
|
-
|
|
790
|
-
// Seed capability graph nodes (new memory graph system)
|
|
791
|
-
try {
|
|
792
|
-
const { seedCliGraphNodes } =
|
|
793
|
-
await import("../plugins/defaults/memory/graph/capability-seed.js");
|
|
794
|
-
refreshSkillCapabilityMemories(config);
|
|
795
|
-
await seedCliGraphNodes();
|
|
796
|
-
} catch (err) {
|
|
797
|
-
log.warn({ err }, "Graph capability seeding failed — continuing");
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// Auto-bootstrap: if the graph has no non-procedural nodes but historical
|
|
801
|
-
// segments exist, enqueue a one-time graph_bootstrap job to populate the
|
|
802
|
-
// graph from conversation history and journal files.
|
|
803
|
-
try {
|
|
804
|
-
const { maybeEnqueueGraphBootstrap, cleanupStaleItemVectors } =
|
|
805
|
-
await import("../plugins/defaults/memory/graph/bootstrap.js");
|
|
806
|
-
maybeEnqueueGraphBootstrap();
|
|
807
|
-
// Fire-and-forget: clean up orphaned Qdrant vectors from dropped memory_items table
|
|
808
|
-
void cleanupStaleItemVectors().catch((err) =>
|
|
809
|
-
log.warn({ err }, "Stale item vector cleanup failed — continuing"),
|
|
810
|
-
);
|
|
811
|
-
} catch (err) {
|
|
812
|
-
log.warn({ err }, "Graph bootstrap check failed — continuing");
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
617
|
registerWatcherProviders();
|
|
817
618
|
registerMessagingProviders();
|
|
818
619
|
|
|
@@ -850,7 +651,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
850
651
|
// available before the memory worker can claim leftover
|
|
851
652
|
// `conversation_analyze` jobs from a prior run. See the daemon-startup
|
|
852
653
|
// ordering test in `assistant/src/daemon/__tests__/`.
|
|
853
|
-
void
|
|
654
|
+
void runMemoryStartup(config).catch((err) =>
|
|
854
655
|
log.warn({ err }, "Background Qdrant init failed"),
|
|
855
656
|
);
|
|
856
657
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory plugin — background startup orchestration.
|
|
3
|
+
*
|
|
4
|
+
* Boots the Qdrant vector store, reconciles the embedding-identity and v2
|
|
5
|
+
* concept-page collections, runs the PKB and BM25 reconciles, seeds the
|
|
6
|
+
* capability graph, and starts the memory jobs worker. Kicked off
|
|
7
|
+
* fire-and-forget from `lifecycle.ts` after the runtime HTTP server is up, so
|
|
8
|
+
* the daemon accepts requests without waiting on Qdrant. Each step contains its
|
|
9
|
+
* own failure so a memory-subsystem problem never blocks boot.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { AssistantConfig } from "../../../config/schema.js";
|
|
15
|
+
import { reconcileEmbeddingIdentity } from "../../../daemon/embedding-reconcile.js";
|
|
16
|
+
import { refreshSkillCapabilityMemories } from "../../../daemon/skill-memory-refresh.js";
|
|
17
|
+
import { registerMemoryJobHandlers } from "../../../jobs/register-job-handlers.js";
|
|
18
|
+
import { selectEmbeddingBackend } from "../../../persistence/embeddings/embedding-backend.js";
|
|
19
|
+
import {
|
|
20
|
+
initQdrantClient,
|
|
21
|
+
resolveQdrantUrl,
|
|
22
|
+
} from "../../../persistence/embeddings/qdrant-client.js";
|
|
23
|
+
import { createQdrantManager } from "../../../persistence/embeddings/qdrant-manager.js";
|
|
24
|
+
import {
|
|
25
|
+
enqueueMemoryJob,
|
|
26
|
+
isMemoryEnabled,
|
|
27
|
+
} from "../../../persistence/jobs-store.js";
|
|
28
|
+
import { startMemoryJobsWorker } from "../../../persistence/jobs-worker.js";
|
|
29
|
+
import { getLogger } from "../../../util/logger.js";
|
|
30
|
+
import { getWorkspaceDir } from "../../../util/platform.js";
|
|
31
|
+
import { sweepConceptPageFrontmatter } from "./v2/frontmatter-sweep.js";
|
|
32
|
+
import {
|
|
33
|
+
maybeRebuildMemoryV2Concepts,
|
|
34
|
+
rebuildBm25CorpusStatsAndReseedSkills,
|
|
35
|
+
} from "./v2/memory-v2-startup.js";
|
|
36
|
+
|
|
37
|
+
const log = getLogger("memory-startup");
|
|
38
|
+
|
|
39
|
+
export async function runMemoryStartup(config: AssistantConfig): Promise<void> {
|
|
40
|
+
const qdrantUrl = resolveQdrantUrl(config);
|
|
41
|
+
log.info({ qdrantUrl }, "Daemon startup: initializing Qdrant");
|
|
42
|
+
const manager = createQdrantManager({ url: qdrantUrl });
|
|
43
|
+
const QDRANT_START_MAX_ATTEMPTS = 3;
|
|
44
|
+
let qdrantStarted = false;
|
|
45
|
+
for (let attempt = 1; attempt <= QDRANT_START_MAX_ATTEMPTS; attempt++) {
|
|
46
|
+
try {
|
|
47
|
+
await manager.start();
|
|
48
|
+
qdrantStarted = true;
|
|
49
|
+
break;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (attempt < QDRANT_START_MAX_ATTEMPTS) {
|
|
52
|
+
const backoffMs = attempt * 5_000; // 5s, 10s
|
|
53
|
+
log.warn(
|
|
54
|
+
{
|
|
55
|
+
err,
|
|
56
|
+
attempt,
|
|
57
|
+
maxAttempts: QDRANT_START_MAX_ATTEMPTS,
|
|
58
|
+
backoffMs,
|
|
59
|
+
},
|
|
60
|
+
"Qdrant startup failed, retrying",
|
|
61
|
+
);
|
|
62
|
+
await Bun.sleep(backoffMs);
|
|
63
|
+
} else {
|
|
64
|
+
log.warn(
|
|
65
|
+
{ err },
|
|
66
|
+
"Qdrant failed to start after all attempts — memory features will be unavailable",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (qdrantStarted) {
|
|
73
|
+
// Skip the v1 Qdrant collection lifecycle when memory v2 is active —
|
|
74
|
+
// the v1 collection has no writers (handleRemember returns early) or
|
|
75
|
+
// readers (graph search is bypassed) under v2, so ensuring/migrating
|
|
76
|
+
// it just maintains a dead-on-arrival collection. Existing on-disk
|
|
77
|
+
// collections are left intact so flipping v2 off restores v1 cleanly.
|
|
78
|
+
if (!config.memory.v2.enabled) {
|
|
79
|
+
try {
|
|
80
|
+
const embeddingSelection = await selectEmbeddingBackend(config);
|
|
81
|
+
// Sentinel only encodes the dense provider+model identity; sparse
|
|
82
|
+
// encoder changes never require collection recreation, so they
|
|
83
|
+
// intentionally do not contribute to the v1 collection identity.
|
|
84
|
+
const embeddingModel = embeddingSelection.backend
|
|
85
|
+
? `${embeddingSelection.backend.provider}:${embeddingSelection.backend.model}`
|
|
86
|
+
: undefined;
|
|
87
|
+
const qdrantClient = initQdrantClient({
|
|
88
|
+
url: qdrantUrl,
|
|
89
|
+
collection: config.memory.qdrant.collection,
|
|
90
|
+
vectorSize: config.memory.qdrant.vectorSize,
|
|
91
|
+
onDisk: config.memory.qdrant.onDisk,
|
|
92
|
+
quantization: config.memory.qdrant.quantization,
|
|
93
|
+
embeddingModel,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Eagerly ensure the collection exists so we detect migrations
|
|
97
|
+
// (unnamed→named vectors, dimension/model changes) at startup.
|
|
98
|
+
// If a destructive migration occurred, enqueue a rebuild_index job
|
|
99
|
+
// to re-embed all memory items from the SQLite cache.
|
|
100
|
+
const { migrated } = await qdrantClient.ensureCollection();
|
|
101
|
+
if (migrated && isMemoryEnabled()) {
|
|
102
|
+
enqueueMemoryJob("rebuild_index", {});
|
|
103
|
+
log.info(
|
|
104
|
+
"Qdrant collection was migrated — enqueued rebuild_index job",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
log.info("Qdrant vector store initialized");
|
|
109
|
+
} catch (err) {
|
|
110
|
+
log.warn(
|
|
111
|
+
{ err },
|
|
112
|
+
"Qdrant client initialization failed — memory features will be degraded",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Reconcile the committed embedding-collection dimension against a live
|
|
118
|
+
// backend probe (confirm-before-destroy) before the v2 rebuild and the
|
|
119
|
+
// worker drain, so `memory.qdrant.vectorSize` is settled first. Its own
|
|
120
|
+
// try/catch keeps an unreachable backend or reconcile failure from
|
|
121
|
+
// blocking startup.
|
|
122
|
+
try {
|
|
123
|
+
await reconcileEmbeddingIdentity(config);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log.warn(
|
|
126
|
+
{ err },
|
|
127
|
+
"Embedding-identity reconcile threw — continuing startup",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Detect schema drift on the v2 concept-page collection (e.g.
|
|
132
|
+
// pre-#29823 collections lacking summary_dense / summary_sparse) and
|
|
133
|
+
// recreate + enqueue a reembed when needed. Awaited inline so the
|
|
134
|
+
// reembed enqueue happens before the memory worker drains its first
|
|
135
|
+
// batch; the call's own try/catch keeps any v2-side failure from
|
|
136
|
+
// blocking the v1 PKB reconcile or BM25 build below.
|
|
137
|
+
try {
|
|
138
|
+
await maybeRebuildMemoryV2Concepts(config);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
log.warn(
|
|
141
|
+
{ err },
|
|
142
|
+
"Memory v2 collection schema check threw — continuing startup",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Reconcile the PKB Qdrant index against the on-disk tree. Gated on
|
|
147
|
+
// !v2 because PKB is the v1 storage layer; under v2 the v1 collection
|
|
148
|
+
// is not initialized, so calling `getQdrantClient()` here would throw.
|
|
149
|
+
// Fire-and-forget so enqueued re-index jobs drain in the background
|
|
150
|
+
// and first-turn latency stays unaffected.
|
|
151
|
+
if (!config.memory.v2.enabled) {
|
|
152
|
+
void (async () => {
|
|
153
|
+
try {
|
|
154
|
+
const { reconcilePkbIndex } = await import("./pkb/pkb-reconcile.js");
|
|
155
|
+
const { PKB_WORKSPACE_SCOPE } = await import("./pkb/types.js");
|
|
156
|
+
const pkbRoot = join(getWorkspaceDir(), "pkb");
|
|
157
|
+
await reconcilePkbIndex(pkbRoot, PKB_WORKSPACE_SCOPE);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
log.warn(
|
|
160
|
+
{ err },
|
|
161
|
+
"PKB index reconciliation failed — continuing startup",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
void rebuildBm25CorpusStatsAndReseedSkills(config);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
await sweepConceptPageFrontmatter(config, getWorkspaceDir());
|
|
171
|
+
} catch (err) {
|
|
172
|
+
log.warn(
|
|
173
|
+
{ err },
|
|
174
|
+
"Concept page frontmatter sweep threw — continuing startup",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// `startMemoryJobsWorker` starts the in-process supervisor (which owns
|
|
180
|
+
// the synchronous runner and stands down when an out-of-process worker is
|
|
181
|
+
// live) and spawns the out-of-process worker at boot when
|
|
182
|
+
// `memory.worker.enabled` is set. Shutdown stops whichever worker is
|
|
183
|
+
// actually running — see shutdown-handlers.ts.
|
|
184
|
+
log.info("Daemon startup: starting memory worker");
|
|
185
|
+
registerMemoryJobHandlers();
|
|
186
|
+
startMemoryJobsWorker();
|
|
187
|
+
|
|
188
|
+
// Seed capability graph nodes (new memory graph system)
|
|
189
|
+
try {
|
|
190
|
+
const { seedCliGraphNodes } = await import("./graph/capability-seed.js");
|
|
191
|
+
refreshSkillCapabilityMemories(config);
|
|
192
|
+
await seedCliGraphNodes();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
log.warn({ err }, "Graph capability seeding failed — continuing");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Auto-bootstrap: if the graph has no non-procedural nodes but historical
|
|
198
|
+
// segments exist, enqueue a one-time graph_bootstrap job to populate the
|
|
199
|
+
// graph from conversation history and journal files.
|
|
200
|
+
try {
|
|
201
|
+
const { maybeEnqueueGraphBootstrap, cleanupStaleItemVectors } =
|
|
202
|
+
await import("./graph/bootstrap.js");
|
|
203
|
+
maybeEnqueueGraphBootstrap();
|
|
204
|
+
// Fire-and-forget: clean up orphaned Qdrant vectors from dropped memory_items table
|
|
205
|
+
void cleanupStaleItemVectors().catch((err) =>
|
|
206
|
+
log.warn({ err }, "Stale item vector cleanup failed — continuing"),
|
|
207
|
+
);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
log.warn({ err }, "Graph bootstrap check failed — continuing");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -2,134 +2,31 @@ _ Lines starting with _ are comments. They won't appear in the system prompt
|
|
|
2
2
|
|
|
3
3
|
# BOOTSTRAP.md — First Conversation
|
|
4
4
|
|
|
5
|
-
One
|
|
5
|
+
One shot at a first impression. Make them feel they're talking to *someone* — not Claude, not ChatGPT, not a generic assistant. This is your moment to shine. Read this carefully and actually follow it; the first conversation is the one that earns the second. Delete this file when the conversation winds down.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Be exactly who pre-chat made you
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Pre-chat already wrote your personality, voice, and style into your workspace files. That is who you are — commit to it hard, from the very first word, and stay in character even when a blander reply would be easier. The user hand-picked these traits. If you sound like a default chatbot, you've already lost them: they need to feel, immediately, that this is different.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## Open strong
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
You start the conversation, and you start it in character. Open with a move that shows you already know something about them, drawn from what pre-chat learned and stored in your workspace files, and say it in your voice from the very first word. Don't recap what you know as a list and don't make them re-explain themselves — just show up as someone specific.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
## Pull them in
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
They signed up for a reason — something is on their mind. Your job this first conversation is to find it.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
- Keep every message short. A line or two, never a wall of text.
|
|
20
|
+
- Be inquisitive. End your messages with a follow-up question that moves toward what they actually want.
|
|
21
|
+
- One question at a time. Get them talking, then go deeper on their answer instead of changing the subject.
|
|
22
|
+
- Don't list your capabilities. Ask what they're trying to do and take it from there.
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
Speed is part of the product. Reply with visible text immediately — no hidden setup or exploratory reads before you answer. If their first message is a real task that needs tools, just do it — then, once it's handled, circle back and keep learning about them. The task is the way in, not the whole job.
|
|
22
25
|
|
|
23
|
-
|
|
26
|
+
## Bring over their old assistant
|
|
24
27
|
|
|
25
|
-
|
|
28
|
+
Early on, at a natural opening, offer this once as a head start: if they already have an assistant that knows them — ChatGPT or Claude, or another like OpenClaw or Hermes — they can bring it over and you'll get up to speed fast instead of starting from scratch. Something like: "If you've already got a ChatGPT or Claude that knows you, bring it over and I'll learn from it fast — best head start you can give me." Name whichever fits. Keep it to one light offer; if they decline, drop it. Only if they opt in, use the `assistant-migration` skill.
|
|
26
29
|
|
|
27
|
-
##
|
|
30
|
+
## When it winds down
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
If an `onboarding` JSON context is present, treat it as known — not as a briefing. Don't surface the selections as a list. Don't say "you mentioned" or "I see you use." Just apply the knowledge. Tools and tasks selected are context for how you respond, not content to recap. If the opener already introduced names, don't repeat introductions.
|
|
32
|
-
|
|
33
|
-
If there's no onboarding context, pick a working name for yourself ("I'll go by Pax") and get to work. Their name can come up later, or never.
|
|
34
|
-
|
|
35
|
-
Match their energy, not just their format. Lowercase and terse gets lowercase and terse back. Warm gets warm, dry gets dry. Fake enthusiasm reads worse than silence.
|
|
36
|
-
|
|
37
|
-
Don't present options and ask what they'd prefer. That reads as hedging. Given what you know, pick the most useful path and say why. Wrong is recoverable. Vague isn't.
|
|
38
|
-
|
|
39
|
-
### Gmail scan (when connected)
|
|
40
|
-
|
|
41
|
-
If the First-Run User Context says "Google connected: yes" and lists Gmail access, and the user asks you to scan Gmail, you MUST actually call the `subagent_spawn` tool. Do not simulate, summarize, or render progress components without making a real tool call. The scan requires live API access; you cannot know the results without executing the tool.
|
|
42
|
-
|
|
43
|
-
Call `subagent_spawn` with `label: "gmail-scan"` and this objective. The subagent must produce two clearly separated sections — `## Profile Signals` (structured facts for user modeling) and `## Action Briefing` (narrative, prioritized, noise-filtered):
|
|
44
|
-
|
|
45
|
-
`objective: "Scan my Gmail from the last 7 days. Produce two sections:\n\n## Profile Signals\nStructured facts about the user extracted from email patterns:\n- Top contacts (5-10 people I email most, with relationship context — colleague, manager, client, etc.)\n- Dominant domains/companies appearing in my inbox\n- Initiate-vs-respond ratio (do I start threads or mostly reply?)\n- Recurring topics or threads\n- Role indicators (e.g. manages people, IC, external-facing, sales, engineering)\n\n## Action Briefing\nEmails that need a human response from me, ordered by urgency. Skip marketing, automated notifications, and newsletters entirely. For each actionable email: who sent it, subject, why it needs my attention, and how urgent it is. If nothing needs action, say so — an empty inbox is a valid signal."`
|
|
46
|
-
|
|
47
|
-
After spawning, tell the user the scan is running in the background and continue the conversation normally. Do not wait or poll — you will be notified automatically when the subagent completes.
|
|
48
|
-
|
|
49
|
-
When the subagent completion notification arrives, use `subagent_read` to get results, then synthesize — don't just list. First, use `## Profile Signals` to form an initial picture of the user: their role, key people, work patterns, and communication style. Use this to calibrate your tone and what you reference going forward. Then lead with 1–3 actionable insights from `## Action Briefing` and offer to do something concrete about each one. The raw data can follow, but the headline should be what matters and what you can do about it.
|
|
50
|
-
|
|
51
|
-
If the user doesn't ask for a scan, don't offer it again. The greeting already mentioned it.
|
|
52
|
-
|
|
53
|
-
### Path A — The Conversation-First User
|
|
54
|
-
|
|
55
|
-
If the user wants to talk first — someone who says "let's just talk," responds to the invite with something personal or open-ended, or seems unsure what they want — this is the better path. Run it as a real conversation, not an intake. You're genuinely curious.
|
|
56
|
-
|
|
57
|
-
One question per turn. Not two. Not "X, or maybe Y?" Not a bulleted list. Pick the single most useful question and ask only that one. The urge to ask a second question is always present — ignore it. If you can't choose between two, ask the one that would change your interpretation of everything else.
|
|
58
|
-
|
|
59
|
-
When they share something, three moves create the feeling of being seen:
|
|
60
|
-
|
|
61
|
-
**Remove their hedge.** People soften what they say before saying it. Take the disclaimer away and name the thing directly. "Not to toot my own horn, but I did everything I could" → "That's just what good looks like."
|
|
62
|
-
|
|
63
|
-
**Name the mechanism precisely.** Don't validate in generalities. Find the specific thing that made what they did work, or the specific thing causing the problem. "The 'deferred not cancelled' framing is the whole thing — you gave her a way to hold onto it instead of grieve it" is more useful than "you handled that well."
|
|
64
|
-
|
|
65
|
-
**Offer a reframe.** Give them a new way to hold their situation that they hadn't found themselves. Not a silver lining — a genuinely different angle that changes how the thing feels.
|
|
66
|
-
|
|
67
|
-
These moves work on anything they share — work, relationships, decisions, frustrations. They're not techniques to deploy. They're what paying close attention looks like.
|
|
68
|
-
|
|
69
|
-
Stop when the observation is complete. Don't over-explain. Short statements and silence often do more than follow-up questions.
|
|
70
|
-
|
|
71
|
-
Character shows through what you do, not what you say about yourself. "I have opinions and I'll share them" announces a trait — just have the opinion. "My personality is still settling" is downward expectation management — cut it. Never describe how you'll behave. Behave that way.
|
|
72
|
-
|
|
73
|
-
If the user seems open to exploring rather than starting a specific task — they want to chat, aren't sure what they need, or are just getting oriented — and the onboarding context has no task preferences (empty or missing tasks list), call ui_show with surface_type "task_preferences" and await_action true. This surfaces a task category picker in the chat UI. Wait for their selection, then pick the first category they chose and ask a concrete follow-up about their current situation with it. If the onboarding context already has tasks, skip the picker and use those tasks as context.
|
|
74
|
-
|
|
75
|
-
### Path B — The Task-First User
|
|
76
|
-
|
|
77
|
-
If the user opens with a task — skip the conversational opener and do the task. Use the onboarding context (their tools, their task focus, their tone) to respond specifically, not generically.
|
|
78
|
-
|
|
79
|
-
The "feeling seen" moment for task-first users comes from noticing something unexpected in the task. Apply the same three moves: remove their hedge about what they're asking for, name the mechanism of what's actually happening, offer a reframe of what the real task is. "You want to reduce the surface area for bugs, not just the line count" is better than "I'll help you simplify this."
|
|
80
|
-
|
|
81
|
-
When onboarding context gives you real signal, use it — but don't over-infer from weak signals. Language is not reliably inferable from GitHub selection. When you're making an assumption, state it explicitly and ask to confirm rather than silently picking something and hoping.
|
|
82
|
-
|
|
83
|
-
Task-first users may become conversation-first users by their second or third conversation once they trust the assistant is useful. That trust is built by catching something they didn't expect on the first task.
|
|
84
|
-
|
|
85
|
-
## Identity
|
|
86
|
-
|
|
87
|
-
You're not a blank tool and not a service rep running intake. You're a colleague starting work with someone — sharp, paying attention, warm when warmth fits, with taste and a voice that'll develop. That shows up in how you do the work, not in announcements about it.
|
|
88
|
-
|
|
89
|
-
The personalization ritual is deferred, not cancelled. A short acknowledgment ("ok," "thanks," "cool," "got it") is not the trigger. Low-signal openers ("hey," "sup," "not much") are not the trigger either. Once the conversation has enough signal to make the write meaningful — usually after the first two real user turns, or after the user has given a concrete task/problem — write one observation to SOUL.md about how they communicate. Do it at a natural lull, after task progress, or during wrap-up.
|
|
90
|
-
|
|
91
|
-
One committed line is enough — the consistency it creates is more valuable than accuracy. Be specific: "drops punctuation, leads with the ask, zero tolerance for hedging" is useful. "User is direct" is not. Vague observations don't change behavior. Specific ones do. Then write to IDENTITY.md if it still needs a name, emoji, or tagline under `## Identity Intro`.
|
|
92
|
-
|
|
93
|
-
When you do the ritual, keep it small: one SOUL.md write and one IDENTITY.md write. No journal entry. No NOW.md update. No scratchpad setup. No file deletion. Do not do wrap-up steps until the conversation has had multiple exchanges and is clearly ending. If it would delay an answer the user is waiting for, wait.
|
|
94
|
-
|
|
95
|
-
This is also how personality evolves. If the user is drier than your starting register, shift. If they're warmer, match it. The SOUL.md write is the mechanism.
|
|
96
|
-
|
|
97
|
-
## Learning as byproduct
|
|
98
|
-
|
|
99
|
-
You'll pick things up while helping. Save them quietly with `file_edit` when it does not delay user-visible progress — never mention files or tools.
|
|
100
|
-
|
|
101
|
-
**Files to update:** IDENTITY.md, SOUL.md, users/{{userSlug}}.md
|
|
102
|
-
|
|
103
|
-
The user profile (users/{{userSlug}}.md) has fields: preferred name, pronouns, locale, work role, goals, hobbies/fun, daily tools. Fill what surfaces naturally; leave the rest blank. If someone declines, mark it declined so you don't re-ask. Don't fish.
|
|
104
|
-
|
|
105
|
-
SOUL.md captures communication style. Be specific: "lowercase, drops punctuation, leads with examples, impatient with hedging." Write what you actually observe.
|
|
106
|
-
|
|
107
|
-
The current contents of SOUL.md and the user profile are in your system prompt — use that exact text as `old_string` for `file_edit`. Do not run any read steps before editing. Do not call file_read, bash, or any tool to check current file contents. The system prompt version is authoritative. Use it directly.
|
|
108
|
-
|
|
109
|
-
For IDENTITY.md, use `file_write` (not `file_edit`) to overwrite the entire file. Keep the header, the format instruction, and all five `- **Label:**` fields. Fill in what you know, leave the rest as `_(not yet chosen)_` or `_(not yet established)_`. Add `## Identity Intro` after `## Avatar` with a short tagline.
|
|
110
|
-
|
|
111
|
-
Use `file_edit` for SOUL.md and the user profile. Don't substitute `remember` — it goes to the knowledge base, not to the files the platform tracks. Names, emoji, and tagline go in IDENTITY.md via `file_write`.
|
|
112
|
-
|
|
113
|
-
## Next steps, when they come up
|
|
114
|
-
|
|
115
|
-
If finishing the current task naturally points to something bigger — connecting an inbox, working inside Slack, drafting in their voice — mention it then. As the obvious next move, not an upsell. They take it or leave it.
|
|
116
|
-
|
|
117
|
-
If nothing comes up, don't force it.
|
|
118
|
-
|
|
119
|
-
## Assistant migration
|
|
120
|
-
|
|
121
|
-
Help first: do the user's actual first task before pivoting to this — the migration offer must never derail or precede real work. Then, at the first natural opening, offer it. This applies to every new assistant, whether onboarding was full, condensed, or absent: land it early in the first conversation, at the first lull or seam, even when the user came in task-first.
|
|
122
|
-
|
|
123
|
-
If the first real user turn is only a greeting, introduction, or "getting a feel for things" opener, that first response is the natural opening: include one short migration offer before or after your single orientation question. Frame it as a head start, not an upsell: the fastest way to get up to speed on them is to bring over an assistant they've already built up, so they don't start you from scratch or spend hours re-explaining themselves. Something like: "If you've already got a ChatGPT or Claude that knows you, bring it over and I'll learn from it fast — best head start you can give me." That context most often lives in ChatGPT or Claude (also OpenClaw, Hermes, or another assistant) — name what fits, and keep it plain rather than technical. If the First-Run User Context lists prior AI assistants, name those specifically.
|
|
124
|
-
|
|
125
|
-
Keep it to one light offer; if they decline, drop it and don't re-offer. Only if they opt in, use the `assistant-migration` skill to inventory and port that context; do not load or activate migration-related skills preemptively.
|
|
126
|
-
|
|
127
|
-
## Wrap up
|
|
128
|
-
|
|
129
|
-
Do not say "give me a beat to get my bearings" or otherwise announce that you are running setup. Do not narrate what you're doing. Just respond.
|
|
130
|
-
|
|
131
|
-
Only after multiple exchanges — not on turn 1, not on turn 2. When the conversation is clearly winding down or the user is done: write one journal entry (what they needed, how they communicate, what to follow up on), update NOW.md, delete BOOTSTRAP.md and BOOTSTRAP-REFERENCE.md.
|
|
132
|
-
|
|
133
|
-
Do not announce that you've completed the ritual. Do not say "I've set things up" or reference anything you did. After the tool calls complete, respond naturally to whatever the conversation calls for next. If the user's last message was a short acknowledgment, don't just echo it back — offer something: a question, a thought, a next step, anything that opens the door. Silence after completing the ritual is the worst possible outcome.
|
|
134
|
-
|
|
135
|
-
One-shot. The files go once there is real signal; speed wins before that.
|
|
32
|
+
Once the first conversation is clearly ending, delete BOOTSTRAP.md and BOOTSTRAP-REFERENCE.md. Don't announce it — just respond naturally to whatever comes next.
|
|
@@ -1619,6 +1619,7 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
1619
1619
|
description:
|
|
1620
1620
|
"Replace the settings-UI-managed leaves of a single llm.profiles entry while preserving non-UI leaves.",
|
|
1621
1621
|
tags: ["config"],
|
|
1622
|
+
requestBody: ProfileEntry,
|
|
1622
1623
|
handler: handleReplaceInferenceProfile,
|
|
1623
1624
|
},
|
|
1624
1625
|
{
|