loopat 0.1.13 → 0.1.14
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 +28 -17
- package/server/src/loops.ts +98 -45
- 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-BVELa8v3.js} +1 -1
- package/web/dist/assets/{Editor-BPkCEBbL.js → Editor-DAe5119W.js} +1 -1
- package/web/dist/assets/{Markdown-By45c-j3.js → Markdown-YZ1MUKQv.js} +1 -1
- package/web/dist/assets/{MilkdownEditor-bBNv-sOZ.js → MilkdownEditor-DfpB9w26.js} +1 -1
- package/web/dist/assets/{Terminal-C2ln9OWr.js → Terminal-DJKsrLxO.js} +1 -1
- package/web/dist/assets/{index-B2M45eKI.js → index-C9m1r5nZ.js} +3 -3
- package/web/dist/index.html +1 -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
|
|
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"
|
|
@@ -335,28 +339,48 @@ async function ensureContextRepo(dir: string, name: string, url?: string): Promi
|
|
|
335
339
|
export async function ensureUserContext(user: string, vault: string = "default"): Promise<void> {
|
|
336
340
|
const cfg = await loadPersonalConfig(user, vault)
|
|
337
341
|
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
|
-
|
|
342
|
+
// Sandbox-side promote: core.sshCommand points at the vault key's SANDBOX path
|
|
343
|
+
// (/loopat/home/<user>/.ssh/id, where the vault home-mount lands) with
|
|
344
|
+
// accept-new, so the AI's `git push` inside the sandbox authenticates as the
|
|
345
|
+
// user without an interactive host-key prompt. Host-side ops override this
|
|
346
|
+
// with GIT_SSH_COMMAND (env beats config), so the sandbox path is never used
|
|
347
|
+
// server-side.
|
|
348
|
+
const sandboxKey = `/loopat/home/${user}/.ssh/id`
|
|
349
|
+
// Clone-or-sync a PER-USER context main repo from `url` with the vault key.
|
|
350
|
+
// STRICT, per the context model: personal wins even when empty — an empty url
|
|
351
|
+
// means the dir is REMOVED so the loop sees nothing (no fallback to any
|
|
352
|
+
// workspace default). Returns whether the repo exists afterwards.
|
|
353
|
+
const ensurePerUserRepo = async (dir: string, url: string | undefined): Promise<boolean> => {
|
|
354
|
+
if (!url) {
|
|
355
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
356
|
+
return false
|
|
357
|
+
}
|
|
358
|
+
if (existsSyncBase(join(dir, ".git"))) {
|
|
359
|
+
const has = await execFileP("git", ["-C", dir, "remote", "get-url", "origin"]).then(() => true).catch(() => false)
|
|
360
|
+
await execFileP("git", ["-C", dir, "remote", has ? "set-url" : "add", "origin", url]).catch(() => {})
|
|
361
|
+
} else {
|
|
362
|
+
try { await rm(dir, { recursive: true, force: true }) } catch {}
|
|
363
|
+
await mkdir(join(dir, ".."), { recursive: true })
|
|
364
|
+
try {
|
|
365
|
+
await execFileP("git", ["clone", "--", url, dir], { env: sshEnv, timeout: 60_000 })
|
|
366
|
+
console.log(`[loopat] cloned per-user context ${url} → ${dir}`)
|
|
367
|
+
} catch (e: any) {
|
|
368
|
+
console.warn(`[loopat] per-user context clone failed (${url}): ${e?.stderr ?? e?.message ?? e}`)
|
|
369
|
+
return false
|
|
370
|
+
}
|
|
371
|
+
}
|
|
354
372
|
await execFileP("git", ["-C", dir, "config", "core.sshCommand",
|
|
355
373
|
`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(() => {})
|
|
374
|
+
await execFileP("git", ["-C", dir, "fetch", "--quiet", "origin"], { env: sshEnv, timeout: 30_000 }).catch(() => {})
|
|
375
|
+
return true
|
|
359
376
|
}
|
|
377
|
+
// knowledge is the entry pointer (personal-declared url); clone it first, then
|
|
378
|
+
// read ITS .loopat/config.json for the notes remote + repo roster — notes and
|
|
379
|
+
// repos now live inside the per-user knowledge repo, not in personal config.
|
|
380
|
+
const hasKnowledge = await ensurePerUserRepo(personalKnowledgeDir(user), cfg.knowledge?.git)
|
|
381
|
+
const kcfg = hasKnowledge ? await loadKnowledgeConfig(user) : { notes: undefined, repos: [] as RepoSpec[] }
|
|
382
|
+
await ensurePerUserRepo(personalNotesDir(user), kcfg.notes?.git)
|
|
383
|
+
await writeReposManifest(personalReposDir(user), kcfg.repos ?? [])
|
|
360
384
|
}
|
|
361
385
|
|
|
362
386
|
/**
|
|
@@ -365,8 +389,8 @@ export async function ensureUserContext(user: string, vault: string = "default")
|
|
|
365
389
|
* clone a repo only when it's actually needed. Per docs/context-flow.md the AI
|
|
366
390
|
* can also clone any listed repo by hand into context/repos/<name>.
|
|
367
391
|
*/
|
|
368
|
-
async function writeReposManifest(specs: RepoSpec[]) {
|
|
369
|
-
await mkdir(
|
|
392
|
+
async function writeReposManifest(reposDir: string, specs: RepoSpec[]) {
|
|
393
|
+
await mkdir(reposDir, { recursive: true })
|
|
370
394
|
const body = [
|
|
371
395
|
"# repos — clone on demand",
|
|
372
396
|
"",
|
|
@@ -376,21 +400,22 @@ async function writeReposManifest(specs: RepoSpec[]) {
|
|
|
376
400
|
...specs.filter((r) => r?.name && r?.git).map((r) => `- **${r.name}** — \`${r.git}\``),
|
|
377
401
|
"",
|
|
378
402
|
].join("\n")
|
|
379
|
-
await writeFile(join(
|
|
403
|
+
await writeFile(join(reposDir, "REPOS.md"), body)
|
|
380
404
|
}
|
|
381
405
|
|
|
382
406
|
/**
|
|
383
407
|
* Clone a single registered repo if it isn't present yet. Returns whether the
|
|
384
408
|
* repo dir exists afterwards. Used by loop creation and any on-demand path.
|
|
385
409
|
*/
|
|
386
|
-
async function ensureRepoCloned(name: string, sshCommand?: string): Promise<boolean> {
|
|
387
|
-
const dir =
|
|
410
|
+
async function ensureRepoCloned(user: string, name: string, sshCommand?: string): Promise<boolean> {
|
|
411
|
+
const dir = personalRepoDir(user, name)
|
|
388
412
|
if (existsSyncBase(dir)) return true
|
|
389
|
-
|
|
390
|
-
const
|
|
413
|
+
// The roster lives in the user's OWN knowledge repo (per-user, no fallback).
|
|
414
|
+
const kcfg = await loadKnowledgeConfig(user)
|
|
415
|
+
const spec = kcfg.repos?.find((r) => r.name === name)
|
|
391
416
|
if (!spec?.git) return false
|
|
392
417
|
try {
|
|
393
|
-
await mkdir(
|
|
418
|
+
await mkdir(personalReposDir(user), { recursive: true })
|
|
394
419
|
const env = sshCommand ? { ...process.env, GIT_SSH_COMMAND: sshCommand } : process.env
|
|
395
420
|
await execFileP("git", ["clone", "--", spec.git, dir], { env })
|
|
396
421
|
console.log(`[loopat] cloned on demand ${spec.git} → ${dir}`)
|
|
@@ -406,11 +431,14 @@ export async function ensureWorkspaceDirs() {
|
|
|
406
431
|
await mkdir(loopsDir(), { recursive: true })
|
|
407
432
|
await mkdir(workspaceReposDir(), { recursive: true })
|
|
408
433
|
|
|
409
|
-
//
|
|
434
|
+
// WORKSPACE-DEFAULT clone (bootstrap display + seed source only — loops use the
|
|
435
|
+
// per-user knowledge/notes, see ensureUserContext). knowledge is the entry
|
|
436
|
+
// pointer; clone it, then read its .loopat/config.json for notes + repo roster.
|
|
410
437
|
const cfg = await loadConfig()
|
|
411
438
|
await ensureContextRepo(workspaceKnowledgeDir(), "knowledge", cfg.knowledge?.git || undefined)
|
|
412
|
-
|
|
413
|
-
await
|
|
439
|
+
const kcfg = await loadKnowledgeConfig()
|
|
440
|
+
await ensureContextRepo(workspaceNotesDir(), "notes", kcfg.notes?.git || undefined)
|
|
441
|
+
await writeReposManifest(workspaceReposDir(), kcfg.repos ?? [])
|
|
414
442
|
|
|
415
443
|
// workspace memory dir + stub
|
|
416
444
|
const tm = workspaceMemoryDir()
|
|
@@ -1327,7 +1355,10 @@ export async function pushPersonalToRemote(
|
|
|
1327
1355
|
* rebuilt from origin if missing.
|
|
1328
1356
|
*/
|
|
1329
1357
|
export async function ensureUiNotesWorktree(user: string): Promise<void> {
|
|
1330
|
-
|
|
1358
|
+
// Ensure the user's per-user notes main repo is cloned from their declared
|
|
1359
|
+
// remote first (notes is per-user now), then open the UI worktree from it.
|
|
1360
|
+
await ensureUserContext(user).catch(() => {})
|
|
1361
|
+
await ensurePerUserContextWorktree(personalNotesDir(user), uiNotesDir(user), `ui/${user}`)
|
|
1331
1362
|
}
|
|
1332
1363
|
|
|
1333
1364
|
/**
|
|
@@ -1803,15 +1834,34 @@ async function remoteStartPoint(repo: string, sshCommand?: string): Promise<stri
|
|
|
1803
1834
|
}
|
|
1804
1835
|
}
|
|
1805
1836
|
|
|
1837
|
+
/**
|
|
1838
|
+
* Worktree from a PER-USER context main repo. When the main repo is absent (the
|
|
1839
|
+
* user declared no remote for this context — see ensureUserContext's strict
|
|
1840
|
+
* rule), the loop gets an EMPTY dir, never a fallback to a workspace default.
|
|
1841
|
+
*/
|
|
1842
|
+
async function ensurePerUserContextWorktree(repo: string, path: string, branch: string) {
|
|
1843
|
+
if (!existsSyncBase(join(repo, ".git"))) {
|
|
1844
|
+
try { await rm(path, { recursive: true, force: true }) } catch {}
|
|
1845
|
+
await mkdir(path, { recursive: true })
|
|
1846
|
+
return
|
|
1847
|
+
}
|
|
1848
|
+
await ensureContextWorktree(repo, path, branch)
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1806
1851
|
export async function ensureContextMounts(id: string, createdBy: string) {
|
|
1807
1852
|
await mkdir(loopContextDir(id), { recursive: true })
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
//
|
|
1811
|
-
//
|
|
1812
|
-
|
|
1853
|
+
// knowledge / notes are per-user (cloned by ensureUserContext from the user's
|
|
1854
|
+
// personal-declared remotes). Each worktree opens from origin/main — a fresh
|
|
1855
|
+
// pull of consensus; the local main repo is just the fetch cache + worktree
|
|
1856
|
+
// host (docs/context-flow.md). Empty when the user declared no remote.
|
|
1857
|
+
await ensurePerUserContextWorktree(personalKnowledgeDir(createdBy), loopContextKnowledge(id), `loop/${id}`)
|
|
1858
|
+
await ensurePerUserContextWorktree(personalNotesDir(createdBy), loopContextNotes(id), `loop/${id}`)
|
|
1859
|
+
// personal is also a per-loop worktree — same shape, wired to the user's
|
|
1860
|
+
// private remote. ensureContextWorktree falls back to a symlink when
|
|
1861
|
+
// personal/ isn't a git repo yet.
|
|
1813
1862
|
await ensureContextWorktree(personalDir(createdBy), loopContextPersonal(id), `loop/${id}`)
|
|
1814
|
-
await
|
|
1863
|
+
await mkdir(personalReposDir(createdBy), { recursive: true })
|
|
1864
|
+
await ensureSymlink(loopContextRepos(id), personalReposDir(createdBy))
|
|
1815
1865
|
}
|
|
1816
1866
|
|
|
1817
1867
|
export async function listLoops(): Promise<LoopMeta[]> {
|
|
@@ -1886,15 +1936,22 @@ export async function createLoop(opts: {
|
|
|
1886
1936
|
await composeLoopClaudeConfig(id, opts.createdBy, opts.profiles)
|
|
1887
1937
|
await writeLoopSettings(id)
|
|
1888
1938
|
|
|
1939
|
+
// Pull the per-user knowledge/notes FIRST: clones the user's knowledge repo
|
|
1940
|
+
// (from personal.knowledge) and reads its .loopat/config.json for the notes
|
|
1941
|
+
// remote + repo roster — which the workdir clone-on-demand below depends on.
|
|
1942
|
+
await ensureUserContext(opts.createdBy, opts.vault ?? "default").catch(
|
|
1943
|
+
(e: any) => console.warn(`[loopat] ensureUserContext(${opts.createdBy}): ${e?.message ?? e}`),
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1889
1946
|
// workdir = git worktree add (if repo selected) OR plain mkdir
|
|
1890
1947
|
if (opts.repo) {
|
|
1891
1948
|
// clone + fetch as the user (their vault key), not the host's ssh.
|
|
1892
1949
|
const userSsh = sshCommandForUser(opts.createdBy, opts.vault ?? "default")
|
|
1893
1950
|
// clone-on-demand: pull the repo down only now that a loop actually needs it
|
|
1894
|
-
if (!(await ensureRepoCloned(opts.repo, userSsh))) {
|
|
1951
|
+
if (!(await ensureRepoCloned(opts.createdBy, opts.repo, userSsh))) {
|
|
1895
1952
|
throw new Error(`repo "${opts.repo}" not found / clone failed`)
|
|
1896
1953
|
}
|
|
1897
|
-
const repoPath =
|
|
1954
|
+
const repoPath = personalRepoDir(opts.createdBy, opts.repo)
|
|
1898
1955
|
const branch = `loop/${(await shortBranchSlug(meta.title))}-${id.slice(0, 6)}`
|
|
1899
1956
|
try {
|
|
1900
1957
|
// ① pull (docs/context-flow.md): base the workdir branch on origin/main
|
|
@@ -1915,11 +1972,7 @@ export async function createLoop(opts: {
|
|
|
1915
1972
|
await mkdir(loopWorkdir(id), { recursive: true })
|
|
1916
1973
|
}
|
|
1917
1974
|
|
|
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
|
-
)
|
|
1975
|
+
// (ensureUserContext already ran above, before the workdir clone.)
|
|
1923
1976
|
await ensureContextMounts(id, effectiveDriver(meta))
|
|
1924
1977
|
await writeFile(loopMetaPath(id), JSON.stringify(meta, null, 2))
|
|
1925
1978
|
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
|
}
|
|
@@ -20,7 +20,7 @@ import { readFile } from "node:fs/promises"
|
|
|
20
20
|
import { execFile } from "node:child_process"
|
|
21
21
|
import { promisify } from "node:util"
|
|
22
22
|
import { effectiveDriver, type LoopMeta } from "./loops"
|
|
23
|
-
import { bundledDoctrinePath,
|
|
23
|
+
import { bundledDoctrinePath, personalNotesDir, personalKnowledgeDir } from "./paths"
|
|
24
24
|
|
|
25
25
|
const execFileP = promisify(execFile)
|
|
26
26
|
|
|
@@ -47,9 +47,10 @@ async function detectTrunkBranch(repoDir: string): Promise<string> {
|
|
|
47
47
|
|
|
48
48
|
async function buildRuntimeBlock(loop: LoopMeta): Promise<string> {
|
|
49
49
|
const repoLine = loop.repo ? `${loop.repo} (branch ${loop.branch ?? "main"})` : "(no repo bound — empty workdir)"
|
|
50
|
+
const driver = effectiveDriver(loop)
|
|
50
51
|
const [notesTrunk, knowledgeTrunk] = await Promise.all([
|
|
51
|
-
detectTrunkBranch(
|
|
52
|
-
detectTrunkBranch(
|
|
52
|
+
detectTrunkBranch(personalNotesDir(driver)),
|
|
53
|
+
detectTrunkBranch(personalKnowledgeDir(driver)),
|
|
53
54
|
])
|
|
54
55
|
const lines = [
|
|
55
56
|
`## Runtime context (this loop)`,
|
package/server/src/workspace.ts
CHANGED
|
@@ -11,10 +11,13 @@ import { promisify } from "node:util"
|
|
|
11
11
|
import { homedir } from "node:os"
|
|
12
12
|
import { join, normalize, relative, resolve as resolvePath, sep, dirname } from "node:path"
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
personalKnowledgeDir,
|
|
15
|
+
personalReposDir,
|
|
16
16
|
personalDir,
|
|
17
17
|
uiNotesDir,
|
|
18
|
+
// workspace-level repos admin (addRepo/listRepos/...) — superseded by the
|
|
19
|
+
// per-user knowledge-config repo roster in batch4b; kept until then.
|
|
20
|
+
workspaceReposDir,
|
|
18
21
|
} from "./paths"
|
|
19
22
|
|
|
20
23
|
const execFileP = promisify(execFile)
|
|
@@ -31,7 +34,7 @@ export type VaultEntry = {
|
|
|
31
34
|
export function vaultRoot(vault: VaultId, user: string): string {
|
|
32
35
|
switch (vault) {
|
|
33
36
|
case "knowledge":
|
|
34
|
-
return
|
|
37
|
+
return personalKnowledgeDir(user)
|
|
35
38
|
case "notes":
|
|
36
39
|
// notes is edited via a per-user UI-loop worktree (opened from origin/main);
|
|
37
40
|
// the endpoint ensures the worktree exists before this resolves.
|
|
@@ -39,7 +42,7 @@ export function vaultRoot(vault: VaultId, user: string): string {
|
|
|
39
42
|
case "personal":
|
|
40
43
|
return personalDir(user)
|
|
41
44
|
case "repos":
|
|
42
|
-
return
|
|
45
|
+
return personalReposDir(user)
|
|
43
46
|
}
|
|
44
47
|
}
|
|
45
48
|
|