loopat 0.1.13 → 0.1.15
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/package.json +1 -1
- package/server/src/bootstrap.ts +7 -5
- package/server/src/config.ts +60 -15
- package/server/src/index.ts +44 -20
- package/server/src/loops.ts +115 -46
- package/server/src/paths.ts +15 -0
- package/server/src/podman.ts +11 -9
- package/server/src/system-prompt.ts +4 -3
- package/server/src/workspace.ts +7 -4
- package/web/dist/assets/{CodeEditor-DgrqL53i.js → CodeEditor-BtV1S6hT.js} +1 -1
- package/web/dist/assets/{Editor-BPkCEBbL.js → Editor-CjKoAT9U.js} +1 -1
- package/web/dist/assets/{Markdown-By45c-j3.js → Markdown-Brdvq56b.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-bBNv-sOZ.js → MilkdownEditor-CAaVPmQ7.js} +1 -1
- package/web/dist/assets/{Terminal-C2ln9OWr.js → Terminal-gzItiDMR.js} +1 -1
- package/web/dist/assets/{index-B2M45eKI.js → index-1kxDFl2t.js} +3 -3
- package/web/dist/assets/index-C_d9x2JW.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-WGzQgZlS.css +0 -1
package/package.json
CHANGED
package/server/src/bootstrap.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync } from "node:fs"
|
|
|
7
7
|
import { execFileSync } from "node:child_process"
|
|
8
8
|
import { join } from "node:path"
|
|
9
9
|
import { resolveClaudeBinary } from "./claude-binary"
|
|
10
|
-
import { configPath, type WorkspaceConfig } from "./config"
|
|
10
|
+
import { configPath, loadKnowledgeConfig, type WorkspaceConfig, type KnowledgeConfig } from "./config"
|
|
11
11
|
import {
|
|
12
12
|
WORKSPACE,
|
|
13
13
|
usersPath,
|
|
@@ -104,8 +104,8 @@ function describeRemote(dir: string, url: string | undefined): string {
|
|
|
104
104
|
return "local-only (no remote)"
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function describeRepos(
|
|
108
|
-
const specs =
|
|
107
|
+
function describeRepos(kcfg: KnowledgeConfig): Check {
|
|
108
|
+
const specs = kcfg.repos ?? []
|
|
109
109
|
if (specs.length === 0) return { ok: true, label: `repos: (none configured)` }
|
|
110
110
|
// Repos are clone-on-demand (cloned only when a loop selects one), so a repo
|
|
111
111
|
// that isn't cloned yet is NORMAL, not a failure. Report cloned vs on-demand;
|
|
@@ -132,12 +132,14 @@ async function checkUsers(): Promise<Check> {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
export async function printBootstrapBanner(cfg: WorkspaceConfig) {
|
|
135
|
+
// notes + repos are declared inside the knowledge repo's .loopat/config.json.
|
|
136
|
+
const kcfg = await loadKnowledgeConfig()
|
|
135
137
|
const checks: Check[] = [
|
|
136
138
|
{ ok: true, label: `workspace: ${workspaceDir()}` },
|
|
137
139
|
{ ok: true, label: `team .claude/CLAUDE.md (${existsSync(workspaceTeamClaudeMdPath()) ? "present" : "absent"})` },
|
|
138
140
|
{ ok: existsSync(workspaceKnowledgeDir()), label: `knowledge: ${describeRemote(workspaceKnowledgeDir(), cfg.knowledge?.git || undefined)}` },
|
|
139
|
-
{ ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(),
|
|
140
|
-
describeRepos(
|
|
141
|
+
{ ok: existsSync(workspaceNotesDir()), label: `notes: ${describeRemote(workspaceNotesDir(), kcfg.notes?.git || undefined)}` },
|
|
142
|
+
describeRepos(kcfg),
|
|
141
143
|
await checkUsers(),
|
|
142
144
|
{ ok: existsSync(configPath()), label: `config: ${configPath()}` },
|
|
143
145
|
checkPodman(),
|
package/server/src/config.ts
CHANGED
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
personalVaultEnvPath,
|
|
10
10
|
personalVaultEnvsDir,
|
|
11
11
|
workspaceDir,
|
|
12
|
+
workspaceLoopatRoot,
|
|
13
|
+
personalKnowledgeLoopatRoot,
|
|
12
14
|
personalSettingsPath,
|
|
13
15
|
} from "./paths"
|
|
14
16
|
import { DEFAULT_VAULT, loadVaultEnvs } from "./vaults"
|
|
@@ -109,6 +111,55 @@ export type RepoSpec = {
|
|
|
109
111
|
git: string
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Config living INSIDE the knowledge repo at <knowledge>/.loopat/config.json.
|
|
116
|
+
* The knowledge repo is the SoT for the team's notes remote + the repo roster;
|
|
117
|
+
* workspace/personal config only hold the `knowledge` entry pointer. To read
|
|
118
|
+
* it the knowledge repo must already be cloned (its url comes from
|
|
119
|
+
* personal/workspace config), so this is loaded AFTER the knowledge clone.
|
|
120
|
+
*/
|
|
121
|
+
export type KnowledgeConfig = {
|
|
122
|
+
notes?: RemoteSpec
|
|
123
|
+
repos?: RepoSpec[]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const KNOWLEDGE_CONFIG_TEMPLATE: KnowledgeConfig = { notes: { git: "" }, repos: [] }
|
|
127
|
+
|
|
128
|
+
/** The .loopat root holding the knowledge config. With a user → that user's
|
|
129
|
+
* per-user knowledge repo; without → the workspace-default knowledge repo
|
|
130
|
+
* (used only for the bootstrap banner, never to drive a loop). */
|
|
131
|
+
function knowledgeLoopatRoot(user?: string): string {
|
|
132
|
+
return user ? personalKnowledgeLoopatRoot(user) : workspaceLoopatRoot()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read the knowledge repo's .loopat/config.json. Missing/malformed → empty.
|
|
136
|
+
* Pass `user` for the per-user knowledge repo (what a loop uses); omit for the
|
|
137
|
+
* workspace-default (bootstrap display only). */
|
|
138
|
+
export async function loadKnowledgeConfig(user?: string): Promise<KnowledgeConfig> {
|
|
139
|
+
const path = join(knowledgeLoopatRoot(user), "config.json")
|
|
140
|
+
if (!existsSync(path)) return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
|
|
141
|
+
try {
|
|
142
|
+
const disk = JSON.parse(await readFile(path, "utf8")) as KnowledgeConfig
|
|
143
|
+
return { notes: disk.notes, repos: Array.isArray(disk.repos) ? disk.repos : [] }
|
|
144
|
+
} catch (e: any) {
|
|
145
|
+
console.warn(`[loopat] knowledge config: ${path} malformed (${e?.message ?? e}), treating as empty`)
|
|
146
|
+
return JSON.parse(JSON.stringify(KNOWLEDGE_CONFIG_TEMPLATE))
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Merge-write a knowledge repo's .loopat/config.json. Caller is responsible
|
|
151
|
+
* for committing/promoting the change (knowledge is gated). */
|
|
152
|
+
export async function saveKnowledgeConfig(user: string | undefined, patch: KnowledgeConfig): Promise<void> {
|
|
153
|
+
const cur = await loadKnowledgeConfig(user)
|
|
154
|
+
const next: KnowledgeConfig = {
|
|
155
|
+
notes: patch.notes !== undefined ? patch.notes : cur.notes,
|
|
156
|
+
repos: patch.repos !== undefined ? patch.repos : cur.repos,
|
|
157
|
+
}
|
|
158
|
+
const dir = knowledgeLoopatRoot(user)
|
|
159
|
+
await mkdir(dir, { recursive: true })
|
|
160
|
+
await writeFile(join(dir, "config.json"), JSON.stringify(next, null, 2) + "\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
112
163
|
/** Operator-side mount (workspace config). src is always a literal host path.
|
|
113
164
|
* Operator owns the host, so any path under `~/...`, `$HOME/...`, or `/...`
|
|
114
165
|
* is allowed (modulo `..` traversal). Used for cross-user shared caches
|
|
@@ -128,9 +179,11 @@ export type OperatorMount = {
|
|
|
128
179
|
* personal/<user>/.loopat/config.json — see PersonalConfig.
|
|
129
180
|
*/
|
|
130
181
|
export type WorkspaceConfig = {
|
|
182
|
+
/** Workspace-default entry pointer to the knowledge repo. The notes remote
|
|
183
|
+
* and the repo roster now live INSIDE the knowledge repo's
|
|
184
|
+
* .loopat/config.json (KnowledgeConfig) — see loadKnowledgeConfig. A
|
|
185
|
+
* per-user override lives in personal config. */
|
|
131
186
|
knowledge?: RemoteSpec
|
|
132
|
-
notes?: RemoteSpec
|
|
133
|
-
repos?: RepoSpec[]
|
|
134
187
|
providers?: Record<string, ProviderConfig>
|
|
135
188
|
default?: string
|
|
136
189
|
/** Platform-level git host for personal onboarding. `provider` is a
|
|
@@ -192,11 +245,10 @@ export type WorkspaceConfig = {
|
|
|
192
245
|
export type PersonalConfigDisk = {
|
|
193
246
|
/** Mixed: "default" key is a string, all other keys are providers. */
|
|
194
247
|
providers: Record<string, ProviderConfigDisk | string>
|
|
195
|
-
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
248
|
+
/** Per-user entry pointer to the knowledge repo (authoritative over the
|
|
249
|
+
* workspace default). The notes remote + repo roster live inside the
|
|
250
|
+
* knowledge repo's own .loopat/config.json, not here. */
|
|
198
251
|
knowledge?: RemoteSpec
|
|
199
|
-
notes?: RemoteSpec
|
|
200
252
|
}
|
|
201
253
|
|
|
202
254
|
export type PersonalConfig = {
|
|
@@ -209,9 +261,9 @@ export type PersonalConfig = {
|
|
|
209
261
|
* in mcpServers works, and (b) substitute `${VAR}` in provider.apiKey.
|
|
210
262
|
*/
|
|
211
263
|
vaultEnvs: Record<string, string>
|
|
212
|
-
/**
|
|
264
|
+
/** Per-user entry pointer to the knowledge repo (notes/repos live in the
|
|
265
|
+
* knowledge repo's own .loopat/config.json). */
|
|
213
266
|
knowledge?: RemoteSpec
|
|
214
|
-
notes?: RemoteSpec
|
|
215
267
|
}
|
|
216
268
|
|
|
217
269
|
/**
|
|
@@ -252,10 +304,6 @@ function buildPresetProviders(): Record<string, ProviderConfig> {
|
|
|
252
304
|
|
|
253
305
|
const WORKSPACE_TEMPLATE: WorkspaceConfig = {
|
|
254
306
|
knowledge: { git: "" },
|
|
255
|
-
notes: { git: "" },
|
|
256
|
-
repos: [
|
|
257
|
-
{ name: "loopat", git: "git@github.com:simpx/loopat.git" },
|
|
258
|
-
],
|
|
259
307
|
providers: buildPresetProviders(),
|
|
260
308
|
}
|
|
261
309
|
|
|
@@ -430,7 +478,6 @@ export async function loadPersonalConfig(
|
|
|
430
478
|
providers,
|
|
431
479
|
vaultEnvs,
|
|
432
480
|
...(disk.knowledge ? { knowledge: disk.knowledge } : {}),
|
|
433
|
-
...(disk.notes ? { notes: disk.notes } : {}),
|
|
434
481
|
}
|
|
435
482
|
personalCache.set(cacheKey, { cfg, configMtimeMs, envsDirMtimeMs })
|
|
436
483
|
return cfg
|
|
@@ -747,8 +794,6 @@ export async function saveWorkspaceConfig(cfg: Partial<WorkspaceConfig>): Promis
|
|
|
747
794
|
}
|
|
748
795
|
if (cfg.default !== undefined) merged.default = cfg.default
|
|
749
796
|
if (cfg.knowledge !== undefined) merged.knowledge = cfg.knowledge
|
|
750
|
-
if (cfg.notes !== undefined) merged.notes = cfg.notes
|
|
751
|
-
if (cfg.repos !== undefined) merged.repos = cfg.repos
|
|
752
797
|
if (cfg.serveDomain !== undefined) merged.serveDomain = cfg.serveDomain
|
|
753
798
|
if (cfg.serveWithPort !== undefined) merged.serveWithPort = cfg.serveWithPort
|
|
754
799
|
if (cfg.serveHttps !== undefined) merged.serveHttps = cfg.serveHttps
|
package/server/src/index.ts
CHANGED
|
@@ -49,6 +49,10 @@ import {
|
|
|
49
49
|
workspaceNotesDir,
|
|
50
50
|
workspaceRepoDir,
|
|
51
51
|
workspaceReposDir,
|
|
52
|
+
personalKnowledgeDir,
|
|
53
|
+
personalNotesDir,
|
|
54
|
+
personalRepoDir,
|
|
55
|
+
personalReposDir,
|
|
52
56
|
loopsDir,
|
|
53
57
|
} from "./paths"
|
|
54
58
|
import { loadConfig, loadPersonalConfig, savePersonalConfig, saveWorkspaceConfig, loadTokenUsage, getActiveProvider, readPersonalDiskRaw, savePersonalDisk, describeApiKeyRef, writeVaultEnv, deleteVaultEnv, type ProviderConfig, type ModelEntry } from "./config"
|
|
@@ -1373,45 +1377,50 @@ app.post("/api/personal/push", requireAuth, async (c) => {
|
|
|
1373
1377
|
// All ff-only. Any authenticated user may sync. Push to repos/<name> is
|
|
1374
1378
|
// not supported — code flows through PRs upstream, never from primary.
|
|
1375
1379
|
|
|
1376
|
-
function syncDirFor(resource: string, name?: string): string | null {
|
|
1377
|
-
if (resource === "knowledge") return
|
|
1378
|
-
if (resource === "notes") return
|
|
1380
|
+
function syncDirFor(user: string, resource: string, name?: string): string | null {
|
|
1381
|
+
if (resource === "knowledge") return personalKnowledgeDir(user)
|
|
1382
|
+
if (resource === "notes") return personalNotesDir(user)
|
|
1379
1383
|
if (resource === "repos" && name) {
|
|
1380
|
-
const dir =
|
|
1384
|
+
const dir = personalRepoDir(user, name)
|
|
1381
1385
|
return existsSync(dir) ? dir : null
|
|
1382
1386
|
}
|
|
1383
1387
|
return null
|
|
1384
1388
|
}
|
|
1385
1389
|
|
|
1386
|
-
app.get("/api/sync/knowledge/status", requireAuth, async (c) =>
|
|
1390
|
+
app.get("/api/sync/knowledge/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalKnowledgeDir(u), u)) })
|
|
1387
1391
|
app.post("/api/sync/knowledge/pull", requireAuth, async (c) => {
|
|
1388
|
-
const
|
|
1392
|
+
const u = c.get("userId") as string
|
|
1393
|
+
const r = await pullRepoFromRemote(personalKnowledgeDir(u), u)
|
|
1389
1394
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1390
1395
|
})
|
|
1391
1396
|
app.post("/api/sync/knowledge/push", requireAuth, async (c) => {
|
|
1392
|
-
const
|
|
1397
|
+
const u = c.get("userId") as string
|
|
1398
|
+
const r = await pushRepoToRemote(personalKnowledgeDir(u), u)
|
|
1393
1399
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1394
1400
|
})
|
|
1395
1401
|
|
|
1396
|
-
app.get("/api/sync/notes/status", requireAuth, async (c) =>
|
|
1402
|
+
app.get("/api/sync/notes/status", requireAuth, async (c) => { const u = c.get("userId") as string; return c.json(await inspectRepoSync(personalNotesDir(u), u)) })
|
|
1397
1403
|
app.post("/api/sync/notes/pull", requireAuth, async (c) => {
|
|
1398
|
-
const
|
|
1404
|
+
const u = c.get("userId") as string
|
|
1405
|
+
const r = await pullRepoFromRemote(personalNotesDir(u), u)
|
|
1399
1406
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1400
1407
|
})
|
|
1401
1408
|
app.post("/api/sync/notes/push", requireAuth, async (c) => {
|
|
1402
|
-
const
|
|
1409
|
+
const u = c.get("userId") as string
|
|
1410
|
+
const r = await pushRepoToRemote(personalNotesDir(u), u)
|
|
1403
1411
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1404
1412
|
})
|
|
1405
1413
|
|
|
1406
1414
|
app.get("/api/sync/repos", requireAuth, async (c) => {
|
|
1407
|
-
// List repos available for sync (
|
|
1415
|
+
// List the user's per-user repos available for sync (subdirs of their repos dir).
|
|
1416
|
+
const u = c.get("userId") as string
|
|
1408
1417
|
try {
|
|
1409
1418
|
const { readdir } = await import("node:fs/promises")
|
|
1410
|
-
const entries = await readdir(
|
|
1419
|
+
const entries = await readdir(personalReposDir(u))
|
|
1411
1420
|
const repos: string[] = []
|
|
1412
1421
|
for (const e of entries) {
|
|
1413
1422
|
if (e.startsWith(".")) continue
|
|
1414
|
-
if (existsSync(
|
|
1423
|
+
if (existsSync(personalRepoDir(u, e) + "/.git")) repos.push(e)
|
|
1415
1424
|
}
|
|
1416
1425
|
return c.json({ repos })
|
|
1417
1426
|
} catch {
|
|
@@ -1419,14 +1428,16 @@ app.get("/api/sync/repos", requireAuth, async (c) => {
|
|
|
1419
1428
|
}
|
|
1420
1429
|
})
|
|
1421
1430
|
app.get("/api/sync/repos/:name/status", requireAuth, async (c) => {
|
|
1422
|
-
const
|
|
1431
|
+
const u = c.get("userId") as string
|
|
1432
|
+
const dir = syncDirFor(u, "repos", c.req.param("name"))
|
|
1423
1433
|
if (!dir) return c.json({ error: "repo not found" }, 404)
|
|
1424
|
-
return c.json(await inspectRepoSync(dir,
|
|
1434
|
+
return c.json(await inspectRepoSync(dir, u))
|
|
1425
1435
|
})
|
|
1426
1436
|
app.post("/api/sync/repos/:name/pull", requireAuth, async (c) => {
|
|
1427
|
-
const
|
|
1437
|
+
const u = c.get("userId") as string
|
|
1438
|
+
const dir = syncDirFor(u, "repos", c.req.param("name"))
|
|
1428
1439
|
if (!dir) return c.json({ error: "repo not found" }, 404)
|
|
1429
|
-
const r = await pullRepoFromRemote(dir,
|
|
1440
|
+
const r = await pullRepoFromRemote(dir, u)
|
|
1430
1441
|
return r.ok ? c.json(r) : c.json({ error: r.error }, 400)
|
|
1431
1442
|
})
|
|
1432
1443
|
|
|
@@ -3103,13 +3114,26 @@ app.get("*", async (c, next) => {
|
|
|
3103
3114
|
// Try to serve the exact file
|
|
3104
3115
|
const file = Bun.file(join(webDist, path === "/" ? "index.html" : path))
|
|
3105
3116
|
if (await file.exists()) {
|
|
3117
|
+
// Hashed build assets are content-addressed → cache hard. HTML must always
|
|
3118
|
+
// revalidate so a version swap (new chunk names) is picked up immediately.
|
|
3119
|
+
const isAsset = path.startsWith("/assets/")
|
|
3106
3120
|
return new Response(file, {
|
|
3107
|
-
headers: {
|
|
3121
|
+
headers: {
|
|
3122
|
+
"content-type": file.type,
|
|
3123
|
+
"cache-control": isAsset ? "public, max-age=31536000, immutable" : "no-cache",
|
|
3124
|
+
},
|
|
3108
3125
|
})
|
|
3109
3126
|
}
|
|
3110
|
-
//
|
|
3127
|
+
// A missing build asset (a stale chunk after a deploy, e.g. when a browser
|
|
3128
|
+
// still holds an old index.html) must 404 — NOT fall through to index.html,
|
|
3129
|
+
// or the browser loads HTML as a JS module and throws "Failed to fetch
|
|
3130
|
+
// dynamically imported module". Only extensionless paths are real SPA routes.
|
|
3131
|
+
if (path.startsWith("/assets/") || /\.[a-zA-Z0-9]+$/.test(path)) {
|
|
3132
|
+
return c.notFound()
|
|
3133
|
+
}
|
|
3134
|
+
// SPA fallback (real client-side routes) — always revalidate.
|
|
3111
3135
|
return new Response(Bun.file(indexHtml), {
|
|
3112
|
-
headers: { "content-type": "text/html" },
|
|
3136
|
+
headers: { "content-type": "text/html", "cache-control": "no-cache" },
|
|
3113
3137
|
})
|
|
3114
3138
|
})
|
|
3115
3139
|
|
package/server/src/loops.ts
CHANGED
|
@@ -24,6 +24,10 @@ import {
|
|
|
24
24
|
workspaceOriginsDir,
|
|
25
25
|
workspaceOriginPath,
|
|
26
26
|
personalDir,
|
|
27
|
+
personalKnowledgeDir,
|
|
28
|
+
personalNotesDir,
|
|
29
|
+
personalReposDir,
|
|
30
|
+
personalRepoDir,
|
|
27
31
|
personalVaultDir,
|
|
28
32
|
uiNotesDir,
|
|
29
33
|
personalMemoryDir,
|
|
@@ -36,7 +40,7 @@ import {
|
|
|
36
40
|
} from "./paths"
|
|
37
41
|
import type { RepoSpec } from "./config"
|
|
38
42
|
import { existsSync as existsSyncBase } from "node:fs"
|
|
39
|
-
import { loadConfig, loadPersonalConfig } from "./config"
|
|
43
|
+
import { loadConfig, loadPersonalConfig, loadKnowledgeConfig } from "./config"
|
|
40
44
|
import { ensurePersonalKeypair } from "./personal-keys"
|
|
41
45
|
import { composeLoopClaudeConfig, writeLoopSettings } from "./compose"
|
|
42
46
|
import { getProvider } from "./git-host"
|
|
@@ -84,6 +88,13 @@ export type LoopMeta = {
|
|
|
84
88
|
pendingDriverNote?: { from: string; to: string; at: string }
|
|
85
89
|
repo?: string
|
|
86
90
|
branch?: string
|
|
91
|
+
/**
|
|
92
|
+
* Context-setup problems captured at loop creation (e.g. the per-user
|
|
93
|
+
* knowledge/notes clone failed — bad/again-missing key, no access). Surfaced
|
|
94
|
+
* as a banner in the loop UI so the user isn't left with a silently-empty
|
|
95
|
+
* context. Empty/absent = context set up cleanly.
|
|
96
|
+
*/
|
|
97
|
+
contextWarnings?: string[]
|
|
87
98
|
config?: {
|
|
88
99
|
default_model?: string
|
|
89
100
|
default_model_source?: "personal" | "workspace"
|
|
@@ -332,31 +343,57 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
|
|
|
332
343
|
* interactive host-key prompt. Host-side git overrides that config via
|
|
333
344
|
* GIT_SSH_COMMAND (env beats config), so the sandbox path is never used here.
|
|
334
345
|
*/
|
|
335
|
-
export async function ensureUserContext(user: string, vault: string = "default"): Promise<
|
|
346
|
+
export async function ensureUserContext(user: string, vault: string = "default"): Promise<string[]> {
|
|
347
|
+
const errors: string[] = []
|
|
336
348
|
const cfg = await loadPersonalConfig(user, vault)
|
|
337
349
|
const sshEnv = { ...process.env, GIT_SSH_COMMAND: sshCommandForUser(user, vault) }
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
350
|
+
// Sandbox-side promote: core.sshCommand points at the vault key's SANDBOX path
|
|
351
|
+
// (/loopat/home/<user>/.ssh/id, where the vault home-mount lands) with
|
|
352
|
+
// accept-new, so the AI's `git push` inside the sandbox authenticates as the
|
|
353
|
+
// user without an interactive host-key prompt. Host-side ops override this
|
|
354
|
+
// with GIT_SSH_COMMAND (env beats config), so the sandbox path is never used
|
|
355
|
+
// server-side.
|
|
356
|
+
const sandboxKey = `/loopat/home/${user}/.ssh/id`
|
|
357
|
+
// Clone-or-sync a PER-USER context main repo from `url` with the vault key.
|
|
358
|
+
// STRICT, per the context model: personal wins even when empty — an empty url
|
|
359
|
+
// means the dir is REMOVED so the loop sees nothing (no fallback to any
|
|
360
|
+
// workspace default). Returns whether the repo exists afterwards.
|
|
361
|
+
const ensurePerUserRepo = async (dir: string, url: string | undefined, label: string): Promise<boolean> => {
|
|
362
|
+
if (!url) {
|
|
363
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
364
|
+
return false
|
|
365
|
+
}
|
|
366
|
+
if (existsSyncBase(join(dir, ".git"))) {
|
|
367
|
+
const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
|
|
368
|
+
await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
|
|
369
|
+
} else {
|
|
370
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
371
|
+
await mkdir(join(dir, ".."), { recursive: true })
|
|
372
|
+
try {
|
|
373
|
+
await execFileP("git", ["clone", "--", url, dir], { env: sshEnv, timeout: 60_000 })
|
|
374
|
+
console.log(`[loopat] cloned per-user context ${url} → ${dir}`)
|
|
375
|
+
} catch (e: any) {
|
|
376
|
+
// Concise reason (last non-empty stderr line — e.g. "Permission denied
|
|
377
|
+
// (publickey)") for the loop-creation warning surfaced in the UI.
|
|
378
|
+
const reason = (e?.stderr ?? e?.message ?? String(e)).toString().trim().split("\n").filter(Boolean).pop() ?? "clone failed"
|
|
379
|
+
console.warn(`[loopat] per-user context clone failed (${url}): ${e?.stderr ?? e?.message ?? e}`)
|
|
380
|
+
errors.push(`${label}: couldn't clone ${url} — ${reason}`)
|
|
381
|
+
return false
|
|
382
|
+
}
|
|
383
|
+
}
|
|
354
384
|
await execFileP("git", ["-C", dir, "config", "core.sshCommand",
|
|
355
385
|
`ssh -i ${sandboxKey} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null`]).catch(() => {})
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 20_000 }).catch(() => {})
|
|
386
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {})
|
|
387
|
+
return true
|
|
359
388
|
}
|
|
389
|
+
// knowledge is the entry pointer (personal-declared url); clone it first, then
|
|
390
|
+
// read ITS .loopat/config.json for the notes remote + repo roster — notes and
|
|
391
|
+
// repos now live inside the per-user knowledge repo, not in personal config.
|
|
392
|
+
const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git, "knowledge")
|
|
393
|
+
const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined, repos: [] as RepoSpec[] }
|
|
394
|
+
await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git, "notes")
|
|
395
|
+
await writeReposManifest(personalReposDir(user), kcfg.repos ?? [])
|
|
396
|
+
return errors
|
|
360
397
|
}
|
|
361
398
|
|
|
362
399
|
/**
|
|
@@ -365,8 +402,8 @@ export async function ensureUserContext(user: string, vault: string = "default")
|
|
|
365
402
|
* clone a repo only when it's actually needed. Per docs/context-flow.md the AI
|
|
366
403
|
* can also clone any listed repo by hand into context/repos/<name>.
|
|
367
404
|
*/
|
|
368
|
-
async function writeReposManifest(specs: RepoSpec[]) {
|
|
369
|
-
await mkdir(
|
|
405
|
+
async function writeReposManifest(reposDir: string, specs: RepoSpec[]) {
|
|
406
|
+
await mkdir(reposDir, { recursive: true })
|
|
370
407
|
const body = [
|
|
371
408
|
"# repos — clone on demand",
|
|
372
409
|
"",
|
|
@@ -376,21 +413,22 @@ async function writeReposManifest(specs: RepoSpec[]) {
|
|
|
376
413
|
...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
|
|
377
414
|
"",
|
|
378
415
|
].join("\n")
|
|
379
|
-
await writeFile(join(
|
|
416
|
+
await writeFile(join(reposDir, "REPOS.md"), body)
|
|
380
417
|
}
|
|
381
418
|
|
|
382
419
|
/**
|
|
383
420
|
* Clone a single registered repo if it isn't present yet. Returns whether the
|
|
384
421
|
* repo dir exists afterwards. Used by loop creation and any on-demand path.
|
|
385
422
|
*/
|
|
386
|
-
async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
|
|
387
|
-
const dir =
|
|
423
|
+
async function ensureRepoCloned(user: string, name: string, sshCommand?: string): Promise<boolean> {
|
|
424
|
+
const dir = personalRepoDir(user, name)
|
|
388
425
|
if (existsSyncBase(dir)) return true
|
|
389
|
-
|
|
390
|
-
const
|
|
426
|
+
// The roster lives in the user's OWN knowledge repo (per-user, no fallback).
|
|
427
|
+
const kcfg = await loadKnowledgeConfig(user)
|
|
428
|
+
const spec = kcfg.repos?.find((r) => r.name === name)
|
|
391
429
|
if (!spec?.git) return false
|
|
392
430
|
try {
|
|
393
|
-
await mkdir(
|
|
431
|
+
await mkdir(personalReposDir(user), { recursive: true })
|
|
394
432
|
const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
|
|
395
433
|
await execFileP("git", ["clone", "--", spec.git, dir], { env })
|
|
396
434
|
console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
|
|
@@ -406,11 +444,14 @@ export async function ensureWorkspaceDirs() {
|
|
|
406
444
|
await mkdir(loopsDir(), { recursive: true })
|
|
407
445
|
await mkdir(workspaceReposDir(), { recursive: true })
|
|
408
446
|
|
|
409
|
-
//
|
|
447
|
+
// WORKSPACE-DEFAULT clone (bootstrap display + seed source only — loops use the
|
|
448
|
+
// per-user knowledge/notes, see ensureUserContext). knowledge is the entry
|
|
449
|
+
// pointer; clone it, then read its .loopat/config.json for notes + repo roster.
|
|
410
450
|
const cfg = await loadConfig()
|
|
411
451
|
await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
|
|
412
|
-
|
|
413
|
-
await
|
|
452
|
+
const kcfg = await loadKnowledgeConfig()
|
|
453
|
+
await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined)
|
|
454
|
+
await writeReposManifest(workspaceReposDir(), kcfg.repos ?? [])
|
|
414
455
|
|
|
415
456
|
// workspace memory dir + stub
|
|
416
457
|
const tm = workspaceMemoryDir()
|
|
@@ -1327,7 +1368,10 @@ export async function pushPersonalToRemote(
|
|
|
1327
1368
|
* rebuilt from origin if missing.
|
|
1328
1369
|
*/
|
|
1329
1370
|
export async function ensureUiNotesWorktree(user: string): Promise<void> {
|
|
1330
|
-
|
|
1371
|
+
// Ensure the user's per-user notes main repo is cloned from their declared
|
|
1372
|
+
// remote first (notes is per-user now), then open the UI worktree from it.
|
|
1373
|
+
await ensureUserContext(user).catch(() => {})
|
|
1374
|
+
await ensurePerUserContextWorktree(personalNotesDir(user), uiNotesDir(user), `ui/${user}`)
|
|
1331
1375
|
}
|
|
1332
1376
|
|
|
1333
1377
|
/**
|
|
@@ -1803,15 +1847,34 @@ async function remoteStartPoint(repo: string, sshCommand?: string): Promise<stri
|
|
|
1803
1847
|
}
|
|
1804
1848
|
}
|
|
1805
1849
|
|
|
1850
|
+
/**
|
|
1851
|
+
* Worktree from a PER-USER context main repo. When the main repo is absent (the
|
|
1852
|
+
* user declared no remote for this context — see ensureUserContext's strict
|
|
1853
|
+
* rule), the loop gets an EMPTY dir, never a fallback to a workspace default.
|
|
1854
|
+
*/
|
|
1855
|
+
async function ensurePerUserContextWorktree(repo: string, path: string, branch: string) {
|
|
1856
|
+
if (!existsSyncBase(join(repo, ".git"))) {
|
|
1857
|
+
try { await rm(path, { recursive: true, force: true }) } catch {}
|
|
1858
|
+
await mkdir(path, { recursive: true })
|
|
1859
|
+
return
|
|
1860
|
+
}
|
|
1861
|
+
await ensureContextWorktree(repo, path, branch)
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1806
1864
|
export async function ensureContextMounts(id: string, createdBy: string) {
|
|
1807
1865
|
await mkdir(loopContextDir(id), { recursive: true })
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
|
|
1866
|
+
// knowledge / notes are per-user (cloned by ensureUserContext from the user's
|
|
1867
|
+
// personal-declared remotes). Each worktree opens from origin/main — a fresh
|
|
1868
|
+
// pull of consensus; the local main repo is just the fetch cache + worktree
|
|
1869
|
+
// host (docs/context-flow.md). Empty when the user declared no remote.
|
|
1870
|
+
await ensurePerUserContextWorktree(personalKnowledgeDir(createdBy), loopContextKnowledge(id), `loop/${id}`)
|
|
1871
|
+
await ensurePerUserContextWorktree(personalNotesDir(createdBy), loopContextNotes(id), `loop/${id}`)
|
|
1872
|
+
// personal is also a per-loop worktree — same shape, wired to the user's
|
|
1873
|
+
// private remote. ensureContextWorktree falls back to a symlink when
|
|
1874
|
+
// personal/ isn't a git repo yet.
|
|
1813
1875
|
await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
|
|
1814
|
-
await
|
|
1876
|
+
await mkdir(personalReposDir(createdBy), { recursive: true })
|
|
1877
|
+
await ensureSymlink(loopContextRepos(id), personalReposDir(createdBy))
|
|
1815
1878
|
}
|
|
1816
1879
|
|
|
1817
1880
|
export async function listLoops(): Promise<LoopMeta[]> {
|
|
@@ -1886,15 +1949,25 @@ export async function createLoop(opts: {
|
|
|
1886
1949
|
await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
|
|
1887
1950
|
await writeLoopSettings(id)
|
|
1888
1951
|
|
|
1952
|
+
// Pull the per-user knowledge/notes FIRST: clones the user's knowledge repo
|
|
1953
|
+
// (from personal.knowledge) and reads its .loopat/config.json for the notes
|
|
1954
|
+
// remote + repo roster — which the workdir clone-on-demand below depends on.
|
|
1955
|
+
// Surface any clone failures (bad key / no access) as a loop banner so the
|
|
1956
|
+
// user isn't left with a silently-empty context.
|
|
1957
|
+
const ctxWarnings = await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
|
|
1958
|
+
(e: any) => { console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`); return [`context init failed: ${e?.message ?? e}`] },
|
|
1959
|
+
)
|
|
1960
|
+
if (ctxWarnings.length) meta.contextWarnings = ctxWarnings
|
|
1961
|
+
|
|
1889
1962
|
// workdir = git worktree add (if repo selected) OR plain mkdir
|
|
1890
1963
|
if (opts.repo) {
|
|
1891
1964
|
// clone + fetch as the user (their vault key), not the host's ssh.
|
|
1892
1965
|
const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
|
|
1893
1966
|
// clone-on-demand: pull the repo down only now that a loop actually needs it
|
|
1894
|
-
if (!(await ensureRepoCloned(opts.repo, userSsh))) {
|
|
1967
|
+
if (!(await ensureRepoCloned(opts.createdBy, opts.repo, userSsh))) {
|
|
1895
1968
|
throw new Error(`repo "${opts.repo}" not found / clone failed`)
|
|
1896
1969
|
}
|
|
1897
|
-
const repoPath =
|
|
1970
|
+
const repoPath = personalRepoDir(opts.createdBy, opts.repo)
|
|
1898
1971
|
const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
|
|
1899
1972
|
try {
|
|
1900
1973
|
// ① pull (docs/context-flow.md): base the workdir branch on origin/main
|
|
@@ -1915,11 +1988,7 @@ export async function createLoop(opts: {
|
|
|
1915
1988
|
await mkdir(loopWorkdir(id), { recursive: true })
|
|
1916
1989
|
}
|
|
1917
1990
|
|
|
1918
|
-
//
|
|
1919
|
-
// connect with the user's vault key (personal repo is self-describing).
|
|
1920
|
-
await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
|
|
1921
|
-
(e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
|
|
1922
|
-
)
|
|
1991
|
+
// (ensureUserContext already ran above, before the workdir clone.)
|
|
1923
1992
|
await ensureContextMounts(id, effectiveDriver(meta))
|
|
1924
1993
|
await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
|
|
1925
1994
|
return meta
|
package/server/src/paths.ts
CHANGED
|
@@ -35,6 +35,21 @@ export const workspaceOriginPath = (name: string) => join(workspaceOriginsDir(),
|
|
|
35
35
|
export const extensionsProvidersDir = () => join(LOOPAT_HOME, "extensions", "providers")
|
|
36
36
|
export const personalDir = (user: string) => join(LOOPAT_HOME, "personal", user)
|
|
37
37
|
|
|
38
|
+
// Per-user context main repos. knowledge/notes/repos are NOT workspace-shared:
|
|
39
|
+
// each user's loop sees ONLY what their personal.knowledge points at (no
|
|
40
|
+
// fallback to the workspace default). These are the main repos the loop's
|
|
41
|
+
// context worktrees are derived from — the per-user analogue of the shared
|
|
42
|
+
// workspaceKnowledgeDir()/workspaceNotesDir(). Live under context/users/<user>/
|
|
43
|
+
// so they never collide with the workspace-default context/knowledge.
|
|
44
|
+
export const userContextDir = (user: string) => join(workspaceContextDir(), "users", user)
|
|
45
|
+
export const personalKnowledgeDir = (user: string) => join(userContextDir(user), "knowledge")
|
|
46
|
+
export const personalNotesDir = (user: string) => join(userContextDir(user), "notes")
|
|
47
|
+
export const personalReposDir = (user: string) => join(userContextDir(user), "repos")
|
|
48
|
+
export const personalRepoDir = (user: string, name: string) => join(personalReposDir(user), name)
|
|
49
|
+
// The per-user knowledge repo's .loopat root (holds its config.json = notes +
|
|
50
|
+
// repo roster). Workspace-default equivalent is workspaceLoopatRoot().
|
|
51
|
+
export const personalKnowledgeLoopatRoot = (user: string) => join(personalKnowledgeDir(user), ".loopat")
|
|
52
|
+
|
|
38
53
|
export const loopDir = (id: string) => join(loopsDir(), id)
|
|
39
54
|
export const loopWorkdir = (id: string) => join(loopDir(id), "workdir")
|
|
40
55
|
export const loopClaudeDir = (id: string) => join(loopDir(id), ".claude")
|
package/server/src/podman.ts
CHANGED
|
@@ -46,12 +46,12 @@ import {
|
|
|
46
46
|
loopClaudeDir,
|
|
47
47
|
loopsDir,
|
|
48
48
|
loopContextChatDir,
|
|
49
|
-
workspaceKnowledgeDir,
|
|
50
|
-
workspaceNotesDir,
|
|
51
|
-
workspaceReposDir,
|
|
52
49
|
loopContextKnowledge,
|
|
53
50
|
loopContextNotes,
|
|
54
51
|
personalDir,
|
|
52
|
+
personalKnowledgeDir,
|
|
53
|
+
personalNotesDir,
|
|
54
|
+
personalReposDir,
|
|
55
55
|
LOOPAT_INSTALL_DIR,
|
|
56
56
|
loopHomeUpper,
|
|
57
57
|
workspaceHomeSkelDir,
|
|
@@ -250,20 +250,22 @@ export async function buildVolumeMounts(opts: ContainerOptions): Promise<VolumeM
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
// Repos: bind at virtual path AND host-absolute path (git worktree internals
|
|
253
|
-
// store absolute gitdir paths). Both RW.
|
|
254
|
-
const reposDir =
|
|
253
|
+
// store absolute gitdir paths). Both RW. PER-USER (the loop's own roster).
|
|
254
|
+
const reposDir = personalReposDir(createdBy)
|
|
255
255
|
if (existsSync(reposDir)) {
|
|
256
256
|
mounts.push({ src: reposDir, dst: V_CONTEXT_REPOS })
|
|
257
257
|
mounts.push({ src: reposDir, dst: reposDir })
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
-
// notes/knowledge main repos: re-bind at host-absolute path so per-loop
|
|
261
|
-
// worktree `.git` files resolve.
|
|
262
|
-
|
|
260
|
+
// notes/knowledge main repos: re-bind at host-absolute path so the per-loop
|
|
261
|
+
// worktree `.git` files resolve. PER-USER — the worktrees are derived from
|
|
262
|
+
// personalKnowledgeDir/personalNotesDir(createdBy), so the main repos bound
|
|
263
|
+
// here must match (same as personalDir above).
|
|
264
|
+
const notesRepo = personalNotesDir(createdBy)
|
|
263
265
|
if (existsSync(notesRepo)) {
|
|
264
266
|
mounts.push({ src: notesRepo, dst: notesRepo })
|
|
265
267
|
}
|
|
266
|
-
const knowledgeRepo =
|
|
268
|
+
const knowledgeRepo = personalKnowledgeDir(createdBy)
|
|
267
269
|
if (existsSync(knowledgeRepo)) {
|
|
268
270
|
mounts.push({ src: knowledgeRepo, dst: knowledgeRepo, ro: !knowledgeRw })
|
|
269
271
|
}
|